Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd69e8d40 | ||
|
|
eebef3371f | ||
|
|
4cd6701c8a | ||
|
|
4ebe6f2a37 | ||
|
|
d553d58fde | ||
|
|
df40a1367a | ||
|
|
607ee77e70 | ||
|
|
9af493aa8a | ||
|
|
f0c94ff789 | ||
|
|
39c7799831 | ||
|
|
da7371f830 | ||
|
|
387cfefc8f | ||
|
|
d92dbde781 | ||
|
|
e36658e1a1 | ||
|
|
51cd942717 | ||
|
|
001554db1a | ||
|
|
7cf86bb98d | ||
|
|
c28c095f48 | ||
|
|
12eac049e5 | ||
|
|
304cc153cf | ||
|
|
b45231f533 | ||
|
|
26eb9d30e8 | ||
|
|
97fa62d12b | ||
|
|
1b092fe955 | ||
|
|
18a59fe835 | ||
|
|
410906ad8e | ||
|
|
8f4b09f346 | ||
|
|
cda021cbbf | ||
|
|
ee4df99cd8 | ||
|
|
53f1873a9b | ||
|
|
9434293a84 | ||
|
|
ed69fe9dcc | ||
|
|
67af70569b | ||
|
|
68a62d8a7c | ||
|
|
55578f4de7 | ||
|
|
47313cb6cc | ||
|
|
a854b804f0 | ||
|
|
08c1a82c30 | ||
|
|
25a1a82de4 | ||
|
|
ff58f2811b | ||
|
|
83b9c167a9 | ||
|
|
ffe0049bab | ||
|
|
c1c39d9e17 | ||
|
|
8d7f7e2476 |
@@ -13,7 +13,8 @@ ENV DEBIAN_FRONTEND="noninteractive" \
|
||||
S6_EXPECTED_SHA256="52460473413601ff7a84ae690b161a074217ddc734990c2cdee9847166cf669e" \
|
||||
S6_DOWNLOAD="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-${ARCH}.tar.gz" \
|
||||
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
|
||||
RUN set -x && \
|
||||
|
||||
80
Pipfile.lock
generated
@@ -39,11 +39,11 @@
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
|
||||
"sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03"
|
||||
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
|
||||
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.4"
|
||||
"version": "==3.1.5"
|
||||
},
|
||||
"django-appconf": {
|
||||
"hashes": [
|
||||
@@ -134,44 +134,48 @@
|
||||
},
|
||||
"pillow": {
|
||||
"hashes": [
|
||||
"sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a",
|
||||
"sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae",
|
||||
"sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce",
|
||||
"sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e",
|
||||
"sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140",
|
||||
"sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb",
|
||||
"sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021",
|
||||
"sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6",
|
||||
"sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302",
|
||||
"sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c",
|
||||
"sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271",
|
||||
"sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09",
|
||||
"sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3",
|
||||
"sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015",
|
||||
"sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3",
|
||||
"sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544",
|
||||
"sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8",
|
||||
"sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792",
|
||||
"sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0",
|
||||
"sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3",
|
||||
"sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8",
|
||||
"sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11",
|
||||
"sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7",
|
||||
"sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11",
|
||||
"sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e",
|
||||
"sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039",
|
||||
"sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5",
|
||||
"sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72"
|
||||
"sha256:165c88bc9d8dba670110c689e3cc5c71dbe4bfb984ffa7cbebf1fac9554071d6",
|
||||
"sha256:1d208e670abfeb41b6143537a681299ef86e92d2a3dac299d3cd6830d5c7bded",
|
||||
"sha256:22d070ca2e60c99929ef274cfced04294d2368193e935c5d6febfd8b601bf865",
|
||||
"sha256:2353834b2c49b95e1313fb34edf18fca4d57446675d05298bb694bca4b194174",
|
||||
"sha256:39725acf2d2e9c17356e6835dccebe7a697db55f25a09207e38b835d5e1bc032",
|
||||
"sha256:3de6b2ee4f78c6b3d89d184ade5d8fa68af0848f9b6b6da2b9ab7943ec46971a",
|
||||
"sha256:47c0d93ee9c8b181f353dbead6530b26980fe4f5485aa18be8f1fd3c3cbc685e",
|
||||
"sha256:5e2fe3bb2363b862671eba632537cd3a823847db4d98be95690b7e382f3d6378",
|
||||
"sha256:604815c55fd92e735f9738f65dabf4edc3e79f88541c221d292faec1904a4b17",
|
||||
"sha256:6c5275bd82711cd3dcd0af8ce0bb99113ae8911fc2952805f1d012de7d600a4c",
|
||||
"sha256:731ca5aabe9085160cf68b2dbef95fc1991015bc0a3a6ea46a371ab88f3d0913",
|
||||
"sha256:7612520e5e1a371d77e1d1ca3a3ee6227eef00d0a9cddb4ef7ecb0b7396eddf7",
|
||||
"sha256:7916cbc94f1c6b1301ac04510d0881b9e9feb20ae34094d3615a8a7c3db0dcc0",
|
||||
"sha256:81c3fa9a75d9f1afafdb916d5995633f319db09bd773cb56b8e39f1e98d90820",
|
||||
"sha256:887668e792b7edbfb1d3c9d8b5d8c859269a0f0eba4dda562adb95500f60dbba",
|
||||
"sha256:93a473b53cc6e0b3ce6bf51b1b95b7b1e7e6084be3a07e40f79b42e83503fbf2",
|
||||
"sha256:96d4dc103d1a0fa6d47c6c55a47de5f5dafd5ef0114fa10c85a1fd8e0216284b",
|
||||
"sha256:a3d3e086474ef12ef13d42e5f9b7bbf09d39cf6bd4940f982263d6954b13f6a9",
|
||||
"sha256:b02a0b9f332086657852b1f7cb380f6a42403a6d9c42a4c34a561aa4530d5234",
|
||||
"sha256:b09e10ec453de97f9a23a5aa5e30b334195e8d2ddd1ce76cc32e52ba63c8b31d",
|
||||
"sha256:b6f00ad5ebe846cc91763b1d0c6d30a8042e02b2316e27b05de04fa6ec831ec5",
|
||||
"sha256:bba80df38cfc17f490ec651c73bb37cd896bc2400cfba27d078c2135223c1206",
|
||||
"sha256:c3d911614b008e8a576b8e5303e3db29224b455d3d66d1b2848ba6ca83f9ece9",
|
||||
"sha256:ca20739e303254287138234485579b28cb0d524401f83d5129b5ff9d606cb0a8",
|
||||
"sha256:cb192176b477d49b0a327b2a5a4979552b7a58cd42037034316b8018ac3ebb59",
|
||||
"sha256:cdbbe7dff4a677fb555a54f9bc0450f2a21a93c5ba2b44e09e54fcb72d2bd13d",
|
||||
"sha256:cf6e33d92b1526190a1de904df21663c46a456758c0424e4f947ae9aa6088bf7",
|
||||
"sha256:d355502dce85ade85a2511b40b4c61a128902f246504f7de29bbeec1ae27933a",
|
||||
"sha256:d673c4990acd016229a5c1c4ee8a9e6d8f481b27ade5fc3d95938697fa443ce0",
|
||||
"sha256:dc577f4cfdda354db3ae37a572428a90ffdbe4e51eda7849bf442fb803f09c9b",
|
||||
"sha256:dd9eef866c70d2cbbea1ae58134eaffda0d4bfea403025f4db6859724b18ab3d",
|
||||
"sha256:f50e7a98b0453f39000619d845be8b06e611e56ee6e8186f7f60c3b1e2f0feae"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==8.0.1"
|
||||
"version": "==8.1.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
|
||||
"sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
|
||||
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||
],
|
||||
"version": "==2020.4"
|
||||
"version": "==2020.5"
|
||||
},
|
||||
"rcssmin": {
|
||||
"hashes": [
|
||||
@@ -236,11 +240,11 @@
|
||||
},
|
||||
"youtube-dl": {
|
||||
"hashes": [
|
||||
"sha256:65968065e66966955dc79fad9251565fcc982566118756da624bd21467f3a04c",
|
||||
"sha256:eaa859f15b6897bec21474b7787dc958118c8088e1f24d4ef1d58eab13188958"
|
||||
"sha256:8f421ca8394d2529e06225e44ec66538d2a28f6f340c03065776894bf3d24ea6",
|
||||
"sha256:acf74701a31b6c3d06f9d4245a46ba8fb6c378931681177412043c6e8276fee7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2020.12.14"
|
||||
"version": "==2021.1.16"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
|
||||
60
README.md
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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,
|
||||
hopefully, quite reliable.
|
||||
|
||||
|
||||
# Latest container image
|
||||
|
||||
```yaml
|
||||
ghcr.io/meeb/tubesync:v0.8
|
||||
```
|
||||
|
||||
**NOTE: the `:latest` tag does exist, but will contain in-development commits and may
|
||||
be broken. Use at your own risk.**
|
||||
|
||||
# Screenshots
|
||||
|
||||
### Dashboard
|
||||
|
||||

|
||||

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

|
||||

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

|
||||

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

|
||||

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

|
||||

|
||||
|
||||
|
||||
# Requirements
|
||||
@@ -92,7 +102,7 @@ Finally, download and run the container:
|
||||
|
||||
```bash
|
||||
# Pull a versioned image
|
||||
$ docker pull ghcr.io/meeb/tubesync:v0.3
|
||||
$ docker pull ghcr.io/meeb/tubesync:v0.8
|
||||
# Start the container using your user ID and group ID
|
||||
$ docker run \
|
||||
-d \
|
||||
@@ -103,7 +113,7 @@ $ docker run \
|
||||
-v /some/directory/tubesync-config:/config \
|
||||
-v /some/directory/tubesync-downloads:/downloads \
|
||||
-p 4848:4848 \
|
||||
ghcr.io/meeb/tubesync:v0.3
|
||||
ghcr.io/meeb/tubesync:v0.8
|
||||
```
|
||||
|
||||
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
|
||||
tubesync:
|
||||
image: ghcr.io/meeb/tubesync:v0.3
|
||||
image: ghcr.io/meeb/tubesync:v0.8
|
||||
container_name: tubesync
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -124,7 +134,7 @@ Alternatively, for Docker Compose, you can use something like:
|
||||
- /some/directory/tubesync-config:/config
|
||||
- /some/directory/tubesync-downloads:/downloads
|
||||
environment:
|
||||
- TZ=$TIMEZONE
|
||||
- TZ=Europe/London
|
||||
- PUID=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.
|
||||
|
||||
```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.
|
||||
@@ -190,10 +200,10 @@ $ docker logs --follow tubesync
|
||||
|
||||
### 1. Index frequency
|
||||
|
||||
It's a good idea to add sources with as low an index frequency as possible. This is the
|
||||
duration between indexes of the source. An index is when TubeSync checks to see
|
||||
It's a good idea to add sources with as long of an index frequency as possible. This is
|
||||
the duration between indexes of the source. An index is when TubeSync checks to see
|
||||
what videos available on a channel or playlist to find new media. Try and keep this as
|
||||
long as possible, 24 hours if possible.
|
||||
long as possible, up to 24 hours.
|
||||
|
||||
|
||||
### 2. Indexing massive channels
|
||||
@@ -203,6 +213,14 @@ every hour" or similar short interval it's entirely possible your TubeSync insta
|
||||
spend its entire time just indexing the massive channel over and over again without
|
||||
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
|
||||
|
||||
@@ -216,7 +234,7 @@ automatically.
|
||||
|
||||
### Does TubeSync support any other video platforms?
|
||||
|
||||
At the moment, no. This is a first release. The library TubeSync uses that does most
|
||||
At the moment, no. This is a pre-release. The library TubeSync uses that does most
|
||||
of the downloading work, `youtube-dl`, supports many hundreds of video sources so it's
|
||||
likely more will be added to TubeSync if there is demand for it.
|
||||
|
||||
@@ -230,7 +248,7 @@ your install is doing check the container logs.
|
||||
### Are there alerts when a download is complete?
|
||||
|
||||
No, this feature is best served by existing services such as the execelent
|
||||
[tautulli](https://tautulli.com/) which can monitor your Plex server and send alerts
|
||||
[Tautulli](https://tautulli.com/) which can monitor your Plex server and send alerts
|
||||
that way.
|
||||
|
||||
### There's errors in my "tasks" tab!
|
||||
@@ -287,13 +305,15 @@ Just `amd64` for the moment. Others may be made available if there is demand.
|
||||
# Advanced configuration
|
||||
|
||||
There are a number of other environment variables you can set. These are, mostly,
|
||||
**NOT** required to be set in the default container installation, they are mostly
|
||||
**NOT** required to be set in the default container installation, they are really only
|
||||
useful if you are manually installing TubeSync in some other environment. These are:
|
||||
|
||||
| Name | What | Example |
|
||||
| ----------------- | ------------------------------------- | ---------------------------------- |
|
||||
| DJANGO_SECRET_KEY | Django secret key | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||
| ------------------------ | ------------------------------------------------------------ | ---------------------------------- |
|
||||
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||
| DJANGO_FORCE_SCRIPT_NAME | Django's FORCE_SCRIPT_NAME | /somepath |
|
||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS | tubesync.example.com,otherhost.com |
|
||||
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||
@@ -303,7 +323,7 @@ useful if you are manually installing TubeSync in some other environment. These
|
||||
# Manual, non-containerised, installation
|
||||
|
||||
As a relatively normal Django app you can run TubeSync without the container. Beyond
|
||||
the following rough guide you are on your own and should be knowledgeable about
|
||||
following this rough guide you are on your own and should be knowledgeable about
|
||||
installing and running WSGI-based Python web applications before attempting this.
|
||||
|
||||
1. Clone or download this repo
|
||||
|
||||
BIN
docs/dashboard-v0.5.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 170 KiB |
BIN
docs/media-item-v0.5.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 420 KiB |
BIN
docs/media-v0.5.png
Normal file
|
After Width: | Height: | Size: 666 KiB |
BIN
docs/media.png
|
Before Width: | Height: | Size: 530 KiB |
BIN
docs/source-v0.5.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
docs/source.png
|
Before Width: | Height: | Size: 137 KiB |
BIN
docs/sources-v0.5.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/sources.png
|
Before Width: | Height: | Size: 51 KiB |
@@ -4,7 +4,7 @@
|
||||
}
|
||||
.help-text {
|
||||
color: $form-help-text-colour;
|
||||
padding: 1rem 0 1rem 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
label {
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -5,6 +5,13 @@ html {
|
||||
color: $text-colour;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
header {
|
||||
|
||||
background-color: $header-background-colour;
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
|
||||
<header>
|
||||
<div class="container">
|
||||
<a href="{% url 'sync:dashboard' %}">
|
||||
@@ -43,6 +45,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>
|
||||
|
||||
@@ -14,3 +14,13 @@ def append_uri_params(uri, params):
|
||||
uri = str(uri)
|
||||
qs = urlencode(params)
|
||||
return urlunsplit(('', '', uri, qs, ''))
|
||||
|
||||
|
||||
def clean_filename(filename):
|
||||
if not isinstance(filename, str):
|
||||
raise ValueError(f'filename must be a str, got {type(filename)}')
|
||||
to_scrub = '<>\/:*?"|'
|
||||
for char in to_scrub:
|
||||
filename = filename.replace(char, '')
|
||||
filename = ''.join([c for c in filename if ord(c) > 30])
|
||||
return ' '.join(filename.split())
|
||||
|
||||
0
tubesync/sync/management/__init__.py
Normal file
0
tubesync/sync/management/commands/__init__.py
Normal file
18
tubesync/sync/management/commands/youtube-dl-info.py
Normal 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')
|
||||
@@ -66,7 +66,7 @@ def get_best_audio_format(media):
|
||||
# No codecs matched
|
||||
if media.source.can_fallback:
|
||||
# Can fallback, find the next highest bitrate non-matching codec
|
||||
return False, audio_formats[0]
|
||||
return False, audio_formats[0]['id']
|
||||
else:
|
||||
# Can't fallback
|
||||
return False, False
|
||||
|
||||
18
tubesync/sync/migrations/0003_source_copy_thumbnails.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
18
tubesync/sync/migrations/0004_source_media_format.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
18
tubesync/sync/migrations/0005_auto_20201219_0312.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-19 03:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sync', '0004_source_media_format'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='source',
|
||||
name='source_type',
|
||||
field=models.CharField(choices=[('c', 'YouTube channel'), ('i', 'YouTube channel by ID'), ('p', 'YouTube playlist')], db_index=True, default='c', help_text='Source type', max_length=1, verbose_name='source type'),
|
||||
),
|
||||
]
|
||||
18
tubesync/sync/migrations/0006_source_write_nfo.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-19 03:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sync', '0005_auto_20201219_0312'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='write_nfo',
|
||||
field=models.BooleanField(default=False, help_text='Write an NFO file with the media, these may be detected and used by some media servers', verbose_name='write nfo'),
|
||||
),
|
||||
]
|
||||
18
tubesync/sync/migrations/0007_auto_20201219_0645.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-19 06:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sync', '0006_source_write_nfo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='source',
|
||||
name='write_nfo',
|
||||
field=models.BooleanField(default=False, help_text='Write an NFO file in XML with the media info, these may be detected and used by some media servers', verbose_name='write nfo'),
|
||||
),
|
||||
]
|
||||
18
tubesync/sync/migrations/0008_source_download_cap.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-19 06:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sync', '0007_auto_20201219_0645'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='download_cap',
|
||||
field=models.IntegerField(choices=[(0, 'No cap'), (604800, '1 week (7 days)'), (2592000, '1 month (30 days)'), (7776000, '3 months (90 days)'), (15552000, '6 months (180 days)'), (31536000, '1 year (365 days)'), (63072000, '2 years (730 days)'), (94608000, '3 years (1095 days)'), (157680000, '5 years (1825 days)'), (315360000, '10 years (3650 days)')], default=0, help_text='Do not download media older than this capped date', verbose_name='download cap'),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,18 @@
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime
|
||||
from xml.etree import ElementTree
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from common.errors import NoFormatException
|
||||
from common.utils import clean_filename
|
||||
from .youtube import (get_media_info as get_youtube_media_info,
|
||||
download_media as download_youtube_media)
|
||||
from .utils import seconds_to_timestr, parse_media_format
|
||||
@@ -27,10 +31,13 @@ class Source(models.Model):
|
||||
'''
|
||||
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL = 'c'
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL_ID = 'i'
|
||||
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_YOUTUBE_CHANNEL, _('YouTube channel')),
|
||||
(SOURCE_TYPE_YOUTUBE_CHANNEL_ID, _('YouTube channel by ID')),
|
||||
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
|
||||
)
|
||||
|
||||
@@ -97,24 +104,40 @@ class Source(models.Model):
|
||||
# Fontawesome icons used for the source on the front end
|
||||
ICONS = {
|
||||
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>',
|
||||
}
|
||||
# Format to use to display a URL for the source
|
||||
URLS = {
|
||||
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}',
|
||||
}
|
||||
# Callback functions to get a list of media from the source
|
||||
INDEXERS = {
|
||||
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,
|
||||
}
|
||||
# Field names to find the media ID used as the key when storing media
|
||||
KEY_FIELD = {
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL: 'id',
|
||||
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '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):
|
||||
EVERY_HOUR = 3600, _('Every hour')
|
||||
EVERY_2_HOURS = 7200, _('Every 2 hours')
|
||||
@@ -174,6 +197,12 @@ class Source(models.Model):
|
||||
unique=True,
|
||||
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'),
|
||||
choices=IndexSchedule.choices,
|
||||
@@ -181,6 +210,12 @@ class Source(models.Model):
|
||||
default=IndexSchedule.EVERY_6_HOURS,
|
||||
help_text=_('Schedule of how often to index the source for new media')
|
||||
)
|
||||
download_cap = models.IntegerField(
|
||||
_('download cap'),
|
||||
choices=CapChoices.choices,
|
||||
default=CapChoices.CAP_NOCAP,
|
||||
help_text=_('Do not download media older than this capped date')
|
||||
)
|
||||
delete_old_media = models.BooleanField(
|
||||
_('delete old media'),
|
||||
default=False,
|
||||
@@ -234,6 +269,16 @@ class Source(models.Model):
|
||||
default=FALLBACK_NEXT_BEST_HD,
|
||||
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'),
|
||||
default=False,
|
||||
@@ -251,6 +296,11 @@ class Source(models.Model):
|
||||
def icon(self):
|
||||
return self.ICONS.get(self.source_type)
|
||||
|
||||
@property
|
||||
def slugname(self):
|
||||
replaced = self.name.replace('_', '-').replace('&', 'and').replace('+', 'and')
|
||||
return slugify(replaced)[:80]
|
||||
|
||||
@property
|
||||
def is_audio(self):
|
||||
return self.source_resolution == self.SOURCE_RESOLUTION_AUDIO
|
||||
@@ -259,6 +309,14 @@ class Source(models.Model):
|
||||
def is_video(self):
|
||||
return not self.is_audio
|
||||
|
||||
@property
|
||||
def download_cap_date(self):
|
||||
delta = self.download_cap
|
||||
if delta > 0:
|
||||
return timezone.now() - timedelta(seconds=delta)
|
||||
else:
|
||||
return False
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
'''
|
||||
@@ -325,6 +383,53 @@ class Source(models.Model):
|
||||
def can_fallback(self):
|
||||
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):
|
||||
'''
|
||||
Index the media source returning a list of media metadata as dicts.
|
||||
@@ -373,34 +478,81 @@ class Media(models.Model):
|
||||
# Format to use to display a URL for the media
|
||||
URLS = {
|
||||
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}',
|
||||
}
|
||||
# Maps standardised names to names used in source metdata
|
||||
METADATA_FIELDS = {
|
||||
'upload_date': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date',
|
||||
},
|
||||
'title': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title',
|
||||
},
|
||||
'thumbnail': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'thumbnail',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail',
|
||||
},
|
||||
'description': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'description',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description',
|
||||
},
|
||||
'duration': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'duration',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration',
|
||||
},
|
||||
'formats': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats',
|
||||
}
|
||||
},
|
||||
'categories': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'categories',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'categories',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'categories',
|
||||
},
|
||||
'rating': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'average_rating',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'average_rating',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'average_rating',
|
||||
},
|
||||
'age_limit': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'age_limit',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'age_limit',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'age_limit',
|
||||
},
|
||||
'uploader': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'uploader',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'uploader',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'uploader',
|
||||
},
|
||||
'upvotes': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'like_count',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'like_count',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'like_count',
|
||||
},
|
||||
'downvotes': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'dislike_count',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'dislike_count',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'dislike_count',
|
||||
},
|
||||
'playlist_index': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_index',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_index',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_index',
|
||||
},
|
||||
'playlist_title': {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title',
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title',
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_title',
|
||||
},
|
||||
}
|
||||
STATE_UNKNOWN = 'unknown'
|
||||
STATE_SCHEDULED = 'scheduled'
|
||||
@@ -634,16 +786,39 @@ class Media(models.Model):
|
||||
('720p', 'avc1', 'mp4a', '60fps', 'hdr')
|
||||
'''
|
||||
fmt = []
|
||||
resolution = ''
|
||||
vcodec = ''
|
||||
acodec = ''
|
||||
height = '0'
|
||||
width = '0'
|
||||
fps = ''
|
||||
hdr = ''
|
||||
# If the download has completed use existing values
|
||||
if self.downloaded:
|
||||
resolution = f'{self.downloaded_height}p'
|
||||
if self.downloaded_format != 'audio':
|
||||
fmt.append(self.downloaded_video_codec.lower())
|
||||
fmt.append(self.downloaded_audio_codec.lower())
|
||||
vcodec = self.downloaded_video_codec.lower()
|
||||
fmt.append(vcodec)
|
||||
acodec = self.downloaded_audio_codec.lower()
|
||||
fmt.append(acodec)
|
||||
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:
|
||||
fmt.append('hdr')
|
||||
return fmt
|
||||
hdr = 'hdr'
|
||||
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
|
||||
vformat = None
|
||||
aformat = None
|
||||
@@ -660,15 +835,32 @@ class Media(models.Model):
|
||||
# Combined
|
||||
vformat = cformat
|
||||
if vformat:
|
||||
fmt.append(vformat['format'].lower())
|
||||
fmt.append(vformat['vcodec'].lower())
|
||||
fmt.append(aformat['acodec'].lower())
|
||||
resolution = vformat['format'].lower()
|
||||
fmt.append(resolution)
|
||||
vcodec = vformat['vcodec'].lower()
|
||||
fmt.append(vcodec)
|
||||
if aformat:
|
||||
acodec = aformat['acodec'].lower()
|
||||
fmt.append(acodec)
|
||||
if vformat:
|
||||
if vformat['is_60fps']:
|
||||
fmt.append('60fps')
|
||||
fps = '60fps'
|
||||
fmt.append(fps)
|
||||
if vformat['is_hdr']:
|
||||
fmt.append('hdr')
|
||||
return tuple(fmt)
|
||||
hdr = 'hdr'
|
||||
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):
|
||||
'''
|
||||
@@ -679,6 +871,39 @@ class Media(models.Model):
|
||||
return fmt
|
||||
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 loaded_metadata(self):
|
||||
try:
|
||||
@@ -701,6 +926,11 @@ class Media(models.Model):
|
||||
field = self.get_metadata_field('title')
|
||||
return self.loaded_metadata.get(field, '').strip()
|
||||
|
||||
@property
|
||||
def slugtitle(self):
|
||||
replaced = self.title.replace('_', '-').replace('&', 'and').replace('+', 'and')
|
||||
return slugify(replaced)[:80]
|
||||
|
||||
@property
|
||||
def thumbnail(self):
|
||||
field = self.get_metadata_field('thumbnail')
|
||||
@@ -732,27 +962,83 @@ class Media(models.Model):
|
||||
return seconds_to_timestr(duration)
|
||||
return '??:??:??'
|
||||
|
||||
@property
|
||||
def categories(self):
|
||||
field = self.get_metadata_field('categories')
|
||||
return self.loaded_metadata.get(field, [])
|
||||
|
||||
@property
|
||||
def rating(self):
|
||||
field = self.get_metadata_field('rating')
|
||||
return self.loaded_metadata.get(field, 0)
|
||||
|
||||
@property
|
||||
def votes(self):
|
||||
field = self.get_metadata_field('upvotes')
|
||||
upvotes = self.loaded_metadata.get(field, 0)
|
||||
field = self.get_metadata_field('downvotes')
|
||||
downvotes = self.loaded_metadata.get(field, 0)
|
||||
return upvotes + downvotes
|
||||
|
||||
@property
|
||||
def age_limit(self):
|
||||
field = self.get_metadata_field('age_limit')
|
||||
return self.loaded_metadata.get(field, 0)
|
||||
|
||||
@property
|
||||
def uploader(self):
|
||||
field = self.get_metadata_field('uploader')
|
||||
return self.loaded_metadata.get(field, '')
|
||||
|
||||
@property
|
||||
def formats(self):
|
||||
field = self.get_metadata_field('formats')
|
||||
return self.loaded_metadata.get(field, [])
|
||||
|
||||
@property
|
||||
def playlist_index(self):
|
||||
field = self.get_metadata_field('playlist_index')
|
||||
return self.loaded_metadata.get(field, 0)
|
||||
|
||||
@property
|
||||
def playlist_title(self):
|
||||
field = self.get_metadata_field('playlist_title')
|
||||
return self.loaded_metadata.get(field, '')
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
if self.media_file:
|
||||
return os.path.basename(self.media_file.name)
|
||||
upload_date = self.upload_date
|
||||
dateobj = upload_date if upload_date else self.created
|
||||
datestr = dateobj.strftime('%Y-%m-%d')
|
||||
source_name = slugify(self.source.name).replace('_', '-')
|
||||
name = slugify(self.name.replace('&', 'and').replace('+', 'and'))
|
||||
name = name.replace('_', '-')[:80]
|
||||
key = self.key.strip().replace('_', '-')[:20]
|
||||
format_str = self.get_format_str()
|
||||
format_tuple = self.get_display_format(format_str)
|
||||
fmt = '-'.join(format_tuple)
|
||||
ext = self.source.extension
|
||||
return f'{datestr}_{source_name}_{name}_{key}_{fmt}.{ext}'
|
||||
# Create a suitable filename from the source media_format
|
||||
media_format = str(self.source.media_format)
|
||||
media_details = self.format_dict
|
||||
return media_format.format(**media_details)
|
||||
|
||||
@property
|
||||
def thumbname(self):
|
||||
filename = self.filename
|
||||
prefix, ext = os.path.splitext(filename)
|
||||
return f'{prefix}.jpg'
|
||||
|
||||
@property
|
||||
def thumbpath(self):
|
||||
return self.source.directory_path / self.thumbname
|
||||
|
||||
@property
|
||||
def nfoname(self):
|
||||
filename = self.filename
|
||||
prefix, ext = os.path.splitext(filename)
|
||||
return f'{prefix}.nfo'
|
||||
|
||||
@property
|
||||
def nfopath(self):
|
||||
return self.source.directory_path / self.nfoname
|
||||
|
||||
@property
|
||||
def directory_path(self):
|
||||
# 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
|
||||
def filepath(self):
|
||||
@@ -770,6 +1056,103 @@ class Media(models.Model):
|
||||
return False
|
||||
return os.path.exists(self.media_file.path)
|
||||
|
||||
@property
|
||||
def nfoxml(self):
|
||||
'''
|
||||
Returns an NFO formatted (prettified) XML string.
|
||||
'''
|
||||
nfo = ElementTree.Element('episodedetails')
|
||||
nfo.text = '\n '
|
||||
# title = media metadata title
|
||||
title = nfo.makeelement('title', {})
|
||||
title.text = str(self.name).strip()
|
||||
title.tail = '\n '
|
||||
nfo.append(title)
|
||||
# showtitle = source name
|
||||
showtitle = nfo.makeelement('showtitle', {})
|
||||
showtitle.text = str(self.source.name).strip()
|
||||
showtitle.tail = '\n '
|
||||
nfo.append(showtitle)
|
||||
# ratings = media metadata youtube rating
|
||||
value = nfo.makeelement('value', {})
|
||||
value.text = str(self.rating)
|
||||
value.tail = '\n '
|
||||
votes = nfo.makeelement('votes', {})
|
||||
votes.text = str(self.votes)
|
||||
votes.tail = '\n '
|
||||
rating_attrs = OrderedDict()
|
||||
rating_attrs['name'] = 'youtube'
|
||||
rating_attrs['max'] = '5'
|
||||
rating_attrs['default'] = 'True'
|
||||
rating = nfo.makeelement('rating', rating_attrs)
|
||||
rating.text = '\n '
|
||||
rating.append(value)
|
||||
rating.append(votes)
|
||||
rating.tail = '\n '
|
||||
ratings = nfo.makeelement('ratings', {})
|
||||
ratings.text = '\n '
|
||||
ratings.append(rating)
|
||||
ratings.tail = '\n '
|
||||
nfo.append(ratings)
|
||||
# plot = media metadata description
|
||||
plot = nfo.makeelement('plot', {})
|
||||
plot.text = str(self.description).strip()
|
||||
plot.tail = '\n '
|
||||
nfo.append(plot)
|
||||
# thumb = local path to media thumbnail
|
||||
thumb = nfo.makeelement('thumb', {})
|
||||
thumb.text = self.thumbname if self.source.copy_thumbnails else ''
|
||||
thumb.tail = '\n '
|
||||
nfo.append(thumb)
|
||||
# mpaa = media metadata age requirement
|
||||
mpaa = nfo.makeelement('mpaa', {})
|
||||
mpaa.text = str(self.age_limit)
|
||||
mpaa.tail = '\n '
|
||||
nfo.append(mpaa)
|
||||
# runtime = media metadata duration in seconds
|
||||
runtime = nfo.makeelement('runtime', {})
|
||||
runtime.text = str(self.duration)
|
||||
runtime.tail = '\n '
|
||||
nfo.append(runtime)
|
||||
# id = media key
|
||||
idn = nfo.makeelement('id', {})
|
||||
idn.text = str(self.key).strip()
|
||||
idn.tail = '\n '
|
||||
nfo.append(idn)
|
||||
# uniqueid = media key
|
||||
uniqueid_attrs = OrderedDict()
|
||||
uniqueid_attrs['type'] = 'youtube'
|
||||
uniqueid_attrs['default'] = 'True'
|
||||
uniqueid = nfo.makeelement('uniqueid', uniqueid_attrs)
|
||||
uniqueid.text = str(self.key).strip()
|
||||
uniqueid.tail = '\n '
|
||||
nfo.append(uniqueid)
|
||||
# studio = media metadata uploader
|
||||
studio = nfo.makeelement('studio', {})
|
||||
studio.text = str(self.uploader).strip()
|
||||
studio.tail = '\n '
|
||||
nfo.append(studio)
|
||||
# aired = media metadata uploaded date
|
||||
aired = nfo.makeelement('aired', {})
|
||||
upload_date = self.upload_date
|
||||
aired.text = upload_date.strftime('%Y-%m-%d') if upload_date else ''
|
||||
aired.tail = '\n '
|
||||
nfo.append(aired)
|
||||
# dateadded = date and time media was created in tubesync
|
||||
dateadded = nfo.makeelement('dateadded', {})
|
||||
dateadded.text = self.created.strftime('%Y-%m-%d %H:%M:%S')
|
||||
dateadded.tail = '\n '
|
||||
nfo.append(dateadded)
|
||||
# genre = any media metadata categories if they exist
|
||||
for category_str in self.categories:
|
||||
genre = nfo.makeelement('genre', {})
|
||||
genre.text = str(category_str).strip()
|
||||
genre.tail = '\n '
|
||||
nfo.append(genre)
|
||||
nfo[-1].tail = '\n'
|
||||
# Return XML tree as a prettified string
|
||||
return ElementTree.tostring(nfo, encoding='utf8', method='xml').decode('utf8')
|
||||
|
||||
def get_download_state(self, task=None):
|
||||
if self.downloaded:
|
||||
return self.STATE_DOWNLOADED
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
|
||||
from django.dispatch import receiver
|
||||
@@ -144,14 +145,7 @@ def media_pre_delete(sender, instance, **kwargs):
|
||||
if thumbnail_url:
|
||||
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
||||
(str(instance.pk), thumbnail_url))
|
||||
# Delete media thumbnail if it exists
|
||||
if instance.thumb:
|
||||
log.info(f'Deleting thumbnail for: {instance} path: {instance.thumb.path}')
|
||||
delete_file(instance.thumb.path)
|
||||
# Delete the media file if it exists
|
||||
if instance.media_file:
|
||||
log.info(f'Deleting media for: {instance} path: {instance.media_file.path}')
|
||||
delete_file(instance.media_file.path)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Media)
|
||||
def media_post_delete(sender, instance, **kwargs):
|
||||
|
||||
@@ -11,6 +11,7 @@ import uuid
|
||||
from io import BytesIO
|
||||
from hashlib import sha1
|
||||
from datetime import timedelta
|
||||
from shutil import copyfile
|
||||
from PIL import Image
|
||||
from django.conf import settings
|
||||
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.errors import NoMediaException, DownloadFailedException
|
||||
from .models import Source, Media, MediaServer
|
||||
from .utils import get_remote_image, resize_image_to_height, delete_file
|
||||
from .utils import (get_remote_image, resize_image_to_height, delete_file,
|
||||
write_text_file)
|
||||
|
||||
|
||||
def get_hash(task_name, pk):
|
||||
@@ -185,6 +187,14 @@ def index_source_task(source_id):
|
||||
else:
|
||||
log.error(f'Media has no upload date, skipping: {source} / {media}')
|
||||
continue
|
||||
# If the source has a download cap date check the upload date is allowed
|
||||
max_cap_age = source.download_cap_date
|
||||
if max_cap_age:
|
||||
if media.published < max_cap_age:
|
||||
# Media was published after the cap date, skip it
|
||||
log.warn(f'Media: {source} / {media} is older than cap age '
|
||||
f'{max_cap_age}, skipping')
|
||||
continue
|
||||
# If the source has a cut-off check the upload date is within the allowed delta
|
||||
if source.delete_old_media and source.days_to_keep > 0:
|
||||
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
||||
@@ -242,7 +252,7 @@ def download_media_thumbnail(media_id, url):
|
||||
f'{width}x{height}: {url}')
|
||||
i = resize_image_to_height(i, width, height)
|
||||
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)
|
||||
media.thumb.save(
|
||||
'thumb',
|
||||
@@ -272,17 +282,18 @@ def download_media(media_id):
|
||||
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but it '
|
||||
f'is now marked to be skipped, not downloading')
|
||||
return
|
||||
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{media.filepath}"')
|
||||
filepath = media.filepath
|
||||
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{filepath}"')
|
||||
format_str, container = media.download_media()
|
||||
if os.path.exists(media.filepath):
|
||||
if os.path.exists(filepath):
|
||||
# Media has been downloaded successfully
|
||||
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
|
||||
media.media_file.name = str(media.filepath)
|
||||
media.media_file.name = str(filepath)
|
||||
media.downloaded = True
|
||||
media.download_date = timezone.now()
|
||||
media.downloaded_filesize = os.path.getsize(media.filepath)
|
||||
media.downloaded_filesize = os.path.getsize(filepath)
|
||||
media.downloaded_container = container
|
||||
if '+' in format_str:
|
||||
# Seperate audio and video streams
|
||||
@@ -304,7 +315,7 @@ def download_media(media_id):
|
||||
media.downloaded_audio_codec = cformat['acodec']
|
||||
if cformat['vcodec']:
|
||||
# Combined
|
||||
media.downloaded_format = vformat['format']
|
||||
media.downloaded_format = cformat['format']
|
||||
media.downloaded_height = cformat['height']
|
||||
media.downloaded_width = cformat['width']
|
||||
media.downloaded_video_codec = cformat['vcodec']
|
||||
@@ -313,6 +324,15 @@ def download_media(media_id):
|
||||
else:
|
||||
media.downloaded_format = 'audio'
|
||||
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
|
||||
for mediaserver in MediaServer.objects.all():
|
||||
log.info(f'Scheduling media server updates')
|
||||
|
||||
117
tubesync/sync/templates/sync/_mediaformatvars.html
Normal 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>
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="collection">
|
||||
{% for media in latest_downloads %}
|
||||
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
||||
<div class="truncate"><strong>{{ media.name }}</strong> ({{ media.source }})</div>
|
||||
<div class="truncate"><strong>{{ media.name }}</strong></div>
|
||||
<div class="truncate"><strong>{{ media.download_date|timesince:now }}</strong> ago from "{{ media.source.name }}"</div>
|
||||
</a>
|
||||
{% empty %}
|
||||
@@ -89,7 +89,7 @@
|
||||
{% for media in largest_downloads %}
|
||||
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
||||
<div class="truncate">{{ media.name }}</div>
|
||||
<div class="truncate"><strong>{{ media.downloaded_filesize|filesizeformat }}</strong>{% if media.downloaded_format %} in {{ media.downloaded_format }}{% endif %}</div>
|
||||
<div class="truncate"><strong>{{ media.downloaded_filesize|filesizeformat }}</strong>{% if media.downloaded_format %} in {{ media.downloaded_format }}{% endif %} from "{{ media.source.name }}"</div>
|
||||
</a>
|
||||
{% empty %}
|
||||
<span class="collection-item">No media has been downloaded.</span>
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
<td class="hide-on-small-only">Filename</td>
|
||||
<td><span class="hide-on-med-and-up">Filename<br></span><strong>{{ media.filename }}</strong></td>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -23,4 +23,9 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
{% include 'sync/_mediaformatvars.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -25,4 +25,9 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
{% include 'sync/_mediaformatvars.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -43,6 +43,20 @@
|
||||
<td class="hide-on-small-only">Directory</td>
|
||||
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
|
||||
</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">
|
||||
<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>
|
||||
@@ -85,6 +99,14 @@
|
||||
<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>
|
||||
</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 %}
|
||||
<tr title="Days after which your media from this source will be locally deleted">
|
||||
<td class="hide-on-small-only">Delete old media</td>
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
</div>
|
||||
{% include 'infobox.html' with message=message %}
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
17
tubesync/sync/testdata/metadata.json
vendored
@@ -3,15 +3,24 @@
|
||||
"upload_date":"20170911",
|
||||
"license":null,
|
||||
"creator":null,
|
||||
"title":"no fancy stuff",
|
||||
"title":"no fancy stuff title",
|
||||
"alt_title":null,
|
||||
"description":"no fancy stuff",
|
||||
"categories":[],
|
||||
"description":"no fancy stuff desc",
|
||||
"average_rating": 1.2345,
|
||||
"dislike_count": 123,
|
||||
"like_count": 456,
|
||||
"playlist_index": 789,
|
||||
"playlist_title": "test playlist",
|
||||
"uploader": "test uploader",
|
||||
"categories":[
|
||||
"test category 1",
|
||||
"test category 2"
|
||||
],
|
||||
"tags":[],
|
||||
"subtitles":{},
|
||||
"automatic_captions":{},
|
||||
"duration":401.0,
|
||||
"age_limit":0,
|
||||
"age_limit":50,
|
||||
"annotations":null,
|
||||
"chapters":null,
|
||||
"formats":[
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit
|
||||
from xml.etree import ElementTree
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, Client
|
||||
from django.utils import timezone
|
||||
@@ -28,6 +30,7 @@ class FrontEndTestCase(TestCase):
|
||||
def test_validate_source(self):
|
||||
test_source_types = {
|
||||
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
||||
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
|
||||
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
|
||||
}
|
||||
test_sources = {
|
||||
@@ -35,6 +38,7 @@ class FrontEndTestCase(TestCase):
|
||||
'valid': (
|
||||
'https://www.youtube.com/testchannel',
|
||||
'https://www.youtube.com/c/testchannel',
|
||||
'https://www.youtube.com/c/testchannel/videos',
|
||||
),
|
||||
'invalid_schema': (
|
||||
'http://www.youtube.com/c/playlist',
|
||||
@@ -50,13 +54,37 @@ class FrontEndTestCase(TestCase):
|
||||
),
|
||||
'invalid_is_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': {
|
||||
'valid': (
|
||||
'https://www.youtube.com/playlist?list=testplaylist'
|
||||
'https://www.youtube.com/watch?v=testvideo&list=testplaylist'
|
||||
'https://www.youtube.com/playlist?list=testplaylist',
|
||||
'https://www.youtube.com/watch?v=testvideo&list=testplaylist',
|
||||
),
|
||||
'invalid_schema': (
|
||||
'http://www.youtube.com/playlist?list=testplaylist',
|
||||
@@ -73,6 +101,7 @@ class FrontEndTestCase(TestCase):
|
||||
'invalid_is_channel': (
|
||||
'https://www.youtube.com/testchannel',
|
||||
'https://www.youtube.com/c/testchannel',
|
||||
'https://www.youtube.com/channel/testchannel',
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -83,9 +112,10 @@ class FrontEndTestCase(TestCase):
|
||||
response = c.get('/source-validate/invalid')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
for (source_type, tests) in test_sources.items():
|
||||
for test, field in tests.items():
|
||||
for test, urls in tests.items():
|
||||
for url in urls:
|
||||
source_type_char = test_source_types.get(source_type)
|
||||
data = {'source_url': field, 'source_type': source_type_char}
|
||||
data = {'source_url': url, 'source_type': source_type_char}
|
||||
response = c.post(f'/source-validate/{source_type}', data)
|
||||
if test == 'valid':
|
||||
# Valid source tests should bounce to /source-add
|
||||
@@ -93,9 +123,10 @@ class FrontEndTestCase(TestCase):
|
||||
url_parts = urlsplit(response.url)
|
||||
self.assertEqual(url_parts.path, '/source-add')
|
||||
else:
|
||||
# Invalid source tests should reload the page with an error message
|
||||
# Invalid source tests should reload the page with an error
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('<ul class="errorlist">', response.content.decode())
|
||||
self.assertIn('<ul class="errorlist">',
|
||||
response.content.decode())
|
||||
|
||||
def test_add_source_prepopulation(self):
|
||||
c = Client()
|
||||
@@ -131,6 +162,8 @@ class FrontEndTestCase(TestCase):
|
||||
'key': 'testkey',
|
||||
'name': 'testname',
|
||||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'index_schedule': 3600,
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
@@ -170,6 +203,8 @@ class FrontEndTestCase(TestCase):
|
||||
'key': 'updatedkey', # changed
|
||||
'name': 'testname',
|
||||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
@@ -197,6 +232,8 @@ class FrontEndTestCase(TestCase):
|
||||
'key': 'updatedkey',
|
||||
'name': 'testname',
|
||||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
@@ -398,7 +435,6 @@ class FrontEndTestCase(TestCase):
|
||||
response = c.get('/tasks-completed')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
def test_mediasevrers(self):
|
||||
# Media servers overview page
|
||||
c = Client()
|
||||
@@ -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):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -108,6 +108,14 @@ def file_is_editable(filepath):
|
||||
return False
|
||||
|
||||
|
||||
def write_text_file(filepath, filedata):
|
||||
if not isinstance(filedata, str):
|
||||
raise ValueError(f'filedata must be a str, got "{type(filedata)}"')
|
||||
with open(filepath, 'wt') as f:
|
||||
bytes_written = f.write(filedata)
|
||||
return bytes_written
|
||||
|
||||
|
||||
def delete_file(filepath):
|
||||
if file_is_editable(filepath):
|
||||
return os.remove(filepath)
|
||||
|
||||
@@ -128,10 +128,12 @@ class ValidateSourceView(FormView):
|
||||
}
|
||||
source_types = {
|
||||
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
||||
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
|
||||
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
|
||||
}
|
||||
help_item = {
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'),
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _('YouTube channel ID'),
|
||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'),
|
||||
}
|
||||
help_texts = {
|
||||
@@ -141,6 +143,13 @@ class ValidateSourceView(FormView):
|
||||
'where <strong>CHANNELNAME</strong> is the name of the channel you want '
|
||||
'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: _(
|
||||
'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='
|
||||
@@ -150,6 +159,8 @@ class ValidateSourceView(FormView):
|
||||
}
|
||||
help_examples = {
|
||||
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='
|
||||
'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r')
|
||||
}
|
||||
@@ -157,12 +168,21 @@ class ValidateSourceView(FormView):
|
||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: {
|
||||
'scheme': 'https',
|
||||
'domain': 'www.youtube.com',
|
||||
'path_regex': '^\/(c\/)?([^\/]+)$',
|
||||
'path_regex': '^\/(c\/)?([^\/]+)(\/videos)?$',
|
||||
'path_must_not_match': ('/playlist', '/c/playlist'),
|
||||
'qs_args': [],
|
||||
'extract_key': ('path_regex', 1),
|
||||
'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: {
|
||||
'scheme': 'https',
|
||||
'domain': 'www.youtube.com',
|
||||
@@ -175,6 +195,7 @@ class ValidateSourceView(FormView):
|
||||
}
|
||||
prepopulate_fields = {
|
||||
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'),
|
||||
}
|
||||
|
||||
@@ -252,9 +273,15 @@ class AddSourceView(CreateView):
|
||||
|
||||
template_name = 'sync/source-add.html'
|
||||
model = Source
|
||||
fields = ('source_type', 'key', 'name', 'directory', 'index_schedule',
|
||||
'delete_old_media', 'days_to_keep', 'source_resolution', 'source_vcodec',
|
||||
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback')
|
||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||
'index_schedule', 'download_cap', 'delete_old_media', 'days_to_keep',
|
||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
||||
'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||
errors = {
|
||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||
'errors or is empty. Check the table at the end of '
|
||||
'this page for valid media name variables'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.prepopulated_data = {}
|
||||
@@ -281,6 +308,20 @@ class AddSourceView(CreateView):
|
||||
initial[k] = v
|
||||
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):
|
||||
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
|
||||
return append_uri_params(url, {'message': 'source-created'})
|
||||
@@ -293,8 +334,8 @@ class SourceView(DetailView):
|
||||
messages = {
|
||||
'source-created': _('Your new source has been created. If you have added a '
|
||||
'very large source such as a channel with hundreds of '
|
||||
'videos it can take several minutes for media to start '
|
||||
'to appear.'),
|
||||
'videos it can take several minutes or up to an hour '
|
||||
'for media to start to appear.'),
|
||||
'source-updated': _('Your source has been updated.'),
|
||||
}
|
||||
|
||||
@@ -323,9 +364,29 @@ class UpdateSourceView(UpdateView):
|
||||
|
||||
template_name = 'sync/source-update.html'
|
||||
model = Source
|
||||
fields = ('source_type', 'key', 'name', 'directory', 'index_schedule',
|
||||
'delete_old_media', 'days_to_keep', 'source_resolution', 'source_vcodec',
|
||||
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback')
|
||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||
'index_schedule', 'download_cap', 'delete_old_media', 'days_to_keep',
|
||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
||||
'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||
errors = {
|
||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||
'errors or is empty. Check the table at the end of '
|
||||
'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):
|
||||
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
|
||||
@@ -350,7 +411,12 @@ class DeleteSourceView(DeleteView, FormMixin):
|
||||
source = self.get_object()
|
||||
for media in Media.objects.filter(source=source):
|
||||
if media.media_file:
|
||||
# Delete the media file
|
||||
delete_file(media.media_file.name)
|
||||
# Delete thumbnail copy if it exists
|
||||
delete_file(media.thumbpath)
|
||||
# Delete NFO file if it exists
|
||||
delete_file(media.nfopath)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -497,6 +563,10 @@ class MediaRedownloadView(FormView, SingleObjectMixin):
|
||||
if self.object.media_file_exists:
|
||||
delete_file(self.object.media_file.path)
|
||||
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
|
||||
self.object.downloaded = False
|
||||
self.object.downloaded_audio_codec = None
|
||||
@@ -538,6 +608,10 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
||||
if self.object.media_file_exists:
|
||||
delete_file(self.object.media_file.path)
|
||||
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
|
||||
self.object.downloaded = False
|
||||
self.object.downloaded_audio_codec = None
|
||||
@@ -937,7 +1011,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
data = super().get_context_data(*args, **kwargs)
|
||||
data['server_help'] = self.object.help_html
|
||||
data['server_help'] = self.object.get_help_html
|
||||
return data
|
||||
|
||||
def get_success_url(self):
|
||||
|
||||
@@ -15,6 +15,7 @@ SECRET_KEY = str(os.getenv('DJANGO_SECRET_KEY', 'tubesync-django-secret'))
|
||||
ALLOWED_HOSTS_STR = str(os.getenv('TUBESYNC_HOSTS', '127.0.0.1,localhost'))
|
||||
ALLOWED_HOSTS = ALLOWED_HOSTS_STR.split(',')
|
||||
DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
|
||||
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', None)
|
||||
|
||||
|
||||
TIME_ZONE = os.getenv('TZ', 'UTC')
|
||||
@@ -27,6 +28,12 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_THREADS = 1
|
||||
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8
|
||||
BACKGROUND_TASK_ASYNC_THREADS = int(os.getenv('TUBESYNC_WORKERS', DEFAULT_THREADS))
|
||||
if BACKGROUND_TASK_ASYNC_THREADS > MAX_BACKGROUND_TASK_ASYNC_THREADS:
|
||||
BACKGROUND_TASK_ASYNC_THREADS = MAX_BACKGROUND_TASK_ASYNC_THREADS
|
||||
|
||||
|
||||
MEDIA_ROOT = CONFIG_BASE_DIR / 'media'
|
||||
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR
|
||||
|
||||
@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||
|
||||
|
||||
VERSION = 0.3
|
||||
VERSION = 0.8
|
||||
SECRET_KEY = ''
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = []
|
||||
@@ -41,6 +41,7 @@ MIDDLEWARE = [
|
||||
|
||||
|
||||
ROOT_URLCONF = 'tubesync.urls'
|
||||
FORCE_SCRIPT_NAME = None
|
||||
|
||||
|
||||
TEMPLATES = [
|
||||
@@ -119,8 +120,9 @@ HEALTHCHECK_ALLOWED_IPS = ('127.0.0.1',)
|
||||
|
||||
MAX_ATTEMPTS = 10 # Number of times tasks will be retried
|
||||
MAX_RUN_TIME = 1800 # Maximum amount of time in seconds a task can run
|
||||
BACKGROUND_TASK_RUN_ASYNC = False # Run tasks async in the background
|
||||
BACKGROUND_TASK_RUN_ASYNC = True # Run tasks async in the background
|
||||
BACKGROUND_TASK_ASYNC_THREADS = 1 # Number of async tasks to run at once
|
||||
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8 # For sanity reasons
|
||||
BACKGROUND_TASK_PRIORITY_ORDERING = 'ASC' # Use 'niceness' task priority ordering
|
||||
COMPLETED_TASKS_DAYS_TO_KEEP = 7 # Number of days to keep completed tasks
|
||||
|
||||
@@ -147,6 +149,9 @@ YOUTUBE_DEFAULTS = {
|
||||
}
|
||||
|
||||
|
||||
MEDIA_FORMATSTR_DEFAULT = '{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}'
|
||||
|
||||
|
||||
try:
|
||||
from .local_settings import *
|
||||
except ImportError as e:
|
||||
|
||||