57 Commits

Author SHA1 Message Date
meeb
30c2127271 bump ffmpeg to 2023-11-14 and yt-dlp to 2023.11.16 2023-11-16 18:54:57 +11:00
meeb
5cac374486 Merge pull request #420 from sparklesmcfadden/delete-removed-media
Adds workflow to delete local media that no longer exists in the source
2023-10-21 14:31:39 +11:00
meeb
69efc9298d Merge pull request #423 from ltomes/patch-1
Update other-database-backends.md
2023-10-21 14:30:06 +11:00
Levi Tomes
1be8dff769 Update other-database-backends.md
django-admin only ran the loaddata for me with the - before the format flag.
2023-10-20 18:22:40 -05:00
cavanfarrell
350e544594 Fixes formatting 2023-10-20 10:25:20 -05:00
cavanfarrell
0542c734e5 Adds workflow to delete local media that no longer exists in the source 2023-10-20 10:19:57 -05:00
meeb
42b337c408 bump ffmpeg to autobuild-2023-10-11-14-20 2023-10-12 15:50:38 +11:00
meeb
2f82f8c599 fix tests 2023-10-12 15:44:51 +11:00
meeb
b57ca110b0 bump to 0.13.1 2023-10-12 15:34:33 +11:00
meeb
e3e7352600 add uploader variable, resolves #270 2023-10-12 15:33:58 +11:00
meeb
6d3a7bf859 move metadata collection to a higher priority over thumbnails, resolves #418 2023-10-12 15:27:19 +11:00
meeb
25f622311f bump to 0.13.0 2023-09-25 18:47:21 +10:00
meeb
adea4a0ecd bump ffmpeg to autobuild-2023-09-24-14-11 2023-09-25 18:45:45 +10:00
meeb
0d76f2f94e bump s6 to 3.1.5.0 2023-09-25 18:37:29 +10:00
meeb
71578d926e fix tests after subs lang pr 2023-09-25 18:31:32 +10:00
meeb
777cdb5ecc Merge pull request #406 from pacoccino/subtitles
Subtitles
2023-09-05 06:33:26 +10:00
pacoccino
3dd445bf96 Add a validator for sub_lang 2023-09-04 14:58:57 +02:00
pacoccino
86744c0510 Remove extension edits 2023-09-02 14:37:07 +02:00
pacoccino
be7454f72a Add subtitles config into sources model 2023-09-02 14:30:23 +02:00
pacoccino
e9f03cb6bf download subtitles draft 2023-08-31 22:40:29 +02:00
meeb
ddc127e6af bump libs, bump ffmpeg to autobuild-2023-08-12-14-12, resolves #399 2023-08-13 17:43:43 +10:00
meeb
63d32a1e11 replace PIL.Image.ANTIALIAS with PIL.Image.LANCZOS, resolves #392 2023-07-16 00:06:05 +10:00
meeb
2ebbb8480e bump ffmpeg to 2023-07-14-14-08 2023-07-15 14:32:16 +10:00
meeb
21785e031a add requests[socks], resolves #391 2023-07-15 14:23:23 +10:00
meeb
f12e13162f ignore media formats which do not have acodecs or vcodecs in their respective matchers, resolves #386 2023-06-29 23:48:35 +10:00
meeb
5c9c1550bf make shell helper 2023-06-29 23:30:47 +10:00
meeb
12638afb60 bump container image base to debian bookworm, update ffmpeg to 2023-06-27 and yt-dlp to 2023-06-22, rework python packages installation after bookworm update 2023-06-28 02:54:09 +10:00
meeb
b9886a3b27 Merge pull request #381 from a-kr/fix_cleanup_old_media
in cleanup_old_media, filter in database rather than in Python
2023-05-27 14:03:35 +10:00
Alexey Kryuchkov
612f78e7eb in cleanup_old_media, filter in database rather than in Python 2023-05-27 01:28:15 +03:00
meeb
0c5a9c53f8 Merge pull request #376 from gautamkrishnar/fix/dockerfile
fixing unavailable ffmpeg version
2023-05-04 12:38:27 +10:00
Gautam krishna R
d439b2f223 fixing unavailable ffmpeg version
fixing unavailable ffmpeg version
2023-05-03 21:33:16 +05:30
meeb
7116617cd2 Merge pull request #374 from garbled1/latest_dl_fix
Fix #364 by checking the filesize is not null.
2023-05-03 02:02:14 +10:00
garbled1
422d228359 Fix #364 by checking the filesize is not null. 2023-05-02 08:24:50 -07:00
meeb
1f68be5c26 update ffmpeg to 2023-04-13-12-52 2023-04-14 12:28:38 +10:00
meeb
089a487f3a add additional library ID help link, resolves #370 2023-04-14 11:45:52 +10:00
meeb
24ae70ea70 add reset-metadata command, related to #287 2023-04-05 11:02:21 +10:00
meeb
72c3242e70 add TUBESYNC_RESET_DOWNLOAD_DIR env var to toggle resetting permissions on /downloads in the container on start, resolves #354 2023-03-26 14:05:47 +11:00
meeb
f3e93c0ecf bump ffmpeg to autobuild-2023-03-23-15-58 2023-03-24 13:17:12 +11:00
meeb
fa8efb178e allow easy container env var override of HEALTHCHECK_ALLOWED_IPS, resolves #168 2023-03-24 13:02:16 +11:00
meeb
2001faea44 Merge pull request #358 from darmiel/fix/font-weight
fix: bold font weight
2023-03-11 15:49:06 +11:00
darmiel
b370e98031 fix: bold font weight 2023-03-10 14:36:29 +01:00
meeb
55bfd911b9 catch typeerrors for duration metadata, resolves #248 2023-03-10 18:23:49 +11:00
meeb
e47d0eb7be Merge pull request #357 from darmiel/fix/bump-ffpmeg
chore: bump ffmpeg to `109977-gaca7ef78cc`
2023-03-10 05:00:15 +11:00
darmiel
a95c64bc10 chore: bump ffmpeg to 109977-gaca7ef78cc 2023-03-09 14:58:41 +01:00
meeb
e9d4f89f39 fix connection kwarg to db_type() in custom field to be compatible with the postgresql backend, resolves #347 2023-02-21 13:55:03 +11:00
meeb
7876b48860 use backend agnostic text type for custom field, related to #345 and #338 2023-02-20 14:56:28 +11:00
meeb
2639d911ab change sponsorblock_categories to a textfield, fixing max charlen=255 for mysql, related to #338 2023-02-20 13:24:38 +11:00
meeb
e4c0f0e98a Merge pull request #338 from kuhnchris/embed-thumbnail
Configurations in Sources
2023-02-20 11:23:18 +11:00
KuhnChris
601449ce08 migrate migrations; split fields into fields.py 2023-02-19 23:44:48 +01:00
KuhnChris
fe4c876fdc "source" overview, fix some edge case(s) 2023-02-18 14:03:32 +01:00
KuhnChris
fbe9546a74 Merge branch 'meeb-main' into embed-thumbnail 2023-02-18 11:38:59 +01:00
KuhnChris
ce14167cee formating 2023-02-18 11:38:23 +01:00
KuhnChris
c927f32aa6 ffmpeg embed thumbnails, configuration 2023-02-18 11:37:28 +01:00
KuhnChris
1d5579aa31 Phase 1 - extend model for new fields 2023-02-18 11:35:45 +01:00
meeb
d8a9572411 bump ffmpeg, fix container build 2023-02-18 13:14:25 +11:00
KuhnChris
2772e85d9f ffmpeg embed thumbnails, configuration 2023-02-15 00:01:44 +01:00
KuhnChris
24a49d2f14 Phase 1 - extend model for new fields 2023-02-14 21:52:50 +01:00
32 changed files with 7356 additions and 80 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@@ -131,4 +132,5 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
Pipfile.lock Pipfile.lock
.vscode/launch.json

