59 Commits
v0.3 ... v0.9

Author SHA1 Message Date
meeb
799c0fce39 bump to v0.9 2021-02-18 17:29:03 +11:00
meeb
2f324f28a9 add sync-missing-metadata command with docs, resolves #25 2021-02-18 17:24:14 +11:00
meeb
895bfe6f87 scrub % in titles, resolves #55 2021-02-18 16:35:10 +11:00
meeb
e0669b107d change default filename date prefix from YYYYMMDD to YYYY-MM-DD 2021-02-18 16:31:27 +11:00
meeb
0dc201b293 guide tweak 2021-02-18 16:26:58 +11:00
meeb
82fa0f6bce add sync.Source.download_media master flag, add manual import existing media command with docs, resolves #24 2021-02-18 16:24:24 +11:00
meeb
8b93cb4a59 handle malformed responses passively 2021-02-18 14:55:40 +11:00
meeb
647254d7f7 set X-Frame-Options to SAMEORIGIN by default, resolves #51 2021-02-17 22:25:13 +11:00
meeb
3567e20600 fix typing issue when videos have no votes and their vote count is None not 0 in metadata, resolves #50 2021-02-17 22:14:29 +11:00
meeb
5348e25303 update libs 2021-02-17 20:08:06 +11:00
meeb
749df3f7bb switch to using flat indexing of media, only index metadata for media once, resolves #38 and dramatically reduces crawl requests to youtube 2021-02-02 17:24:19 +11:00
meeb
2c2f53e5b2 update packages 2021-02-02 15:05:53 +11:00
meeb
06cfafb803 assert the healthcheck.py exec permissions are preserved, should resolve #41 2021-01-25 13:35:42 +11:00
meeb
f5a37f2e86 update deps 2021-01-25 13:15:43 +11:00
meeb
36747a47e0 create FUNDING.yml 2021-01-22 15:40:26 +11:00
meeb
ffd69e8d40 bump to v0.8 2021-01-20 18:13:28 +11:00
meeb
eebef3371f fix form label padding from overlapping field, resolves #37 2021-01-20 18:03:24 +11:00
meeb
4cd6701c8a fix bug handling audio fallback detection, resolves #31 2021-01-20 18:00:28 +11:00
meeb
4ebe6f2a37 pin footer at the bottom of the viewport, resolves #26 2021-01-20 17:42:16 +11:00
meeb
d553d58fde update youtube-dl 2021-01-20 17:34:50 +11:00
meeb
df40a1367a sanitise youtube video titles for use in sane filenames, resolves #35 2021-01-20 17:34:19 +11:00
meeb
607ee77e70 update deps 2021-01-16 03:50:16 +11:00
meeb
9af493aa8a update upstream library versions 2021-01-03 15:18:31 +11:00
meeb
f0c94ff789 Merge pull request #29 from ltomes/main
Added format descriptions.
2020-12-30 15:40:41 +11:00
Levi Tomes
39c7799831 Changed PR intent
Changed PR intent
2020-12-29 22:36:43 -06:00
Levi Tomes
da7371f830 Added format descriptions
Added format descriptions to the media format help_text.
2020-12-29 21:50:04 -06:00
meeb
387cfefc8f issues with dupe background async worker threads, drop defaults back down to 1 worker 2020-12-21 03:04:54 +11:00
meeb
d92dbde781 bump to 0.7 2020-12-20 18:24:04 +11:00
meeb
e36658e1a1 missing default setting for container build 2020-12-20 17:57:04 +11:00
meeb
51cd942717 use async workers, spawn two workers by default, add TUBESYNC_WORKERS env var to adjust the number of workers, resolves #19 2020-12-20 17:52:45 +11:00
meeb
001554db1a fix format container name, resolves #22 2020-12-20 13:01:14 +11:00
meeb
7cf86bb98d fix attribute name, resolves #21 2020-12-20 04:15:43 +11:00
meeb
c28c095f48 add :latest tag warning 2020-12-19 19:29:26 +11:00
meeb
12eac049e5 bump to 0.6 2020-12-19 18:14:31 +11:00
meeb
304cc153cf add DJANGO_FORCE_SCRIPT_NAME env var to change Djangos FORCE_SCRIPT_NAME if needed, part of support for running TubeSync in a /suburi and not a domain root, resolves #18 2020-12-19 18:10:10 +11:00
meeb
b45231f533 add secondary time based cap to allow sources to not download everything in a channel, resolves #15 2020-12-19 18:05:01 +11:00
meeb
26eb9d30e8 tweak field help text 2020-12-19 18:04:26 +11:00
meeb
97fa62d12b add playlist_index and playlist_title as media format options, fix paths for files in media format subdirs post download, resolves #13 2020-12-19 17:33:08 +11:00
meeb
1b092fe955 use xml parsing for tests to fix annoying attr ordering 2020-12-19 16:31:44 +11:00
meeb
18a59fe835 use OrderedDict for XML attrs so testing is consistent 2020-12-19 16:09:19 +11:00
meeb
410906ad8e add XML NFO file writing support, rework media cleanup deletion, resolves #11 2020-12-19 16:00:37 +11:00
meeb
8f4b09f346 add {mm} and {dd} media format support, resolves #12 2020-12-18 21:02:06 +11:00
meeb
cda021cbbf update screenshots 2020-12-18 19:01:35 +11:00
meeb
ee4df99cd8 update screenshots 2020-12-18 18:57:52 +11:00
meeb
53f1873a9b Merge branch 'main' of github.com:meeb/tubesync into main 2020-12-18 18:41:36 +11:00
meeb
9434293a84 fix dupe info on dashboard 2020-12-18 18:41:24 +11:00
meeb
ed69fe9dcc README tweaks 2020-12-18 18:35:58 +11:00
meeb
67af70569b bump to 0.5 2020-12-18 18:07:33 +11:00
meeb
68a62d8a7c add full support for YouTube channels with no vanity name, resolves #9 2020-12-18 17:43:58 +11:00
meeb
55578f4de7 add pretty-json-info-spam wrapper command to aid debugging urls 2020-12-18 17:31:47 +11:00
meeb
47313cb6cc bump to v0.4 2020-12-18 16:20:29 +11:00
meeb
a854b804f0 typo in test 2020-12-18 16:01:53 +11:00
meeb
08c1a82c30 custom filenames with media templates, resolves #5 2020-12-18 15:59:01 +11:00
meeb
25a1a82de4 add copy thumbnails status to source overview page 2020-12-18 13:02:13 +11:00
meeb
ff58f2811b add field for media file name format, part of #5 2020-12-18 13:00:33 +11:00
meeb
83b9c167a9 add option at source level to copy over thumbnails with media, resolves #8 2020-12-18 12:51:04 +11:00
meeb
ffe0049bab support youtube channels without vanity URLs, resolves #6 2020-12-18 12:30:31 +11:00
meeb
c1c39d9e17 typos 2020-12-18 12:26:05 +11:00
meeb
8d7f7e2476 change ffmpeg download location, resolves #4 2020-12-18 11:29:35 +11:00
54 changed files with 1671 additions and 234 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [meeb]

View File

