Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd69e8d40 | ||
|
|
eebef3371f | ||
|
|
4cd6701c8a | ||
|
|
4ebe6f2a37 | ||
|
|
d553d58fde | ||
|
|
df40a1367a | ||
|
|
607ee77e70 | ||
|
|
9af493aa8a | ||
|
|
f0c94ff789 | ||
|
|
39c7799831 | ||
|
|
da7371f830 | ||
|
|
387cfefc8f | ||
|
|
d92dbde781 | ||
|
|
e36658e1a1 | ||
|
|
51cd942717 | ||
|
|
001554db1a | ||
|
|
7cf86bb98d | ||
|
|
c28c095f48 | ||
|
|
12eac049e5 | ||
|
|
304cc153cf | ||
|
|
b45231f533 | ||
|
|
26eb9d30e8 | ||
|
|
97fa62d12b | ||
|
|
1b092fe955 | ||
|
|
18a59fe835 | ||
|
|
410906ad8e | ||
|
|
8f4b09f346 | ||
|
|
cda021cbbf | ||
|
|
ee4df99cd8 | ||
|
|
53f1873a9b | ||
|
|
9434293a84 | ||
|
|
ed69fe9dcc |
80
Pipfile.lock
generated
@@ -39,11 +39,11 @@
|
|||||||
},
|
},
|
||||||
"django": {
|
"django": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
|
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
|
||||||
"sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03"
|
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.1.4"
|
"version": "==3.1.5"
|
||||||
},
|
},
|
||||||
"django-appconf": {
|
"django-appconf": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -134,44 +134,48 @@
|
|||||||
},
|
},
|
||||||
"pillow": {
|
"pillow": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a",
|
"sha256:165c88bc9d8dba670110c689e3cc5c71dbe4bfb984ffa7cbebf1fac9554071d6",
|
||||||
"sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae",
|
"sha256:1d208e670abfeb41b6143537a681299ef86e92d2a3dac299d3cd6830d5c7bded",
|
||||||
"sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce",
|
"sha256:22d070ca2e60c99929ef274cfced04294d2368193e935c5d6febfd8b601bf865",
|
||||||
"sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e",
|
"sha256:2353834b2c49b95e1313fb34edf18fca4d57446675d05298bb694bca4b194174",
|
||||||
"sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140",
|
"sha256:39725acf2d2e9c17356e6835dccebe7a697db55f25a09207e38b835d5e1bc032",
|
||||||
"sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb",
|
"sha256:3de6b2ee4f78c6b3d89d184ade5d8fa68af0848f9b6b6da2b9ab7943ec46971a",
|
||||||
"sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021",
|
"sha256:47c0d93ee9c8b181f353dbead6530b26980fe4f5485aa18be8f1fd3c3cbc685e",
|
||||||
"sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6",
|
"sha256:5e2fe3bb2363b862671eba632537cd3a823847db4d98be95690b7e382f3d6378",
|
||||||
"sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302",
|
"sha256:604815c55fd92e735f9738f65dabf4edc3e79f88541c221d292faec1904a4b17",
|
||||||
"sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c",
|
"sha256:6c5275bd82711cd3dcd0af8ce0bb99113ae8911fc2952805f1d012de7d600a4c",
|
||||||
"sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271",
|
"sha256:731ca5aabe9085160cf68b2dbef95fc1991015bc0a3a6ea46a371ab88f3d0913",
|
||||||
"sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09",
|
"sha256:7612520e5e1a371d77e1d1ca3a3ee6227eef00d0a9cddb4ef7ecb0b7396eddf7",
|
||||||
"sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3",
|
"sha256:7916cbc94f1c6b1301ac04510d0881b9e9feb20ae34094d3615a8a7c3db0dcc0",
|
||||||
"sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015",
|
"sha256:81c3fa9a75d9f1afafdb916d5995633f319db09bd773cb56b8e39f1e98d90820",
|
||||||
"sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3",
|
"sha256:887668e792b7edbfb1d3c9d8b5d8c859269a0f0eba4dda562adb95500f60dbba",
|
||||||
"sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544",
|
"sha256:93a473b53cc6e0b3ce6bf51b1b95b7b1e7e6084be3a07e40f79b42e83503fbf2",
|
||||||
"sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8",
|
"sha256:96d4dc103d1a0fa6d47c6c55a47de5f5dafd5ef0114fa10c85a1fd8e0216284b",
|
||||||
"sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792",
|
"sha256:a3d3e086474ef12ef13d42e5f9b7bbf09d39cf6bd4940f982263d6954b13f6a9",
|
||||||
"sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0",
|
"sha256:b02a0b9f332086657852b1f7cb380f6a42403a6d9c42a4c34a561aa4530d5234",
|
||||||
"sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3",
|
"sha256:b09e10ec453de97f9a23a5aa5e30b334195e8d2ddd1ce76cc32e52ba63c8b31d",
|
||||||
"sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8",
|
"sha256:b6f00ad5ebe846cc91763b1d0c6d30a8042e02b2316e27b05de04fa6ec831ec5",
|
||||||
"sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11",
|
"sha256:bba80df38cfc17f490ec651c73bb37cd896bc2400cfba27d078c2135223c1206",
|
||||||
"sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7",
|
"sha256:c3d911614b008e8a576b8e5303e3db29224b455d3d66d1b2848ba6ca83f9ece9",
|
||||||
"sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11",
|
"sha256:ca20739e303254287138234485579b28cb0d524401f83d5129b5ff9d606cb0a8",
|
||||||
"sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e",
|
"sha256:cb192176b477d49b0a327b2a5a4979552b7a58cd42037034316b8018ac3ebb59",
|
||||||
"sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039",
|
"sha256:cdbbe7dff4a677fb555a54f9bc0450f2a21a93c5ba2b44e09e54fcb72d2bd13d",
|
||||||
"sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5",
|
"sha256:cf6e33d92b1526190a1de904df21663c46a456758c0424e4f947ae9aa6088bf7",
|
||||||
"sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72"
|
"sha256:d355502dce85ade85a2511b40b4c61a128902f246504f7de29bbeec1ae27933a",
|
||||||
|
"sha256:d673c4990acd016229a5c1c4ee8a9e6d8f481b27ade5fc3d95938697fa443ce0",
|
||||||
|
"sha256:dc577f4cfdda354db3ae37a572428a90ffdbe4e51eda7849bf442fb803f09c9b",
|
||||||
|
"sha256:dd9eef866c70d2cbbea1ae58134eaffda0d4bfea403025f4db6859724b18ab3d",
|
||||||
|
"sha256:f50e7a98b0453f39000619d845be8b06e611e56ee6e8186f7f60c3b1e2f0feae"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==8.0.1"
|
"version": "==8.1.0"
|
||||||
},
|
},
|
||||||
"pytz": {
|
"pytz": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
|
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||||
"sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
|
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||||
],
|
],
|
||||||
"version": "==2020.4"
|
"version": "==2020.5"
|
||||||
},
|
},
|
||||||
"rcssmin": {
|
"rcssmin": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -236,11 +240,11 @@
|
|||||||
},
|
},
|
||||||
"youtube-dl": {
|
"youtube-dl": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:65968065e66966955dc79fad9251565fcc982566118756da624bd21467f3a04c",
|
"sha256:8f421ca8394d2529e06225e44ec66538d2a28f6f340c03065776894bf3d24ea6",
|
||||||
"sha256:eaa859f15b6897bec21474b7787dc958118c8088e1f24d4ef1d58eab13188958"
|
"sha256:acf74701a31b6c3d06f9d4245a46ba8fb6c378931681177412043c6e8276fee7"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2020.12.14"
|
"version": "==2021.1.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"develop": {}
|
"develop": {}
|
||||||
|
|||||||
62
README.md
@@ -18,33 +18,37 @@ hands-free as possible, TubeSync has gradual retrying of failures with back-off
|
|||||||
so media which fails to download will be retried for an extended period making it,
|
so media which fails to download will be retried for an extended period making it,
|
||||||
hopefully, quite reliable.
|
hopefully, quite reliable.
|
||||||
|
|
||||||
|
|
||||||
# Latest container image
|
# Latest container image
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ghcr.io/meeb/tubesync:v0.5
|
ghcr.io/meeb/tubesync:v0.8
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**NOTE: the `:latest` tag does exist, but will contain in-development commits and may
|
||||||
|
be broken. Use at your own risk.**
|
||||||
|
|
||||||
# Screenshots
|
# Screenshots
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Sources overview
|
### Sources overview
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Source details
|
### Source details
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Media overview
|
### Media overview
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Media details
|
### Media details
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
# Requirements
|
# Requirements
|
||||||
@@ -98,7 +102,7 @@ Finally, download and run the container:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull a versioned image
|
# Pull a versioned image
|
||||||
$ docker pull ghcr.io/meeb/tubesync:v0.5
|
$ docker pull ghcr.io/meeb/tubesync:v0.8
|
||||||
# Start the container using your user ID and group ID
|
# Start the container using your user ID and group ID
|
||||||
$ docker run \
|
$ docker run \
|
||||||
-d \
|
-d \
|
||||||
@@ -109,7 +113,7 @@ $ docker run \
|
|||||||
-v /some/directory/tubesync-config:/config \
|
-v /some/directory/tubesync-config:/config \
|
||||||
-v /some/directory/tubesync-downloads:/downloads \
|
-v /some/directory/tubesync-downloads:/downloads \
|
||||||
-p 4848:4848 \
|
-p 4848:4848 \
|
||||||
ghcr.io/meeb/tubesync:v0.5
|
ghcr.io/meeb/tubesync:v0.8
|
||||||
```
|
```
|
||||||
|
|
||||||
Once running, open `http://localhost:4848` in your browser and you should see the
|
Once running, open `http://localhost:4848` in your browser and you should see the
|
||||||
@@ -121,7 +125,7 @@ Alternatively, for Docker Compose, you can use something like:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
tubesync:
|
tubesync:
|
||||||
image: ghcr.io/meeb/tubesync:v0.5
|
image: ghcr.io/meeb/tubesync:v0.8
|
||||||
container_name: tubesync
|
container_name: tubesync
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -196,10 +200,10 @@ $ docker logs --follow tubesync
|
|||||||
|
|
||||||
### 1. Index frequency
|
### 1. Index frequency
|
||||||
|
|
||||||
It's a good idea to add sources with as low an index frequency as possible. This is the
|
It's a good idea to add sources with as long of an index frequency as possible. This is
|
||||||
duration between indexes of the source. An index is when TubeSync checks to see
|
the duration between indexes of the source. An index is when TubeSync checks to see
|
||||||
what videos available on a channel or playlist to find new media. Try and keep this as
|
what videos available on a channel or playlist to find new media. Try and keep this as
|
||||||
long as possible, 24 hours if possible.
|
long as possible, up to 24 hours.
|
||||||
|
|
||||||
|
|
||||||
### 2. Indexing massive channels
|
### 2. Indexing massive channels
|
||||||
@@ -209,6 +213,14 @@ every hour" or similar short interval it's entirely possible your TubeSync insta
|
|||||||
spend its entire time just indexing the massive channel over and over again without
|
spend its entire time just indexing the massive channel over and over again without
|
||||||
downloading any media. Check your tasks for the status of your TubeSync install.
|
downloading any media. Check your tasks for the status of your TubeSync install.
|
||||||
|
|
||||||
|
If you add a significant amount of "work" due to adding many large channels you may
|
||||||
|
need to increase the number of background workers by setting the `TUBESYNC_WORKERS`
|
||||||
|
environment variable. Try around ~4 at most, although the absolute maximum allowed is 8.
|
||||||
|
|
||||||
|
**Be nice.** it's likely entirely possible your IP address could get throttled by the
|
||||||
|
source if you try and crawl extremely large amounts very quickly. **Try and be polite
|
||||||
|
with the smallest amount of indexing and concurrent downloads possible for your needs.**
|
||||||
|
|
||||||
|
|
||||||
# FAQ
|
# FAQ
|
||||||
|
|
||||||
@@ -222,7 +234,7 @@ automatically.
|
|||||||
|
|
||||||
### Does TubeSync support any other video platforms?
|
### Does TubeSync support any other video platforms?
|
||||||
|
|
||||||
At the moment, no. This is a first release. The library TubeSync uses that does most
|
At the moment, no. This is a pre-release. The library TubeSync uses that does most
|
||||||
of the downloading work, `youtube-dl`, supports many hundreds of video sources so it's
|
of the downloading work, `youtube-dl`, supports many hundreds of video sources so it's
|
||||||
likely more will be added to TubeSync if there is demand for it.
|
likely more will be added to TubeSync if there is demand for it.
|
||||||
|
|
||||||
@@ -236,7 +248,7 @@ your install is doing check the container logs.
|
|||||||
### Are there alerts when a download is complete?
|
### Are there alerts when a download is complete?
|
||||||
|
|
||||||
No, this feature is best served by existing services such as the execelent
|
No, this feature is best served by existing services such as the execelent
|
||||||
[tautulli](https://tautulli.com/) which can monitor your Plex server and send alerts
|
[Tautulli](https://tautulli.com/) which can monitor your Plex server and send alerts
|
||||||
that way.
|
that way.
|
||||||
|
|
||||||
### There's errors in my "tasks" tab!
|
### There's errors in my "tasks" tab!
|
||||||
@@ -293,23 +305,25 @@ Just `amd64` for the moment. Others may be made available if there is demand.
|
|||||||
# Advanced configuration
|
# Advanced configuration
|
||||||
|
|
||||||
There are a number of other environment variables you can set. These are, mostly,
|
There are a number of other environment variables you can set. These are, mostly,
|
||||||
**NOT** required to be set in the default container installation, they are mostly
|
**NOT** required to be set in the default container installation, they are really only
|
||||||
useful if you are manually installing TubeSync in some other environment. These are:
|
useful if you are manually installing TubeSync in some other environment. These are:
|
||||||
|
|
||||||
| Name | What | Example |
|
| Name | What | Example |
|
||||||
| ----------------- | ------------------------------------- | ---------------------------------- |
|
| ------------------------ | ------------------------------------------------------------ | ---------------------------------- |
|
||||||
| DJANGO_SECRET_KEY | Django secret key | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
| DJANGO_FORCE_SCRIPT_NAME | Django's FORCE_SCRIPT_NAME | /somepath |
|
||||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS | tubesync.example.com,otherhost.com |
|
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||||
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
||||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS | tubesync.example.com,otherhost.com |
|
||||||
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
||||||
|
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||||
|
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
||||||
|
|
||||||
|
|
||||||
# Manual, non-containerised, installation
|
# Manual, non-containerised, installation
|
||||||
|
|
||||||
As a relatively normal Django app you can run TubeSync without the container. Beyond
|
As a relatively normal Django app you can run TubeSync without the container. Beyond
|
||||||
the following rough guide you are on your own and should be knowledgeable about
|
following this rough guide you are on your own and should be knowledgeable about
|
||||||
installing and running WSGI-based Python web applications before attempting this.
|
installing and running WSGI-based Python web applications before attempting this.
|
||||||
|
|
||||||
1. Clone or download this repo
|
1. Clone or download this repo
|
||||||
|
|||||||
BIN
docs/dashboard-v0.5.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 170 KiB |
BIN
docs/media-item-v0.5.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 420 KiB |
BIN
docs/media-v0.5.png
Normal file
|
After Width: | Height: | Size: 666 KiB |
BIN
docs/media.png
|
Before Width: | Height: | Size: 530 KiB |
BIN
docs/source-v0.5.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
docs/source.png
|
Before Width: | Height: | Size: 137 KiB |
BIN
docs/sources-v0.5.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/sources.png
|
Before Width: | Height: | Size: 51 KiB |
@@ -4,7 +4,7 @@
|
|||||||
}
|
}
|
||||||
.help-text {
|
.help-text {
|
||||||
color: $form-help-text-colour;
|
color: $form-help-text-colour;
|
||||||
padding: 1rem 0 1rem 0;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ html {
|
|||||||
color: $text-colour;
|
color: $text-colour;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
|
|
||||||
background-color: $header-background-colour;
|
background-color: $header-background-colour;
|
||||||
|
|||||||
@@ -16,32 +16,36 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<div class="app">
|
||||||
<div class="container">
|
|
||||||
<a href="{% url 'sync:dashboard' %}">
|
|
||||||
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
|
||||||
<h1>TubeSync</h1>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ul>
|
<a href="{% url 'sync:dashboard' %}">
|
||||||
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
||||||
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
<h1>TubeSync</h1>
|
||||||
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
</a>
|
||||||
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
</div>
|
||||||
<li><a href="{% url 'sync:mediaservers' %}"><i class="fas fa-fw fa-stream"></i><span class="hide-on-med-and-down"> Media Servers</span></a></li>
|
</header>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
<nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
<ul>
|
||||||
</div>
|
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
||||||
</main>
|
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:mediaservers' %}"><i class="fas fa-fw fa-stream"></i><span class="hide-on-med-and-down"> Media Servers</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -14,3 +14,13 @@ def append_uri_params(uri, params):
|
|||||||
uri = str(uri)
|
uri = str(uri)
|
||||||
qs = urlencode(params)
|
qs = urlencode(params)
|
||||||
return urlunsplit(('', '', uri, qs, ''))
|
return urlunsplit(('', '', uri, qs, ''))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename(filename):
|
||||||
|
if not isinstance(filename, str):
|
||||||
|
raise ValueError(f'filename must be a str, got {type(filename)}')
|
||||||
|
to_scrub = '<>\/:*?"|'
|
||||||
|
for char in to_scrub:
|
||||||
|
filename = filename.replace(char, '')
|
||||||
|
filename = ''.join([c for c in filename if ord(c) > 30])
|
||||||
|
return ' '.join(filename.split())
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def get_best_audio_format(media):
|
|||||||
# No codecs matched
|
# No codecs matched
|
||||||
if media.source.can_fallback:
|
if media.source.can_fallback:
|
||||||
# Can fallback, find the next highest bitrate non-matching codec
|
# Can fallback, find the next highest bitrate non-matching codec
|
||||||
return False, audio_formats[0]
|
return False, audio_formats[0]['id']
|
||||||
else:
|
else:
|
||||||
# Can't fallback
|
# Can't fallback
|
||||||
return False, False
|
return False, False
|
||||||
|
|||||||
18
tubesync/sync/migrations/0005_auto_20201219_0312.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 03:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0004_source_media_format'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='source_type',
|
||||||
|
field=models.CharField(choices=[('c', 'YouTube channel'), ('i', 'YouTube channel by ID'), ('p', 'YouTube playlist')], db_index=True, default='c', help_text='Source type', max_length=1, verbose_name='source type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
tubesync/sync/migrations/0006_source_write_nfo.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 03:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0005_auto_20201219_0312'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='write_nfo',
|
||||||
|
field=models.BooleanField(default=False, help_text='Write an NFO file with the media, these may be detected and used by some media servers', verbose_name='write nfo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
tubesync/sync/migrations/0007_auto_20201219_0645.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 06:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0006_source_write_nfo'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='write_nfo',
|
||||||
|
field=models.BooleanField(default=False, help_text='Write an NFO file in XML with the media info, these may be detected and used by some media servers', verbose_name='write nfo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
tubesync/sync/migrations/0008_source_download_cap.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 06:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0007_auto_20201219_0645'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='download_cap',
|
||||||
|
field=models.IntegerField(choices=[(0, 'No cap'), (604800, '1 week (7 days)'), (2592000, '1 month (30 days)'), (7776000, '3 months (90 days)'), (15552000, '6 months (180 days)'), (31536000, '1 year (365 days)'), (63072000, '2 years (730 days)'), (94608000, '3 years (1095 days)'), (157680000, '5 years (1825 days)'), (315360000, '10 years (3650 days)')], default=0, help_text='Do not download media older than this capped date', verbose_name='download cap'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from xml.etree import ElementTree
|
||||||
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -10,6 +12,7 @@ from django.utils.text import slugify
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from common.errors import NoFormatException
|
from common.errors import NoFormatException
|
||||||
|
from common.utils import clean_filename
|
||||||
from .youtube import (get_media_info as get_youtube_media_info,
|
from .youtube import (get_media_info as get_youtube_media_info,
|
||||||
download_media as download_youtube_media)
|
download_media as download_youtube_media)
|
||||||
from .utils import seconds_to_timestr, parse_media_format
|
from .utils import seconds_to_timestr, parse_media_format
|
||||||
@@ -123,6 +126,18 @@ class Source(models.Model):
|
|||||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id',
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CapChoices(models.IntegerChoices):
|
||||||
|
CAP_NOCAP = 0, _('No cap')
|
||||||
|
CAP_7DAYS = 604800, _('1 week (7 days)')
|
||||||
|
CAP_30DAYS = 2592000, _('1 month (30 days)')
|
||||||
|
CAP_90DAYS = 7776000, _('3 months (90 days)')
|
||||||
|
CAP_6MONTHS = 15552000, _('6 months (180 days)')
|
||||||
|
CAP_1YEAR = 31536000, _('1 year (365 days)')
|
||||||
|
CAP_2YEARs = 63072000, _('2 years (730 days)')
|
||||||
|
CAP_3YEARs = 94608000, _('3 years (1095 days)')
|
||||||
|
CAP_5YEARs = 157680000, _('5 years (1825 days)')
|
||||||
|
CAP_10YEARS = 315360000, _('10 years (3650 days)')
|
||||||
|
|
||||||
class IndexSchedule(models.IntegerChoices):
|
class IndexSchedule(models.IntegerChoices):
|
||||||
EVERY_HOUR = 3600, _('Every hour')
|
EVERY_HOUR = 3600, _('Every hour')
|
||||||
EVERY_2_HOURS = 7200, _('Every 2 hours')
|
EVERY_2_HOURS = 7200, _('Every 2 hours')
|
||||||
@@ -186,7 +201,7 @@ class Source(models.Model):
|
|||||||
_('media format'),
|
_('media format'),
|
||||||
max_length=200,
|
max_length=200,
|
||||||
default=settings.MEDIA_FORMATSTR_DEFAULT,
|
default=settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
help_text=_('File format to use for saving files')
|
help_text=_('File format to use for saving files, detailed options at bottom of page.')
|
||||||
)
|
)
|
||||||
index_schedule = models.IntegerField(
|
index_schedule = models.IntegerField(
|
||||||
_('index schedule'),
|
_('index schedule'),
|
||||||
@@ -195,6 +210,12 @@ class Source(models.Model):
|
|||||||
default=IndexSchedule.EVERY_6_HOURS,
|
default=IndexSchedule.EVERY_6_HOURS,
|
||||||
help_text=_('Schedule of how often to index the source for new media')
|
help_text=_('Schedule of how often to index the source for new media')
|
||||||
)
|
)
|
||||||
|
download_cap = models.IntegerField(
|
||||||
|
_('download cap'),
|
||||||
|
choices=CapChoices.choices,
|
||||||
|
default=CapChoices.CAP_NOCAP,
|
||||||
|
help_text=_('Do not download media older than this capped date')
|
||||||
|
)
|
||||||
delete_old_media = models.BooleanField(
|
delete_old_media = models.BooleanField(
|
||||||
_('delete old media'),
|
_('delete old media'),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -253,6 +274,11 @@ class Source(models.Model):
|
|||||||
default=False,
|
default=False,
|
||||||
help_text=_('Copy thumbnails with the media, these may be detected and used by some media servers')
|
help_text=_('Copy thumbnails with the media, these may be detected and used by some media servers')
|
||||||
)
|
)
|
||||||
|
write_nfo = models.BooleanField(
|
||||||
|
_('write nfo'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Write an NFO file in XML with the media info, these may be detected and used by some media servers')
|
||||||
|
)
|
||||||
has_failed = models.BooleanField(
|
has_failed = models.BooleanField(
|
||||||
_('has failed'),
|
_('has failed'),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -283,6 +309,14 @@ class Source(models.Model):
|
|||||||
def is_video(self):
|
def is_video(self):
|
||||||
return not self.is_audio
|
return not self.is_audio
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_cap_date(self):
|
||||||
|
delta = self.download_cap
|
||||||
|
if delta > 0:
|
||||||
|
return timezone.now() - timedelta(seconds=delta)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extension(self):
|
def extension(self):
|
||||||
'''
|
'''
|
||||||
@@ -370,12 +404,16 @@ class Source(models.Model):
|
|||||||
'yyyymmdd': timezone.now().strftime('%Y%m%d'),
|
'yyyymmdd': timezone.now().strftime('%Y%m%d'),
|
||||||
'yyyy_mm_dd': timezone.now().strftime('%Y-%m-%d'),
|
'yyyy_mm_dd': timezone.now().strftime('%Y-%m-%d'),
|
||||||
'yyyy': timezone.now().strftime('%Y'),
|
'yyyy': timezone.now().strftime('%Y'),
|
||||||
|
'mm': timezone.now().strftime('%m'),
|
||||||
|
'dd': timezone.now().strftime('%d'),
|
||||||
'source': self.slugname,
|
'source': self.slugname,
|
||||||
'source_full': self.name,
|
'source_full': self.name,
|
||||||
'title': 'some-media-title-name',
|
'title': 'some-media-title-name',
|
||||||
'title_full': 'Some Media Title Name',
|
'title_full': 'Some Media Title Name',
|
||||||
'key': 'SoMeUnIqUiD',
|
'key': 'SoMeUnIqUiD',
|
||||||
'format': '-'.join(fmt),
|
'format': '-'.join(fmt),
|
||||||
|
'playlist_index': 1,
|
||||||
|
'playlist_title': 'Some Playlist Title',
|
||||||
'ext': self.extension,
|
'ext': self.extension,
|
||||||
'resolution': self.source_resolution if self.source_resolution else '',
|
'resolution': self.source_resolution if self.source_resolution else '',
|
||||||
'height': '720' if self.source_resolution else '',
|
'height': '720' if self.source_resolution else '',
|
||||||
@@ -389,7 +427,7 @@ class Source(models.Model):
|
|||||||
def get_example_media_format(self):
|
def get_example_media_format(self):
|
||||||
try:
|
try:
|
||||||
return self.media_format.format(**self.example_media_format_dict)
|
return self.media_format.format(**self.example_media_format_dict)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def index_media(self):
|
def index_media(self):
|
||||||
@@ -474,7 +512,47 @@ class Media(models.Model):
|
|||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats',
|
||||||
}
|
},
|
||||||
|
'categories': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'categories',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'categories',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'categories',
|
||||||
|
},
|
||||||
|
'rating': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'average_rating',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'average_rating',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'average_rating',
|
||||||
|
},
|
||||||
|
'age_limit': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'age_limit',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'age_limit',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'age_limit',
|
||||||
|
},
|
||||||
|
'uploader': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'uploader',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'uploader',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'uploader',
|
||||||
|
},
|
||||||
|
'upvotes': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'like_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'like_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'like_count',
|
||||||
|
},
|
||||||
|
'downvotes': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'dislike_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'dislike_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'dislike_count',
|
||||||
|
},
|
||||||
|
'playlist_index': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_index',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_index',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_index',
|
||||||
|
},
|
||||||
|
'playlist_title': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_title',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
STATE_UNKNOWN = 'unknown'
|
STATE_UNKNOWN = 'unknown'
|
||||||
STATE_SCHEDULED = 'scheduled'
|
STATE_SCHEDULED = 'scheduled'
|
||||||
@@ -761,8 +839,9 @@ class Media(models.Model):
|
|||||||
fmt.append(resolution)
|
fmt.append(resolution)
|
||||||
vcodec = vformat['vcodec'].lower()
|
vcodec = vformat['vcodec'].lower()
|
||||||
fmt.append(vcodec)
|
fmt.append(vcodec)
|
||||||
acodec = aformat['acodec'].lower()
|
if aformat:
|
||||||
fmt.append(acodec)
|
acodec = aformat['acodec'].lower()
|
||||||
|
fmt.append(acodec)
|
||||||
if vformat:
|
if vformat:
|
||||||
if vformat['is_60fps']:
|
if vformat['is_60fps']:
|
||||||
fps = '60fps'
|
fps = '60fps'
|
||||||
@@ -805,12 +884,16 @@ class Media(models.Model):
|
|||||||
'yyyymmdd': dateobj.strftime('%Y%m%d'),
|
'yyyymmdd': dateobj.strftime('%Y%m%d'),
|
||||||
'yyyy_mm_dd': dateobj.strftime('%Y-%m-%d'),
|
'yyyy_mm_dd': dateobj.strftime('%Y-%m-%d'),
|
||||||
'yyyy': dateobj.strftime('%Y'),
|
'yyyy': dateobj.strftime('%Y'),
|
||||||
|
'mm': dateobj.strftime('%m'),
|
||||||
|
'dd': dateobj.strftime('%d'),
|
||||||
'source': self.source.slugname,
|
'source': self.source.slugname,
|
||||||
'source_full': self.source.name,
|
'source_full': self.source.name,
|
||||||
'title': self.slugtitle,
|
'title': self.slugtitle,
|
||||||
'title_full': self.title,
|
'title_full': clean_filename(self.title),
|
||||||
'key': self.key,
|
'key': self.key,
|
||||||
'format': '-'.join(display_format['format']),
|
'format': '-'.join(display_format['format']),
|
||||||
|
'playlist_index': self.playlist_index,
|
||||||
|
'playlist_title': self.playlist_title,
|
||||||
'ext': self.source.extension,
|
'ext': self.source.extension,
|
||||||
'resolution': display_format['resolution'],
|
'resolution': display_format['resolution'],
|
||||||
'height': display_format['height'],
|
'height': display_format['height'],
|
||||||
@@ -879,26 +962,78 @@ class Media(models.Model):
|
|||||||
return seconds_to_timestr(duration)
|
return seconds_to_timestr(duration)
|
||||||
return '??:??:??'
|
return '??:??:??'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def categories(self):
|
||||||
|
field = self.get_metadata_field('categories')
|
||||||
|
return self.loaded_metadata.get(field, [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rating(self):
|
||||||
|
field = self.get_metadata_field('rating')
|
||||||
|
return self.loaded_metadata.get(field, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def votes(self):
|
||||||
|
field = self.get_metadata_field('upvotes')
|
||||||
|
upvotes = self.loaded_metadata.get(field, 0)
|
||||||
|
field = self.get_metadata_field('downvotes')
|
||||||
|
downvotes = self.loaded_metadata.get(field, 0)
|
||||||
|
return upvotes + downvotes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def age_limit(self):
|
||||||
|
field = self.get_metadata_field('age_limit')
|
||||||
|
return self.loaded_metadata.get(field, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uploader(self):
|
||||||
|
field = self.get_metadata_field('uploader')
|
||||||
|
return self.loaded_metadata.get(field, '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formats(self):
|
def formats(self):
|
||||||
field = self.get_metadata_field('formats')
|
field = self.get_metadata_field('formats')
|
||||||
return self.loaded_metadata.get(field, [])
|
return self.loaded_metadata.get(field, [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlist_index(self):
|
||||||
|
field = self.get_metadata_field('playlist_index')
|
||||||
|
return self.loaded_metadata.get(field, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlist_title(self):
|
||||||
|
field = self.get_metadata_field('playlist_title')
|
||||||
|
return self.loaded_metadata.get(field, '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
# If a media_file has been downloaded use its existing name
|
# Create a suitable filename from the source media_format
|
||||||
if self.media_file:
|
|
||||||
return os.path.basename(self.media_file.name)
|
|
||||||
# Otherwise, create a suitable filename from the source media_format
|
|
||||||
media_format = str(self.source.media_format)
|
media_format = str(self.source.media_format)
|
||||||
media_details = self.format_dict
|
media_details = self.format_dict
|
||||||
return media_format.format(**media_details)
|
return media_format.format(**media_details)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbname(self):
|
||||||
|
filename = self.filename
|
||||||
|
prefix, ext = os.path.splitext(filename)
|
||||||
|
return f'{prefix}.jpg'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbpath(self):
|
||||||
|
return self.source.directory_path / self.thumbname
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nfoname(self):
|
||||||
|
filename = self.filename
|
||||||
|
prefix, ext = os.path.splitext(filename)
|
||||||
|
return f'{prefix}.nfo'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nfopath(self):
|
||||||
|
return self.source.directory_path / self.nfoname
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def directory_path(self):
|
def directory_path(self):
|
||||||
# If a media_file has been downloaded use its existing directory
|
|
||||||
if self.media_file:
|
|
||||||
return os.path.dirname(self.media_file.name)
|
|
||||||
# Otherwise, create a suitable filename from the source media_format
|
# Otherwise, create a suitable filename from the source media_format
|
||||||
media_format = str(self.source.media_format)
|
media_format = str(self.source.media_format)
|
||||||
media_details = self.format_dict
|
media_details = self.format_dict
|
||||||
@@ -921,6 +1056,103 @@ class Media(models.Model):
|
|||||||
return False
|
return False
|
||||||
return os.path.exists(self.media_file.path)
|
return os.path.exists(self.media_file.path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nfoxml(self):
|
||||||
|
'''
|
||||||
|
Returns an NFO formatted (prettified) XML string.
|
||||||
|
'''
|
||||||
|
nfo = ElementTree.Element('episodedetails')
|
||||||
|
nfo.text = '\n '
|
||||||
|
# title = media metadata title
|
||||||
|
title = nfo.makeelement('title', {})
|
||||||
|
title.text = str(self.name).strip()
|
||||||
|
title.tail = '\n '
|
||||||
|
nfo.append(title)
|
||||||
|
# showtitle = source name
|
||||||
|
showtitle = nfo.makeelement('showtitle', {})
|
||||||
|
showtitle.text = str(self.source.name).strip()
|
||||||
|
showtitle.tail = '\n '
|
||||||
|
nfo.append(showtitle)
|
||||||
|
# ratings = media metadata youtube rating
|
||||||
|
value = nfo.makeelement('value', {})
|
||||||
|
value.text = str(self.rating)
|
||||||
|
value.tail = '\n '
|
||||||
|
votes = nfo.makeelement('votes', {})
|
||||||
|
votes.text = str(self.votes)
|
||||||
|
votes.tail = '\n '
|
||||||
|
rating_attrs = OrderedDict()
|
||||||
|
rating_attrs['name'] = 'youtube'
|
||||||
|
rating_attrs['max'] = '5'
|
||||||
|
rating_attrs['default'] = 'True'
|
||||||
|
rating = nfo.makeelement('rating', rating_attrs)
|
||||||
|
rating.text = '\n '
|
||||||
|
rating.append(value)
|
||||||
|
rating.append(votes)
|
||||||
|
rating.tail = '\n '
|
||||||
|
ratings = nfo.makeelement('ratings', {})
|
||||||
|
ratings.text = '\n '
|
||||||
|
ratings.append(rating)
|
||||||
|
ratings.tail = '\n '
|
||||||
|
nfo.append(ratings)
|
||||||
|
# plot = media metadata description
|
||||||
|
plot = nfo.makeelement('plot', {})
|
||||||
|
plot.text = str(self.description).strip()
|
||||||
|
plot.tail = '\n '
|
||||||
|
nfo.append(plot)
|
||||||
|
# thumb = local path to media thumbnail
|
||||||
|
thumb = nfo.makeelement('thumb', {})
|
||||||
|
thumb.text = self.thumbname if self.source.copy_thumbnails else ''
|
||||||
|
thumb.tail = '\n '
|
||||||
|
nfo.append(thumb)
|
||||||
|
# mpaa = media metadata age requirement
|
||||||
|
mpaa = nfo.makeelement('mpaa', {})
|
||||||
|
mpaa.text = str(self.age_limit)
|
||||||
|
mpaa.tail = '\n '
|
||||||
|
nfo.append(mpaa)
|
||||||
|
# runtime = media metadata duration in seconds
|
||||||
|
runtime = nfo.makeelement('runtime', {})
|
||||||
|
runtime.text = str(self.duration)
|
||||||
|
runtime.tail = '\n '
|
||||||
|
nfo.append(runtime)
|
||||||
|
# id = media key
|
||||||
|
idn = nfo.makeelement('id', {})
|
||||||
|
idn.text = str(self.key).strip()
|
||||||
|
idn.tail = '\n '
|
||||||
|
nfo.append(idn)
|
||||||
|
# uniqueid = media key
|
||||||
|
uniqueid_attrs = OrderedDict()
|
||||||
|
uniqueid_attrs['type'] = 'youtube'
|
||||||
|
uniqueid_attrs['default'] = 'True'
|
||||||
|
uniqueid = nfo.makeelement('uniqueid', uniqueid_attrs)
|
||||||
|
uniqueid.text = str(self.key).strip()
|
||||||
|
uniqueid.tail = '\n '
|
||||||
|
nfo.append(uniqueid)
|
||||||
|
# studio = media metadata uploader
|
||||||
|
studio = nfo.makeelement('studio', {})
|
||||||
|
studio.text = str(self.uploader).strip()
|
||||||
|
studio.tail = '\n '
|
||||||
|
nfo.append(studio)
|
||||||
|
# aired = media metadata uploaded date
|
||||||
|
aired = nfo.makeelement('aired', {})
|
||||||
|
upload_date = self.upload_date
|
||||||
|
aired.text = upload_date.strftime('%Y-%m-%d') if upload_date else ''
|
||||||
|
aired.tail = '\n '
|
||||||
|
nfo.append(aired)
|
||||||
|
# dateadded = date and time media was created in tubesync
|
||||||
|
dateadded = nfo.makeelement('dateadded', {})
|
||||||
|
dateadded.text = self.created.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
dateadded.tail = '\n '
|
||||||
|
nfo.append(dateadded)
|
||||||
|
# genre = any media metadata categories if they exist
|
||||||
|
for category_str in self.categories:
|
||||||
|
genre = nfo.makeelement('genre', {})
|
||||||
|
genre.text = str(category_str).strip()
|
||||||
|
genre.tail = '\n '
|
||||||
|
nfo.append(genre)
|
||||||
|
nfo[-1].tail = '\n'
|
||||||
|
# Return XML tree as a prettified string
|
||||||
|
return ElementTree.tostring(nfo, encoding='utf8', method='xml').decode('utf8')
|
||||||
|
|
||||||
def get_download_state(self, task=None):
|
def get_download_state(self, task=None):
|
||||||
if self.downloaded:
|
if self.downloaded:
|
||||||
return self.STATE_DOWNLOADED
|
return self.STATE_DOWNLOADED
|
||||||
|
|||||||
@@ -145,20 +145,6 @@ def media_pre_delete(sender, instance, **kwargs):
|
|||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
||||||
(str(instance.pk), thumbnail_url))
|
(str(instance.pk), thumbnail_url))
|
||||||
# Delete media thumbnail if it exists
|
|
||||||
if instance.thumb:
|
|
||||||
log.info(f'Deleting thumbnail for: {instance} path: {instance.thumb.path}')
|
|
||||||
delete_file(instance.thumb.path)
|
|
||||||
# Delete the media file if it exists
|
|
||||||
if instance.media_file:
|
|
||||||
filepath = instance.media_file.path
|
|
||||||
log.info(f'Deleting media for: {instance} path: {filepath}')
|
|
||||||
delete_file(filepath)
|
|
||||||
# Delete thumbnail copy if it exists
|
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
|
||||||
log.info(f'Deleting thumbnail for: {instance} path: {thumbpath}')
|
|
||||||
delete_file(thumbpath)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=Media)
|
@receiver(post_delete, sender=Media)
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ from background_task.models import Task, CompletedTask
|
|||||||
from common.logger import log
|
from common.logger import log
|
||||||
from common.errors import NoMediaException, DownloadFailedException
|
from common.errors import NoMediaException, DownloadFailedException
|
||||||
from .models import Source, Media, MediaServer
|
from .models import Source, Media, MediaServer
|
||||||
from .utils import get_remote_image, resize_image_to_height, delete_file
|
from .utils import (get_remote_image, resize_image_to_height, delete_file,
|
||||||
|
write_text_file)
|
||||||
|
|
||||||
|
|
||||||
def get_hash(task_name, pk):
|
def get_hash(task_name, pk):
|
||||||
@@ -186,6 +187,14 @@ def index_source_task(source_id):
|
|||||||
else:
|
else:
|
||||||
log.error(f'Media has no upload date, skipping: {source} / {media}')
|
log.error(f'Media has no upload date, skipping: {source} / {media}')
|
||||||
continue
|
continue
|
||||||
|
# If the source has a download cap date check the upload date is allowed
|
||||||
|
max_cap_age = source.download_cap_date
|
||||||
|
if max_cap_age:
|
||||||
|
if media.published < max_cap_age:
|
||||||
|
# Media was published after the cap date, skip it
|
||||||
|
log.warn(f'Media: {source} / {media} is older than cap age '
|
||||||
|
f'{max_cap_age}, skipping')
|
||||||
|
continue
|
||||||
# If the source has a cut-off check the upload date is within the allowed delta
|
# If the source has a cut-off check the upload date is within the allowed delta
|
||||||
if source.delete_old_media and source.days_to_keep > 0:
|
if source.delete_old_media and source.days_to_keep > 0:
|
||||||
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
||||||
@@ -306,7 +315,7 @@ def download_media(media_id):
|
|||||||
media.downloaded_audio_codec = cformat['acodec']
|
media.downloaded_audio_codec = cformat['acodec']
|
||||||
if cformat['vcodec']:
|
if cformat['vcodec']:
|
||||||
# Combined
|
# Combined
|
||||||
media.downloaded_format = vformat['format']
|
media.downloaded_format = cformat['format']
|
||||||
media.downloaded_height = cformat['height']
|
media.downloaded_height = cformat['height']
|
||||||
media.downloaded_width = cformat['width']
|
media.downloaded_width = cformat['width']
|
||||||
media.downloaded_video_codec = cformat['vcodec']
|
media.downloaded_video_codec = cformat['vcodec']
|
||||||
@@ -317,11 +326,13 @@ def download_media(media_id):
|
|||||||
media.save()
|
media.save()
|
||||||
# If selected, copy the thumbnail over as well
|
# If selected, copy the thumbnail over as well
|
||||||
if media.source.copy_thumbnails and media.thumb:
|
if media.source.copy_thumbnails and media.thumb:
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
|
||||||
log.info(f'Copying media thumbnail from: {media.thumb.path} '
|
log.info(f'Copying media thumbnail from: {media.thumb.path} '
|
||||||
f'to: {thumbpath}')
|
f'to: {media.thumbpath}')
|
||||||
copyfile(media.thumb.path, thumbpath)
|
copyfile(media.thumb.path, media.thumbpath)
|
||||||
|
# If selected, write an NFO file
|
||||||
|
if media.source.write_nfo:
|
||||||
|
log.info(f'Writing media NFO file to: to: {media.nfopath}')
|
||||||
|
write_text_file(media.nfopath, media.nfoxml)
|
||||||
# Schedule a task to update media servers
|
# Schedule a task to update media servers
|
||||||
for mediaserver in MediaServer.objects.all():
|
for mediaserver in MediaServer.objects.all():
|
||||||
log.info(f'Scheduling media server updates')
|
log.info(f'Scheduling media server updates')
|
||||||
|
|||||||
@@ -11,18 +11,28 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{yyyymmdd}</td>
|
<td>{yyyymmdd}</td>
|
||||||
<td>Media publish date in YYYYMMDD</td>
|
<td>Media publish date in YYYYMMDD</td>
|
||||||
<td>20210101</td>
|
<td>20210131</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{yyyy_mm_dd}</td>
|
<td>{yyyy_mm_dd}</td>
|
||||||
<td>Media publish date in YYYY-MM-DD</td>
|
<td>Media publish date in YYYY-MM-DD</td>
|
||||||
<td>2021-01-01</td>
|
<td>2021-01-31</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{yyyy}</td>
|
<td>{yyyy}</td>
|
||||||
<td>Media publish year in YYYY</td>
|
<td>Media publish year in YYYY</td>
|
||||||
<td>2021</td>
|
<td>2021</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{mm}</td>
|
||||||
|
<td>Media publish year in MM</td>
|
||||||
|
<td>01</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{dd}</td>
|
||||||
|
<td>Media publish year in DD</td>
|
||||||
|
<td>31</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{source}</td>
|
<td>{source}</td>
|
||||||
<td>Lower case source name, max 80 chars</td>
|
<td>Lower case source name, max 80 chars</td>
|
||||||
@@ -53,6 +63,16 @@
|
|||||||
<td>Media format string</td>
|
<td>Media format string</td>
|
||||||
<td>720p-avc1-mp4a</td>
|
<td>720p-avc1-mp4a</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{playlist_index}</td>
|
||||||
|
<td>Playlist index of media, if it's in a playlist</td>
|
||||||
|
<td>12</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{playlist_title}</td>
|
||||||
|
<td>Playlist title of media, if it's in a playlist</td>
|
||||||
|
<td>Some Playlist</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ext}</td>
|
<td>{ext}</td>
|
||||||
<td>File extension</td>
|
<td>File extension</td>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
<div class="collection">
|
<div class="collection">
|
||||||
{% for media in latest_downloads %}
|
{% for media in latest_downloads %}
|
||||||
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
||||||
<div class="truncate"><strong>{{ media.name }}</strong> ({{ media.source }})</div>
|
<div class="truncate"><strong>{{ media.name }}</strong></div>
|
||||||
<div class="truncate"><strong>{{ media.download_date|timesince:now }}</strong> ago from "{{ media.source.name }}"</div>
|
<div class="truncate"><strong>{{ media.download_date|timesince:now }}</strong> ago from "{{ media.source.name }}"</div>
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
{% for media in largest_downloads %}
|
{% for media in largest_downloads %}
|
||||||
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
||||||
<div class="truncate">{{ media.name }}</div>
|
<div class="truncate">{{ media.name }}</div>
|
||||||
<div class="truncate"><strong>{{ media.downloaded_filesize|filesizeformat }}</strong>{% if media.downloaded_format %} in {{ media.downloaded_format }}{% endif %}</div>
|
<div class="truncate"><strong>{{ media.downloaded_filesize|filesizeformat }}</strong>{% if media.downloaded_format %} in {{ media.downloaded_format }}{% endif %} from "{{ media.source.name }}"</div>
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="collection-item">No media has been downloaded.</span>
|
<span class="collection-item">No media has been downloaded.</span>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
{% include 'mediaformatvars.html' %}
|
{% include 'sync/_mediaformatvars.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
{% include 'mediaformatvars.html' %}
|
{% include 'sync/_mediaformatvars.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@
|
|||||||
<td class="hide-on-small-only">Example filename</td>
|
<td class="hide-on-small-only">Example filename</td>
|
||||||
<td><span class="hide-on-med-and-up">Example filename<br></span><strong>{{ source.get_example_media_format }}</strong></td>
|
<td><span class="hide-on-med-and-up">Example filename<br></span><strong>{{ source.get_example_media_format }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if source.download_cap > 0 %}
|
||||||
|
<tr title="Do not download videos older than this cap">
|
||||||
|
<td class="hide-on-small-only">Download cap</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Download cap<br></span><strong>{{ source.get_download_cap_display }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
<tr title="Schedule of how often to index the source for new media">
|
<tr title="Schedule of how often to index the source for new media">
|
||||||
<td class="hide-on-small-only">Index schedule</td>
|
<td class="hide-on-small-only">Index schedule</td>
|
||||||
<td><span class="hide-on-med-and-up">Index schedule<br></span><strong>{{ source.get_index_schedule_display }}</strong></td>
|
<td><span class="hide-on-med-and-up">Index schedule<br></span><strong>{{ source.get_index_schedule_display }}</strong></td>
|
||||||
@@ -97,6 +103,10 @@
|
|||||||
<td class="hide-on-small-only">Copy thumbnails?</td>
|
<td class="hide-on-small-only">Copy thumbnails?</td>
|
||||||
<td><span class="hide-on-med-and-up">Copy thumbnails?<br></span><strong>{% if source.copy_thumbnails %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
<td><span class="hide-on-med-and-up">Copy thumbnails?<br></span><strong>{% if source.copy_thumbnails %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Should an NFO file be written with the media?">
|
||||||
|
<td class="hide-on-small-only">Write NFO?</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Write NFO?<br></span><strong>{% if source.write_nfo %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
|
</tr>
|
||||||
{% if source.delete_old_media and source.days_to_keep > 0 %}
|
{% if source.delete_old_media and source.days_to_keep > 0 %}
|
||||||
<tr title="Days after which your media from this source will be locally deleted">
|
<tr title="Days after which your media from this source will be locally deleted">
|
||||||
<td class="hide-on-small-only">Delete old media</td>
|
<td class="hide-on-small-only">Delete old media</td>
|
||||||
|
|||||||
17
tubesync/sync/testdata/metadata.json
vendored
@@ -3,15 +3,24 @@
|
|||||||
"upload_date":"20170911",
|
"upload_date":"20170911",
|
||||||
"license":null,
|
"license":null,
|
||||||
"creator":null,
|
"creator":null,
|
||||||
"title":"no fancy stuff",
|
"title":"no fancy stuff title",
|
||||||
"alt_title":null,
|
"alt_title":null,
|
||||||
"description":"no fancy stuff",
|
"description":"no fancy stuff desc",
|
||||||
"categories":[],
|
"average_rating": 1.2345,
|
||||||
|
"dislike_count": 123,
|
||||||
|
"like_count": 456,
|
||||||
|
"playlist_index": 789,
|
||||||
|
"playlist_title": "test playlist",
|
||||||
|
"uploader": "test uploader",
|
||||||
|
"categories":[
|
||||||
|
"test category 1",
|
||||||
|
"test category 2"
|
||||||
|
],
|
||||||
"tags":[],
|
"tags":[],
|
||||||
"subtitles":{},
|
"subtitles":{},
|
||||||
"automatic_captions":{},
|
"automatic_captions":{},
|
||||||
"duration":401.0,
|
"duration":401.0,
|
||||||
"age_limit":0,
|
"age_limit":50,
|
||||||
"annotations":null,
|
"annotations":null,
|
||||||
"chapters":null,
|
"chapters":null,
|
||||||
"formats":[
|
"formats":[
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
from xml.etree import ElementTree
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -161,6 +163,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'name': 'testname',
|
'name': 'testname',
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
'download_cap': 0,
|
||||||
'index_schedule': 3600,
|
'index_schedule': 3600,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -201,6 +204,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'name': 'testname',
|
'name': 'testname',
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
'download_cap': 0,
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -229,6 +233,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'name': 'testname',
|
'name': 'testname',
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
'download_cap': 0,
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -430,7 +435,6 @@ class FrontEndTestCase(TestCase):
|
|||||||
response = c.get('/tasks-completed')
|
response = c.get('/tasks-completed')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
def test_mediasevrers(self):
|
def test_mediasevrers(self):
|
||||||
# Media servers overview page
|
# Media servers overview page
|
||||||
c = Client()
|
c = Client()
|
||||||
@@ -507,6 +511,12 @@ class FilepathTestCase(TestCase):
|
|||||||
self.source.media_format = 'test-{yyyy}'
|
self.source.media_format = 'test-{yyyy}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-' + timezone.now().strftime('%Y'))
|
'test-' + timezone.now().strftime('%Y'))
|
||||||
|
self.source.media_format = 'test-{mm}'
|
||||||
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
|
'test-' + timezone.now().strftime('%m'))
|
||||||
|
self.source.media_format = 'test-{dd}'
|
||||||
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
|
'test-' + timezone.now().strftime('%d'))
|
||||||
self.source.media_format = 'test-{source}'
|
self.source.media_format = 'test-{source}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-' + self.source.slugname)
|
'test-' + self.source.slugname)
|
||||||
@@ -525,6 +535,12 @@ class FilepathTestCase(TestCase):
|
|||||||
self.source.media_format = 'test-{format}'
|
self.source.media_format = 'test-{format}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-1080p-vp9-opus')
|
'test-1080p-vp9-opus')
|
||||||
|
self.source.media_format = 'test-{playlist_index}'
|
||||||
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
|
'test-1')
|
||||||
|
self.source.media_format = 'test-{playlist_title}'
|
||||||
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
|
'test-Some Playlist Title')
|
||||||
self.source.media_format = 'test-{ext}'
|
self.source.media_format = 'test-{ext}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-' + self.source.extension)
|
'test-' + self.source.extension)
|
||||||
@@ -582,7 +598,79 @@ class FilepathTestCase(TestCase):
|
|||||||
self.source.media_format = ('{title}_{key}_{resolution}-{height}x{width}-'
|
self.source.media_format = ('{title}_{key}_{resolution}-{height}x{width}-'
|
||||||
'{acodec}-{vcodec}-{fps}fps-{hdr}.{ext}')
|
'{acodec}-{vcodec}-{fps}fps-{hdr}.{ext}')
|
||||||
self.assertEqual(test_media.filename,
|
self.assertEqual(test_media.filename,
|
||||||
'no-fancy-stuff_test_720p-720x1280-opus-vp9-30fps-hdr.mkv')
|
('no-fancy-stuff-title_test_720p-720x1280-opus'
|
||||||
|
'-vp9-30fps-hdr.mkv'))
|
||||||
|
|
||||||
|
|
||||||
|
class MediaTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Disable general logging for test case
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
# Add a test source
|
||||||
|
self.source = Source.objects.create(
|
||||||
|
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
||||||
|
key='testkey',
|
||||||
|
name='testname',
|
||||||
|
directory='testdirectory',
|
||||||
|
media_format=settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
index_schedule=3600,
|
||||||
|
delete_old_media=False,
|
||||||
|
days_to_keep=14,
|
||||||
|
source_resolution=Source.SOURCE_RESOLUTION_1080P,
|
||||||
|
source_vcodec=Source.SOURCE_VCODEC_VP9,
|
||||||
|
source_acodec=Source.SOURCE_ACODEC_OPUS,
|
||||||
|
prefer_60fps=False,
|
||||||
|
prefer_hdr=False,
|
||||||
|
fallback=Source.FALLBACK_FAIL
|
||||||
|
)
|
||||||
|
# Add some test media
|
||||||
|
self.media = Media.objects.create(
|
||||||
|
key='mediakey',
|
||||||
|
source=self.source,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
# Fix a created datetime for predictable testing
|
||||||
|
self.media.created = datetime(year=2020, month=1, day=1, hour=1,
|
||||||
|
minute=1, second=1)
|
||||||
|
|
||||||
|
def test_nfo(self):
|
||||||
|
expected_nfo = [
|
||||||
|
"<?xml version='1.0' encoding='utf8'?>",
|
||||||
|
'<episodedetails>',
|
||||||
|
' <title>no fancy stuff title</title>',
|
||||||
|
' <showtitle>testname</showtitle>',
|
||||||
|
' <ratings>',
|
||||||
|
' <rating default="True" max="5" name="youtube">',
|
||||||
|
' <value>1.2345</value>',
|
||||||
|
' <votes>579</votes>',
|
||||||
|
' </rating>',
|
||||||
|
' </ratings>',
|
||||||
|
' <plot>no fancy stuff desc</plot>',
|
||||||
|
' <thumb />', # media.thumbfile is empty without media existing
|
||||||
|
' <mpaa>50</mpaa>',
|
||||||
|
' <runtime>401</runtime>',
|
||||||
|
' <id>mediakey</id>',
|
||||||
|
' <uniqueid default="True" type="youtube">mediakey</uniqueid>',
|
||||||
|
' <studio>test uploader</studio>',
|
||||||
|
' <aired>2017-09-11</aired>',
|
||||||
|
' <dateadded>2020-01-01 01:01:01</dateadded>',
|
||||||
|
' <genre>test category 1</genre>',
|
||||||
|
' <genre>test category 2</genre>',
|
||||||
|
'</episodedetails>',
|
||||||
|
]
|
||||||
|
expected_tree = ElementTree.fromstring('\n'.join(expected_nfo))
|
||||||
|
nfo_tree = ElementTree.fromstring(self.media.nfoxml)
|
||||||
|
# Check each node with attribs in expected_tree is present in test_nfo
|
||||||
|
for expected_node in expected_tree:
|
||||||
|
# Ignore checking <genre>, only tag we may have multiple of
|
||||||
|
if expected_node.tag == 'genre':
|
||||||
|
continue
|
||||||
|
# Find the same node in the NFO XML tree
|
||||||
|
nfo_node = nfo_tree.find(expected_node.tag)
|
||||||
|
self.assertEqual(expected_node.attrib, nfo_node.attrib)
|
||||||
|
self.assertEqual(expected_node.tag, nfo_node.tag)
|
||||||
|
self.assertEqual(expected_node.text, nfo_node.text)
|
||||||
|
|
||||||
|
|
||||||
class FormatMatchingTestCase(TestCase):
|
class FormatMatchingTestCase(TestCase):
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ def file_is_editable(filepath):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def write_text_file(filepath, filedata):
|
||||||
|
if not isinstance(filedata, str):
|
||||||
|
raise ValueError(f'filedata must be a str, got "{type(filedata)}"')
|
||||||
|
with open(filepath, 'wt') as f:
|
||||||
|
bytes_written = f.write(filedata)
|
||||||
|
return bytes_written
|
||||||
|
|
||||||
|
|
||||||
def delete_file(filepath):
|
def delete_file(filepath):
|
||||||
if file_is_editable(filepath):
|
if file_is_editable(filepath):
|
||||||
return os.remove(filepath)
|
return os.remove(filepath)
|
||||||
|
|||||||
@@ -274,9 +274,9 @@ class AddSourceView(CreateView):
|
|||||||
template_name = 'sync/source-add.html'
|
template_name = 'sync/source-add.html'
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||||
'index_schedule', 'delete_old_media', 'days_to_keep',
|
'index_schedule', 'download_cap', 'delete_old_media', 'days_to_keep',
|
||||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
||||||
'prefer_hdr', 'fallback', 'copy_thumbnails')
|
'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||||
errors = {
|
errors = {
|
||||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||||
'errors or is empty. Check the table at the end of '
|
'errors or is empty. Check the table at the end of '
|
||||||
@@ -334,8 +334,8 @@ class SourceView(DetailView):
|
|||||||
messages = {
|
messages = {
|
||||||
'source-created': _('Your new source has been created. If you have added a '
|
'source-created': _('Your new source has been created. If you have added a '
|
||||||
'very large source such as a channel with hundreds of '
|
'very large source such as a channel with hundreds of '
|
||||||
'videos it can take several minutes for media to start '
|
'videos it can take several minutes or up to an hour '
|
||||||
'to appear.'),
|
'for media to start to appear.'),
|
||||||
'source-updated': _('Your source has been updated.'),
|
'source-updated': _('Your source has been updated.'),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,9 +365,9 @@ class UpdateSourceView(UpdateView):
|
|||||||
template_name = 'sync/source-update.html'
|
template_name = 'sync/source-update.html'
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||||
'index_schedule', 'delete_old_media', 'days_to_keep',
|
'index_schedule', 'download_cap', 'delete_old_media', 'days_to_keep',
|
||||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
||||||
'prefer_hdr', 'fallback', 'copy_thumbnails')
|
'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||||
errors = {
|
errors = {
|
||||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||||
'errors or is empty. Check the table at the end of '
|
'errors or is empty. Check the table at the end of '
|
||||||
@@ -411,7 +411,12 @@ class DeleteSourceView(DeleteView, FormMixin):
|
|||||||
source = self.get_object()
|
source = self.get_object()
|
||||||
for media in Media.objects.filter(source=source):
|
for media in Media.objects.filter(source=source):
|
||||||
if media.media_file:
|
if media.media_file:
|
||||||
|
# Delete the media file
|
||||||
delete_file(media.media_file.name)
|
delete_file(media.media_file.name)
|
||||||
|
# Delete thumbnail copy if it exists
|
||||||
|
delete_file(media.thumbpath)
|
||||||
|
# Delete NFO file if it exists
|
||||||
|
delete_file(media.nfopath)
|
||||||
return super().post(request, *args, **kwargs)
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
@@ -556,13 +561,12 @@ class MediaRedownloadView(FormView, SingleObjectMixin):
|
|||||||
self.object.thumb = None
|
self.object.thumb = None
|
||||||
# If the media file exists on disk, delete it
|
# If the media file exists on disk, delete it
|
||||||
if self.object.media_file_exists:
|
if self.object.media_file_exists:
|
||||||
filepath = self.object.media_file.path
|
delete_file(self.object.media_file.path)
|
||||||
delete_file(filepath)
|
|
||||||
self.object.media_file = None
|
self.object.media_file = None
|
||||||
# If the media has an associated thumbnail copied, also delete it
|
# If the media has an associated thumbnail copied, also delete it
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
delete_file(self.object.thumbpath)
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
# If the media has an associated NFO file with it, also delete it
|
||||||
delete_file(thumbpath)
|
delete_file(self.object.nfopath)
|
||||||
# Reset all download data
|
# Reset all download data
|
||||||
self.object.downloaded = False
|
self.object.downloaded = False
|
||||||
self.object.downloaded_audio_codec = None
|
self.object.downloaded_audio_codec = None
|
||||||
@@ -602,13 +606,12 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
|||||||
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
|
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
|
||||||
# If the media file exists on disk, delete it
|
# If the media file exists on disk, delete it
|
||||||
if self.object.media_file_exists:
|
if self.object.media_file_exists:
|
||||||
filepath = self.object.media_file.path
|
|
||||||
delete_file(self.object.media_file.path)
|
delete_file(self.object.media_file.path)
|
||||||
self.object.media_file = None
|
self.object.media_file = None
|
||||||
# If the media has an associated thumbnail copied, also delete it
|
# If the media has an associated thumbnail copied, also delete it
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
delete_file(self.object.thumbpath)
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
# If the media has an associated NFO file with it, also delete it
|
||||||
delete_file(thumbpath)
|
delete_file(self.object.nfopath)
|
||||||
# Reset all download data
|
# Reset all download data
|
||||||
self.object.downloaded = False
|
self.object.downloaded = False
|
||||||
self.object.downloaded_audio_codec = None
|
self.object.downloaded_audio_codec = None
|
||||||
@@ -1008,7 +1011,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
|
|||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
data = super().get_context_data(*args, **kwargs)
|
data = super().get_context_data(*args, **kwargs)
|
||||||
data['server_help'] = self.object.help_html
|
data['server_help'] = self.object.get_help_html
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ SECRET_KEY = str(os.getenv('DJANGO_SECRET_KEY', 'tubesync-django-secret'))
|
|||||||
ALLOWED_HOSTS_STR = str(os.getenv('TUBESYNC_HOSTS', '127.0.0.1,localhost'))
|
ALLOWED_HOSTS_STR = str(os.getenv('TUBESYNC_HOSTS', '127.0.0.1,localhost'))
|
||||||
ALLOWED_HOSTS = ALLOWED_HOSTS_STR.split(',')
|
ALLOWED_HOSTS = ALLOWED_HOSTS_STR.split(',')
|
||||||
DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
|
DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
|
||||||
|
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', None)
|
||||||
|
|
||||||
|
|
||||||
TIME_ZONE = os.getenv('TZ', 'UTC')
|
TIME_ZONE = os.getenv('TZ', 'UTC')
|
||||||
@@ -27,6 +28,12 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEFAULT_THREADS = 1
|
||||||
|
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8
|
||||||
|
BACKGROUND_TASK_ASYNC_THREADS = int(os.getenv('TUBESYNC_WORKERS', DEFAULT_THREADS))
|
||||||
|
if BACKGROUND_TASK_ASYNC_THREADS > MAX_BACKGROUND_TASK_ASYNC_THREADS:
|
||||||
|
BACKGROUND_TASK_ASYNC_THREADS = MAX_BACKGROUND_TASK_ASYNC_THREADS
|
||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = CONFIG_BASE_DIR / 'media'
|
MEDIA_ROOT = CONFIG_BASE_DIR / 'media'
|
||||||
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR
|
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
|||||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
VERSION = 0.5
|
VERSION = 0.8
|
||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
@@ -41,6 +41,7 @@ MIDDLEWARE = [
|
|||||||
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'tubesync.urls'
|
ROOT_URLCONF = 'tubesync.urls'
|
||||||
|
FORCE_SCRIPT_NAME = None
|
||||||
|
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
@@ -119,8 +120,9 @@ HEALTHCHECK_ALLOWED_IPS = ('127.0.0.1',)
|
|||||||
|
|
||||||
MAX_ATTEMPTS = 10 # Number of times tasks will be retried
|
MAX_ATTEMPTS = 10 # Number of times tasks will be retried
|
||||||
MAX_RUN_TIME = 1800 # Maximum amount of time in seconds a task can run
|
MAX_RUN_TIME = 1800 # Maximum amount of time in seconds a task can run
|
||||||
BACKGROUND_TASK_RUN_ASYNC = False # Run tasks async in the background
|
BACKGROUND_TASK_RUN_ASYNC = True # Run tasks async in the background
|
||||||
BACKGROUND_TASK_ASYNC_THREADS = 1 # Number of async tasks to run at once
|
BACKGROUND_TASK_ASYNC_THREADS = 1 # Number of async tasks to run at once
|
||||||
|
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8 # For sanity reasons
|
||||||
BACKGROUND_TASK_PRIORITY_ORDERING = 'ASC' # Use 'niceness' task priority ordering
|
BACKGROUND_TASK_PRIORITY_ORDERING = 'ASC' # Use 'niceness' task priority ordering
|
||||||
COMPLETED_TASKS_DAYS_TO_KEEP = 7 # Number of days to keep completed tasks
|
COMPLETED_TASKS_DAYS_TO_KEEP = 7 # Number of days to keep completed tasks
|
||||||
|
|
||||||
|
|||||||