View File

@@ -1,9 +1,9 @@
FROM debian:bullseye-slim FROM debian:bookworm-slim
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG S6_VERSION="3.1.2.1" ARG S6_VERSION="3.1.5.0"
ARG FFMPEG_DATE="autobuild-2023-01-03-12-55" ARG FFMPEG_DATE="autobuild-2023-11-14-14-18"
ARG FFMPEG_VERSION="109474-gc94988a781" ARG FFMPEG_VERSION="112750-g6d60cc7baf"
ENV DEBIAN_FRONTEND="noninteractive" \ ENV DEBIAN_FRONTEND="noninteractive" \
HOME="/root" \ HOME="/root" \
@@ -19,22 +19,22 @@ RUN export ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/arm64") echo "aarch64" ;; \ "linux/arm64") echo "aarch64" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
export S6_ARCH_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \ export S6_ARCH_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "6019b6b06cfdbb1d1cd572d46b9b158a4904fd19ca59d374de4ddaaa6a3727d5" ;; \ "linux/amd64") echo "65d0d0f353d2ff9d0af202b268b4bf53a9948a5007650854855c729289085739" ;; \
"linux/arm64") echo "e73f9a021b64f88278830742149c14ef8a52331102881ba025bf32a66a0e7c78" ;; \ "linux/arm64") echo "3fbd14201473710a592b2189e81f00f3c8998e96d34f16bd2429c35d1bc36d00" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
export S6_DOWNLOAD_ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \ export S6_DOWNLOAD_ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-x86_64.tar.xz" ;; \ "linux/amd64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-x86_64.tar.xz" ;; \
"linux/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.xz" ;; \ "linux/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.xz" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
export FFMPEG_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \ export FFMPEG_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "ed9059668e4a6dac9bde122a775f52ad08cbb90df3658f8c1e328477c13c242e" ;; \ "linux/amd64") echo "d905684195f16412d8ee4a61a5a32d4bea530b4f93260e800b5a74904f6a1528" ;; \
"linux/arm64") echo "dd1375bd351d38ea1cc3efd68a998699366e28bd9b90df65d11af2b9121746b7" ;; \ "linux/arm64") echo "5fdbf8d83d05b39d3e1cd666d485340115bc31cfc686993dcb77f99d1b35751e" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \ export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linux64-gpl.tar.xz" ;; \ "linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linux64-gpl.tar.xz" ;; \
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linuxarm64-gpl.tar.xz" ;; \ "linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linuxarm64-gpl.tar.xz" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
export S6_NOARCH_EXPECTED_SHA256="cee89d3eeabdfe15239b2c5c3581d9352d2197d4fd23bba3f1e64bf916ccf496" && \ export S6_NOARCH_EXPECTED_SHA256="fd80c231e8ae1a0667b7ae2078b9ad0e1269c4d117bf447a4506815a700dbff3" && \
export S6_DOWNLOAD_NOARCH="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-noarch.tar.xz" && \ export S6_DOWNLOAD_NOARCH="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-noarch.tar.xz" && \
echo "Building for arch: ${ARCH}|${ARCH44}, downloading S6 from: ${S6_DOWNLOAD}}, expecting S6 SHA256: ${S6_EXPECTED_SHA256}" && \ echo "Building for arch: ${ARCH}|${ARCH44}, downloading S6 from: ${S6_DOWNLOAD}}, expecting S6 SHA256: ${S6_EXPECTED_SHA256}" && \
set -x && \ set -x && \
@@ -83,30 +83,30 @@ RUN set -x && \
apt-get -y install nginx-light && \ apt-get -y install nginx-light && \
apt-get -y --no-install-recommends install \ apt-get -y --no-install-recommends install \
python3 \ python3 \
python3-setuptools \
python3-pip \
python3-dev \ python3-dev \
python3-pip \
python3-wheel \
pipenv \
gcc \ gcc \
g++ \ g++ \
make \ make \
pkgconf \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
libmariadb3 \ libmariadb3 \
postgresql-common \ postgresql-common \
libpq-dev \ libpq-dev \
libpq5 \ libpq5 \
libjpeg62-turbo \ libjpeg62-turbo \
libwebp6 \ libwebp7 \
libjpeg-dev \ libjpeg-dev \
zlib1g-dev \ zlib1g-dev \
libwebp-dev \ libwebp-dev \
redis-server && \ redis-server && \
# Install pipenv
pip3 --disable-pip-version-check install wheel pipenv && \
# Create a 'app' user which the application will run as # Create a 'app' user which the application will run as
groupadd app && \ groupadd app && \
useradd -M -d /app -s /bin/false -g app app && \ useradd -M -d /app -s /bin/false -g app app && \
# Install non-distro packages # Install non-distro packages
pipenv install --system --skip-lock && \ PIPENV_VERBOSITY=64 pipenv install --system --skip-lock && \
# Make absolutely sure we didn't accidentally bundle a SQLite dev database # Make absolutely sure we didn't accidentally bundle a SQLite dev database
rm -rf /app/db.sqlite3 && \ rm -rf /app/db.sqlite3 && \
# Run any required app commands # Run any required app commands
@@ -120,7 +120,6 @@ RUN set -x && \
# Clean up # Clean up
rm /app/Pipfile && \ rm /app/Pipfile && \
pipenv --clear && \ pipenv --clear && \
pip3 --disable-pip-version-check uninstall -y pipenv wheel virtualenv && \
apt-get -y autoremove --purge \ apt-get -y autoremove --purge \
python3-pip \ python3-pip \
python3-dev \ python3-dev \

View File

@@ -31,3 +31,7 @@ runcontainer:
test: build test: build
cd tubesync && $(python) manage.py test --verbosity=2 && cd .. cd tubesync && $(python) manage.py test --verbosity=2 && cd ..
shell:
cd tubesync && $(python) manage.py shell

View File

@@ -4,6 +4,7 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages] [dev-packages]
autopep8 = "*"
[packages] [packages]
django = "~=3.2" django = "~=3.2"
@@ -15,10 +16,10 @@ gunicorn = "*"
django-compressor = "*" django-compressor = "*"
httptools = "*" httptools = "*"
django-background-tasks = "*" django-background-tasks = "*"
requests = "*"
django-basicauth = "*" django-basicauth = "*"
psycopg2-binary = "*" psycopg2-binary = "*"
mysqlclient = "*" mysqlclient = "*"
yt-dlp = "*" yt-dlp = "*"
redis = "*" redis = "*"
hiredis = "*" hiredis = "*"
requests = {extras = ["socks"], version = "*"}