@@ -13,7 +13,8 @@ ENV DEBIAN_FRONTEND="noninteractive" \
S6_EXPECTED_SHA256="52460473413601ff7a84ae690b161a074217ddc734990c2cdee9847166cf669e" \ S6_EXPECTED_SHA256="52460473413601ff7a84ae690b161a074217ddc734990c2cdee9847166cf669e" \
S6_DOWNLOAD="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-${ARCH}.tar.gz" \ S6_DOWNLOAD="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-${ARCH}.tar.gz" \
FFMPEG_EXPECTED_SHA256="47d95c0129fba27d051748a442a44a73ce1bd38d1e3f9fe1e9dd7258c7581fa5" \ FFMPEG_EXPECTED_SHA256="47d95c0129fba27d051748a442a44a73ce1bd38d1e3f9fe1e9dd7258c7581fa5" \
FFMPEG_DOWNLOAD="https://johnvansickle.com/ffmpeg/releases/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static.tar.xz" FFMPEG_DOWNLOAD="https://tubesync.sfo2.digitaloceanspaces.com/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static.tar.xz"
# Install third party software # Install third party software
RUN set -x && \ RUN set -x && \

86
Pipfile.lock generated
View File

@@ -39,11 +39,11 @@
}, },
"django": { "django": {
"hashes": [ "hashes": [
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2", "sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f",
"sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03" "sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.1.4" "version": "==3.1.6"
}, },
"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:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
"sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
], ],
"version": "==2020.4" "version": "==2021.1"
}, },
"rcssmin": { "rcssmin": {
"hashes": [ "hashes": [
@@ -221,10 +225,10 @@
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80",
"sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
], ],
"version": "==1.26.2" "version": "==1.26.3"
}, },
"whitenoise": { "whitenoise": {
"hashes": [ "hashes": [
@@ -236,11 +240,11 @@
}, },
"youtube-dl": { "youtube-dl": {
"hashes": [ "hashes": [
"sha256:65968065e66966955dc79fad9251565fcc982566118756da624bd21467f3a04c", "sha256:b390cddbd4d605bd887d0d4063988cef0fa13f916d2e1e3564badbb22504d754",
"sha256:eaa859f15b6897bec21474b7787dc958118c8088e1f24d4ef1d58eab13188958" "sha256:e7d48cd42f3081e1e0064e69f31f2856508ef31c0fc80eeebd8e70c6a031a24d"
], ],
"index": "pypi", "index": "pypi",
"version": "==2020.12.14" "version": "==2021.2.10"
} }
}, },
"develop": {} "develop": {}

View File

