Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
799c0fce39 | ||
|
|
2f324f28a9 | ||
|
|
895bfe6f87 | ||
|
|
e0669b107d | ||
|
|
0dc201b293 | ||
|
|
82fa0f6bce | ||
|
|
8b93cb4a59 | ||
|
|
647254d7f7 | ||
|
|
3567e20600 | ||
|
|
5348e25303 | ||
|
|
749df3f7bb | ||
|
|
2c2f53e5b2 | ||
|
|
06cfafb803 | ||
|
|
f5a37f2e86 | ||
|
|
36747a47e0 | ||
|
|
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 |
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [meeb]
|
||||
86
Pipfile.lock
generated
@@ -39,11 +39,11 @@
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
|
||||
"sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03"
|
||||
"sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f",
|
||||
"sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.4"
|
||||
"version": "==3.1.6"
|
||||
},
|
||||
"django-appconf": {
|
||||
"hashes": [
|
||||
@@ -134,44 +134,48 @@
|
||||
},
|
||||
"pillow": {
|
||||
"hashes": [
|
||||
"sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a",
|
||||
"sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae",
|
||||
"sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce",
|
||||
"sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e",
|
||||
"sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140",
|
||||
"sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb",
|
||||
"sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021",
|
||||
"sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6",
|
||||
"sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302",
|
||||
"sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c",
|
||||
"sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271",
|
||||
"sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09",
|
||||
"sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3",
|
||||
"sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015",
|
||||
"sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3",
|
||||
"sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544",
|
||||
"sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8",
|
||||
"sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792",
|
||||
"sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0",
|
||||
"sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3",
|
||||
"sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8",
|
||||
"sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11",
|
||||
"sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7",
|
||||
"sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11",
|
||||
"sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e",
|
||||
"sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039",
|
||||
"sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5",
|
||||
"sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72"
|
||||
"sha256:165c88bc9d8dba670110c689e3cc5c71dbe4bfb984ffa7cbebf1fac9554071d6",
|
||||
"sha256:1d208e670abfeb41b6143537a681299ef86e92d2a3dac299d3cd6830d5c7bded",
|
||||
"sha256:22d070ca2e60c99929ef274cfced04294d2368193e935c5d6febfd8b601bf865",
|
||||
"sha256:2353834b2c49b95e1313fb34edf18fca4d57446675d05298bb694bca4b194174",
|
||||
"sha256:39725acf2d2e9c17356e6835dccebe7a697db55f25a09207e38b835d5e1bc032",
|
||||
"sha256:3de6b2ee4f78c6b3d89d184ade5d8fa68af0848f9b6b6da2b9ab7943ec46971a",
|
||||
"sha256:47c0d93ee9c8b181f353dbead6530b26980fe4f5485aa18be8f1fd3c3cbc685e",
|
||||
"sha256:5e2fe3bb2363b862671eba632537cd3a823847db4d98be95690b7e382f3d6378",
|
||||
"sha256:604815c55fd92e735f9738f65dabf4edc3e79f88541c221d292faec1904a4b17",
|
||||
"sha256:6c5275bd82711cd3dcd0af8ce0bb99113ae8911fc2952805f1d012de7d600a4c",
|
||||
"sha256:731ca5aabe9085160cf68b2dbef95fc1991015bc0a3a6ea46a371ab88f3d0913",
|
||||
"sha256:7612520e5e1a371d77e1d1ca3a3ee6227eef00d0a9cddb4ef7ecb0b7396eddf7",
|
||||
"sha256:7916cbc94f1c6b1301ac04510d0881b9e9feb20ae34094d3615a8a7c3db0dcc0",
|
||||
"sha256:81c3fa9a75d9f1afafdb916d5995633f319db09bd773cb56b8e39f1e98d90820",
|
||||
"sha256:887668e792b7edbfb1d3c9d8b5d8c859269a0f0eba4dda562adb95500f60dbba",
|
||||
"sha256:93a473b53cc6e0b3ce6bf51b1b95b7b1e7e6084be3a07e40f79b42e83503fbf2",
|
||||
"sha256:96d4dc103d1a0fa6d47c6c55a47de5f5dafd5ef0114fa10c85a1fd8e0216284b",
|
||||
"sha256:a3d3e086474ef12ef13d42e5f9b7bbf09d39cf6bd4940f982263d6954b13f6a9",
|
||||
"sha256:b02a0b9f332086657852b1f7cb380f6a42403a6d9c42a4c34a561aa4530d5234",
|
||||
"sha256:b09e10ec453de97f9a23a5aa5e30b334195e8d2ddd1ce76cc32e52ba63c8b31d",
|
||||
"sha256:b6f00ad5ebe846cc91763b1d0c6d30a8042e02b2316e27b05de04fa6ec831ec5",
|
||||
"sha256:bba80df38cfc17f490ec651c73bb37cd896bc2400cfba27d078c2135223c1206",
|
||||
"sha256:c3d911614b008e8a576b8e5303e3db29224b455d3d66d1b2848ba6ca83f9ece9",
|
||||
"sha256:ca20739e303254287138234485579b28cb0d524401f83d5129b5ff9d606cb0a8",
|
||||
"sha256:cb192176b477d49b0a327b2a5a4979552b7a58cd42037034316b8018ac3ebb59",
|
||||
"sha256:cdbbe7dff4a677fb555a54f9bc0450f2a21a93c5ba2b44e09e54fcb72d2bd13d",
|
||||
"sha256:cf6e33d92b1526190a1de904df21663c46a456758c0424e4f947ae9aa6088bf7",
|
||||
"sha256:d355502dce85ade85a2511b40b4c61a128902f246504f7de29bbeec1ae27933a",
|
||||
"sha256:d673c4990acd016229a5c1c4ee8a9e6d8f481b27ade5fc3d95938697fa443ce0",
|
||||
"sha256:dc577f4cfdda354db3ae37a572428a90ffdbe4e51eda7849bf442fb803f09c9b",
|
||||
"sha256:dd9eef866c70d2cbbea1ae58134eaffda0d4bfea403025f4db6859724b18ab3d",
|
||||
"sha256:f50e7a98b0453f39000619d845be8b06e611e56ee6e8186f7f60c3b1e2f0feae"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==8.0.1"
|
||||
"version": "==8.1.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
|
||||
"sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
|
||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
||||
],
|
||||
"version": "==2020.4"
|
||||
"version": "==2021.1"
|
||||
},
|
||||
"rcssmin": {
|
||||
"hashes": [
|
||||
@@ -221,10 +225,10 @@
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
|
||||
"sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
|
||||
"sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80",
|
||||
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
|
||||
],
|
||||
"version": "==1.26.2"
|
||||
"version": "==1.26.3"
|
||||
},
|
||||
"whitenoise": {
|
||||
"hashes": [
|
||||
@@ -236,11 +240,11 @@
|
||||
},
|
||||
"youtube-dl": {
|
||||
"hashes": [
|
||||
"sha256:65968065e66966955dc79fad9251565fcc982566118756da624bd21467f3a04c",
|
||||
"sha256:eaa859f15b6897bec21474b7787dc958118c8088e1f24d4ef1d58eab13188958"
|
||||
"sha256:b390cddbd4d605bd887d0d4063988cef0fa13f916d2e1e3564badbb22504d754",
|
||||
"sha256:e7d48cd42f3081e1e0064e69f31f2856508ef31c0fc80eeebd8e70c6a031a24d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2020.12.14"
|
||||
"version": "==2021.2.10"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
|
||||
72
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,
|
||||
hopefully, quite reliable.
|
||||
|
||||
|
||||
# Latest container image
|
||||
|
||||
```yaml
|
||||
ghcr.io/meeb/tubesync:v0.5
|
||||
ghcr.io/meeb/tubesync:v0.9
|
||||
```
|
||||
|
||||
**NOTE: the `:latest` tag does exist, but will contain in-development commits and may
|
||||
be broken. Use at your own risk.**
|
||||
|
||||
# Screenshots
|
||||
|
||||
### Dashboard
|
||||
|
||||

|
||||

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

|
||||

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

|
||||

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

|
||||

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

|
||||

|
||||
|
||||
|
||||
# Requirements
|
||||
@@ -98,7 +102,7 @@ Finally, download and run the container:
|
||||
|
||||
```bash
|
||||
# Pull a versioned image
|
||||
$ docker pull ghcr.io/meeb/tubesync:v0.5
|
||||
$ docker pull ghcr.io/meeb/tubesync:v0.9
|
||||
# Start the container using your user ID and group ID
|
||||
$ docker run \
|
||||
-d \
|
||||
@@ -109,7 +113,7 @@ $ docker run \
|
||||
-v /some/directory/tubesync-config:/config \
|
||||
-v /some/directory/tubesync-downloads:/downloads \
|
||||
-p 4848:4848 \
|
||||
ghcr.io/meeb/tubesync:v0.5
|
||||
ghcr.io/meeb/tubesync:v0.9
|
||||
```
|
||||
|
||||
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
|
||||
tubesync:
|
||||
image: ghcr.io/meeb/tubesync:v0.5
|
||||
image: ghcr.io/meeb/tubesync:v0.9
|
||||
container_name: tubesync
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -192,14 +196,24 @@ $ docker logs --follow tubesync
|
||||
```
|
||||
|
||||
|
||||
# Advanced usage guides
|
||||
|
||||
Once you're happy using TubeSync there are some advanced usage guides for more complex
|
||||
and less common features:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
# Warnings
|
||||
|
||||
### 1. Index frequency
|
||||
|
||||
It's a good idea to add sources with as low an index frequency as possible. This is the
|
||||
duration between indexes of the source. An index is when TubeSync checks to see
|
||||
It's a good idea to add sources with as long of an index frequency as possible. This is
|
||||
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
|
||||
long as possible, 24 hours if possible.
|
||||
long as possible, up to 24 hours.
|
||||
|
||||
|
||||
### 2. Indexing massive channels
|
||||
@@ -209,6 +223,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
|
||||
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
|
||||
|
||||
@@ -222,7 +244,7 @@ automatically.
|
||||
|
||||
### 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
|
||||
likely more will be added to TubeSync if there is demand for it.
|
||||
|
||||
@@ -236,7 +258,7 @@ your install is doing check the container logs.
|
||||
### Are there alerts when a download is complete?
|
||||
|
||||
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.
|
||||
|
||||
### There's errors in my "tasks" tab!
|
||||
@@ -293,23 +315,25 @@ Just `amd64` for the moment. Others may be made available if there is demand.
|
||||
# Advanced configuration
|
||||
|
||||
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:
|
||||
|
||||
| Name | What | Example |
|
||||
| ----------------- | ------------------------------------- | ---------------------------------- |
|
||||
| DJANGO_SECRET_KEY | Django secret key | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS | tubesync.example.com,otherhost.com |
|
||||
| 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 |
|
||||
| Name | What | Example |
|
||||
| ------------------------ | ------------------------------------------------------------ | ---------------------------------- |
|
||||
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||
| DJANGO_FORCE_SCRIPT_NAME | Django's FORCE_SCRIPT_NAME | /somepath |
|
||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS | tubesync.example.com,otherhost.com |
|
||||
| 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
|
||||
|
||||
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.
|
||||
|
||||
1. Clone or download this repo
|
||||
|
||||
@@ -19,8 +19,8 @@ chown -R app:app /app/common/static && \
|
||||
chmod -R 0750 /app/common/static && \
|
||||
chown -R app:app /app/static && \
|
||||
chmod -R 0750 /app/static && \
|
||||
find /app -type f -exec chmod 640 {} \; && \
|
||||
chmod +x /app/healthcheck.py
|
||||
find /app -type f ! -iname healthcheck.py -exec chmod 640 {} \; && \
|
||||
chmod 0755 /app/healthcheck.py
|
||||
|
||||
# Run migrations
|
||||
exec s6-setuidgid app \
|
||||
|
||||
37
docs/create-missing-metadata.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# TubeSync
|
||||
|
||||
## Advanced usage guide - creating missing metadata
|
||||
|
||||
This is a new feature in v0.9 of TubeSync and later. It allows you to create or
|
||||
re-create missing metadata in your TubeSync download directories for missing `nfo`
|
||||
files and thumbnails.
|
||||
|
||||
If you add a source with "write NFO files" or "copy thumbnails" disabled, download
|
||||
some media and then update the source to write NFO files or copy thumbnails then
|
||||
TubeSync will not automatically retroactively attempt to copy or create your missing
|
||||
metadata files. You can use a special one-off command to manually write missing
|
||||
metadata files to the correct locations.
|
||||
|
||||
## Requirements
|
||||
|
||||
You have added a source without metadata writing enabled, downloaded some media, then
|
||||
updated the source to enable metadata writing.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Run the batch metadata sync command
|
||||
|
||||
Execute the following Django command:
|
||||
|
||||
`./manage.py sync-missing-metadata`
|
||||
|
||||
When deploying TubeSync inside a container, you can execute this with:
|
||||
|
||||
`docker exec -ti tubesync python3 /app/manage.py sync-missing-metadata`
|
||||
|
||||
This command will log what its doing to the terminal when you run it.
|
||||
|
||||
Internally, this command loops over all your sources which have been saved with
|
||||
"write NFO files" or "copy thumbnails" enabled. Then, loops over all media saved to
|
||||
that source and confirms that the appropriate thumbnail files have been copied over and
|
||||
the NFO file has been written if enabled.
|
||||
BIN
docs/dashboard-v0.5.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 170 KiB |
81
docs/import-existing-media.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# TubeSync
|
||||
|
||||
## Advanced usage guide - importing existing media
|
||||
|
||||
This is a new feature in v0.9 of TubeSync and later. It allows you to mark existing
|
||||
downloaded media as "downloaded" in TubeSync. You can use this feature if, for example,
|
||||
you already have an extensive catalogue of downloaded media which you want to mark
|
||||
as downloaded into TubeSync so TubeSync doesn't re-download media you already have.
|
||||
|
||||
## Requirements
|
||||
|
||||
Your existing downloaded media MUST contain the unique ID. For YouTube videos, this is
|
||||
means the YouTube video ID MUST be in the filename.
|
||||
|
||||
Supported extensions to be imported are .m4a, .ogg, .mkv, .mp3, .mp4 and .avi. Your
|
||||
media you want to import must end in one of these file extensions.
|
||||
|
||||
## Caveats
|
||||
|
||||
As TubeSync does not probe media and your existing media may be re-encoded or in
|
||||
different formats to what is available in the current media metadata there is no way
|
||||
for TubeSync to know what codecs, resolution, bitrate etc. your imported media is in.
|
||||
Any manually imported existing local media will display blank boxes for this
|
||||
information on the TubeSync interface as it's unavailable.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Add your source to TubeSync
|
||||
|
||||
Add your source to TubeSync, such as a YouTube channel. **Make sure you untick the
|
||||
"download media" checkbox.**
|
||||
|
||||
This will allow TubeSync to index all the available media on your source, but won't
|
||||
start downloading any media.
|
||||
|
||||
### 2. Wait
|
||||
|
||||
Wait for all the media on your source to be indexed. This may take some time.
|
||||
|
||||
### 3. Move your existing media into TubeSync
|
||||
|
||||
You now need to move your existing media into TubeSync. You need to move the media
|
||||
files into the correct download directories created by TubeSync. For example, if you
|
||||
have downloaded videos for a YouTube channel "TestChannel", you would have added this
|
||||
as a source called TestChannel and in a directory called test-channel in Tubesync. It
|
||||
would have a download directory created on disk at:
|
||||
|
||||
`/path/to/downloads/test-channel`
|
||||
|
||||
You would move all of your pre-existing videos you downloaded outside of TubeSync for
|
||||
this channel into this directory.
|
||||
|
||||
In short, your existing media needs to be moved into the correct TubeSync source
|
||||
directory to be detected.
|
||||
|
||||
This is required so TubeSync can known which Source to link the media to.
|
||||
|
||||
### 4. Run the batch import command
|
||||
|
||||
Execute the following Django command:
|
||||
|
||||
`./manage.py import-existing-media`
|
||||
|
||||
When deploying TubeSync inside a container, you can execute this with:
|
||||
|
||||
`docker exec -ti tubesync python3 /app/manage.py import-existing-media`
|
||||
|
||||
This command will log what its doing to the terminal when you run it.
|
||||
|
||||
Internally, `import-existing-media` looks for the unique media key (for YouTube, this
|
||||
is the YouTube video ID) in the filename and detects the source to link it to based
|
||||
on the directory the media file is inside.
|
||||
|
||||
|
||||
### 5. Re-enable downloading at the source
|
||||
|
||||
Edit your source and re-enable / tick the "download media" option. This will allow
|
||||
TubeSync to download any missing media you did not manually import.
|
||||
|
||||
Note that TubeSync will still get screenshots write `nfo` files etc. for files you
|
||||
manually import if enabled at the source level.
|
||||
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 {
|
||||
color: $form-help-text-colour;
|
||||
padding: 1rem 0 1rem 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
label {
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -5,6 +5,13 @@ html {
|
||||
color: $text-colour;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
header {
|
||||
|
||||
background-color: $header-background-colour;
|
||||
|
||||
@@ -16,32 +16,36 @@
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="container">
|
||||
<a href="{% url 'sync:dashboard' %}">
|
||||
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
||||
<h1>TubeSync</h1>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="app">
|
||||
|
||||
<nav>
|
||||
<div class="container">
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
<header>
|
||||
<div class="container">
|
||||
<a href="{% url 'sync:dashboard' %}">
|
||||
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
||||
<h1>TubeSync</h1>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
<nav>
|
||||
<div class="container">
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
<div class="container">
|
||||
|
||||
@@ -14,3 +14,13 @@ def append_uri_params(uri, params):
|
||||
uri = str(uri)
|
||||
qs = urlencode(params)
|
||||
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())
|
||||
|
||||
0
tubesync/healthcheck.py
Normal file → Executable file
@@ -7,7 +7,7 @@ class SourceAdmin(admin.ModelAdmin):
|
||||
|
||||
ordering = ('-created',)
|
||||
list_display = ('uuid', 'name', 'source_type', 'last_crawl',
|
||||
'has_failed')
|
||||
'download_media', 'has_failed')
|
||||
readonly_fields = ('uuid', 'created')
|
||||
search_fields = ('uuid', 'key', 'name')
|
||||
|
||||
|
||||
55
tubesync/sync/management/commands/import-existing-media.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from common.logger import log
|
||||
from sync.models import Source, Media
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
help = ('Scans download media directories for media not yet downloaded and ',
|
||||
'marks them as downloaded')
|
||||
extra_extensions = ['mp3', 'mp4', 'avi']
|
||||
|
||||
def handle(self, *args, **options):
|
||||
log.info('Building directory to Source map...')
|
||||
dirmap = {}
|
||||
for s in Source.objects.all():
|
||||
dirmap[s.directory_path] = s
|
||||
log.info(f'Scanning sources...')
|
||||
file_extensions = list(Source.EXTENSIONS) + self.extra_extensions
|
||||
for sourceroot, source in dirmap.items():
|
||||
media = list(Media.objects.filter(source=source, downloaded=False,
|
||||
skip=False))
|
||||
if not media:
|
||||
log.info(f'Source "{source}" has no missing media')
|
||||
continue
|
||||
log.info(f'Scanning Source "{source}" directory for media to '
|
||||
f'import: {sourceroot}, looking for {len(media)} '
|
||||
f'undownloaded and unskipped items')
|
||||
on_disk = []
|
||||
for (root, dirs, files) in os.walk(sourceroot):
|
||||
rootpath = Path(root)
|
||||
for filename in files:
|
||||
filepart, ext = os.path.splitext(filename)
|
||||
if ext.startswith('.'):
|
||||
ext = ext[1:]
|
||||
ext = ext.strip().lower()
|
||||
if ext not in file_extensions:
|
||||
continue
|
||||
on_disk.append(str(rootpath / filename))
|
||||
filemap = {}
|
||||
for item in media:
|
||||
for filepath in on_disk:
|
||||
if item.key in filepath:
|
||||
# The unique item key is in the file name on disk, map it to
|
||||
# the undownloaded media item
|
||||
filemap[filepath] = item
|
||||
continue
|
||||
for filepath, item in filemap.items():
|
||||
log.info(f'Matched on-disk file: {filepath} '
|
||||
f'to media item: {item.source} / {item}')
|
||||
item.media_file.name = filepath
|
||||
item.downloaded = True
|
||||
item.save()
|
||||
log.info('Done')
|
||||
34
tubesync/sync/management/commands/sync-missing-metadata.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
from shutil import copyfile
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db.models import Q
|
||||
from common.logger import log
|
||||
from sync.models import Source, Media
|
||||
from sync.utils import write_text_file
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
help = 'Syncs missing metadata (such as nfo files) if source settings are updated'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
log.info('Syncing missing metadata...')
|
||||
sources = Source.objects.filter(Q(copy_thumbnails=True) | Q(write_nfo=True))
|
||||
for source in sources.order_by('name'):
|
||||
log.info(f'Finding media for source: {source}')
|
||||
for item in Media.objects.filter(source=source, downloaded=True):
|
||||
log.info(f'Checking media for missing metadata: {source} / {item}')
|
||||
thumbpath = item.thumbpath
|
||||
if not thumbpath.is_file():
|
||||
if item.thumb:
|
||||
log.info(f'Copying missing thumbnail from: {item.thumb.path} '
|
||||
f'to: {thumbpath}')
|
||||
copyfile(item.thumb.path, thumbpath)
|
||||
else:
|
||||
log.error(f'Tried to copy missing thumbnail for {item} but '
|
||||
f'the thumbnail has not been downloaded')
|
||||
nfopath = item.nfopath
|
||||
if not nfopath.is_file():
|
||||
log.info(f'Writing missing NFO file: {nfopath}')
|
||||
write_text_file(nfopath, item.nfoxml)
|
||||
log.info('Done')
|
||||
@@ -66,7 +66,7 @@ def get_best_audio_format(media):
|
||||
# No codecs matched
|
||||
if media.source.can_fallback:
|
||||
# Can fallback, find the next highest bitrate non-matching codec
|
||||
return False, audio_formats[0]
|
||||
return False, audio_formats[0]['id']
|
||||
else:
|
||||
# Can't fallback
|
||||
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'),
|
||||
),
|
||||
]
|
||||
30
tubesync/sync/migrations/0009_auto_20210218_0442.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-18 04:42
|
||||
|
||||
import django.core.files.storage
|
||||
from django.db import migrations, models
|
||||
import sync.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sync', '0008_source_download_cap'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='download_media',
|
||||
field=models.BooleanField(default=True, help_text='Download media from this source, if not selected the source will only be indexed', verbose_name='download media'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='media',
|
||||
name='media_file',
|
||||
field=models.FileField(blank=True, help_text='Media file', max_length=200, null=True, storage=django.core.files.storage.FileSystemStorage(location='/home/meeb/Repos/github.com/meeb/tubesync/tubesync/downloads'), upload_to=sync.models.get_media_file_path, verbose_name='media file'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='source',
|
||||
name='media_format',
|
||||
field=models.CharField(default='{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}', help_text='File format to use for saving files, detailed options at bottom of page.', max_length=200, verbose_name='media format'),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import uuid
|
||||
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 django.conf import settings
|
||||
from django.db import models
|
||||
@@ -10,6 +12,7 @@ from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from common.errors import NoFormatException
|
||||
from common.utils import clean_filename
|
||||
from .youtube import (get_media_info as get_youtube_media_info,
|
||||
download_media as download_youtube_media)
|
||||
from .utils import seconds_to_timestr, parse_media_format
|
||||
@@ -98,6 +101,11 @@ class Source(models.Model):
|
||||
(FALLBACK_NEXT_BEST_HD, _('Get next best resolution but at least HD'))
|
||||
)
|
||||
|
||||
EXTENSION_M4A = 'm4a'
|
||||
EXTENSION_OGG = 'ogg'
|
||||
EXTENSION_MKV = 'mkv'
|
||||
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV)
|
||||
|
||||
# Fontawesome icons used for the source on the front end
|
||||
ICONS = {
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
|
||||
@@ -110,6 +118,12 @@ class Source(models.Model):
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}',
|
||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
||||
}
|
||||
# Format used to create indexable URLs
|
||||
INDEX_URLS = {
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}/videos',
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}/videos',
|
||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
||||
}
|
||||
# Callback functions to get a list of media from the source
|
||||
INDEXERS = {
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
|
||||
@@ -123,6 +137,18 @@ class Source(models.Model):
|
||||
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):
|
||||
EVERY_HOUR = 3600, _('Every hour')
|
||||
EVERY_2_HOURS = 7200, _('Every 2 hours')
|
||||
@@ -186,7 +212,7 @@ class Source(models.Model):
|
||||
_('media format'),
|
||||
max_length=200,
|
||||
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'),
|
||||
@@ -195,6 +221,17 @@ class Source(models.Model):
|
||||
default=IndexSchedule.EVERY_6_HOURS,
|
||||
help_text=_('Schedule of how often to index the source for new media')
|
||||
)
|
||||
download_media = models.BooleanField(
|
||||
_('download media'),
|
||||
default=True,
|
||||
help_text=_('Download media from this source, if not selected the source will only be indexed')
|
||||
)
|
||||
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'),
|
||||
default=False,
|
||||
@@ -253,6 +290,11 @@ class Source(models.Model):
|
||||
default=False,
|
||||
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'),
|
||||
default=False,
|
||||
@@ -283,6 +325,14 @@ class Source(models.Model):
|
||||
def is_video(self):
|
||||
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
|
||||
def extension(self):
|
||||
'''
|
||||
@@ -294,23 +344,32 @@ class Source(models.Model):
|
||||
'''
|
||||
if self.is_audio:
|
||||
if self.source_acodec == self.SOURCE_ACODEC_MP4A:
|
||||
return 'm4a'
|
||||
return self.EXTENSION_M4A
|
||||
elif self.source_acodec == self.SOURCE_ACODEC_OPUS:
|
||||
return 'ogg'
|
||||
return self.EXTENSION_OGG
|
||||
else:
|
||||
raise ValueError('Unable to choose audio extension, uknown acodec')
|
||||
else:
|
||||
return 'mkv'
|
||||
return self.EXTENSION_MKV
|
||||
|
||||
@classmethod
|
||||
def create_url(obj, source_type, key):
|
||||
url = obj.URLS.get(source_type)
|
||||
return url.format(key=key)
|
||||
|
||||
@classmethod
|
||||
def create_index_url(obj, source_type, key):
|
||||
url = obj.INDEX_URLS.get(source_type)
|
||||
return url.format(key=key)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return Source.create_url(self.source_type, self.key)
|
||||
|
||||
@property
|
||||
def index_url(self):
|
||||
return Source.create_index_url(self.source_type, self.key)
|
||||
|
||||
@property
|
||||
def format_summary(self):
|
||||
if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO:
|
||||
@@ -370,12 +429,16 @@ class Source(models.Model):
|
||||
'yyyymmdd': timezone.now().strftime('%Y%m%d'),
|
||||
'yyyy_mm_dd': timezone.now().strftime('%Y-%m-%d'),
|
||||
'yyyy': timezone.now().strftime('%Y'),
|
||||
'mm': timezone.now().strftime('%m'),
|
||||
'dd': timezone.now().strftime('%d'),
|
||||
'source': self.slugname,
|
||||
'source_full': self.name,
|
||||
'title': 'some-media-title-name',
|
||||
'title_full': 'Some Media Title Name',
|
||||
'key': 'SoMeUnIqUiD',
|
||||
'format': '-'.join(fmt),
|
||||
'playlist_index': 1,
|
||||
'playlist_title': 'Some Playlist Title',
|
||||
'ext': self.extension,
|
||||
'resolution': self.source_resolution if self.source_resolution else '',
|
||||
'height': '720' if self.source_resolution else '',
|
||||
@@ -389,7 +452,7 @@ class Source(models.Model):
|
||||
def get_example_media_format(self):
|
||||
try:
|
||||
return self.media_format.format(**self.example_media_format_dict)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
return ''
|
||||
|
||||
def index_media(self):
|
||||
@@ -399,25 +462,10 @@ class Source(models.Model):
|
||||
indexer = self.INDEXERS.get(self.source_type, None)
|
||||
if not callable(indexer):
|
||||
raise Exception(f'Source type f"{self.source_type}" has no indexer')
|
||||
response = indexer(self.url)
|
||||
|
||||
# Account for nested playlists, such as a channel of playlists of playlists
|
||||
def _recurse_playlists(playlist):
|
||||
videos = []
|
||||
if not playlist:
|
||||
return videos
|
||||
entries = playlist.get('entries', [])
|
||||
for entry in entries:
|
||||
if not entry:
|
||||
continue
|
||||
subentries = entry.get('entries', [])
|
||||
if subentries:
|
||||
videos = videos + _recurse_playlists(entry)
|
||||
else:
|
||||
videos.append(entry)
|
||||
return videos
|
||||
|
||||
return _recurse_playlists(response)
|
||||
response = indexer(self.index_url)
|
||||
if not isinstance(response, dict):
|
||||
return []
|
||||
return response.get('entries', [])
|
||||
|
||||
|
||||
def get_media_thumb_path(instance, filename):
|
||||
@@ -443,6 +491,12 @@ class Media(models.Model):
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/watch?v={key}',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/watch?v={key}',
|
||||
}
|
||||
# Callback functions to get a list of media from the source
|
||||
INDEXERS = {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: get_youtube_media_info,
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info,
|
||||
}
|
||||
# Maps standardised names to names used in source metdata
|
||||
METADATA_FIELDS = {
|
||||
'upload_date': {
|
||||
@@ -474,20 +528,64 @@ class Media(models.Model):
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '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_SCHEDULED = 'scheduled'
|
||||
STATE_DOWNLOADING = 'downloading'
|
||||
STATE_DOWNLOADED = 'downloaded'
|
||||
STATE_SKIPPED = 'skipped'
|
||||
STATE_DISABLED_AT_SOURCE = 'source-disabled'
|
||||
STATE_ERROR = 'error'
|
||||
STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED,
|
||||
STATE_ERROR)
|
||||
STATE_SKIPPED, STATE_DISABLED_AT_SOURCE, STATE_ERROR)
|
||||
STATE_ICONS = {
|
||||
STATE_UNKNOWN: '<i class="far fa-question-circle" title="Unknown download state"></i>',
|
||||
STATE_SCHEDULED: '<i class="far fa-clock" title="Scheduled to download"></i>',
|
||||
STATE_DOWNLOADING: '<i class="fas fa-download" title="Downloading now"></i>',
|
||||
STATE_DOWNLOADED: '<i class="far fa-check-circle" title="Downloaded"></i>',
|
||||
STATE_SKIPPED: '<i class="fas fa-exclamation-circle" title="Skipped"></i>',
|
||||
STATE_DISABLED_AT_SOURCE: '<i class="fas fa-stop-circle" title="Media downloading disabled at source"></i>',
|
||||
STATE_ERROR: '<i class="fas fa-exclamation-triangle" title="Error downloading"></i>',
|
||||
}
|
||||
|
||||
@@ -761,8 +859,9 @@ class Media(models.Model):
|
||||
fmt.append(resolution)
|
||||
vcodec = vformat['vcodec'].lower()
|
||||
fmt.append(vcodec)
|
||||
acodec = aformat['acodec'].lower()
|
||||
fmt.append(acodec)
|
||||
if aformat:
|
||||
acodec = aformat['acodec'].lower()
|
||||
fmt.append(acodec)
|
||||
if vformat:
|
||||
if vformat['is_60fps']:
|
||||
fps = '60fps'
|
||||
@@ -805,12 +904,16 @@ class Media(models.Model):
|
||||
'yyyymmdd': dateobj.strftime('%Y%m%d'),
|
||||
'yyyy_mm_dd': dateobj.strftime('%Y-%m-%d'),
|
||||
'yyyy': dateobj.strftime('%Y'),
|
||||
'mm': dateobj.strftime('%m'),
|
||||
'dd': dateobj.strftime('%d'),
|
||||
'source': self.source.slugname,
|
||||
'source_full': self.source.name,
|
||||
'title': self.slugtitle,
|
||||
'title_full': self.title,
|
||||
'title_full': clean_filename(self.title),
|
||||
'key': self.key,
|
||||
'format': '-'.join(display_format['format']),
|
||||
'playlist_index': self.playlist_index,
|
||||
'playlist_title': self.playlist_title,
|
||||
'ext': self.source.extension,
|
||||
'resolution': display_format['resolution'],
|
||||
'height': display_format['height'],
|
||||
@@ -821,6 +924,10 @@ class Media(models.Model):
|
||||
'hdr': display_format['hdr'],
|
||||
}
|
||||
|
||||
@property
|
||||
def has_metadata(self):
|
||||
return self.metadata is not None
|
||||
|
||||
@property
|
||||
def loaded_metadata(self):
|
||||
try:
|
||||
@@ -879,26 +986,82 @@ class Media(models.Model):
|
||||
return seconds_to_timestr(duration)
|
||||
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)
|
||||
if not isinstance(upvotes, int):
|
||||
upvotes = 0
|
||||
field = self.get_metadata_field('downvotes')
|
||||
downvotes = self.loaded_metadata.get(field, 0)
|
||||
if not isinstance(downvotes, int):
|
||||
downvotes = 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
|
||||
def formats(self):
|
||||
field = self.get_metadata_field('formats')
|
||||
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
|
||||
def filename(self):
|
||||
# If a media_file has been downloaded use its existing name
|
||||
if self.media_file:
|
||||
return os.path.basename(self.media_file.name)
|
||||
# Otherwise, create a suitable filename from the source media_format
|
||||
# Create a suitable filename from the source media_format
|
||||
media_format = str(self.source.media_format)
|
||||
media_details = self.format_dict
|
||||
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
|
||||
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
|
||||
media_format = str(self.source.media_format)
|
||||
media_details = self.format_dict
|
||||
@@ -921,6 +1084,103 @@ class Media(models.Model):
|
||||
return False
|
||||
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):
|
||||
if self.downloaded:
|
||||
return self.STATE_DOWNLOADED
|
||||
@@ -931,6 +1191,10 @@ class Media(models.Model):
|
||||
return self.STATE_ERROR
|
||||
else:
|
||||
return self.STATE_SCHEDULED
|
||||
if self.skip:
|
||||
return self.STATE_SKIPPED
|
||||
if not self.source.download_media:
|
||||
return self.STATE_DISABLED_AT_SOURCE
|
||||
return self.STATE_UNKNOWN
|
||||
|
||||
def get_download_state_icon(self, task=None):
|
||||
@@ -948,6 +1212,16 @@ class Media(models.Model):
|
||||
# Return the download paramaters
|
||||
return format_str, self.source.extension
|
||||
|
||||
def index_metadata(self):
|
||||
'''
|
||||
Index the media metadata returning a dict of info.
|
||||
'''
|
||||
indexer = self.INDEXERS.get(self.source.source_type, None)
|
||||
if not callable(indexer):
|
||||
raise Exception(f'Meida with source type f"{self.source.source_type}" '
|
||||
f'has no indexer')
|
||||
return indexer(self.url)
|
||||
|
||||
|
||||
class MediaServer(models.Model):
|
||||
'''
|
||||
|
||||
@@ -8,8 +8,9 @@ from background_task.models import Task
|
||||
from common.logger import log
|
||||
from .models import Source, Media, MediaServer
|
||||
from .tasks import (delete_task_by_source, delete_task_by_media, index_source_task,
|
||||
download_media_thumbnail, map_task_to_instance,
|
||||
check_source_directory_exists, download_media, rescan_media_server)
|
||||
download_media_thumbnail, download_media_metadata,
|
||||
map_task_to_instance, check_source_directory_exists,
|
||||
download_media, rescan_media_server)
|
||||
from .utils import delete_file
|
||||
|
||||
|
||||
@@ -93,16 +94,27 @@ def task_task_failed(sender, task_id, completed_task, **kwargs):
|
||||
def media_post_save(sender, instance, created, **kwargs):
|
||||
# Triggered after media is saved, Recalculate the "can_download" flag, this may
|
||||
# need to change if the source specifications have been changed
|
||||
post_save.disconnect(media_post_save, sender=Media)
|
||||
if instance.get_format_str():
|
||||
if not instance.can_download:
|
||||
instance.can_download = True
|
||||
instance.save()
|
||||
else:
|
||||
if instance.can_download:
|
||||
instance.can_download = False
|
||||
instance.save()
|
||||
post_save.connect(media_post_save, sender=Media)
|
||||
if instance.metadata:
|
||||
post_save.disconnect(media_post_save, sender=Media)
|
||||
if instance.get_format_str():
|
||||
if not instance.can_download:
|
||||
instance.can_download = True
|
||||
instance.save()
|
||||
else:
|
||||
if instance.can_download:
|
||||
instance.can_download = False
|
||||
instance.save()
|
||||
post_save.connect(media_post_save, sender=Media)
|
||||
# If the media is missing metadata schedule it to be downloaded
|
||||
if not instance.metadata:
|
||||
log.info(f'Scheduling task to download metadata for: {instance.url}')
|
||||
verbose_name = _('Downloading metadata for "{}"')
|
||||
download_media_metadata(
|
||||
str(instance.pk),
|
||||
priority=10,
|
||||
verbose_name=verbose_name.format(instance.pk),
|
||||
remove_existing_tasks=True
|
||||
)
|
||||
# If the media is missing a thumbnail schedule it to be downloaded
|
||||
if not instance.thumb_file_exists:
|
||||
instance.thumb = None
|
||||
@@ -124,7 +136,8 @@ def media_post_save(sender, instance, created, **kwargs):
|
||||
if not instance.media_file_exists:
|
||||
instance.downloaded = False
|
||||
instance.media_file = None
|
||||
if not instance.downloaded and instance.can_download and not instance.skip:
|
||||
if (not instance.downloaded and instance.can_download and not instance.skip
|
||||
and instance.source.download_media):
|
||||
delete_task_by_media('sync.tasks.download_media', (str(instance.pk),))
|
||||
verbose_name = _('Downloading media for "{}"')
|
||||
download_media(
|
||||
@@ -145,20 +158,6 @@ def media_pre_delete(sender, instance, **kwargs):
|
||||
if thumbnail_url:
|
||||
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
||||
(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)
|
||||
|
||||
@@ -23,7 +23,8 @@ from background_task.models import Task, CompletedTask
|
||||
from common.logger import log
|
||||
from common.errors import NoMediaException, DownloadFailedException
|
||||
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):
|
||||
@@ -178,22 +179,6 @@ def index_source_task(source_id):
|
||||
except Media.DoesNotExist:
|
||||
media = Media(key=key)
|
||||
media.source = source
|
||||
media.metadata = json.dumps(video)
|
||||
upload_date = media.upload_date
|
||||
# Media must have a valid upload date
|
||||
if upload_date:
|
||||
media.published = timezone.make_aware(upload_date)
|
||||
else:
|
||||
log.error(f'Media has no upload date, skipping: {source} / {media}')
|
||||
continue
|
||||
# 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:
|
||||
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
||||
if media.published < delta:
|
||||
# Media was published after the cutoff date, skip it
|
||||
log.warn(f'Media: {source} / {media} is older than '
|
||||
f'{source.days_to_keep} days, skipping')
|
||||
continue
|
||||
try:
|
||||
media.save()
|
||||
log.info(f'Indexed media: {source} / {media}')
|
||||
@@ -225,6 +210,56 @@ def check_source_directory_exists(source_id):
|
||||
source.make_directory()
|
||||
|
||||
|
||||
@background(schedule=0)
|
||||
def download_media_metadata(media_id):
|
||||
'''
|
||||
Downloads the metadata for a media item.
|
||||
'''
|
||||
try:
|
||||
media = Media.objects.get(pk=media_id)
|
||||
except Media.DoesNotExist:
|
||||
# Task triggered but the media no longer exists, do nothing
|
||||
log.error(f'Task download_media_metadata(pk={media_id}) called but no '
|
||||
f'media exists with ID: {media_id}')
|
||||
return
|
||||
source = media.source
|
||||
metadata = media.index_metadata()
|
||||
media.metadata = json.dumps(metadata)
|
||||
upload_date = media.upload_date
|
||||
# Media must have a valid upload date
|
||||
if upload_date:
|
||||
media.published = timezone.make_aware(upload_date)
|
||||
else:
|
||||
log.error(f'Media has no upload date, skipping: {source} / {media}')
|
||||
media.skip = True
|
||||
# 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')
|
||||
media.skip = True
|
||||
# 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:
|
||||
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
||||
if media.published < delta:
|
||||
# Media was published after the cutoff date, skip it
|
||||
log.warn(f'Media: {source} / {media} is older than '
|
||||
f'{source.days_to_keep} days, skipping')
|
||||
media.skip = True
|
||||
# Check we can download the media item
|
||||
if not media.skip:
|
||||
if media.get_format_str():
|
||||
media.can_download = True
|
||||
else:
|
||||
media.can_download = False
|
||||
# Save the media
|
||||
media.save()
|
||||
log.info(f'Saved {len(media.metadata)} bytes of metadata for: '
|
||||
f'{source} / {media_id}')
|
||||
|
||||
|
||||
@background(schedule=0)
|
||||
def download_media_thumbnail(media_id, url):
|
||||
'''
|
||||
@@ -273,6 +308,17 @@ def download_media(media_id):
|
||||
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but it '
|
||||
f'is now marked to be skipped, not downloading')
|
||||
return
|
||||
if media.downloaded and media.media_file:
|
||||
# Media has been marked as downloaded before the download_media task was fired,
|
||||
# skip it
|
||||
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but it '
|
||||
f'has already been marked as downloaded, not downloading again')
|
||||
return
|
||||
if not media.source.download_media:
|
||||
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but the '
|
||||
f'source {media.source} has since been marked to not download media, '
|
||||
f'not downloading')
|
||||
return
|
||||
filepath = media.filepath
|
||||
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{filepath}"')
|
||||
format_str, container = media.download_media()
|
||||
@@ -306,7 +352,7 @@ def download_media(media_id):
|
||||
media.downloaded_audio_codec = cformat['acodec']
|
||||
if cformat['vcodec']:
|
||||
# Combined
|
||||
media.downloaded_format = vformat['format']
|
||||
media.downloaded_format = cformat['format']
|
||||
media.downloaded_height = cformat['height']
|
||||
media.downloaded_width = cformat['width']
|
||||
media.downloaded_video_codec = cformat['vcodec']
|
||||
@@ -317,11 +363,13 @@ def download_media(media_id):
|
||||
media.save()
|
||||
# If selected, copy the thumbnail over as well
|
||||
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} '
|
||||
f'to: {thumbpath}')
|
||||
copyfile(media.thumb.path, thumbpath)
|
||||
f'to: {media.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
|
||||
for mediaserver in MediaServer.objects.all():
|
||||
log.info(f'Scheduling media server updates')
|
||||
|
||||
@@ -11,18 +11,28 @@
|
||||
<tr>
|
||||
<td>{yyyymmdd}</td>
|
||||
<td>Media publish date in YYYYMMDD</td>
|
||||
<td>20210101</td>
|
||||
<td>20210131</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{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>
|
||||
<td>{yyyy}</td>
|
||||
<td>Media publish year in YYYY</td>
|
||||
<td>2021</td>
|
||||
</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>
|
||||
<td>{source}</td>
|
||||
<td>Lower case source name, max 80 chars</td>
|
||||
@@ -53,6 +63,16 @@
|
||||
<td>Media format string</td>
|
||||
<td>720p-avc1-mp4a</td>
|
||||
</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>
|
||||
<td>{ext}</td>
|
||||
<td>File extension</td>
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="collection">
|
||||
{% for media in latest_downloads %}
|
||||
<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>
|
||||
</a>
|
||||
{% empty %}
|
||||
@@ -89,7 +89,7 @@
|
||||
{% for media in largest_downloads %}
|
||||
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
||||
<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>
|
||||
{% empty %}
|
||||
<span class="collection-item">No media has been downloaded.</span>
|
||||
|
||||
@@ -64,8 +64,14 @@
|
||||
<td class="hide-on-small-only">Fallback</td>
|
||||
<td><span class="hide-on-med-and-up">Fallback<br></span><strong>{{ media.source.get_fallback_display }}</strong></td>
|
||||
</tr>
|
||||
{% if not media.source.download_media %}
|
||||
<tr title="Is media marked to be downloaded at the source?">
|
||||
<td class="hide-on-small-only">Source download?</td>
|
||||
<td><span class="hide-on-med-and-up">Source download?<br></span><strong>{% if media.source.download_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if media.skip %}
|
||||
<tr title="Has the media been downloaded?">
|
||||
<tr title="Is the media marked to be skipped?">
|
||||
<td class="hide-on-small-only">Skipping?</td>
|
||||
<td><span class="hide-on-med-and-up">Skipping?<br></span><strong>{% if media.skip %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||
</tr>
|
||||
@@ -109,7 +115,7 @@
|
||||
{% else %}
|
||||
<tr title="Can the media be downloaded?">
|
||||
<td class="hide-on-small-only">Can download?</td>
|
||||
<td><span class="hide-on-med-and-up">Can download?<br></span><strong>{% if youtube_dl_format %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||
<td><span class="hide-on-med-and-up">Can download?<br></span><strong>{% if media.can_download %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr title="The available media formats">
|
||||
|
||||
@@ -24,8 +24,12 @@
|
||||
{% else %}
|
||||
{% if m.skip %}
|
||||
<span class="error-text"><i class="fas fa-times" title="Skipping media"></i> Skipped</span>
|
||||
{% elif not m.source.download_media %}
|
||||
<span class="error-text"><i class="fas fa-times" title="Not downloading media for this source"></i> Disabled at source</span>
|
||||
{% elif not m.has_metadata %}
|
||||
<i class="far fa-clock" title="Waiting for metadata"></i> Fetching metadata
|
||||
{% elif m.can_download %}
|
||||
<i class="far fa-clock" title="Waiting to download or downloading"></i> {{ m.published|date:'Y-m-d' }}
|
||||
<i class="far fa-clock" title="Waiting to download or downloading"></i> Downloading
|
||||
{% else %}
|
||||
<span class="error-text"><i class="fas fa-exclamation-triangle" title="No matching formats to download"></i> No matching formats</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
{% include 'mediaformatvars.html' %}
|
||||
{% include 'sync/_mediaformatvars.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
{% include 'mediaformatvars.html' %}
|
||||
{% include 'sync/_mediaformatvars.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -51,10 +51,20 @@
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</tr>
|
||||
<tr title="Download media from this source">
|
||||
<td class="hide-on-small-only">Download media?</td>
|
||||
<td><span class="hide-on-med-and-up">Download media?<br></span><strong>{% if source.download_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||
</tr>
|
||||
<tr title="When then source was created locally in TubeSync">
|
||||
<td class="hide-on-small-only">Created</td>
|
||||
<td><span class="hide-on-med-and-up">Created<br></span><strong>{{ source.created|date:'Y-m-d H:i:s' }}</strong></td>
|
||||
@@ -97,6 +107,10 @@
|
||||
<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>
|
||||
</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 %}
|
||||
<tr title="Days after which your media from this source will be locally deleted">
|
||||
<td class="hide-on-small-only">Delete old media</td>
|
||||
|
||||
17
tubesync/sync/testdata/metadata.json
vendored
@@ -3,15 +3,24 @@
|
||||
"upload_date":"20170911",
|
||||
"license":null,
|
||||
"creator":null,
|
||||
"title":"no fancy stuff",
|
||||
"title":"no fancy stuff title",
|
||||
"alt_title":null,
|
||||
"description":"no fancy stuff",
|
||||
"categories":[],
|
||||
"description":"no fancy stuff desc",
|
||||
"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":[],
|
||||
"subtitles":{},
|
||||
"automatic_captions":{},
|
||||
"duration":401.0,
|
||||
"age_limit":0,
|
||||
"age_limit":50,
|
||||
"annotations":null,
|
||||
"chapters":null,
|
||||
"formats":[
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit
|
||||
from xml.etree import ElementTree
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, Client
|
||||
from django.utils import timezone
|
||||
@@ -161,6 +163,7 @@ class FrontEndTestCase(TestCase):
|
||||
'name': 'testname',
|
||||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'index_schedule': 3600,
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
@@ -201,6 +204,7 @@ class FrontEndTestCase(TestCase):
|
||||
'name': 'testname',
|
||||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
@@ -229,6 +233,7 @@ class FrontEndTestCase(TestCase):
|
||||
'name': 'testname',
|
||||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
@@ -430,7 +435,6 @@ class FrontEndTestCase(TestCase):
|
||||
response = c.get('/tasks-completed')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
def test_mediasevrers(self):
|
||||
# Media servers overview page
|
||||
c = Client()
|
||||
@@ -507,6 +511,12 @@ class FilepathTestCase(TestCase):
|
||||
self.source.media_format = 'test-{yyyy}'
|
||||
self.assertEqual(self.source.get_example_media_format(),
|
||||
'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.assertEqual(self.source.get_example_media_format(),
|
||||
'test-' + self.source.slugname)
|
||||
@@ -525,6 +535,12 @@ class FilepathTestCase(TestCase):
|
||||
self.source.media_format = 'test-{format}'
|
||||
self.assertEqual(self.source.get_example_media_format(),
|
||||
'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.assertEqual(self.source.get_example_media_format(),
|
||||
'test-' + self.source.extension)
|
||||
@@ -582,7 +598,79 @@ class FilepathTestCase(TestCase):
|
||||
self.source.media_format = ('{title}_{key}_{resolution}-{height}x{width}-'
|
||||
'{acodec}-{vcodec}-{fps}fps-{hdr}.{ext}')
|
||||
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):
|
||||
|
||||
@@ -108,6 +108,14 @@ def file_is_editable(filepath):
|
||||
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):
|
||||
if file_is_editable(filepath):
|
||||
return os.remove(filepath)
|
||||
|
||||
@@ -274,9 +274,9 @@ class AddSourceView(CreateView):
|
||||
template_name = 'sync/source-add.html'
|
||||
model = Source
|
||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||
'index_schedule', 'delete_old_media', 'days_to_keep',
|
||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
||||
'prefer_hdr', 'fallback', 'copy_thumbnails')
|
||||
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||
errors = {
|
||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||
'errors or is empty. Check the table at the end of '
|
||||
@@ -334,8 +334,8 @@ class SourceView(DetailView):
|
||||
messages = {
|
||||
'source-created': _('Your new source has been created. If you have added a '
|
||||
'very large source such as a channel with hundreds of '
|
||||
'videos it can take several minutes for media to start '
|
||||
'to appear.'),
|
||||
'videos it can take several minutes or up to an hour '
|
||||
'for media to start to appear.'),
|
||||
'source-updated': _('Your source has been updated.'),
|
||||
}
|
||||
|
||||
@@ -365,9 +365,9 @@ class UpdateSourceView(UpdateView):
|
||||
template_name = 'sync/source-update.html'
|
||||
model = Source
|
||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||
'index_schedule', 'delete_old_media', 'days_to_keep',
|
||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
||||
'prefer_hdr', 'fallback', 'copy_thumbnails')
|
||||
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||
errors = {
|
||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||
'errors or is empty. Check the table at the end of '
|
||||
@@ -411,7 +411,12 @@ class DeleteSourceView(DeleteView, FormMixin):
|
||||
source = self.get_object()
|
||||
for media in Media.objects.filter(source=source):
|
||||
if media.media_file:
|
||||
# Delete the media file
|
||||
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)
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -556,13 +561,12 @@ class MediaRedownloadView(FormView, SingleObjectMixin):
|
||||
self.object.thumb = None
|
||||
# If the media file exists on disk, delete it
|
||||
if self.object.media_file_exists:
|
||||
filepath = self.object.media_file.path
|
||||
delete_file(filepath)
|
||||
delete_file(self.object.media_file.path)
|
||||
self.object.media_file = None
|
||||
# If the media has an associated thumbnail copied, also delete it
|
||||
barefilepath, fileext = os.path.splitext(filepath)
|
||||
thumbpath = f'{barefilepath}.jpg'
|
||||
delete_file(thumbpath)
|
||||
delete_file(self.object.thumbpath)
|
||||
# If the media has an associated NFO file with it, also delete it
|
||||
delete_file(self.object.nfopath)
|
||||
# Reset all download data
|
||||
self.object.downloaded = False
|
||||
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),))
|
||||
# If the media file exists on disk, delete it
|
||||
if self.object.media_file_exists:
|
||||
filepath = self.object.media_file.path
|
||||
delete_file(self.object.media_file.path)
|
||||
self.object.media_file = None
|
||||
# If the media has an associated thumbnail copied, also delete it
|
||||
barefilepath, fileext = os.path.splitext(filepath)
|
||||
thumbpath = f'{barefilepath}.jpg'
|
||||
delete_file(thumbpath)
|
||||
delete_file(self.object.thumbpath)
|
||||
# If the media has an associated NFO file with it, also delete it
|
||||
delete_file(self.object.nfopath)
|
||||
# Reset all download data
|
||||
self.object.downloaded = False
|
||||
self.object.downloaded_audio_codec = None
|
||||
@@ -1008,7 +1011,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
|
||||
|
||||
def get_context_data(self, *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
|
||||
|
||||
def get_success_url(self):
|
||||
|
||||
@@ -37,7 +37,8 @@ def get_media_info(url):
|
||||
'skip_download': True,
|
||||
'forcejson': True,
|
||||
'simulate': True,
|
||||
'logger': log
|
||||
'logger': log,
|
||||
'extract_flat': True,
|
||||
})
|
||||
response = {}
|
||||
with youtube_dl.YoutubeDL(opts) as y:
|
||||
|
||||
@@ -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 = ALLOWED_HOSTS_STR.split(',')
|
||||
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')
|
||||
@@ -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'
|
||||
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR
|
||||
|
||||
@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||
|
||||
|
||||
VERSION = 0.5
|
||||
VERSION = 0.9
|
||||
SECRET_KEY = ''
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = []
|
||||
@@ -41,6 +41,7 @@ MIDDLEWARE = [
|
||||
|
||||
|
||||
ROOT_URLCONF = 'tubesync.urls'
|
||||
FORCE_SCRIPT_NAME = None
|
||||
|
||||
|
||||
TEMPLATES = [
|
||||
@@ -113,14 +114,18 @@ Disallow: /
|
||||
'''.strip()
|
||||
|
||||
|
||||
X_FRAME_OPTIONS = 'SAMEORIGIN'
|
||||
|
||||
|
||||
HEALTHCHECK_FIREWALL = True
|
||||
HEALTHCHECK_ALLOWED_IPS = ('127.0.0.1',)
|
||||
|
||||
|
||||
MAX_ATTEMPTS = 10 # Number of times tasks will be retried
|
||||
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
|
||||
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8 # For sanity reasons
|
||||
BACKGROUND_TASK_PRIORITY_ORDERING = 'ASC' # Use 'niceness' task priority ordering
|
||||
COMPLETED_TASKS_DAYS_TO_KEEP = 7 # Number of days to keep completed tasks
|
||||
|
||||
@@ -147,7 +152,7 @@ YOUTUBE_DEFAULTS = {
|
||||
}
|
||||
|
||||
|
||||
MEDIA_FORMATSTR_DEFAULT = '{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}'
|
||||
MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}'
|
||||
|
||||
|
||||
try:
|
||||
|
||||