View File

@@ -241,6 +241,7 @@ and less common features:
* [Reset tasks from the command line](https://github.com/meeb/tubesync/blob/main/docs/reset-tasks.md) * [Reset tasks from the command line](https://github.com/meeb/tubesync/blob/main/docs/reset-tasks.md)
* [Using PostgreSQL, MySQL or MariaDB as database backends](https://github.com/meeb/tubesync/blob/main/docs/other-database-backends.md) * [Using PostgreSQL, MySQL or MariaDB as database backends](https://github.com/meeb/tubesync/blob/main/docs/other-database-backends.md)
* [Using cookies](https://github.com/meeb/tubesync/blob/main/docs/using-cookies.md) * [Using cookies](https://github.com/meeb/tubesync/blob/main/docs/using-cookies.md)
* [Reset metadata](https://github.com/meeb/tubesync/blob/main/docs/reset-metadata.md)
# Warnings # Warnings
@@ -361,19 +362,20 @@ 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 really only **NOT** required to be set in the default container installation, they are really only
useful if you are manually installing TubeSync in some other environment. These are: useful if you are manually installing TubeSync in some other environment. These are:
| Name | What | Example | | Name | What | Example |
| ------------------------ | ------------------------------------------------------------ | ------------------------------------ | | --------------------------- | ------------------------------------------------------------ | ------------------------------------ |
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l | | DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
| DJANGO_URL_PREFIX | Run TubeSync in a sub-URL on the web server | /somepath/ | | DJANGO_URL_PREFIX | Run TubeSync in a sub-URL on the web server | /somepath/ |
| TUBESYNC_DEBUG | Enable debugging | True | | TUBESYNC_DEBUG | Enable debugging | True |
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 | | TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS, defaults to `*` | tubesync.example.com,otherhost.com | | TUBESYNC_HOSTS | Django's ALLOWED_HOSTS, defaults to `*` | tubesync.example.com,otherhost.com |
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 | | TUBESYNC_RESET_DOWNLOAD_DIR | Toggle resetting `/downloads` permissions, defaults to True | True
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 | | GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 | | LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
| HTTP_USER | Sets the username for HTTP basic authentication | some-username | | LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
| HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password | | HTTP_USER | Sets the username for HTTP basic authentication | some-username |
| DATABASE_CONNECTION | Optional external database connection details | mysql://user:pass@host:port/database | | HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password |
| DATABASE_CONNECTION | Optional external database connection details | mysql://user:pass@host:port/database |
# Manual, non-containerised, installation # Manual, non-containerised, installation

View File

@@ -11,8 +11,6 @@ chown -R app:app /run/app
chmod -R 0700 /run/app chmod -R 0700 /run/app
chown -R app:app /config chown -R app:app /config
chmod -R 0755 /config chmod -R 0755 /config
chown -R app:app /downloads
chmod -R 0755 /downloads
chown -R root:app /app chown -R root:app /app
chmod -R 0750 /app chmod -R 0750 /app
chown -R app:app /app/common/static chown -R app:app /app/common/static
@@ -22,6 +20,15 @@ chmod -R 0750 /app/static
find /app -type f ! -iname healthcheck.py -exec chmod 640 {} \; find /app -type f ! -iname healthcheck.py -exec chmod 640 {} \;
chmod 0755 /app/healthcheck.py chmod 0755 /app/healthcheck.py
# Optionally reset the download dir permissions
TUBESYNC_RESET_DOWNLOAD_DIR="${TUBESYNC_RESET_DOWNLOAD_DIR:-True}"
if [ "$TUBESYNC_RESET_DOWNLOAD_DIR" == "True" ]
then
echo "TUBESYNC_RESET_DOWNLOAD_DIR=True, Resetting /downloads directory permissions"
chown -R app:app /downloads
chmod -R 0755 /downloads
fi
# Run migrations # Run migrations
exec s6-setuidgid app \ exec s6-setuidgid app \
/usr/bin/python3 /app/manage.py migrate /usr/bin/python3 /app/manage.py migrate

View File

@@ -24,7 +24,7 @@ $ docker exec -i tubesync python3 /app/manage.py dumpdata > some-file.json
Then change you database backend over, then use Then change you database backend over, then use
```bash ```bash
$ cat some-file.json | docker exec -i tubesync python3 /app/manage.py loaddata --format=json - $ cat some-file.json | docker exec -i tubesync python3 /app/manage.py loaddata - --format=json
``` ```
As detailed in the Django documentation: As detailed in the Django documentation:

30
docs/reset-metadata.md Normal file
View File

@@ -0,0 +1,30 @@
# TubeSync
## Advanced usage guide - reset media metadata from the command line
This command allows you to reset all media item metadata. You might want to use
this if you have a lot of media items with invalid metadata and you want to
wipe it which triggers the metadata to be redownloaded.
## Requirements
You have added some sources and media
## Steps
### 1. Run the reset tasks command
Execute the following Django command:
`./manage.py reset-metadata`
When deploying TubeSync inside a container, you can execute this with:
`docker exec -ti tubesync python3 /app/manage.py reset-metadata`
This command will log what its doing to the terminal when you run it.
When this is run, new tasks will be immediately created so all your media
items will start downloading updated metadata straight away, any missing information
such as thumbnails will be redownloaded, etc.

View File

@@ -1,19 +1,19 @@
@font-face { @font-face {
font-family: 'roboto-light'; font-family: 'roboto';
src: url('../fonts/roboto/roboto-light.woff') format('woff'); src: url('../fonts/roboto/roboto-light.woff') format('woff');
font-weight: normal; font-weight: lighter;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: 'roboto-regular'; font-family: 'roboto';
src: url('../fonts/roboto/roboto-regular.woff') format('woff'); src: url('../fonts/roboto/roboto-regular.woff') format('woff');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: 'roboto-bold'; font-family: 'roboto';
src: url('../fonts/roboto/roboto-bold.woff') format('woff'); src: url('../fonts/roboto/roboto-bold.woff') format('woff');
font-weight: bold; font-weight: bold;
font-style: normal; font-style: normal;

View File

@@ -1,2 +1,2 @@
$font-family: 'roboto-regular', Arial, Helvetica, sans-serif; $font-family: 'roboto', Arial, Helvetica, sans-serif;
$font-size: 1.05rem; $font-size: 1.05rem;

109
tubesync/sync/fields.py Normal file
View File

@@ -0,0 +1,109 @@
from django.forms import MultipleChoiceField, CheckboxSelectMultiple, Field, TypedMultipleChoiceField
from django.db import models
from typing import Any, Optional, Dict
from django.utils.translation import gettext_lazy as _
# this is a form field!
class CustomCheckboxSelectMultiple(CheckboxSelectMultiple):
template_name = 'widgets/checkbox_select.html'
option_template_name = 'widgets/checkbox_option.html'
def get_context(self, name: str, value: Any, attrs) -> Dict[str, Any]:
ctx = super().get_context(name, value, attrs)['widget']
ctx["multipleChoiceProperties"] = []
for _group, options, _index in ctx["optgroups"]:
for option in options:
if not isinstance(value,str) and not isinstance(value,list) and ( option["value"] in value.selected_choices or ( value.allow_all and value.all_choice in value.selected_choices ) ):
checked = True
else:
checked = False
ctx["multipleChoiceProperties"].append({
"template_name": option["template_name"],
"type": option["type"],
"value": option["value"],
"label": option["label"],
"name": option["name"],
"checked": checked})
return { 'widget': ctx }
# this is a database field!
class CommaSepChoiceField(models.Field):
"Implements comma-separated storage of lists"
def __init__(self, separator=",", possible_choices=(("","")), all_choice="", all_label="All", allow_all=False, *args, **kwargs):
self.separator = separator
self.possible_choices = possible_choices
self.selected_choices = []
self.allow_all = allow_all
self.all_label = all_label
self.all_choice = all_choice
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.separator != ",":
kwargs['separator'] = self.separator
kwargs['possible_choices'] = self.possible_choices
return name, path, args, kwargs
def db_type(self, connection):
return 'text'
def get_my_choices(self):
choiceArray = []
if self.possible_choices is None:
return choiceArray
if self.allow_all:
choiceArray.append((self.all_choice, _(self.all_label)))
for t in self.possible_choices:
choiceArray.append(t)
return choiceArray
def formfield(self, **kwargs):
# This is a fairly standard way to set up some defaults
# while letting the caller override them.
defaults = {'form_class': MultipleChoiceField,
'choices': self.get_my_choices,
'widget': CustomCheckboxSelectMultiple,
'label': '',
'required': False}
defaults.update(kwargs)
#del defaults.required
return super().formfield(**defaults)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
# Only include kwarg if it's not the default
if self.separator != ",":
kwargs['separator'] = self.separator
return name, path, args, kwargs
def from_db_value(self, value, expr, conn):
if value is None:
self.selected_choices = []
else:
self.selected_choices = value.split(",")
return self
def get_prep_value(self, value):
if value is None:
return ""
if not isinstance(value,list):
return ""
if self.all_choice not in value:
return ",".join(value)
else:
return self.all_choice
def get_text_for_value(self, val):
fval = [i for i in self.possible_choices if i[0] == val]
if len(fval) <= 0:
return []
else:
return fval[0][1]

View File

@@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from sync.models import Media
from common.logger import log
class Command(BaseCommand):
help = 'Resets all media item metadata'
def handle(self, *args, **options):
log.info('Resettings all media metadata...')
# Delete all metadata
Media.objects.update(metadata=None)
# Trigger the save signal on each media item
for item in Media.objects.all():
item.save()
log.info('Done')

View File

@@ -53,6 +53,8 @@ def get_best_audio_format(media):
# If the format has a video stream, skip it # If the format has a video stream, skip it
if fmt['vcodec'] is not None: if fmt['vcodec'] is not None:
continue continue
if not fmt['acodec']:
continue
audio_formats.append(fmt) audio_formats.append(fmt)
audio_formats = list(reversed(sorted(audio_formats, key=lambda k: k['abr']))) audio_formats = list(reversed(sorted(audio_formats, key=lambda k: k['abr'])))
if not audio_formats: if not audio_formats:
@@ -88,6 +90,8 @@ def get_best_video_format(media):
# If the format has an audio stream, skip it # If the format has an audio stream, skip it
if fmt['acodec'] is not None: if fmt['acodec'] is not None:
continue continue
if not fmt['vcodec']:
continue
if media.source.source_resolution.strip().upper() == fmt['format']: if media.source.source_resolution.strip().upper() == fmt['format']:
video_formats.append(fmt) video_formats.append(fmt)
# Check we matched some streams # Check we matched some streams

View File

@@ -44,7 +44,9 @@ class PlexMediaServer(MediaServer):
'<p>The <strong>libraries</strong> is a comma-separated list of Plex ' '<p>The <strong>libraries</strong> is a comma-separated list of Plex '
'library or section IDs, you can find out how to get your library or ' 'library or section IDs, you can find out how to get your library or '
'section IDs <a href="https://support.plex.tv/articles/201242707-plex-' 'section IDs <a href="https://support.plex.tv/articles/201242707-plex-'
'media-scanner-via-command-line/#toc-1" target="_blank">here</a>.</p>') 'media-scanner-via-command-line/#toc-1" target="_blank">here</a> or '
'<a href="https://www.plexopedia.com/plex-media-server/api/server/libraries/" '
'target="_blank">here</a></p>.')
def make_request(self, uri='/', params={}): def make_request(self, uri='/', params={}):
headers = {'User-Agent': 'TubeSync'} headers = {'User-Agent': 'TubeSync'}

View File

@@ -0,0 +1,34 @@
# Generated by Django 3.2.18 on 2023-02-14 20:52
from django.db import migrations, models
import sync.models
class Migration(migrations.Migration):
dependencies = [
('sync', '0015_auto_20230213_0603'),
]
operations = [
migrations.AddField(
model_name='source',
name='embed_metadata',
field=models.BooleanField(default=False, help_text='Embed metadata from source into file', verbose_name='embed metadata'),
),
migrations.AddField(
model_name='source',
name='embed_thumbnail',
field=models.BooleanField(default=False, help_text='Embed thumbnail into the file', verbose_name='embed thumbnail'),
),
migrations.AddField(
model_name='source',
name='enable_sponsorblock',
field=models.BooleanField(default=True, help_text='Use SponsorBlock?', verbose_name='enable sponsorblock'),
),
migrations.AddField(
model_name='source',
name='sponsorblock_categories',
field=sync.models.CommaSepChoiceField(default='all', possible_choices=(('all', 'All'), ('sponsor', 'Sponsor'), ('intro', 'Intermission/Intro Animation'), ('outro', 'Endcards/Credits'), ('selfpromo', 'Unpaid/Self Promotion'), ('preview', 'Preview/Recap'), ('filler', 'Filler Tangent'), ('interaction', 'Interaction Reminder'), ('music_offtopic', 'Non-Music Section'))),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.18 on 2023-02-20 02:23
from django.db import migrations
import sync.fields
class Migration(migrations.Migration):
dependencies = [
('sync', '0016_auto_20230214_2052'),
]
operations = [
migrations.AlterField(
model_name='source',
name='sponsorblock_categories',
field=sync.fields.CommaSepChoiceField(default='all', help_text='Select the sponsorblocks you want to enforce', separator=''),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by pac
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0017_alter_source_sponsorblock_categories'),
]
operations = [
migrations.AddField(
model_name='source',
name='write_subtitles',
field=models.BooleanField(default=False, help_text='Download video subtitles', verbose_name='write subtitles'),
),
migrations.AddField(
model_name='source',
name='auto_subtitles',
field=models.BooleanField(default=False, help_text='Accept auto-generated subtitles', verbose_name='accept auto subtitles'),
),
migrations.AddField(
model_name='source',
name='sub_langs',
field=models.CharField(default='en', help_text='List of subtitles langs to download comma-separated. Example: en,fr',max_length=30),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by pac
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0018_source_subtitles'),
]
operations = [
migrations.AddField(
model_name='source',
name='delete_removed_media',
field=models.BooleanField(default=False, help_text='Delete media that is no longer on this playlist', verbose_name='delete removed media'),
),
]

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.core.validators import RegexValidator
from django.utils.text import slugify 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 _
@@ -19,11 +20,10 @@ from .utils import seconds_to_timestr, parse_media_format
from .matching import (get_best_combined_format, get_best_audio_format, from .matching import (get_best_combined_format, get_best_audio_format,
get_best_video_format) get_best_video_format)
from .mediaservers import PlexMediaServer from .mediaservers import PlexMediaServer
from .fields import CommaSepChoiceField
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT), base_url='/media-data/') media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT), base_url='/media-data/')
class Source(models.Model): class Source(models.Model):
''' '''
A Source is a source of media. Currently, this is either a YouTube channel A Source is a source of media. Currently, this is either a YouTube channel
@@ -106,6 +106,47 @@ class Source(models.Model):
EXTENSION_MKV = 'mkv' EXTENSION_MKV = 'mkv'
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV) EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV)
# as stolen from: https://wiki.sponsor.ajay.app/w/Types / https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/postprocessor/sponsorblock.py
SPONSORBLOCK_CATEGORIES_CHOICES = (
('sponsor', 'Sponsor'),
('intro', 'Intermission/Intro Animation'),
('outro', 'Endcards/Credits'),
('selfpromo', 'Unpaid/Self Promotion'),
('preview', 'Preview/Recap'),
('filler', 'Filler Tangent'),
('interaction', 'Interaction Reminder'),
('music_offtopic', 'Non-Music Section'),
)
sponsorblock_categories = CommaSepChoiceField(
_(''),
possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES,
all_choice="all",
allow_all=True,
all_label="(all options)",
default="all",
help_text=_("Select the sponsorblocks you want to enforce")
)
embed_metadata = models.BooleanField(
_('embed metadata'),
default=False,
help_text=_('Embed metadata from source into file')
)
embed_thumbnail = models.BooleanField(
_('embed thumbnail'),
default=False,
help_text=_('Embed thumbnail into the file')
)
enable_sponsorblock = models.BooleanField(
_('enable sponsorblock'),
default=True,
help_text=_('Use SponsorBlock?')
)
# 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>',
@@ -246,6 +287,11 @@ class Source(models.Model):
help_text=_('If "delete old media" is ticked, the number of days after which ' help_text=_('If "delete old media" is ticked, the number of days after which '
'to automatically delete media') 'to automatically delete media')
) )
delete_removed_media = models.BooleanField(
_('delete removed media'),
default=False,
help_text=_('Delete media that is no longer on this playlist')
)
source_resolution = models.CharField( source_resolution = models.CharField(
_('source resolution'), _('source resolution'),
max_length=8, max_length=8,
@@ -309,6 +355,30 @@ class Source(models.Model):
help_text=_('Source has failed to index media') help_text=_('Source has failed to index media')
) )
write_subtitles = models.BooleanField(
_('write subtitles'),
default=False,
help_text=_('Download video subtitles')
)
auto_subtitles = models.BooleanField(
_('accept auto-generated subs'),
default=False,
help_text=_('Accept auto-generated subtitles')
)
sub_langs = models.CharField(
_('subs langs'),
max_length=30,
default='en',
help_text=_('List of subtitles langs to download, comma-separated. Example: en,fr or all,-fr,-live_chat'),
validators=[
RegexValidator(
regex=r"^(\-?[\_\.a-zA-Z]+,)*(\-?[\_\.a-zA-Z]+){1}$",
message=_('Subtitle langs must be a comma-separated list of langs. example: en,fr or all,-fr,-live_chat')
)
]
)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -446,6 +516,7 @@ class Source(models.Model):
'dd': now.strftime('%d'), 'dd': now.strftime('%d'),
'source': self.slugname, 'source': self.slugname,
'source_full': self.name, 'source_full': self.name,
'uploader': 'Some Channel Name',
'title': 'some-media-title-name', 'title': 'some-media-title-name',
'title_full': 'Some Media Title Name', 'title_full': 'Some Media Title Name',
'key': 'SoMeUnIqUiD', 'key': 'SoMeUnIqUiD',
@@ -955,6 +1026,7 @@ class Media(models.Model):
'acodec': display_format['acodec'], 'acodec': display_format['acodec'],
'fps': display_format['fps'], 'fps': display_format['fps'],
'hdr': display_format['hdr'], 'hdr': display_format['hdr'],
'uploader': self.uploader,
} }
@property @property
@@ -1019,7 +1091,7 @@ class Media(models.Model):
duration = self.loaded_metadata.get(field, 0) duration = self.loaded_metadata.get(field, 0)
try: try:
duration = int(duration) duration = int(duration)
except ValueError: except (TypeError, ValueError):
duration = 0 duration = 0
return duration return duration
@@ -1291,7 +1363,10 @@ class Media(models.Model):
f'no valid format available') f'no valid format available')
# Download the media with youtube-dl # Download the media with youtube-dl
download_youtube_media(self.url, format_str, self.source.extension, download_youtube_media(self.url, format_str, self.source.extension,
str(self.filepath), self.source.write_json) str(self.filepath), self.source.write_json,
self.source.sponsorblock_categories, self.source.embed_thumbnail,
self.source.embed_metadata, self.source.enable_sponsorblock,
self.source.write_subtitles, self.source.auto_subtitles,self.source.sub_langs )
# Return the download paramaters # Return the download paramaters
return format_str, self.source.extension return format_str, self.source.extension