@@ -1,6 +1,6 @@
# TubeSync # TubeSync
**This is a preview release of TubeSync, it may contain the bugs but should be usable** **This is a preview release of TubeSync, it may contain bugs but should be usable**
TubeSync is a PVR (personal video recorder) for YouTube. Or, like Sonarr but for TubeSync is a PVR (personal video recorder) for YouTube. Or, like Sonarr but for
YouTube (with a built-in download client). It is designed to synchronize channels and YouTube (with a built-in download client). It is designed to synchronize channels and
@@ -18,27 +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
```yaml
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 # Screenshots
### Dashboard ### Dashboard
![TubeSync Dashboard](https://github.com/meeb/tubesync/blob/main/docs/dashboard.png?raw=true) ![TubeSync Dashboard](https://github.com/meeb/tubesync/blob/main/docs/dashboard-v0.5.png?raw=true)
### Sources overview ### Sources overview
![TubeSync sources overview](https://github.com/meeb/tubesync/blob/main/docs/sources.png?raw=true) ![TubeSync sources overview](https://github.com/meeb/tubesync/blob/main/docs/sources-v0.5.png?raw=true)
### Source details ### Source details
![TubeSync source details](https://github.com/meeb/tubesync/blob/main/docs/source.png?raw=true) ![TubeSync source details](https://github.com/meeb/tubesync/blob/main/docs/source-v0.5.png?raw=true)
### Media overview ### Media overview
![TubeSync media overview](https://github.com/meeb/tubesync/blob/main/docs/media.png?raw=true) ![TubeSync media overview](https://github.com/meeb/tubesync/blob/main/docs/media-v0.5.png?raw=true)
### Media details ### Media details
![TubeSync media-details](https://github.com/meeb/tubesync/blob/main/docs/media-item.png?raw=true) ![TubeSync media-details](https://github.com/meeb/tubesync/blob/main/docs/media-item-v0.5.png?raw=true)
# Requirements # Requirements
@@ -92,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.3 $ docker pull ghcr.io/meeb/tubesync:v0.9
# 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 \
@@ -103,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.3 ghcr.io/meeb/tubesync:v0.9
``` ```
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
@@ -115,7 +125,7 @@ Alternatively, for Docker Compose, you can use something like:
```yaml ```yaml
tubesync: tubesync:
image: ghcr.io/meeb/tubesync:v0.3 image: ghcr.io/meeb/tubesync:v0.9
container_name: tubesync container_name: tubesync
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -124,7 +134,7 @@ Alternatively, for Docker Compose, you can use something like:
- /some/directory/tubesync-config:/config - /some/directory/tubesync-config:/config
- /some/directory/tubesync-downloads:/downloads - /some/directory/tubesync-downloads:/downloads
environment: environment:
- TZ=$TIMEZONE - TZ=Europe/London
- PUID=1000 - PUID=1000
- PGID=1000 - PGID=1000
``` ```
@@ -134,7 +144,7 @@ Alternatively, for Docker Compose, you can use something like:
To update, you can just pull a new version of the container image as they are released. To update, you can just pull a new version of the container image as they are released.
```bash ```bash
$ docker pull pull ghcr.io/meeb/tubesync:v[number] $ docker pull ghcr.io/meeb/tubesync:v[number]
``` ```
Back-end updates such as database migrations should be automatic. Back-end updates such as database migrations should be automatic.
@@ -186,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:
![Import existing media into TubeSync](https://github.com/meeb/tubesync/blob/main/docs/import-existing-media.md)
![Sync or create missing metadata files](https://github.com/meeb/tubesync/blob/main/docs/create-missing-metadata.md)
# Warnings # Warnings
### 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
@@ -203,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 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
@@ -216,7 +244,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.
@@ -230,7 +258,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!
@@ -287,23 +315,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

View File

@@ -19,8 +19,8 @@ chown -R app:app /app/common/static && \
chmod -R 0750 /app/common/static && \ chmod -R 0750 /app/common/static && \
chown -R app:app /app/static && \ chown -R app:app /app/static && \
chmod -R 0750 /app/static && \ chmod -R 0750 /app/static && \
find /app -type f -exec chmod 640 {} \; && \ find /app -type f ! -iname healthcheck.py -exec chmod 640 {} \; && \
chmod +x /app/healthcheck.py chmod 0755 /app/healthcheck.py
# Run migrations # Run migrations
exec s6-setuidgid app \ exec s6-setuidgid app \

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

BIN
docs/media-v0.5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

BIN
docs/source-v0.5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

BIN
docs/sources-v0.5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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())

0
tubesync/healthcheck.py Normal file → Executable file
View File

View File

@@ -7,7 +7,7 @@ class SourceAdmin(admin.ModelAdmin):
ordering = ('-created',) ordering = ('-created',)
list_display = ('uuid', 'name', 'source_type', 'last_crawl', list_display = ('uuid', 'name', 'source_type', 'last_crawl',
'has_failed') 'download_media', 'has_failed')
readonly_fields = ('uuid', 'created') readonly_fields = ('uuid', 'created')
search_fields = ('uuid', 'key', 'name') search_fields = ('uuid', 'key', 'name')

View File

View 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')

View 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')

View File

@@ -0,0 +1,18 @@
import json
from django.core.management.base import BaseCommand, CommandError
from sync.youtube import get_media_info
class Command(BaseCommand):
help = 'Displays information obtained by youtube-dl in JSON to the console'
def add_arguments(self, parser):
parser.add_argument('url', type=str)
def handle(self, *args, **options):
url = options['url']
self.stdout.write(f'Showing information for URL: {url}')
info = get_media_info(url)
self.stdout.write(json.dumps(info, indent=4, sort_keys=True))
self.stdout.write('Done')

View File

@@ -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

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.1.4 on 2020-12-18 01:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0002_auto_20201213_0817'),
]
operations = [
migrations.AddField(
model_name='source',
name='copy_thumbnails',
field=models.BooleanField(default=False, help_text='Copy thumbnails with the media, these may be detected and used by some media servers', verbose_name='copy thumbnails'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.1.4 on 2020-12-18 01:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0003_source_copy_thumbnails'),
]
operations = [
migrations.AddField(
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', max_length=200, verbose_name='media format'),
),
]

View 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'),
),
]

View 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'),
),
]

View 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'),
),
]

View 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'),
),
]

View 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'),
),
]

View File

@@ -1,14 +1,18 @@
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
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.utils.text import slugify from django.utils.text import slugify
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
@@ -27,10 +31,13 @@ class Source(models.Model):
''' '''
SOURCE_TYPE_YOUTUBE_CHANNEL = 'c' SOURCE_TYPE_YOUTUBE_CHANNEL = 'c'
SOURCE_TYPE_YOUTUBE_CHANNEL_ID = 'i'
SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p' SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p'
SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_PLAYLIST) SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
SOURCE_TYPE_YOUTUBE_PLAYLIST)
SOURCE_TYPE_CHOICES = ( SOURCE_TYPE_CHOICES = (
(SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')), (SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')),
(SOURCE_TYPE_YOUTUBE_CHANNEL_ID, _('YouTube channel by ID')),
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')), (SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
) )
@@ -94,27 +101,54 @@ class Source(models.Model):
(FALLBACK_NEXT_BEST_HD, _('Get next best resolution but at least HD')) (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 # Fontawesome icons used for the source on the front end
ICONS = { ICONS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>', SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '<i class="fab fa-youtube"></i>',
SOURCE_TYPE_YOUTUBE_PLAYLIST: '<i class="fab fa-youtube"></i>', SOURCE_TYPE_YOUTUBE_PLAYLIST: '<i class="fab fa-youtube"></i>',
} }
# Format to use to display a URL for the source # Format to use to display a URL for the source
URLS = { URLS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}', SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}',
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}', SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
} }
# Callback functions to get a list of media from the source # Callback functions to get a list of media from the source
INDEXERS = { INDEXERS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info, SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: get_youtube_media_info,
SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info, SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info,
} }
# Field names to find the media ID used as the key when storing media # Field names to find the media ID used as the key when storing media
KEY_FIELD = { KEY_FIELD = {
SOURCE_TYPE_YOUTUBE_CHANNEL: 'id', SOURCE_TYPE_YOUTUBE_CHANNEL: 'id',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'id',
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')
@@ -174,6 +208,12 @@ class Source(models.Model):
unique=True, unique=True,
help_text=_('Directory name to save the media into') help_text=_('Directory name to save the media into')
) )
media_format = models.CharField(
_('media format'),
max_length=200,
default=settings.MEDIA_FORMATSTR_DEFAULT,
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'),
choices=IndexSchedule.choices, choices=IndexSchedule.choices,
@@ -181,6 +221,17 @@ 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_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 = models.BooleanField(
_('delete old media'), _('delete old media'),
default=False, default=False,
@@ -234,6 +285,16 @@ class Source(models.Model):
default=FALLBACK_NEXT_BEST_HD, default=FALLBACK_NEXT_BEST_HD,
help_text=_('What do do when media in your source resolution and codecs is not available') help_text=_('What do do when media in your source resolution and codecs is not available')
) )
copy_thumbnails = models.BooleanField(
_('copy thumbnails'),
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 = models.BooleanField(
_('has failed'), _('has failed'),
default=False, default=False,
@@ -251,6 +312,11 @@ class Source(models.Model):
def icon(self): def icon(self):
return self.ICONS.get(self.source_type) return self.ICONS.get(self.source_type)
@property
def slugname(self):
replaced = self.name.replace('_', '-').replace('&', 'and').replace('+', 'and')
return slugify(replaced)[:80]
@property @property
def is_audio(self): def is_audio(self):
return self.source_resolution == self.SOURCE_RESOLUTION_AUDIO return self.source_resolution == self.SOURCE_RESOLUTION_AUDIO
@@ -259,6 +325,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):
''' '''
@@ -270,23 +344,32 @@ class Source(models.Model):
''' '''
if self.is_audio: if self.is_audio:
if self.source_acodec == self.SOURCE_ACODEC_MP4A: if self.source_acodec == self.SOURCE_ACODEC_MP4A:
return 'm4a' return self.EXTENSION_M4A
elif self.source_acodec == self.SOURCE_ACODEC_OPUS: elif self.source_acodec == self.SOURCE_ACODEC_OPUS:
return 'ogg' return self.EXTENSION_OGG
else: else:
raise ValueError('Unable to choose audio extension, uknown acodec') raise ValueError('Unable to choose audio extension, uknown acodec')
else: else:
return 'mkv' return self.EXTENSION_MKV
@classmethod @classmethod
def create_url(obj, source_type, key): def create_url(obj, source_type, key):
url = obj.URLS.get(source_type) url = obj.URLS.get(source_type)
return url.format(key=key) 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 @property
def url(self): def url(self):
return Source.create_url(self.source_type, self.key) 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 @property
def format_summary(self): def format_summary(self):
if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO: if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO:
@@ -325,6 +408,53 @@ class Source(models.Model):
def can_fallback(self): def can_fallback(self):
return self.fallback != self.FALLBACK_FAIL return self.fallback != self.FALLBACK_FAIL
@property
def example_media_format_dict(self):
'''
Populates a dict with real-ish and some placeholder data for media name
format strings. Used for example filenames and media_format validation.
'''
fmt = []
if self.source_resolution:
fmt.append(self.source_resolution)
if self.source_vcodec:
fmt.append(self.source_vcodec.lower())
if self.source_acodec:
fmt.append(self.source_acodec.lower())
if self.prefer_60fps:
fmt.append('60fps')
if self.prefer_hdr:
fmt.append('hdr')
return {
'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 '',
'width': '1280' if self.source_resolution else '',
'vcodec': self.source_vcodec.lower() if self.source_vcodec else '',
'acodec': self.source_acodec.lower(),
'fps': '24' if self.source_resolution else '',
'hdr': 'hdr' if self.source_resolution else ''
}
def get_example_media_format(self):
try:
return self.media_format.format(**self.example_media_format_dict)
except Exception as e:
return ''
def index_media(self): def index_media(self):
''' '''
Index the media source returning a list of media metadata as dicts. Index the media source returning a list of media metadata as dicts.
@@ -332,25 +462,10 @@ class Source(models.Model):
indexer = self.INDEXERS.get(self.source_type, None) indexer = self.INDEXERS.get(self.source_type, None)
if not callable(indexer): if not callable(indexer):
raise Exception(f'Source type f"{self.source_type}" has no indexer') raise Exception(f'Source type f"{self.source_type}" has no indexer')
response = indexer(self.url) response = indexer(self.index_url)
if not isinstance(response, dict):
# Account for nested playlists, such as a channel of playlists of playlists return []
def _recurse_playlists(playlist): return response.get('entries', [])
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)
def get_media_thumb_path(instance, filename): def get_media_thumb_path(instance, filename):
@@ -373,47 +488,104 @@ class Media(models.Model):
# Format to use to display a URL for the media # Format to use to display a URL for the media
URLS = { URLS = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/watch?v={key}', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/watch?v={key}',
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}', 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 # Maps standardised names to names used in source metdata
METADATA_FIELDS = { METADATA_FIELDS = {
'upload_date': { 'upload_date': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date',
}, },
'title': { 'title': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title',
}, },
'thumbnail': { 'thumbnail': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'thumbnail',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail',
}, },
'description': { 'description': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'description',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description',
}, },
'duration': { 'duration': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'duration',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration',
}, },
'formats': { 'formats': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: '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'
STATE_DOWNLOADING = 'downloading' STATE_DOWNLOADING = 'downloading'
STATE_DOWNLOADED = 'downloaded' STATE_DOWNLOADED = 'downloaded'
STATE_SKIPPED = 'skipped'
STATE_DISABLED_AT_SOURCE = 'source-disabled'
STATE_ERROR = 'error' STATE_ERROR = 'error'
STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED, STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED,
STATE_ERROR) STATE_SKIPPED, STATE_DISABLED_AT_SOURCE, STATE_ERROR)
STATE_ICONS = { STATE_ICONS = {
STATE_UNKNOWN: '<i class="far fa-question-circle" title="Unknown download state"></i>', 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_SCHEDULED: '<i class="far fa-clock" title="Scheduled to download"></i>',
STATE_DOWNLOADING: '<i class="fas fa-download" title="Downloading now"></i>', STATE_DOWNLOADING: '<i class="fas fa-download" title="Downloading now"></i>',
STATE_DOWNLOADED: '<i class="far fa-check-circle" title="Downloaded"></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>', STATE_ERROR: '<i class="fas fa-exclamation-triangle" title="Error downloading"></i>',
} }
@@ -634,16 +806,39 @@ class Media(models.Model):
('720p', 'avc1', 'mp4a', '60fps', 'hdr') ('720p', 'avc1', 'mp4a', '60fps', 'hdr')
''' '''
fmt = [] fmt = []
resolution = ''
vcodec = ''
acodec = ''
height = '0'
width = '0'
fps = ''
hdr = ''
# If the download has completed use existing values # If the download has completed use existing values
if self.downloaded: if self.downloaded:
resolution = f'{self.downloaded_height}p'
if self.downloaded_format != 'audio': if self.downloaded_format != 'audio':
fmt.append(self.downloaded_video_codec.lower()) vcodec = self.downloaded_video_codec.lower()
fmt.append(self.downloaded_audio_codec.lower()) fmt.append(vcodec)
acodec = self.downloaded_audio_codec.lower()
fmt.append(acodec)
if self.downloaded_format != 'audio': if self.downloaded_format != 'audio':
fmt.append(str(self.downloaded_fps)) fps = str(self.downloaded_fps)
fmt.append(f'{fps}fps')
if self.downloaded_hdr: if self.downloaded_hdr:
fmt.append('hdr') hdr = 'hdr'
return fmt fmt.append(hdr)
height = str(self.downloaded_height)
width = str(self.downloaded_width)
return {
'resolution': resolution,
'height': height,
'width': width,
'vcodec': vcodec,
'acodec': acodec,
'fps': fps,
'hdr': hdr,
'format': tuple(fmt),
}
# Otherwise, calculate from matched format codes # Otherwise, calculate from matched format codes
vformat = None vformat = None
aformat = None aformat = None
@@ -660,15 +855,32 @@ class Media(models.Model):
# Combined # Combined
vformat = cformat vformat = cformat
if vformat: if vformat:
fmt.append(vformat['format'].lower()) resolution = vformat['format'].lower()
fmt.append(vformat['vcodec'].lower()) fmt.append(resolution)
fmt.append(aformat['acodec'].lower()) vcodec = vformat['vcodec'].lower()
fmt.append(vcodec)
if aformat:
acodec = aformat['acodec'].lower()
fmt.append(acodec)
if vformat: if vformat:
if vformat['is_60fps']: if vformat['is_60fps']:
fmt.append('60fps') fps = '60fps'
fmt.append(fps)
if vformat['is_hdr']: if vformat['is_hdr']:
fmt.append('hdr') hdr = 'hdr'
return tuple(fmt) fmt.append(hdr)
height = str(vformat['height'])
width = str(vformat['width'])
return {
'resolution': resolution,
'height': height,
'width': width,
'vcodec': vcodec,
'acodec': acodec,
'fps': fps,
'hdr': hdr,
'format': tuple(fmt),
}
def get_format_by_code(self, format_code): def get_format_by_code(self, format_code):
''' '''
@@ -679,6 +891,43 @@ class Media(models.Model):
return fmt return fmt
return False return False
@property
def format_dict(self):
'''
Returns a dict matching the media_format key requirements for this item
of media.
'''
format_str = self.get_format_str()
display_format = self.get_display_format(format_str)
dateobj = self.upload_date if self.upload_date else self.created
return {
'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': 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'],
'width': display_format['width'],
'vcodec': display_format['vcodec'],
'acodec': display_format['acodec'],
'fps': display_format['fps'],
'hdr': display_format['hdr'],
}
@property
def has_metadata(self):
return self.metadata is not None
@property @property
def loaded_metadata(self): def loaded_metadata(self):
try: try:
@@ -701,6 +950,11 @@ class Media(models.Model):
field = self.get_metadata_field('title') field = self.get_metadata_field('title')
return self.loaded_metadata.get(field, '').strip() return self.loaded_metadata.get(field, '').strip()
@property
def slugtitle(self):
replaced = self.title.replace('_', '-').replace('&', 'and').replace('+', 'and')
return slugify(replaced)[:80]
@property @property
def thumbnail(self): def thumbnail(self):
field = self.get_metadata_field('thumbnail') field = self.get_metadata_field('thumbnail')
@@ -732,27 +986,87 @@ 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)
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 @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 self.media_file: # Create a suitable filename from the source media_format
return os.path.basename(self.media_file.name) media_format = str(self.source.media_format)
upload_date = self.upload_date media_details = self.format_dict
dateobj = upload_date if upload_date else self.created return media_format.format(**media_details)
datestr = dateobj.strftime('%Y-%m-%d')
source_name = slugify(self.source.name).replace('_', '-') @property
name = slugify(self.name.replace('&', 'and').replace('+', 'and')) def thumbname(self):
name = name.replace('_', '-')[:80] filename = self.filename
key = self.key.strip().replace('_', '-')[:20] prefix, ext = os.path.splitext(filename)
format_str = self.get_format_str() return f'{prefix}.jpg'
format_tuple = self.get_display_format(format_str)
fmt = '-'.join(format_tuple) @property
ext = self.source.extension def thumbpath(self):
return f'{datestr}_{source_name}_{name}_{key}_{fmt}.{ext}' 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):
# Otherwise, create a suitable filename from the source media_format
media_format = str(self.source.media_format)
media_details = self.format_dict
dirname = self.source.directory_path / media_format.format(**media_details)
return os.path.dirname(str(dirname))
@property @property
def filepath(self): def filepath(self):
@@ -770,6 +1084,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
@@ -780,6 +1191,10 @@ class Media(models.Model):
return self.STATE_ERROR return self.STATE_ERROR
else: else:
return self.STATE_SCHEDULED 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 return self.STATE_UNKNOWN
def get_download_state_icon(self, task=None): def get_download_state_icon(self, task=None):
@@ -797,6 +1212,16 @@ class Media(models.Model):
# Return the download paramaters # Return the download paramaters
return format_str, self.source.extension 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): class MediaServer(models.Model):
''' '''

View File

@@ -1,3 +1,4 @@
import os
from django.conf import settings from django.conf import settings
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
from django.dispatch import receiver from django.dispatch import receiver
@@ -7,8 +8,9 @@ from background_task.models import Task
from common.logger import log from common.logger import log
from .models import Source, Media, MediaServer from .models import Source, Media, MediaServer
from .tasks import (delete_task_by_source, delete_task_by_media, index_source_task, from .tasks import (delete_task_by_source, delete_task_by_media, index_source_task,
download_media_thumbnail, map_task_to_instance, download_media_thumbnail, download_media_metadata,
check_source_directory_exists, download_media, rescan_media_server) map_task_to_instance, check_source_directory_exists,
download_media, rescan_media_server)
from .utils import delete_file from .utils import delete_file
@@ -92,16 +94,27 @@ def task_task_failed(sender, task_id, completed_task, **kwargs):
def media_post_save(sender, instance, created, **kwargs): def media_post_save(sender, instance, created, **kwargs):
# Triggered after media is saved, Recalculate the "can_download" flag, this may # Triggered after media is saved, Recalculate the "can_download" flag, this may
# need to change if the source specifications have been changed # need to change if the source specifications have been changed
post_save.disconnect(media_post_save, sender=Media) if instance.metadata:
if instance.get_format_str(): post_save.disconnect(media_post_save, sender=Media)
if not instance.can_download: if instance.get_format_str():
instance.can_download = True if not instance.can_download:
instance.save() instance.can_download = True
else: instance.save()
if instance.can_download: else:
instance.can_download = False if instance.can_download:
instance.save() instance.can_download = False
post_save.connect(media_post_save, sender=Media) 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 the media is missing a thumbnail schedule it to be downloaded
if not instance.thumb_file_exists: if not instance.thumb_file_exists:
instance.thumb = None instance.thumb = None
@@ -123,7 +136,8 @@ def media_post_save(sender, instance, created, **kwargs):
if not instance.media_file_exists: if not instance.media_file_exists:
instance.downloaded = False instance.downloaded = False
instance.media_file = None 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),)) delete_task_by_media('sync.tasks.download_media', (str(instance.pk),))
verbose_name = _('Downloading media for "{}"') verbose_name = _('Downloading media for "{}"')
download_media( download_media(
@@ -144,14 +158,7 @@ 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:
log.info(f'Deleting media for: {instance} path: {instance.media_file.path}')
delete_file(instance.media_file.path)
@receiver(post_delete, sender=Media) @receiver(post_delete, sender=Media)
def media_post_delete(sender, instance, **kwargs): def media_post_delete(sender, instance, **kwargs):

View File

@@ -11,6 +11,7 @@ import uuid
from io import BytesIO from io import BytesIO
from hashlib import sha1 from hashlib import sha1
from datetime import timedelta from datetime import timedelta
from shutil import copyfile
from PIL import Image from PIL import Image
from django.conf import settings from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
@@ -22,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):
@@ -177,22 +179,6 @@ def index_source_task(source_id):
except Media.DoesNotExist: except Media.DoesNotExist:
media = Media(key=key) media = Media(key=key)
media.source = source 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: try:
media.save() media.save()
log.info(f'Indexed media: {source} / {media}') log.info(f'Indexed media: {source} / {media}')
@@ -224,6 +210,56 @@ def check_source_directory_exists(source_id):
source.make_directory() 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) @background(schedule=0)
def download_media_thumbnail(media_id, url): def download_media_thumbnail(media_id, url):
''' '''
@@ -242,7 +278,7 @@ def download_media_thumbnail(media_id, url):
f'{width}x{height}: {url}') f'{width}x{height}: {url}')
i = resize_image_to_height(i, width, height) i = resize_image_to_height(i, width, height)
image_file = BytesIO() image_file = BytesIO()
i.save(image_file, 'JPEG', quality=80, optimize=True, progressive=True) i.save(image_file, 'JPEG', quality=85, optimize=True, progressive=True)
image_file.seek(0) image_file.seek(0)
media.thumb.save( media.thumb.save(
'thumb', 'thumb',
@@ -272,17 +308,29 @@ def download_media(media_id):
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but it ' log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but it '
f'is now marked to be skipped, not downloading') f'is now marked to be skipped, not downloading')
return return
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{media.filepath}"') 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() format_str, container = media.download_media()
if os.path.exists(media.filepath): if os.path.exists(filepath):
# Media has been downloaded successfully # Media has been downloaded successfully
log.info(f'Successfully downloaded media: {media} (UUID: {media.pk}) to: ' log.info(f'Successfully downloaded media: {media} (UUID: {media.pk}) to: '
f'"{media.filepath}"') f'"{filepath}"')
# Link the media file to the object and update info about the download # Link the media file to the object and update info about the download
media.media_file.name = str(media.filepath) media.media_file.name = str(filepath)
media.downloaded = True media.downloaded = True
media.download_date = timezone.now() media.download_date = timezone.now()
media.downloaded_filesize = os.path.getsize(media.filepath) media.downloaded_filesize = os.path.getsize(filepath)
media.downloaded_container = container media.downloaded_container = container
if '+' in format_str: if '+' in format_str:
# Seperate audio and video streams # Seperate audio and video streams
@@ -304,7 +352,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']
@@ -313,6 +361,15 @@ def download_media(media_id):
else: else:
media.downloaded_format = 'audio' media.downloaded_format = 'audio'
media.save() media.save()
# If selected, copy the thumbnail over as well
if media.source.copy_thumbnails and media.thumb:
log.info(f'Copying media thumbnail from: {media.thumb.path} '
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 # 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')

View File

@@ -0,0 +1,117 @@
<h2>Available media name variables</h2>
<table class="striped">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Output example</th>
</tr>
</thead>
<tbody>
<tr>
<td>{yyyymmdd}</td>
<td>Media publish date in YYYYMMDD</td>
<td>20210131</td>
</tr>
<tr>
<td>{yyyy_mm_dd}</td>
<td>Media publish date in YYYY-MM-DD</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>
<td>my-source</td>
</tr>
<tr>
<td>{source_full}</td>
<td>Full source name</td>
<td>My Source</td>
</tr>
<tr>
<td>{title}</td>
<td>Lower case media title, max 80 chars</td>
<td>my-video</td>
</tr>
<tr>
<td>{title_full}</td>
<td>Full media title</td>
<td>My Video</td>
</tr>
<tr>
<td>{key}</td>
<td>Media unique key or video ID</td>
<td>SoMeUnIqUeId</td>
</tr>
<tr>
<td>{format}</td>
<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>
<td>mkv</td>
</tr>
<tr>
<td>{resolution}</td>
<td>Resolution</td>
<td>720p</td>
</tr>
<tr>
<td>{height}</td>
<td>Media height in pixels</td>
<td>720</td>
</tr>
<tr>
<td>{width}</td>
<td>Media width in pixels</td>
<td>1280</td>
</tr>
<tr>
<td>{vcodec}</td>
<td>Media video codec</td>
<td>avc1</td>
</tr>
<tr>
<td>{acodec}</td>
<td>Media audio codec</td>
<td>opus</td>
</tr>
<tr>
<td>{fps}</td>
<td>Media fps</td>
<td>60fps</td>
</tr>
<tr>
<td>{flag_hdr}</td>
<td>Media has HDR flag</td>
<td>hdr</td>
</tr>
</tbody>
</table>

View File

@@ -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 &quot;{{ media.source.name }}&quot;</div> <div class="truncate"><strong>{{ media.download_date|timesince:now }}</strong> ago from &quot;{{ media.source.name }}&quot;</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 &quot;{{ media.source.name }}&quot;</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>

View File

@@ -64,8 +64,14 @@
<td class="hide-on-small-only">Fallback</td> <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> <td><span class="hide-on-med-and-up">Fallback<br></span><strong>{{ media.source.get_fallback_display }}</strong></td>
</tr> </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 %} {% 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 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> <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> </tr>
@@ -80,6 +86,10 @@
<td class="hide-on-small-only">Filename</td> <td class="hide-on-small-only">Filename</td>
<td><span class="hide-on-med-and-up">Filename<br></span><strong>{{ media.filename }}</strong></td> <td><span class="hide-on-med-and-up">Filename<br></span><strong>{{ media.filename }}</strong></td>
</tr> </tr>
<tr title="The filename the media will be downloaded as">
<td class="hide-on-small-only">Directory</td>
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ media.directory_path }}</strong></td>
</tr>
<tr title="Size of the file on disk"> <tr title="Size of the file on disk">
<td class="hide-on-small-only">File size</td> <td class="hide-on-small-only">File size</td>
<td><span class="hide-on-med-and-up">File size<br></span><strong>{{ media.downloaded_filesize|filesizeformat }}</strong></td> <td><span class="hide-on-med-and-up">File size<br></span><strong>{{ media.downloaded_filesize|filesizeformat }}</strong></td>
@@ -105,7 +115,7 @@
{% else %} {% else %}
<tr title="Can the media be downloaded?"> <tr title="Can the media be downloaded?">
<td class="hide-on-small-only">Can download?</td> <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> </tr>
{% endif %} {% endif %}
<tr title="The available media formats"> <tr title="The available media formats">

View File

@@ -24,8 +24,12 @@
{% else %} {% else %}
{% if m.skip %} {% if m.skip %}
<span class="error-text"><i class="fas fa-times" title="Skipping media"></i> Skipped</span> <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 %} {% 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 %} {% else %}
<span class="error-text"><i class="fas fa-exclamation-triangle" title="No matching formats to download"></i> No matching formats</span> <span class="error-text"><i class="fas fa-exclamation-triangle" title="No matching formats to download"></i> No matching formats</span>
{% endif %} {% endif %}

View File

@@ -23,4 +23,9 @@
</div> </div>
</form> </form>
</div> </div>
<div class="row">
<div class="col s12">
{% include 'sync/_mediaformatvars.html' %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -25,4 +25,9 @@
</div> </div>
</form> </form>
</div> </div>
<div class="row">
<div class="col s12">
{% include 'sync/_mediaformatvars.html' %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -43,10 +43,28 @@
<td class="hide-on-small-only">Directory</td> <td class="hide-on-small-only">Directory</td>
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td> <td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
</tr> </tr>
<tr title="Media file name format to use for saving files">
<td class="hide-on-small-only">Media format</td>
<td><span class="hide-on-med-and-up">Media format<br></span><strong>{{ source.media_format }}</strong></td>
</tr>
<tr title="Example file name for media format">
<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"> <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>
</tr> </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"> <tr title="When then source was created locally in TubeSync">
<td class="hide-on-small-only">Created</td> <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> <td><span class="hide-on-med-and-up">Created<br></span><strong>{{ source.created|date:'Y-m-d H:i:s' }}</strong></td>
@@ -85,6 +103,14 @@
<td class="hide-on-small-only">Fallback</td> <td class="hide-on-small-only">Fallback</td>
<td><span class="hide-on-med-and-up">Fallback<br></span><strong>{{ source.get_fallback_display }}</strong></td> <td><span class="hide-on-med-and-up">Fallback<br></span><strong>{{ source.get_fallback_display }}</strong></td>
</tr> </tr>
<tr title="Should media thumbnails be copied over with the media?">
<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 %} {% 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>

View File

@@ -10,10 +10,13 @@
</div> </div>
{% include 'infobox.html' with message=message %} {% include 'infobox.html' with message=message %}
<div class="row"> <div class="row">
<div class="col s12 l6 margin-bottom"> <div class="col m12 xl4 margin-bottom">
<a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a> <a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a>
</div> </div>
<div class="col s12 l6 margin-bottom"> <div class="col m12 xl4 margin-bottom">
<a href="{% url 'sync:validate-source' source_type='youtube-channel-id' %}" class="btn">Add a YouTube channel by ID <i class="fab fa-youtube"></i></a>
</div>
<div class="col m12 xl4 margin-bottom">
<a href="{% url 'sync:validate-source' source_type='youtube-playlist' %}" class="btn">Add a YouTube playlist <i class="fab fa-youtube"></i></a> <a href="{% url 'sync:validate-source' source_type='youtube-playlist' %}" class="btn">Add a YouTube playlist <i class="fab fa-youtube"></i></a>
</div> </div>
</div> </div>

View File

@@ -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":[

View File

@@ -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
@@ -28,6 +30,7 @@ class FrontEndTestCase(TestCase):
def test_validate_source(self): def test_validate_source(self):
test_source_types = { test_source_types = {
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, 'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, 'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
} }
test_sources = { test_sources = {
@@ -35,6 +38,7 @@ class FrontEndTestCase(TestCase):
'valid': ( 'valid': (
'https://www.youtube.com/testchannel', 'https://www.youtube.com/testchannel',
'https://www.youtube.com/c/testchannel', 'https://www.youtube.com/c/testchannel',
'https://www.youtube.com/c/testchannel/videos',
), ),
'invalid_schema': ( 'invalid_schema': (
'http://www.youtube.com/c/playlist', 'http://www.youtube.com/c/playlist',
@@ -50,13 +54,37 @@ class FrontEndTestCase(TestCase):
), ),
'invalid_is_playlist': ( 'invalid_is_playlist': (
'https://www.youtube.com/c/playlist', 'https://www.youtube.com/c/playlist',
'https://www.youtube.com/c/playlist', ),
'invalid_channel_with_id': (
'https://www.youtube.com/channel/channelid',
'https://www.youtube.com/channel/channelid/videos',
),
},
'youtube-channel-id': {
'valid': (
'https://www.youtube.com/channel/channelid',
'https://www.youtube.com/channel/channelid/videos',
),
'invalid_schema': (
'http://www.youtube.com/channel/channelid',
'ftp://www.youtube.com/channel/channelid',
),
'invalid_domain': (
'https://www.test.com/channel/channelid',
'https://www.example.com/channel/channelid',
),
'invalid_path': (
'https://www.youtube.com/test/invalid',
'https://www.youtube.com/channel/test/invalid',
),
'invalid_is_named_channel': (
'https://www.youtube.com/c/testname',
), ),
}, },
'youtube-playlist': { 'youtube-playlist': {
'valid': ( 'valid': (
'https://www.youtube.com/playlist?list=testplaylist' 'https://www.youtube.com/playlist?list=testplaylist',
'https://www.youtube.com/watch?v=testvideo&list=testplaylist' 'https://www.youtube.com/watch?v=testvideo&list=testplaylist',
), ),
'invalid_schema': ( 'invalid_schema': (
'http://www.youtube.com/playlist?list=testplaylist', 'http://www.youtube.com/playlist?list=testplaylist',
@@ -73,6 +101,7 @@ class FrontEndTestCase(TestCase):
'invalid_is_channel': ( 'invalid_is_channel': (
'https://www.youtube.com/testchannel', 'https://www.youtube.com/testchannel',
'https://www.youtube.com/c/testchannel', 'https://www.youtube.com/c/testchannel',
'https://www.youtube.com/channel/testchannel',
), ),
} }
} }
@@ -83,19 +112,21 @@ class FrontEndTestCase(TestCase):
response = c.get('/source-validate/invalid') response = c.get('/source-validate/invalid')
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
for (source_type, tests) in test_sources.items(): for (source_type, tests) in test_sources.items():
for test, field in tests.items(): for test, urls in tests.items():
source_type_char = test_source_types.get(source_type) for url in urls:
data = {'source_url': field, 'source_type': source_type_char} source_type_char = test_source_types.get(source_type)
response = c.post(f'/source-validate/{source_type}', data) data = {'source_url': url, 'source_type': source_type_char}
if test == 'valid': response = c.post(f'/source-validate/{source_type}', data)
# Valid source tests should bounce to /source-add if test == 'valid':
self.assertEqual(response.status_code, 302) # Valid source tests should bounce to /source-add
url_parts = urlsplit(response.url) self.assertEqual(response.status_code, 302)
self.assertEqual(url_parts.path, '/source-add') url_parts = urlsplit(response.url)
else: self.assertEqual(url_parts.path, '/source-add')
# Invalid source tests should reload the page with an error message else:
self.assertEqual(response.status_code, 200) # Invalid source tests should reload the page with an error
self.assertIn('<ul class="errorlist">', response.content.decode()) self.assertEqual(response.status_code, 200)
self.assertIn('<ul class="errorlist">',
response.content.decode())
def test_add_source_prepopulation(self): def test_add_source_prepopulation(self):
c = Client() c = Client()
@@ -131,6 +162,8 @@ class FrontEndTestCase(TestCase):
'key': 'testkey', 'key': 'testkey',
'name': 'testname', 'name': 'testname',
'directory': 'testdirectory', 'directory': 'testdirectory',
'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,
@@ -170,6 +203,8 @@ class FrontEndTestCase(TestCase):
'key': 'updatedkey', # changed 'key': 'updatedkey', # changed
'name': 'testname', 'name': 'testname',
'directory': 'testdirectory', 'directory': 'testdirectory',
'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,
@@ -197,6 +232,8 @@ class FrontEndTestCase(TestCase):
'key': 'updatedkey', 'key': 'updatedkey',
'name': 'testname', 'name': 'testname',
'directory': 'testdirectory', 'directory': 'testdirectory',
'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,
@@ -398,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()
@@ -422,6 +458,221 @@ all_test_metadata = {
} }
class FilepathTestCase(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,
)
def test_source_dirname(self):
# Check media format validation is working
# Empty
self.source.media_format = ''
self.assertEqual(self.source.get_example_media_format(), '')
# Invalid, bad key
self.source.media_format = '{test}'
self.assertEqual(self.source.get_example_media_format(), '')
# Invalid, extra brackets
self.source.media_format = '{key}}'
self.assertEqual(self.source.get_example_media_format(), '')
# Invalid, not a string
self.source.media_format = 1
self.assertEqual(self.source.get_example_media_format(), '')
# Check all expected keys validate
self.source.media_format = 'test-{yyyymmdd}'
self.assertEqual(self.source.get_example_media_format(),
'test-' + timezone.now().strftime('%Y%m%d'))
self.source.media_format = 'test-{yyyy_mm_dd}'
self.assertEqual(self.source.get_example_media_format(),
'test-' + timezone.now().strftime('%Y-%m-%d'))
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)
self.source.media_format = 'test-{source_full}'
self.assertEqual(self.source.get_example_media_format(),
'test-' + self.source.name)
self.source.media_format = 'test-{title}'
self.assertEqual(self.source.get_example_media_format(),
'test-some-media-title-name')
self.source.media_format = 'test-{title_full}'
self.assertEqual(self.source.get_example_media_format(),
'test-Some Media Title Name')
self.source.media_format = 'test-{key}'
self.assertEqual(self.source.get_example_media_format(),
'test-SoMeUnIqUiD')
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)
self.source.media_format = 'test-{resolution}'
self.assertEqual(self.source.get_example_media_format(),
'test-' + self.source.source_resolution)
self.source.media_format = 'test-{height}'
self.assertEqual(self.source.get_example_media_format(),
'test-720')
self.source.media_format = 'test-{width}'
self.assertEqual(self.source.get_example_media_format(),
'test-1280')
self.source.media_format = 'test-{vcodec}'
self.assertEqual(self.source.get_example_media_format(),
'test-' + self.source.source_vcodec.lower())
self.source.media_format = 'test-{acodec}'
self.assertEqual(self.source.get_example_media_format(),
'test-' + self.source.source_acodec.lower())
self.source.media_format = 'test-{fps}'
self.assertEqual(self.source.get_example_media_format(),
'test-24')
self.source.media_format = 'test-{hdr}'
self.assertEqual(self.source.get_example_media_format(),
'test-hdr')
def test_media_filename(self):
# Check child directories work
self.source.media_format = '{yyyy}/{key}.{ext}'
self.assertEqual(self.media.directory_path,
str(self.source.directory_path / '2017'))
self.assertEqual(self.media.filename, '2017/mediakey.mkv')
self.source.media_format = '{yyyy}/{yyyy_mm_dd}/{key}.{ext}'
self.assertEqual(self.media.directory_path,
str(self.source.directory_path / '2017/2017-09-11'))
self.assertEqual(self.media.filename, '2017/2017-09-11/mediakey.mkv')
# Check media specific media format keys work
test_media = Media.objects.create(
key='test',
source=self.source,
metadata=metadata,
downloaded=True,
download_date=timezone.now(),
downloaded_format='720p',
downloaded_height=720,
downloaded_width=1280,
downloaded_audio_codec='opus',
downloaded_video_codec='vp9',
downloaded_container='mkv',
downloaded_fps=30,
downloaded_hdr=True,
downloaded_filesize=12345
)
# Bypass media-file-exists on-save signal
test_media.downloaded = True
self.source.media_format = ('{title}_{key}_{resolution}-{height}x{width}-'
'{acodec}-{vcodec}-{fps}fps-{hdr}.{ext}')
self.assertEqual(test_media.filename,
('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):
def setUp(self): def setUp(self):

View File

@@ -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)

View File

@@ -128,10 +128,12 @@ class ValidateSourceView(FormView):
} }
source_types = { source_types = {
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, 'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, 'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
} }
help_item = { help_item = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'), Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _('YouTube channel ID'),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'), Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'),
} }
help_texts = { help_texts = {
@@ -141,6 +143,13 @@ class ValidateSourceView(FormView):
'where <strong>CHANNELNAME</strong> is the name of the channel you want ' 'where <strong>CHANNELNAME</strong> is the name of the channel you want '
'to add.' 'to add.'
), ),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _(
'Enter a YouTube channel URL by channel ID into the box below. A channel '
'URL by channel ID will be in the format of <strong>'
'https://www.youtube.com/channel/BiGLoNgUnIqUeId</strong> '
'where <strong>BiGLoNgUnIqUeId</strong> is the ID of the channel you want '
'to add.'
),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _( Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _(
'Enter a YouTube playlist URL into the box below. A playlist URL will be ' 'Enter a YouTube playlist URL into the box below. A playlist URL will be '
'in the format of <strong>https://www.youtube.com/playlist?list=' 'in the format of <strong>https://www.youtube.com/playlist?list='
@@ -150,6 +159,8 @@ class ValidateSourceView(FormView):
} }
help_examples = { help_examples = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/google', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/google',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('https://www.youtube.com/channel/'
'UCK8sQmJBp8GCxrOtXWBpyEA'),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('https://www.youtube.com/playlist?list=' Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('https://www.youtube.com/playlist?list='
'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r') 'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r')
} }
@@ -157,12 +168,21 @@ class ValidateSourceView(FormView):
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: {
'scheme': 'https', 'scheme': 'https',
'domain': 'www.youtube.com', 'domain': 'www.youtube.com',
'path_regex': '^\/(c\/)?([^\/]+)$', 'path_regex': '^\/(c\/)?([^\/]+)(\/videos)?$',
'path_must_not_match': ('/playlist', '/c/playlist'), 'path_must_not_match': ('/playlist', '/c/playlist'),
'qs_args': [], 'qs_args': [],
'extract_key': ('path_regex', 1), 'extract_key': ('path_regex', 1),
'example': 'https://www.youtube.com/SOMECHANNEL' 'example': 'https://www.youtube.com/SOMECHANNEL'
}, },
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: {
'scheme': 'https',
'domain': 'www.youtube.com',
'path_regex': '^\/channel\/([^\/]+)(\/videos)?$',
'path_must_not_match': ('/playlist', '/c/playlist'),
'qs_args': [],
'extract_key': ('path_regex', 0),
'example': 'https://www.youtube.com/channel/CHANNELID'
},
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: { Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: {
'scheme': 'https', 'scheme': 'https',
'domain': 'www.youtube.com', 'domain': 'www.youtube.com',
@@ -175,6 +195,7 @@ class ValidateSourceView(FormView):
} }
prepopulate_fields = { prepopulate_fields = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'), Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('source_type', 'key'),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'), Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'),
} }
@@ -252,9 +273,15 @@ 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', 'index_schedule', fields = ('source_type', 'key', 'name', 'directory', 'media_format',
'delete_old_media', 'days_to_keep', 'source_resolution', 'source_vcodec', 'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback') '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 '
'this page for valid media name variables'),
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.prepopulated_data = {} self.prepopulated_data = {}
@@ -281,6 +308,20 @@ class AddSourceView(CreateView):
initial[k] = v initial[k] = v
return initial return initial
def form_valid(self, form):
# Perform extra validation to make sure the media_format is valid
obj = form.save(commit=False)
source_type = form.cleaned_data['media_format']
example_media_file = obj.get_example_media_format()
if example_media_file == '':
form.add_error(
'media_format',
ValidationError(self.errors['invalid_media_format'])
)
if form.errors:
return super().form_invalid(form)
return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk}) url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
return append_uri_params(url, {'message': 'source-created'}) return append_uri_params(url, {'message': 'source-created'})
@@ -293,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.'),
} }
@@ -323,9 +364,29 @@ 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', 'index_schedule', fields = ('source_type', 'key', 'name', 'directory', 'media_format',
'delete_old_media', 'days_to_keep', 'source_resolution', 'source_vcodec', 'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback') '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 '
'this page for valid media name variables'),
}
def form_valid(self, form):
# Perform extra validation to make sure the media_format is valid
obj = form.save(commit=False)
source_type = form.cleaned_data['media_format']
example_media_file = obj.get_example_media_format()
if example_media_file == '':
form.add_error(
'media_format',
ValidationError(self.errors['invalid_media_format'])
)
if form.errors:
return super().form_invalid(form)
return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk}) url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
@@ -350,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):
@@ -497,6 +563,10 @@ class MediaRedownloadView(FormView, SingleObjectMixin):
if self.object.media_file_exists: if self.object.media_file_exists:
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
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 # Reset all download data
self.object.downloaded = False self.object.downloaded = False
self.object.downloaded_audio_codec = None self.object.downloaded_audio_codec = None
@@ -538,6 +608,10 @@ class MediaSkipView(FormView, SingleObjectMixin):
if self.object.media_file_exists: if self.object.media_file_exists:
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
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 # Reset all download data
self.object.downloaded = False self.object.downloaded = False
self.object.downloaded_audio_codec = None self.object.downloaded_audio_codec = None
@@ -937,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):

View File

@@ -37,7 +37,8 @@ def get_media_info(url):
'skip_download': True, 'skip_download': True,
'forcejson': True, 'forcejson': True,
'simulate': True, 'simulate': True,
'logger': log 'logger': log,
'extract_flat': True,
}) })
response = {} response = {}
with youtube_dl.YoutubeDL(opts) as y: with youtube_dl.YoutubeDL(opts) as y:

View File

@@ -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

View File

@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
DOWNLOADS_BASE_DIR = BASE_DIR DOWNLOADS_BASE_DIR = BASE_DIR
VERSION = 0.3 VERSION = 0.9
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 = [
@@ -113,14 +114,18 @@ Disallow: /
'''.strip() '''.strip()
X_FRAME_OPTIONS = 'SAMEORIGIN'
HEALTHCHECK_FIREWALL = True HEALTHCHECK_FIREWALL = True
HEALTHCHECK_ALLOWED_IPS = ('127.0.0.1',) 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
@@ -147,6 +152,9 @@ YOUTUBE_DEFAULTS = {
} }
MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}'
try: try:
from .local_settings import * from .local_settings import *
except ImportError as e: except ImportError as e: