Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
799c0fce39 | ||
|
|
2f324f28a9 | ||
|
|
895bfe6f87 | ||
|
|
e0669b107d | ||
|
|
0dc201b293 | ||
|
|
82fa0f6bce | ||
|
|
8b93cb4a59 | ||
|
|
647254d7f7 | ||
|
|
3567e20600 | ||
|
|
5348e25303 | ||
|
|
749df3f7bb | ||
|
|
2c2f53e5b2 | ||
|
|
06cfafb803 | ||
|
|
f5a37f2e86 | ||
|
|
36747a47e0 | ||
|
|
ffd69e8d40 | ||
|
|
eebef3371f | ||
|
|
4cd6701c8a | ||
|
|
4ebe6f2a37 | ||
|
|
d553d58fde | ||
|
|
df40a1367a | ||
|
|
607ee77e70 | ||
|
|
9af493aa8a | ||
|
|
f0c94ff789 | ||
|
|
39c7799831 | ||
|
|
da7371f830 | ||
|
|
387cfefc8f |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: [meeb]
|
||||||
86
Pipfile.lock
generated
86
Pipfile.lock
generated
@@ -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": {}
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -22,7 +22,7 @@ hopefully, quite reliable.
|
|||||||
# Latest container image
|
# Latest container image
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ghcr.io/meeb/tubesync:v0.7
|
ghcr.io/meeb/tubesync:v0.9
|
||||||
```
|
```
|
||||||
|
|
||||||
**NOTE: the `:latest` tag does exist, but will contain in-development commits and may
|
**NOTE: the `:latest` tag does exist, but will contain in-development commits and may
|
||||||
@@ -102,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.7
|
$ 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 \
|
||||||
@@ -113,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.7
|
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
|
||||||
@@ -125,7 +125,7 @@ Alternatively, for Docker Compose, you can use something like:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
tubesync:
|
tubesync:
|
||||||
image: ghcr.io/meeb/tubesync:v0.7
|
image: ghcr.io/meeb/tubesync:v0.9
|
||||||
container_name: tubesync
|
container_name: tubesync
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -196,6 +196,16 @@ $ docker logs --follow tubesync
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Advanced usage guides
|
||||||
|
|
||||||
|
Once you're happy using TubeSync there are some advanced usage guides for more complex
|
||||||
|
and less common features:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
# Warnings
|
# Warnings
|
||||||
|
|
||||||
### 1. Index frequency
|
### 1. Index frequency
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
37
docs/create-missing-metadata.md
Normal file
37
docs/create-missing-metadata.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# TubeSync
|
||||||
|
|
||||||
|
## Advanced usage guide - creating missing metadata
|
||||||
|
|
||||||
|
This is a new feature in v0.9 of TubeSync and later. It allows you to create or
|
||||||
|
re-create missing metadata in your TubeSync download directories for missing `nfo`
|
||||||
|
files and thumbnails.
|
||||||
|
|
||||||
|
If you add a source with "write NFO files" or "copy thumbnails" disabled, download
|
||||||
|
some media and then update the source to write NFO files or copy thumbnails then
|
||||||
|
TubeSync will not automatically retroactively attempt to copy or create your missing
|
||||||
|
metadata files. You can use a special one-off command to manually write missing
|
||||||
|
metadata files to the correct locations.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
You have added a source without metadata writing enabled, downloaded some media, then
|
||||||
|
updated the source to enable metadata writing.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Run the batch metadata sync command
|
||||||
|
|
||||||
|
Execute the following Django command:
|
||||||
|
|
||||||
|
`./manage.py sync-missing-metadata`
|
||||||
|
|
||||||
|
When deploying TubeSync inside a container, you can execute this with:
|
||||||
|
|
||||||
|
`docker exec -ti tubesync python3 /app/manage.py sync-missing-metadata`
|
||||||
|
|
||||||
|
This command will log what its doing to the terminal when you run it.
|
||||||
|
|
||||||
|
Internally, this command loops over all your sources which have been saved with
|
||||||
|
"write NFO files" or "copy thumbnails" enabled. Then, loops over all media saved to
|
||||||
|
that source and confirms that the appropriate thumbnail files have been copied over and
|
||||||
|
the NFO file has been written if enabled.
|
||||||
81
docs/import-existing-media.md
Normal file
81
docs/import-existing-media.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# TubeSync
|
||||||
|
|
||||||
|
## Advanced usage guide - importing existing media
|
||||||
|
|
||||||
|
This is a new feature in v0.9 of TubeSync and later. It allows you to mark existing
|
||||||
|
downloaded media as "downloaded" in TubeSync. You can use this feature if, for example,
|
||||||
|
you already have an extensive catalogue of downloaded media which you want to mark
|
||||||
|
as downloaded into TubeSync so TubeSync doesn't re-download media you already have.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Your existing downloaded media MUST contain the unique ID. For YouTube videos, this is
|
||||||
|
means the YouTube video ID MUST be in the filename.
|
||||||
|
|
||||||
|
Supported extensions to be imported are .m4a, .ogg, .mkv, .mp3, .mp4 and .avi. Your
|
||||||
|
media you want to import must end in one of these file extensions.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
As TubeSync does not probe media and your existing media may be re-encoded or in
|
||||||
|
different formats to what is available in the current media metadata there is no way
|
||||||
|
for TubeSync to know what codecs, resolution, bitrate etc. your imported media is in.
|
||||||
|
Any manually imported existing local media will display blank boxes for this
|
||||||
|
information on the TubeSync interface as it's unavailable.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Add your source to TubeSync
|
||||||
|
|
||||||
|
Add your source to TubeSync, such as a YouTube channel. **Make sure you untick the
|
||||||
|
"download media" checkbox.**
|
||||||
|
|
||||||
|
This will allow TubeSync to index all the available media on your source, but won't
|
||||||
|
start downloading any media.
|
||||||
|
|
||||||
|
### 2. Wait
|
||||||
|
|
||||||
|
Wait for all the media on your source to be indexed. This may take some time.
|
||||||
|
|
||||||
|
### 3. Move your existing media into TubeSync
|
||||||
|
|
||||||
|
You now need to move your existing media into TubeSync. You need to move the media
|
||||||
|
files into the correct download directories created by TubeSync. For example, if you
|
||||||
|
have downloaded videos for a YouTube channel "TestChannel", you would have added this
|
||||||
|
as a source called TestChannel and in a directory called test-channel in Tubesync. It
|
||||||
|
would have a download directory created on disk at:
|
||||||
|
|
||||||
|
`/path/to/downloads/test-channel`
|
||||||
|
|
||||||
|
You would move all of your pre-existing videos you downloaded outside of TubeSync for
|
||||||
|
this channel into this directory.
|
||||||
|
|
||||||
|
In short, your existing media needs to be moved into the correct TubeSync source
|
||||||
|
directory to be detected.
|
||||||
|
|
||||||
|
This is required so TubeSync can known which Source to link the media to.
|
||||||
|
|
||||||
|
### 4. Run the batch import command
|
||||||
|
|
||||||
|
Execute the following Django command:
|
||||||
|
|
||||||
|
`./manage.py import-existing-media`
|
||||||
|
|
||||||
|
When deploying TubeSync inside a container, you can execute this with:
|
||||||
|
|
||||||
|
`docker exec -ti tubesync python3 /app/manage.py import-existing-media`
|
||||||
|
|
||||||
|
This command will log what its doing to the terminal when you run it.
|
||||||
|
|
||||||
|
Internally, `import-existing-media` looks for the unique media key (for YouTube, this
|
||||||
|
is the YouTube video ID) in the filename and detects the source to link it to based
|
||||||
|
on the directory the media file is inside.
|
||||||
|
|
||||||
|
|
||||||
|
### 5. Re-enable downloading at the source
|
||||||
|
|
||||||
|
Edit your source and re-enable / tick the "download media" option. This will allow
|
||||||
|
TubeSync to download any missing media you did not manually import.
|
||||||
|
|
||||||
|
Note that TubeSync will still get screenshots write `nfo` files etc. for files you
|
||||||
|
manually import if enabled at the source level.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
}
|
}
|
||||||
.help-text {
|
.help-text {
|
||||||
color: $form-help-text-colour;
|
color: $form-help-text-colour;
|
||||||
padding: 1rem 0 1rem 0;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ html {
|
|||||||
color: $text-colour;
|
color: $text-colour;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
|
|
||||||
background-color: $header-background-colour;
|
background-color: $header-background-colour;
|
||||||
|
|||||||
@@ -16,32 +16,36 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<div class="app">
|
||||||
<div class="container">
|
|
||||||
<a href="{% url 'sync:dashboard' %}">
|
|
||||||
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
|
||||||
<h1>TubeSync</h1>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ul>
|
<a href="{% url 'sync:dashboard' %}">
|
||||||
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
||||||
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
<h1>TubeSync</h1>
|
||||||
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
</a>
|
||||||
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
</div>
|
||||||
<li><a href="{% url 'sync:mediaservers' %}"><i class="fas fa-fw fa-stream"></i><span class="hide-on-med-and-down"> Media Servers</span></a></li>
|
</header>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
<nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
<ul>
|
||||||
</div>
|
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
||||||
</main>
|
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:mediaservers' %}"><i class="fas fa-fw fa-stream"></i><span class="hide-on-med-and-down"> Media Servers</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -14,3 +14,13 @@ def append_uri_params(uri, params):
|
|||||||
uri = str(uri)
|
uri = str(uri)
|
||||||
qs = urlencode(params)
|
qs = urlencode(params)
|
||||||
return urlunsplit(('', '', uri, qs, ''))
|
return urlunsplit(('', '', uri, qs, ''))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename(filename):
|
||||||
|
if not isinstance(filename, str):
|
||||||
|
raise ValueError(f'filename must be a str, got {type(filename)}')
|
||||||
|
to_scrub = '<>\/:*?"|%'
|
||||||
|
for char in to_scrub:
|
||||||
|
filename = filename.replace(char, '')
|
||||||
|
filename = ''.join([c for c in filename if ord(c) > 30])
|
||||||
|
return ' '.join(filename.split())
|
||||||
|
|||||||
0
tubesync/healthcheck.py
Normal file → Executable file
0
tubesync/healthcheck.py
Normal file → Executable file
1183
tubesync/spam
1183
tubesync/spam
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||||
|
|
||||||
|
|||||||
55
tubesync/sync/management/commands/import-existing-media.py
Normal file
55
tubesync/sync/management/commands/import-existing-media.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from common.logger import log
|
||||||
|
from sync.models import Source, Media
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = ('Scans download media directories for media not yet downloaded and ',
|
||||||
|
'marks them as downloaded')
|
||||||
|
extra_extensions = ['mp3', 'mp4', 'avi']
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
log.info('Building directory to Source map...')
|
||||||
|
dirmap = {}
|
||||||
|
for s in Source.objects.all():
|
||||||
|
dirmap[s.directory_path] = s
|
||||||
|
log.info(f'Scanning sources...')
|
||||||
|
file_extensions = list(Source.EXTENSIONS) + self.extra_extensions
|
||||||
|
for sourceroot, source in dirmap.items():
|
||||||
|
media = list(Media.objects.filter(source=source, downloaded=False,
|
||||||
|
skip=False))
|
||||||
|
if not media:
|
||||||
|
log.info(f'Source "{source}" has no missing media')
|
||||||
|
continue
|
||||||
|
log.info(f'Scanning Source "{source}" directory for media to '
|
||||||
|
f'import: {sourceroot}, looking for {len(media)} '
|
||||||
|
f'undownloaded and unskipped items')
|
||||||
|
on_disk = []
|
||||||
|
for (root, dirs, files) in os.walk(sourceroot):
|
||||||
|
rootpath = Path(root)
|
||||||
|
for filename in files:
|
||||||
|
filepart, ext = os.path.splitext(filename)
|
||||||
|
if ext.startswith('.'):
|
||||||
|
ext = ext[1:]
|
||||||
|
ext = ext.strip().lower()
|
||||||
|
if ext not in file_extensions:
|
||||||
|
continue
|
||||||
|
on_disk.append(str(rootpath / filename))
|
||||||
|
filemap = {}
|
||||||
|
for item in media:
|
||||||
|
for filepath in on_disk:
|
||||||
|
if item.key in filepath:
|
||||||
|
# The unique item key is in the file name on disk, map it to
|
||||||
|
# the undownloaded media item
|
||||||
|
filemap[filepath] = item
|
||||||
|
continue
|
||||||
|
for filepath, item in filemap.items():
|
||||||
|
log.info(f'Matched on-disk file: {filepath} '
|
||||||
|
f'to media item: {item.source} / {item}')
|
||||||
|
item.media_file.name = filepath
|
||||||
|
item.downloaded = True
|
||||||
|
item.save()
|
||||||
|
log.info('Done')
|
||||||
34
tubesync/sync/management/commands/sync-missing-metadata.py
Normal file
34
tubesync/sync/management/commands/sync-missing-metadata.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import os
|
||||||
|
from shutil import copyfile
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db.models import Q
|
||||||
|
from common.logger import log
|
||||||
|
from sync.models import Source, Media
|
||||||
|
from sync.utils import write_text_file
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = 'Syncs missing metadata (such as nfo files) if source settings are updated'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
log.info('Syncing missing metadata...')
|
||||||
|
sources = Source.objects.filter(Q(copy_thumbnails=True) | Q(write_nfo=True))
|
||||||
|
for source in sources.order_by('name'):
|
||||||
|
log.info(f'Finding media for source: {source}')
|
||||||
|
for item in Media.objects.filter(source=source, downloaded=True):
|
||||||
|
log.info(f'Checking media for missing metadata: {source} / {item}')
|
||||||
|
thumbpath = item.thumbpath
|
||||||
|
if not thumbpath.is_file():
|
||||||
|
if item.thumb:
|
||||||
|
log.info(f'Copying missing thumbnail from: {item.thumb.path} '
|
||||||
|
f'to: {thumbpath}')
|
||||||
|
copyfile(item.thumb.path, thumbpath)
|
||||||
|
else:
|
||||||
|
log.error(f'Tried to copy missing thumbnail for {item} but '
|
||||||
|
f'the thumbnail has not been downloaded')
|
||||||
|
nfopath = item.nfopath
|
||||||
|
if not nfopath.is_file():
|
||||||
|
log.info(f'Writing missing NFO file: {nfopath}')
|
||||||
|
write_text_file(nfopath, item.nfoxml)
|
||||||
|
log.info('Done')
|
||||||
@@ -66,7 +66,7 @@ def get_best_audio_format(media):
|
|||||||
# No codecs matched
|
# 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
|
||||||
|
|||||||
30
tubesync/sync/migrations/0009_auto_20210218_0442.py
Normal file
30
tubesync/sync/migrations/0009_auto_20210218_0442.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 3.1.6 on 2021-02-18 04:42
|
||||||
|
|
||||||
|
import django.core.files.storage
|
||||||
|
from django.db import migrations, models
|
||||||
|
import sync.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0008_source_download_cap'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='download_media',
|
||||||
|
field=models.BooleanField(default=True, help_text='Download media from this source, if not selected the source will only be indexed', verbose_name='download media'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='media_file',
|
||||||
|
field=models.FileField(blank=True, help_text='Media file', max_length=200, null=True, storage=django.core.files.storage.FileSystemStorage(location='/home/meeb/Repos/github.com/meeb/tubesync/tubesync/downloads'), upload_to=sync.models.get_media_file_path, verbose_name='media file'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='media_format',
|
||||||
|
field=models.CharField(default='{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}', help_text='File format to use for saving files, detailed options at bottom of page.', max_length=200, verbose_name='media format'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -12,6 +12,7 @@ from django.utils.text import slugify
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from common.errors import NoFormatException
|
from common.errors import NoFormatException
|
||||||
|
from common.utils import clean_filename
|
||||||
from .youtube import (get_media_info as get_youtube_media_info,
|
from .youtube import (get_media_info as get_youtube_media_info,
|
||||||
download_media as download_youtube_media)
|
download_media as download_youtube_media)
|
||||||
from .utils import seconds_to_timestr, parse_media_format
|
from .utils import seconds_to_timestr, parse_media_format
|
||||||
@@ -100,6 +101,11 @@ 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>',
|
||||||
@@ -112,6 +118,12 @@ class Source(models.Model):
|
|||||||
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}',
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}',
|
||||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
||||||
}
|
}
|
||||||
|
# Format used to create indexable URLs
|
||||||
|
INDEX_URLS = {
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}/videos',
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}/videos',
|
||||||
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
||||||
|
}
|
||||||
# Callback functions to get a list of media from the source
|
# 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,
|
||||||
@@ -200,7 +212,7 @@ class Source(models.Model):
|
|||||||
_('media format'),
|
_('media format'),
|
||||||
max_length=200,
|
max_length=200,
|
||||||
default=settings.MEDIA_FORMATSTR_DEFAULT,
|
default=settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
help_text=_('File format to use for saving files')
|
help_text=_('File format to use for saving files, detailed options at bottom of page.')
|
||||||
)
|
)
|
||||||
index_schedule = models.IntegerField(
|
index_schedule = models.IntegerField(
|
||||||
_('index schedule'),
|
_('index schedule'),
|
||||||
@@ -209,6 +221,11 @@ 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 = models.IntegerField(
|
||||||
_('download cap'),
|
_('download cap'),
|
||||||
choices=CapChoices.choices,
|
choices=CapChoices.choices,
|
||||||
@@ -327,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:
|
||||||
@@ -436,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):
|
||||||
@@ -480,6 +491,12 @@ class Media(models.Model):
|
|||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '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': {
|
||||||
@@ -557,14 +574,18 @@ class Media(models.Model):
|
|||||||
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>',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,8 +859,9 @@ class Media(models.Model):
|
|||||||
fmt.append(resolution)
|
fmt.append(resolution)
|
||||||
vcodec = vformat['vcodec'].lower()
|
vcodec = vformat['vcodec'].lower()
|
||||||
fmt.append(vcodec)
|
fmt.append(vcodec)
|
||||||
acodec = aformat['acodec'].lower()
|
if aformat:
|
||||||
fmt.append(acodec)
|
acodec = aformat['acodec'].lower()
|
||||||
|
fmt.append(acodec)
|
||||||
if vformat:
|
if vformat:
|
||||||
if vformat['is_60fps']:
|
if vformat['is_60fps']:
|
||||||
fps = '60fps'
|
fps = '60fps'
|
||||||
@@ -887,7 +909,7 @@ class Media(models.Model):
|
|||||||
'source': self.source.slugname,
|
'source': self.source.slugname,
|
||||||
'source_full': self.source.name,
|
'source_full': self.source.name,
|
||||||
'title': self.slugtitle,
|
'title': self.slugtitle,
|
||||||
'title_full': self.title,
|
'title_full': clean_filename(self.title),
|
||||||
'key': self.key,
|
'key': self.key,
|
||||||
'format': '-'.join(display_format['format']),
|
'format': '-'.join(display_format['format']),
|
||||||
'playlist_index': self.playlist_index,
|
'playlist_index': self.playlist_index,
|
||||||
@@ -902,6 +924,10 @@ class Media(models.Model):
|
|||||||
'hdr': display_format['hdr'],
|
'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:
|
||||||
@@ -974,8 +1000,12 @@ class Media(models.Model):
|
|||||||
def votes(self):
|
def votes(self):
|
||||||
field = self.get_metadata_field('upvotes')
|
field = self.get_metadata_field('upvotes')
|
||||||
upvotes = self.loaded_metadata.get(field, 0)
|
upvotes = self.loaded_metadata.get(field, 0)
|
||||||
|
if not isinstance(upvotes, int):
|
||||||
|
upvotes = 0
|
||||||
field = self.get_metadata_field('downvotes')
|
field = self.get_metadata_field('downvotes')
|
||||||
downvotes = self.loaded_metadata.get(field, 0)
|
downvotes = self.loaded_metadata.get(field, 0)
|
||||||
|
if not isinstance(downvotes, int):
|
||||||
|
downvotes = 0
|
||||||
return upvotes + downvotes
|
return upvotes + downvotes
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1005,7 +1035,7 @@ class Media(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
# Otherwise, create a suitable filename from the source media_format
|
# Create a suitable filename from the source media_format
|
||||||
media_format = str(self.source.media_format)
|
media_format = str(self.source.media_format)
|
||||||
media_details = self.format_dict
|
media_details = self.format_dict
|
||||||
return media_format.format(**media_details)
|
return media_format.format(**media_details)
|
||||||
@@ -1161,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):
|
||||||
@@ -1178,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):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -8,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
|
||||||
|
|
||||||
|
|
||||||
@@ -93,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
|
||||||
@@ -124,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(
|
||||||
|
|||||||
@@ -179,30 +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 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)
|
|
||||||
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}')
|
||||||
@@ -234,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):
|
||||||
'''
|
'''
|
||||||
@@ -282,6 +308,17 @@ 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
|
||||||
|
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
|
filepath = media.filepath
|
||||||
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{filepath}"')
|
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{filepath}"')
|
||||||
format_str, container = media.download_media()
|
format_str, container = media.download_media()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -109,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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -61,6 +61,10 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -274,9 +274,9 @@ class AddSourceView(CreateView):
|
|||||||
template_name = 'sync/source-add.html'
|
template_name = 'sync/source-add.html'
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||||
'index_schedule', 'download_cap', 'delete_old_media', 'days_to_keep',
|
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||||
'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||||
errors = {
|
errors = {
|
||||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||||
'errors or is empty. Check the table at the end of '
|
'errors or is empty. Check the table at the end of '
|
||||||
@@ -365,9 +365,9 @@ class UpdateSourceView(UpdateView):
|
|||||||
template_name = 'sync/source-update.html'
|
template_name = 'sync/source-update.html'
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||||
'index_schedule', 'download_cap', 'delete_old_media', 'days_to_keep',
|
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||||
'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo')
|
||||||
errors = {
|
errors = {
|
||||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||||
'errors or is empty. Check the table at the end of '
|
'errors or is empty. Check the table at the end of '
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_THREADS = 2
|
DEFAULT_THREADS = 1
|
||||||
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8
|
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8
|
||||||
BACKGROUND_TASK_ASYNC_THREADS = int(os.getenv('TUBESYNC_WORKERS', DEFAULT_THREADS))
|
BACKGROUND_TASK_ASYNC_THREADS = int(os.getenv('TUBESYNC_WORKERS', DEFAULT_THREADS))
|
||||||
if BACKGROUND_TASK_ASYNC_THREADS > MAX_BACKGROUND_TASK_ASYNC_THREADS:
|
if BACKGROUND_TASK_ASYNC_THREADS > MAX_BACKGROUND_TASK_ASYNC_THREADS:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
|||||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
VERSION = 0.7
|
VERSION = 0.9
|
||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
@@ -114,6 +114,9 @@ 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',)
|
||||||
|
|
||||||
@@ -149,7 +152,7 @@ YOUTUBE_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
MEDIA_FORMATSTR_DEFAULT = '{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}'
|
MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user