View File

@@ -156,7 +156,7 @@ def media_post_save(sender, instance, created, **kwargs):
verbose_name = _('Downloading metadata for "{}"') verbose_name = _('Downloading metadata for "{}"')
download_media_metadata( download_media_metadata(
str(instance.pk), str(instance.pk),
priority=10, priority=5,
verbose_name=verbose_name.format(instance.pk), verbose_name=verbose_name.format(instance.pk),
remove_existing_tasks=True remove_existing_tasks=True
) )

View File

@@ -132,16 +132,23 @@ def cleanup_completed_tasks():
def cleanup_old_media(): def cleanup_old_media():
for media in Media.objects.filter(download_date__isnull=False): for source in Source.objects.filter(delete_old_media=True, days_to_keep__gt=0):
if media.source.delete_old_media and media.source.days_to_keep > 0: delta = timezone.now() - timedelta(days=source.days_to_keep)
delta = timezone.now() - timedelta(days=media.source.days_to_keep) for media in source.media_source.filter(downloaded=True, download_date__lt=delta):
if media.downloaded and media.download_date < delta: log.info(f'Deleting expired media: {source} / {media} '
# Media was downloaded after the cutoff date, delete it f'(now older than {source.days_to_keep} days / '
log.info(f'Deleting expired media: {media.source} / {media} ' f'download_date before {delta})')
f'(now older than {media.source.days_to_keep} days / ' # .delete() also triggers a pre_delete signal that removes the files
f'download_date before {delta})') media.delete()
# .delete() also triggers a pre_delete signal that removes the files
media.delete()
def cleanup_removed_media(source, videos):
media_objects = Media.objects.filter(source=source, downloaded=True)
for item in media_objects:
matching_source_item = [video['id'] for video in videos if video['id'] == item.key]
if not matching_source_item:
log.info(f'{item.title} is no longer in source, removing')
item.delete()
@background(schedule=0) @background(schedule=0)
@@ -188,6 +195,9 @@ def index_source_task(source_id):
cleanup_completed_tasks() cleanup_completed_tasks()
# Tack on a cleanup of old media # Tack on a cleanup of old media
cleanup_old_media() cleanup_old_media()
if source.delete_removed_media:
log.info(f'Cleaning up media no longer in source {source}')
cleanup_removed_media(source, videos)
@background(schedule=0) @background(schedule=0)

View File

@@ -43,6 +43,11 @@
<td>Full source name</td> <td>Full source name</td>
<td>My Source</td> <td>My Source</td>
</tr> </tr>
<tr>
<td>{uploader}</td>
<td>Uploader name</td>
<td>Some Channel Name</td>
</tr>
<tr> <tr>
<td>{title}</td> <td>{title}</td>
<td>Lower case media title, max 80 chars</td> <td>Lower case media title, max 80 chars</td>

View File

@@ -115,6 +115,10 @@
<td class="hide-on-small-only">Write JSON?</td> <td class="hide-on-small-only">Write JSON?</td>
<td><span class="hide-on-med-and-up">Write JSON?<br></span><strong>{% if source.write_json %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td> <td><span class="hide-on-med-and-up">Write JSON?<br></span><strong>{% if source.write_json %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr> </tr>
<tr title="Delete media that is no longer on this playlist?">
<td class="hide-on-small-only">Delete removed media</td>
<td><span class="hide-on-med-and-up">Delete removed media<br></span><strong>{% if source.delete_removed_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr>
{% if source.delete_old_media and source.days_to_keep > 0 %} {% if source.delete_old_media and source.days_to_keep > 0 %}
<tr title="Days after which your media from this source will be locally deleted"> <tr title="Days after which your media from this source will be locally deleted">
<td class="hide-on-small-only">Delete old media</td> <td class="hide-on-small-only">Delete old media</td>
@@ -130,6 +134,55 @@
<td class="hide-on-small-only">UUID</td> <td class="hide-on-small-only">UUID</td>
<td><span class="hide-on-med-and-up">UUID<br></span><strong>{{ source.uuid }}</strong></td> <td><span class="hide-on-med-and-up">UUID<br></span><strong>{{ source.uuid }}</strong></td>
</tr> </tr>
<tr title="{{ _('Embedding thumbnail?') }}">
<td class="hide-on-small-only">{{ _("Embed thumbnail?") }}:</td>
<td><span class="hide-on-med-and-up">{{ _("Embed thumbnail?") }}<br></span><strong><i class="fas {% if source.embed_thumbnail %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
</tr>
<tr title="{{ _('Embedding metadata?') }}">
<td class="hide-on-small-only">{{ _("Embed metadata?") }}:</td>
<td><span class="hide-on-med-and-up">{{ _("Embed metadata?") }}<br></span><strong><i class="fas {% if source.embed_metadata %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
</tr>
<tr title="{{ _('Is sponsorblock enabled?') }}">
<td class="hide-on-small-only">{{ _("SponsorBlock?") }}:</td>
<td><span class="hide-on-med-and-up">{{ _("Sponsorblock enabled?") }}<br></span><strong><i class="fas {% if source.enable_sponsorblock %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
</tr>
{% if source.enable_sponsorblock %}
<tr title="{{ _('SponsorBlock: What to block?') }}">
<td class="hide-on-small-only">{{ _("What blocked?") }}:</td>
<td><span class="hide-on-med-and-up">{{ _("What blocked?") }}<br></span><strong>
{% if source.sponsorblock_categories.all_choice in source.sponsorblock_categories.selected_choices %}
{% for k,v in source.sponsorblock_categories.possible_choices %}
{{ v }}: <i class="fas fa-check"></i><BR>
{% endfor %}
{% else %}
{% for c in source.sponsorblock_categories.selected_choices %}
{% for k,v in source.sponsorblock_categories.possible_choices %}
{% if k == c %} {{ v }}: <i class="fas fa-check"></i><BR>{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
</strong></td>
</tr>
{% endif %}
<tr title="{{ _('Are Subtitles downloaded?') }}">
<td class="hide-on-small-only">{{ _("Download subtitles?") }}:</td>
<td><span class="hide-on-med-and-up">{{ _("Download subtitles?") }}:</span><strong><i class="fas {% if source.write_subtitles %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
</tr>
{% if source.write_subtitles %}
<tr title="{{ _('Are auto subs accepted?') }}">
<td class="hide-on-small-only">{{ _("Auto-generated subtitles?") }}:</td>
<td><span class="hide-on-med-and-up">{{ _("Auto-generated subtitles?") }}:</span><strong><i class="fas {% if source.auto_subtitles %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
</tr>
<tr title="{{ _('Subs langs?') }}">
<td class="hide-on-small-only">{{ _("Subs langs?") }}:</td>
<td><span class="hide-on-med-and-up">{{ _("Subs langs?") }}:</span><strong>{{source.sub_langs}}</strong></td>
</tr>
{% endif %}
</table> </table>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,7 @@
<!--<input type="{{ option.type }}" name="{{ option.name }}" value="{{ option.value }}" id="{{ option.value }}"><BR>
<label for="{{ option.value }}">{{option.label}}</label>-->
<label>
<input type="{{ option.type }}" name="{{ option.name }}" value="{{ option.value }}" id="{{ option.value }}" {% if option.checked %}checked{% endif %}>
<span>{{option.label}}</span>
</label>

View File

@@ -0,0 +1,5 @@
</label>
{% for option in widget.multipleChoiceProperties %}
{% include option.template_name with option=option %}
{% endfor %}
<label>

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from urllib.parse import urlsplit from urllib.parse import urlsplit
from xml.etree import ElementTree from xml.etree import ElementTree
from django.conf import settings from django.conf import settings
@@ -14,6 +14,7 @@ from django.test import TestCase, Client
from django.utils import timezone from django.utils import timezone
from background_task.models import Task from background_task.models import Task
from .models import Source, Media from .models import Source, Media
from .tasks import cleanup_old_media
class FrontEndTestCase(TestCase): class FrontEndTestCase(TestCase):
@@ -182,7 +183,8 @@ class FrontEndTestCase(TestCase):
'source_acodec': 'OPUS', 'source_acodec': 'OPUS',
'prefer_60fps': False, 'prefer_60fps': False,
'prefer_hdr': False, 'prefer_hdr': False,
'fallback': 'f' 'fallback': 'f',
'sub_langs': 'en',
} }
response = c.post('/source-add', data) response = c.post('/source-add', data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -223,7 +225,8 @@ class FrontEndTestCase(TestCase):
'source_acodec': Source.SOURCE_ACODEC_OPUS, 'source_acodec': Source.SOURCE_ACODEC_OPUS,
'prefer_60fps': False, 'prefer_60fps': False,
'prefer_hdr': False, 'prefer_hdr': False,
'fallback': Source.FALLBACK_FAIL 'fallback': Source.FALLBACK_FAIL,
'sub_langs': 'en',
} }
response = c.post(f'/source-update/{source_uuid}', data) response = c.post(f'/source-update/{source_uuid}', data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -252,7 +255,8 @@ class FrontEndTestCase(TestCase):
'source_acodec': Source.SOURCE_ACODEC_OPUS, 'source_acodec': Source.SOURCE_ACODEC_OPUS,
'prefer_60fps': False, 'prefer_60fps': False,
'prefer_hdr': False, 'prefer_hdr': False,
'fallback': Source.FALLBACK_FAIL 'fallback': Source.FALLBACK_FAIL,
'sub_langs': 'en',
} }
response = c.post(f'/source-update/{source_uuid}', data) response = c.post(f'/source-update/{source_uuid}', data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -464,11 +468,14 @@ metadata_60fps_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_60
metadata_60fps = open(metadata_60fps_filepath, 'rt').read() metadata_60fps = open(metadata_60fps_filepath, 'rt').read()
metadata_60fps_hdr_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_60fps_hdr.json' metadata_60fps_hdr_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_60fps_hdr.json'
metadata_60fps_hdr = open(metadata_60fps_hdr_filepath, 'rt').read() metadata_60fps_hdr = open(metadata_60fps_hdr_filepath, 'rt').read()
metadata_20230629_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_2023-06-29.json'
metadata_20230629 = open(metadata_20230629_filepath, 'rt').read()
all_test_metadata = { all_test_metadata = {
'boring': metadata, 'boring': metadata,
'hdr': metadata_hdr, 'hdr': metadata_hdr,
'60fps': metadata_60fps, '60fps': metadata_60fps,
'60fps+hdr': metadata_60fps_hdr, '60fps+hdr': metadata_60fps_hdr,
'20230629': metadata_20230629,
} }
@@ -1397,3 +1404,95 @@ class FormatMatchingTestCase(TestCase):
match_type, format_code = self.media.get_best_video_format() match_type, format_code = self.media.get_best_video_format()
self.assertEqual(format_code, expected_format_code) self.assertEqual(format_code, expected_format_code)
self.assertEqual(match_type, expeceted_match_type) self.assertEqual(match_type, expeceted_match_type)
def test_metadata_20230629(self):
self.media.metadata = all_test_metadata['20230629']
expected_matches = {
# (format, vcodec, prefer_60fps, prefer_hdr): (match_type, code),
('360p', 'AVC1', False, True): (False, '134'), # Fallback match, no hdr
('360p', 'AVC1', True, False): (False, '134'), # Fallback match, no 60fps
('360p', 'AVC1', True, True): (False, '332'), # Fallback match, 60fps+hdr, switched to VP9
('360p', 'VP9', False, False): (True, '243'), # Exact match
('360p', 'VP9', False, True): (True, '332'), # Exact match, hdr
('360p', 'VP9', True, False): (False, '332'), # Fallback match, 60fps, extra hdr
('360p', 'VP9', True, True): (True, '332'), # Exact match, 60fps+hdr
('480p', 'AVC1', False, False): (True, '135'), # Exact match
('480p', 'AVC1', False, True): (False, '135'), # Fallback match, no hdr
('480p', 'AVC1', True, False): (False, '135'), # Fallback match, no 60fps
('480p', 'AVC1', True, True): (False, '333'), # Fallback match, 60fps+hdr, switched to VP9
('480p', 'VP9', False, False): (True, '244'), # Exact match
('480p', 'VP9', False, True): (True, '333'), # Exact match, hdr
('480p', 'VP9', True, False): (False, '333'), # Fallback match, 60fps, extra hdr
('480p', 'VP9', True, True): (True, '333'), # Exact match, 60fps+hdr
('720p', 'AVC1', False, False): (True, '136'), # Exact match
('720p', 'AVC1', False, True): (False, '136'), # Fallback match, no hdr
('720p', 'AVC1', True, False): (True, '298'), # Exact match, 60fps
('720p', 'AVC1', True, True): (False, '334'), # Fallback match, 60fps+hdr, switched to VP9
('720p', 'VP9', False, False): (True, '247'), # Exact match
('720p', 'VP9', False, True): (True, '334'), # Exact match, hdr
('720p', 'VP9', True, False): (True, '302'), # Exact match, 60fps
('720p', 'VP9', True, True): (True, '334'), # Exact match, 60fps+hdr
('1440p', 'AVC1', False, False): (False, '308'), # Fallback match, 60fps, switched to VP9 (no 1440p AVC1)
('1440p', 'AVC1', False, True): (False, '336'), # Fallback match, 60fps+hdr, switched to VP9 (no 1440p AVC1)
('1440p', 'AVC1', True, False): (False, '308'), # Fallback match, 60fps, switched to VP9 (no 1440p AVC1)
('1440p', 'AVC1', True, True): (False, '336'), # Fallback match, 60fps+hdr, switched to VP9 (no 1440p AVC1)
('1440p', 'VP9', False, False): (False, '308'), # Fallback, 60fps
('1440p', 'VP9', False, True): (True, '336'), # Exact match, hdr
('1440p', 'VP9', True, False): (True, '308'), # Exact match, 60fps
('1440p', 'VP9', True, True): (True, '336'), # Exact match, 60fps+hdr
('2160p', 'AVC1', False, False): (False, '315'), # Fallback, 60fps, switched to VP9 (no 2160p AVC1)
('2160p', 'AVC1', False, True): (False, '337'), # Fallback match, 60fps+hdr, switched to VP9 (no 2160p AVC1)
('2160p', 'AVC1', True, False): (False, '315'), # Fallback, switched to VP9 (no 2160p AVC1)
('2160p', 'AVC1', True, True): (False, '337'), # Fallback match, 60fps+hdr, switched to VP9 (no 2160p AVC1)
('2160p', 'VP9', False, False): (False, '315'), # Fallback, 60fps
('2160p', 'VP9', False, True): (True, '337'), # Exact match, hdr
('2160p', 'VP9', True, False): (True, '315'), # Exact match, 60fps
('2160p', 'VP9', True, True): (True, '337'), # Exact match, 60fps+hdr
('4320p', 'AVC1', False, False): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
('4320p', 'AVC1', False, True): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
('4320p', 'AVC1', True, False): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
('4320p', 'AVC1', True, True): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
('4320p', 'VP9', False, False): (False, '272'), # Fallback, 60fps (no other 8k streams)
('4320p', 'VP9', False, True): (False, '272'), # Fallback, 60fps (no other 8k streams)
('4320p', 'VP9', True, False): (True, '272'), # Exact match, 60fps
('4320p', 'VP9', True, True): (False, '272'), # Fallback, 60fps (no other 8k streams)
}
for params, expected in expected_matches.items():
resolution, vcodec, prefer_60fps, prefer_hdr = params
expeceted_match_type, expected_format_code = expected
self.source.source_resolution = resolution
self.source.source_vcodec = vcodec
self.source.prefer_60fps = prefer_60fps
self.source.prefer_hdr = prefer_hdr
# The aim here is to execute the matching code to find error paths, specific testing isn't required
self.media.get_best_video_format()
self.media.get_best_audio_format()
class TasksTestCase(TestCase):
def setUp(self):
# Disable general logging for test case
logging.disable(logging.CRITICAL)
def test_delete_old_media(self):
src1 = Source.objects.create(key='aaa', name='aaa', directory='/tmp/a', delete_old_media=False, days_to_keep=14)
src2 = Source.objects.create(key='bbb', name='bbb', directory='/tmp/b', delete_old_media=True, days_to_keep=14)
now = timezone.now()
m11 = Media.objects.create(source=src1, downloaded=True, key='a11', download_date=now - timedelta(days=5))
m12 = Media.objects.create(source=src1, downloaded=True, key='a12', download_date=now - timedelta(days=25))
m13 = Media.objects.create(source=src1, downloaded=False, key='a13')
m21 = Media.objects.create(source=src2, downloaded=True, key='a21', download_date=now - timedelta(days=5))
m22 = Media.objects.create(source=src2, downloaded=True, key='a22', download_date=now - timedelta(days=25))
m23 = Media.objects.create(source=src2, downloaded=False, key='a23')
self.assertEquals(src1.media_source.all().count(), 3)
self.assertEquals(src2.media_source.all().count(), 3)
cleanup_old_media()
self.assertEquals(src1.media_source.all().count(), 3)
self.assertEquals(src2.media_source.all().count(), 2)
self.assertEquals(Media.objects.filter(pk=m22.pk).exists(), False)

View File

@@ -78,7 +78,7 @@ def resize_image_to_height(image, width, height):
if scaled_width < width: if scaled_width < width:
# Width too small, stretch it # Width too small, stretch it
scaled_width = width scaled_width = width
image = image.resize((scaled_width, height), Image.ANTIALIAS) image = image.resize((scaled_width, height), Image.LANCZOS)
if scaled_width > width: if scaled_width > width:
# Width too large, crop it # Width too large, crop it
delta = scaled_width - width delta = scaled_width - width

View File

@@ -70,7 +70,7 @@ class DashboardView(TemplateView):
data['average_bytes_per_media'] = 0 data['average_bytes_per_media'] = 0
# Latest downloads # Latest downloads
data['latest_downloads'] = Media.objects.filter( data['latest_downloads'] = Media.objects.filter(
downloaded=True downloaded=True, downloaded_filesize__isnull=False
).order_by('-download_date')[:10] ).order_by('-download_date')[:10]
# Largest downloads # Largest downloads
data['largest_downloads'] = Media.objects.filter( data['largest_downloads'] = Media.objects.filter(
@@ -296,8 +296,11 @@ class EditSourceMixin:
model = Source model = Source
fields = ('source_type', 'key', 'name', 'directory', 'media_format', fields = ('source_type', 'key', 'name', 'directory', 'media_format',
'index_schedule', 'download_media', 'download_cap', 'delete_old_media', 'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec', 'delete_removed_media', 'days_to_keep', 'source_resolution', 'source_vcodec',
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo', 'write_json') 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails',
'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles',
'auto_subtitles', 'sub_langs')
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 '

View File

@@ -64,13 +64,20 @@ def get_media_info(url):
return response return response
def download_media(url, media_format, extension, output_file, info_json, sponsor_categories="all"): def download_media(url, media_format, extension, output_file, info_json,
sponsor_categories="all",
embed_thumbnail=False, embed_metadata=False, skip_sponsors=True,
write_subtitles=False, auto_subtitles=False, sub_langs='en'):
''' '''
Downloads a YouTube URL to a file on disk. Downloads a YouTube URL to a file on disk.
''' '''
def hook(event): def hook(event):
filename = os.path.basename(event['filename']) filename = os.path.basename(event['filename'])
if event.get('downloaded_bytes') is None or event.get('total_bytes') is None:
return None
if event['status'] == 'error': if event['status'] == 'error':
log.error(f'[youtube-dl] error occured downloading: {filename}') log.error(f'[youtube-dl] error occured downloading: {filename}')
elif event['status'] == 'downloading': elif event['status'] == 'downloading':
@@ -100,27 +107,42 @@ def download_media(url, media_format, extension, output_file, info_json, sponsor
else: else:
log.warn(f'[youtube-dl] unknown event: {str(event)}') log.warn(f'[youtube-dl] unknown event: {str(event)}')
hook.download_progress = 0 hook.download_progress = 0
postprocessors = []
postprocessors.append({ ytopts = {
'key': 'FFmpegMetadata',
'add_chapters': True,
'add_metadata': True
})
# Pending configuration options from PR #338
#postprocessors.append({
# 'key': 'SponsorBlock',
# 'categories': [sponsor_categories]
#})
opts = get_yt_opts()
opts.update({
'format': media_format, 'format': media_format,
'merge_output_format': extension, 'merge_output_format': extension,
'outtmpl': output_file, 'outtmpl': output_file,
'quiet': True, 'quiet': True,
'progress_hooks': [hook], 'progress_hooks': [hook],
'writeinfojson': info_json, 'writeinfojson': info_json,
'postprocessors': postprocessors, 'postprocessors': [],
}) 'writesubtitles': write_subtitles,
'writeautomaticsub': auto_subtitles,
'subtitleslangs': sub_langs.split(','),
}
sbopt = {
'key': 'SponsorBlock',
'categories': [sponsor_categories]
}
ffmdopt = {
'key': 'FFmpegMetadata',
'add_chapters': True,
'add_metadata': True
}
opts = get_yt_opts()
if embed_thumbnail:
ytopts['postprocessors'].append({'key': 'EmbedThumbnail'})
if embed_metadata:
ffmdopt["add_metadata"] = True
if skip_sponsors:
ytopts['postprocessors'].append(sbopt)
ytopts['postprocessors'].append(ffmdopt)
opts.update(ytopts)
with yt_dlp.YoutubeDL(opts) as y: with yt_dlp.YoutubeDL(opts) as y:
try: try:
return y.download([url]) return y.download([url])

View File

@@ -67,6 +67,12 @@ YOUTUBE_DL_CACHEDIR = CONFIG_BASE_DIR / 'cache'
COOKIES_FILE = CONFIG_BASE_DIR / 'cookies.txt' COOKIES_FILE = CONFIG_BASE_DIR / 'cookies.txt'
HEALTHCHECK_FIREWALL_STR = str(os.getenv('TUBESYNC_HEALTHCHECK_FIREWAL', 'True')).strip().lower()
HEALTHCHECK_FIREWALL = True if HEALTHCHECK_FIREWALL_STR == 'true' else False
HEALTHCHECK_ALLOWED_IPS_STR = str(os.getenv('TUBESYNC_HEALTHCHECK_ALLOWED_IPS', '127.0.0.1'))
HEALTHCHECK_ALLOWED_IPS = HEALTHCHECK_ALLOWED_IPS_STR.split(',')
BASICAUTH_USERNAME = os.getenv('HTTP_USER', '').strip() BASICAUTH_USERNAME = os.getenv('HTTP_USER', '').strip()
BASICAUTH_PASSWORD = os.getenv('HTTP_PASS', '').strip() BASICAUTH_PASSWORD = os.getenv('HTTP_PASS', '').strip()
if BASICAUTH_USERNAME and BASICAUTH_PASSWORD: if BASICAUTH_USERNAME and BASICAUTH_PASSWORD:

View File

@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
DOWNLOADS_BASE_DIR = BASE_DIR DOWNLOADS_BASE_DIR = BASE_DIR
VERSION = '0.12.1' VERSION = '0.13.2'
SECRET_KEY = '' SECRET_KEY = ''
DEBUG = False DEBUG = False
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []