Compare commits
133 Commits
Author | SHA1 | Date |
---|---|---|
|
c6acd5378c | |
|
e7788eb8fb | |
|
e4e0b48c0b | |
|
3573c1187f | |
|
b11b667aff | |
|
1b581aa4ba | |
|
7384c00713 | |
|
4fdd172b05 | |
|
9c18115032 | |
|
6853c1fa76 | |
|
ed07073cf4 | |
|
af94b37ee6 | |
|
ad1d49a835 | |
|
46ba2593a2 | |
|
46a43b968a | |
|
805a0eefbd | |
|
3a87b5779e | |
|
f86e72aa92 | |
|
f550e32b5e | |
|
034d877d6a | |
|
b9b702ab85 | |
|
c159c24d15 | |
|
6c9772d573 | |
|
45b8b3f65b | |
|
7aa9c0ec8a | |
|
e54a762a7b | |
|
512b70adad | |
|
6c21ff15ab | |
|
adf26cb4e3 | |
|
45c12561ba | |
|
2d6f485a5d | |
|
33b471175a | |
|
7f4e8586b7 | |
|
bab4b9b056 | |
|
30c2127271 | |
|
d1cb7ef76c | |
|
1fd4f87c53 | |
|
cf06f4cbc2 | |
|
0523f481d2 | |
|
aa4bd4ec26 | |
|
96d9ee93ef | |
|
43cf532903 | |
|
8240c49d5c | |
|
0c5e3d3818 | |
|
22edd1bbda | |
|
fea0bb191e | |
|
0f65a4027a | |
|
5cac374486 | |
|
69efc9298d | |
|
1be8dff769 | |
|
350e544594 | |
|
0542c734e5 | |
|
42b337c408 | |
|
2f82f8c599 | |
|
b57ca110b0 | |
|
e3e7352600 | |
|
6d3a7bf859 | |
|
25f622311f | |
|
adea4a0ecd | |
|
0d76f2f94e | |
|
71578d926e | |
|
777cdb5ecc | |
|
3dd445bf96 | |
|
86744c0510 | |
|
be7454f72a | |
|
e9f03cb6bf | |
|
ddc127e6af | |
|
63d32a1e11 | |
|
2ebbb8480e | |
|
21785e031a | |
|
f12e13162f | |
|
5c9c1550bf | |
|
12638afb60 | |
|
b9886a3b27 | |
|
612f78e7eb | |
|
0c5a9c53f8 | |
|
d439b2f223 | |
|
7116617cd2 | |
|
422d228359 | |
|
1f68be5c26 | |
|
089a487f3a | |
|
24ae70ea70 | |
|
72c3242e70 | |
|
f3e93c0ecf | |
|
fa8efb178e | |
|
2001faea44 | |
|
b370e98031 | |
|
55bfd911b9 | |
|
e47d0eb7be | |
|
a95c64bc10 | |
|
e9d4f89f39 | |
|
7876b48860 | |
|
2639d911ab | |
|
e4c0f0e98a | |
|
601449ce08 | |
|
fe4c876fdc | |
|
fbe9546a74 | |
|
ce14167cee | |
|
c927f32aa6 | |
|
1d5579aa31 | |
|
d8a9572411 | |
|
8315efac03 | |
|
35678e3be9 | |
|
e75b446883 | |
|
dd05595558 | |
|
2772e85d9f | |
|
931aa78815 | |
|
24a49d2f14 | |
|
f14d2dd29e | |
|
f4e5b6e76c | |
|
977f996d8e | |
|
dc5491455c | |
|
70ef11d552 | |
|
b04e237cb8 | |
|
55c58b4836 | |
|
e871983707 | |
|
b3f93ddef7 | |
|
bf7a0fcec0 | |
|
598ee2bd0a | |
|
7b12fe3fad | |
|
7358b52184 | |
|
4b4b4eb58d | |
|
b719fd5122 | |
|
4696aebebc | |
|
7d333487fe | |
|
844d17006e | |
|
f9a27eb33e | |
|
b8434ff444 | |
|
932eb4caf4 | |
|
812fbc5f46 | |
|
fdc591cc7c | |
|
4ae454a4f3 | |
|
4f6af702ae |
|
@ -4,12 +4,10 @@ env:
|
||||||
IMAGE_NAME: tubesync
|
IMAGE_NAME: tubesync
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
.DS_Store
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
@ -130,3 +131,6 @@ dmypy.json
|
||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
Pipfile.lock
|
||||||
|
.vscode/launch.json
|
||||||
|
|
34
Dockerfile
34
Dockerfile
|
@ -1,16 +1,17 @@
|
||||||
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-29-14-19"
|
||||||
ARG FFMPEG_VERSION="109474-gc94988a781"
|
ARG FFMPEG_VERSION="112875-g47e214245b"
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||||
HOME="/root" \
|
HOME="/root" \
|
||||||
LANGUAGE="en_US.UTF-8" \
|
LANGUAGE="en_US.UTF-8" \
|
||||||
LANG="en_US.UTF-8" \
|
LANG="en_US.UTF-8" \
|
||||||
LC_ALL="en_US.UTF-8" \
|
LC_ALL="en_US.UTF-8" \
|
||||||
TERM="xterm"
|
TERM="xterm" \
|
||||||
|
S6_CMD_WAIT_FOR_SERVICES_MAXTIME="0"
|
||||||
|
|
||||||
# Install third party software
|
# Install third party software
|
||||||
RUN export ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
RUN export ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
|
@ -18,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 "36bac8c527bf390603416f749ab0dd860142b0a66f0865b67366062a9c286c8b" ;; \
|
||||||
"linux/arm64") echo "dd1375bd351d38ea1cc3efd68a998699366e28bd9b90df65d11af2b9121746b7" ;; \
|
"linux/arm64") echo "8f36e45d99d2367a5c0c220ee3164fa48f4f0cec35f78204ccced8dc303bfbdc" ;; \
|
||||||
*) 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 && \
|
||||||
|
@ -82,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
|
||||||
|
@ -119,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 \
|
||||||
|
|
8
Makefile
8
Makefile
|
@ -29,5 +29,13 @@ runcontainer:
|
||||||
$(docker) run --rm --name $(name) --env-file dev.env --log-opt max-size=50m -ti -p 4848:4848 $(image)
|
$(docker) run --rm --name $(name) --env-file dev.env --log-opt max-size=50m -ti -p 4848:4848 $(image)
|
||||||
|
|
||||||
|
|
||||||
|
stopcontainer:
|
||||||
|
$(docker) stop $(name)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
3
Pipfile
3
Pipfile
|
@ -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 = "*"}
|
||||||
|
|
|
@ -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
|
||||||
|
@ -350,6 +351,10 @@ etc.). Configuration of this is beyond the scope of this README.
|
||||||
|
|
||||||
Just `amd64` for the moment. Others may be made available if there is demand.
|
Just `amd64` for the moment. Others may be made available if there is demand.
|
||||||
|
|
||||||
|
### The pipenv install fails with "Locking failed"!
|
||||||
|
|
||||||
|
Make sure that you have `mysql_config` or `mariadb_config` available, as required by the python module `mysqlclient`. On Debian-based systems this is usually found in the package `libmysqlclient-dev`
|
||||||
|
|
||||||
|
|
||||||
# Advanced configuration
|
# Advanced configuration
|
||||||
|
|
||||||
|
@ -358,12 +363,13 @@ There are a number of other environment variables you can set. These are, mostly
|
||||||
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 |
|
||||||
|
| TUBESYNC_RESET_DOWNLOAD_DIR | Toggle resetting `/downloads` permissions, defaults to True | True
|
||||||
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
||||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||||
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
60000
|
|
|
@ -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:
|
||||||
|
@ -78,3 +78,55 @@ entry in the container or stdout logs:
|
||||||
|
|
||||||
If you see a line similar to the above and the web interface loads, congratulations,
|
If you see a line similar to the above and the web interface loads, congratulations,
|
||||||
you are now using an external database server for your TubeSync data!
|
you are now using an external database server for your TubeSync data!
|
||||||
|
|
||||||
|
## Database Compression (For MariaDB)
|
||||||
|
With a lot of media files the `sync_media` table grows in size quickly.
|
||||||
|
You can save space using column compression using the following steps while using MariaDB:
|
||||||
|
|
||||||
|
1. Stop tubesync
|
||||||
|
2. Execute `ALTER TABLE sync_media MODIFY metadata LONGTEXT COMPRESSED;` on database tubesync
|
||||||
|
3. Start tunesync and confirm the connection still works.
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
If you're using Docker Compose and simply want to connect to another container with
|
||||||
|
the DB for the performance benefits, a configuration like this would be enough:
|
||||||
|
|
||||||
|
```
|
||||||
|
tubesync-db:
|
||||||
|
image: postgres:15.2
|
||||||
|
container_name: tubesync-db
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /<path/to>/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
- /<path/to>/tubesync-db:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=testpassword
|
||||||
|
|
||||||
|
tubesync:
|
||||||
|
image: ghcr.io/meeb/tubesync:latest
|
||||||
|
container_name: tubesync
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 4848:4848
|
||||||
|
volumes:
|
||||||
|
- /<path/to>/tubesync/config:/config
|
||||||
|
- /<path/to>/YouTube:/downloads
|
||||||
|
environment:
|
||||||
|
- DATABASE_CONNECTION=postgresql://postgres:testpassword@tubesync-db:5432/tubesync
|
||||||
|
depends_on:
|
||||||
|
- tubesync-db
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that an `init.sql` file is needed to initialize the `tubesync`
|
||||||
|
database before it can be written to. This file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
CREATE DATABASE tubesync;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Then it must be mapped to `/docker-entrypoint-initdb.d/init.sql` for it
|
||||||
|
to be executed on first startup of the container. See the `tubesync-db`
|
||||||
|
volume mapping above for how to do this.
|
||||||
|
|
|
@ -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.
|
|
@ -1,10 +1,14 @@
|
||||||
import logging
|
import logging
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
logging_level = logging.DEBUG if settings.DEBUG else logging.INFO
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('tubesync')
|
log = logging.getLogger('tubesync')
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging_level)
|
||||||
ch = logging.StreamHandler()
|
ch = logging.StreamHandler()
|
||||||
ch.setLevel(logging.DEBUG)
|
ch.setLevel(logging_level)
|
||||||
formatter = logging.Formatter('%(asctime)s [%(name)s/%(levelname)s] %(message)s')
|
formatter = logging.Formatter('%(asctime)s [%(name)s/%(levelname)s] %(message)s')
|
||||||
ch.setFormatter(formatter)
|
ch.setFormatter(formatter)
|
||||||
log.addHandler(ch)
|
log.addHandler(ch)
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -65,6 +65,7 @@ readers do not read off random characters that represent icons */
|
||||||
.#{$fa-css-prefix}-arrows-alt-h:before { content: fa-content($fa-var-arrows-alt-h); }
|
.#{$fa-css-prefix}-arrows-alt-h:before { content: fa-content($fa-var-arrows-alt-h); }
|
||||||
.#{$fa-css-prefix}-arrows-alt-v:before { content: fa-content($fa-var-arrows-alt-v); }
|
.#{$fa-css-prefix}-arrows-alt-v:before { content: fa-content($fa-var-arrows-alt-v); }
|
||||||
.#{$fa-css-prefix}-artstation:before { content: fa-content($fa-var-artstation); }
|
.#{$fa-css-prefix}-artstation:before { content: fa-content($fa-var-artstation); }
|
||||||
|
.#{$fa-css-prefix}-arrow-rotate-right:before { content: fa-content($fa-var-arrow-rotate-right); }
|
||||||
.#{$fa-css-prefix}-assistive-listening-systems:before { content: fa-content($fa-var-assistive-listening-systems); }
|
.#{$fa-css-prefix}-assistive-listening-systems:before { content: fa-content($fa-var-assistive-listening-systems); }
|
||||||
.#{$fa-css-prefix}-asterisk:before { content: fa-content($fa-var-asterisk); }
|
.#{$fa-css-prefix}-asterisk:before { content: fa-content($fa-var-asterisk); }
|
||||||
.#{$fa-css-prefix}-asymmetrik:before { content: fa-content($fa-var-asymmetrik); }
|
.#{$fa-css-prefix}-asymmetrik:before { content: fa-content($fa-var-asymmetrik); }
|
||||||
|
|
|
@ -80,6 +80,7 @@ $fa-var-arrow-right: \f061;
|
||||||
$fa-var-arrow-up: \f062;
|
$fa-var-arrow-up: \f062;
|
||||||
$fa-var-arrows-alt: \f0b2;
|
$fa-var-arrows-alt: \f0b2;
|
||||||
$fa-var-arrows-alt-h: \f337;
|
$fa-var-arrows-alt-h: \f337;
|
||||||
|
$fa-var-arrow-rotate-right: \f01e;
|
||||||
$fa-var-arrows-alt-v: \f338;
|
$fa-var-arrows-alt-v: \f338;
|
||||||
$fa-var-artstation: \f77a;
|
$fa-var-artstation: \f77a;
|
||||||
$fa-var-assistive-listening-systems: \f2a2;
|
$fa-var-assistive-listening-systems: \f2a2;
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
// Text Label Style
|
// Text Label Style
|
||||||
+ span:not(.lever) {
|
+ span:not(.lever) {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 35px;
|
padding-left: 27px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
|
|
|
@ -17,3 +17,16 @@ html {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-collection-container {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text > i {
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% for i in paginator.page_range %}
|
{% for i in paginator.page_range %}
|
||||||
<a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}{% if show_skipped %}&show_skipped=yes{% endif %}">{{ i }}</a>
|
<a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}{% if show_skipped %}&show_skipped=yes{% endif %}{% if only_skipped %}&only_skipped=yes{% endif %}">{{ i }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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]
|
|
@ -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')
|
|
@ -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
|
||||||
|
|
|
@ -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'}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.2.17 on 2023-02-13 06:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0014_alter_media_media_file'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='media',
|
||||||
|
name='manual_skip',
|
||||||
|
field=models.BooleanField(db_index=True, default=False, help_text='Media marked as "skipped", won\' be downloaded', verbose_name='manual_skip'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='skip',
|
||||||
|
field=models.BooleanField(db_index=True, default=False, help_text='INTERNAL FLAG - Media will be skipped and not downloaded', verbose_name='skip'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'))),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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=''),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 3.2.22 on 2023-10-24 17:25
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0019_add_delete_removed_media'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='filter_text',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Regex compatible filter string for video titles', max_length=100, verbose_name='filter string'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='auto_subtitles',
|
||||||
|
field=models.BooleanField(default=False, help_text='Accept auto-generated subtitles', verbose_name='accept auto-generated subs'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='sub_langs',
|
||||||
|
field=models.CharField(default='en', help_text='List of subtitles langs to download, comma-separated. Example: en,fr or all,-fr,-live_chat', max_length=30, validators=[django.core.validators.RegexValidator(message='Subtitle langs must be a comma-separated list of langs. example: en,fr or all,-fr,-live_chat', regex='^(\\-?[\\_\\.a-zA-Z]+,)*(\\-?[\\_\\.a-zA-Z]+){1}$')], verbose_name='subs langs'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by pac
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0020_auto_20231024_1825'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='delete_files_on_disk',
|
||||||
|
field=models.BooleanField(default=False, help_text='Delete files on disk when they are removed from TubeSync', verbose_name='delete files on disk'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,6 +1,7 @@
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
@ -8,6 +9,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 +21,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 +107,43 @@ 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 +284,23 @@ 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')
|
||||||
)
|
)
|
||||||
|
filter_text = models.CharField(
|
||||||
|
_('filter string'),
|
||||||
|
max_length=100,
|
||||||
|
default='',
|
||||||
|
blank=True,
|
||||||
|
help_text=_('Regex compatible filter string for video titles')
|
||||||
|
)
|
||||||
|
delete_removed_media = models.BooleanField(
|
||||||
|
_('delete removed media'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Delete media that is no longer on this playlist')
|
||||||
|
)
|
||||||
|
delete_files_on_disk = models.BooleanField(
|
||||||
|
_('delete files on disk'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Delete files on disk when they are removed from TubeSync')
|
||||||
|
)
|
||||||
source_resolution = models.CharField(
|
source_resolution = models.CharField(
|
||||||
_('source resolution'),
|
_('source resolution'),
|
||||||
max_length=8,
|
max_length=8,
|
||||||
|
@ -309,6 +364,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 +525,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',
|
||||||
|
@ -467,6 +547,11 @@ class Source(models.Model):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def is_regex_match(self, media_item_title):
|
||||||
|
if not self.filter_text:
|
||||||
|
return True
|
||||||
|
return bool(re.search(self.filter_text, media_item_title))
|
||||||
|
|
||||||
def index_media(self):
|
def index_media(self):
|
||||||
'''
|
'''
|
||||||
Index the media source returning a list of media metadata as dicts.
|
Index the media source returning a list of media metadata as dicts.
|
||||||
|
@ -679,7 +764,13 @@ class Media(models.Model):
|
||||||
_('skip'),
|
_('skip'),
|
||||||
db_index=True,
|
db_index=True,
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('Media will be skipped and not downloaded')
|
help_text=_('INTERNAL FLAG - Media will be skipped and not downloaded')
|
||||||
|
)
|
||||||
|
manual_skip = models.BooleanField(
|
||||||
|
_('manual_skip'),
|
||||||
|
db_index=True,
|
||||||
|
default=False,
|
||||||
|
help_text=_('Media marked as "skipped", won\' be downloaded')
|
||||||
)
|
)
|
||||||
downloaded = models.BooleanField(
|
downloaded = models.BooleanField(
|
||||||
_('downloaded'),
|
_('downloaded'),
|
||||||
|
@ -949,6 +1040,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
|
||||||
|
@ -1013,7 +1105,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
|
||||||
|
|
||||||
|
@ -1136,6 +1228,29 @@ class Media(models.Model):
|
||||||
return False
|
return False
|
||||||
return os.path.exists(self.media_file.path)
|
return os.path.exists(self.media_file.path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_type(self):
|
||||||
|
if not self.downloaded:
|
||||||
|
return 'video/mp4'
|
||||||
|
vcodec = self.downloaded_video_codec
|
||||||
|
if vcodec is None:
|
||||||
|
acodec = self.downloaded_audio_codec
|
||||||
|
if acodec is None:
|
||||||
|
raise TypeError() # nothing here.
|
||||||
|
acodec = acodec.lower()
|
||||||
|
if acodec == "mp4a":
|
||||||
|
return "audio/mp4"
|
||||||
|
elif acodec == "opus":
|
||||||
|
return "audio/opus"
|
||||||
|
else:
|
||||||
|
# fall-fall-back.
|
||||||
|
return 'audio/ogg'
|
||||||
|
vcodec = vcodec.lower()
|
||||||
|
if vcodec == 'vp9':
|
||||||
|
return 'video/webm'
|
||||||
|
else:
|
||||||
|
return 'video/mp4'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def nfoxml(self):
|
def nfoxml(self):
|
||||||
'''
|
'''
|
||||||
|
@ -1153,6 +1268,22 @@ class Media(models.Model):
|
||||||
showtitle.text = str(self.source.name).strip()
|
showtitle.text = str(self.source.name).strip()
|
||||||
showtitle.tail = '\n '
|
showtitle.tail = '\n '
|
||||||
nfo.append(showtitle)
|
nfo.append(showtitle)
|
||||||
|
# season = upload date year
|
||||||
|
season = nfo.makeelement('season', {})
|
||||||
|
if self.source.source_type == Source.SOURCE_TYPE_YOUTUBE_PLAYLIST:
|
||||||
|
# If it's a playlist, set season to 1
|
||||||
|
season.text = '1'
|
||||||
|
else:
|
||||||
|
# If it's not a playlist, set season to upload date year
|
||||||
|
season.text = str(self.upload_date.year) if self.upload_date else ''
|
||||||
|
season.tail = '\n '
|
||||||
|
nfo.append(season)
|
||||||
|
# episode = number of video in the year
|
||||||
|
episode = nfo.makeelement('episode', {})
|
||||||
|
episode_number = self.calculate_episode_number()
|
||||||
|
episode.text = str(episode_number) if episode_number else ''
|
||||||
|
episode.tail = '\n '
|
||||||
|
nfo.append(episode)
|
||||||
# ratings = media metadata youtube rating
|
# ratings = media metadata youtube rating
|
||||||
value = nfo.makeelement('value', {})
|
value = nfo.makeelement('value', {})
|
||||||
value.text = str(self.rating)
|
value.text = str(self.rating)
|
||||||
|
@ -1260,7 +1391,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.selected_choices, 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
|
||||||
|
|
||||||
|
@ -1274,6 +1408,19 @@ class Media(models.Model):
|
||||||
f'has no indexer')
|
f'has no indexer')
|
||||||
return indexer(self.url)
|
return indexer(self.url)
|
||||||
|
|
||||||
|
def calculate_episode_number(self):
|
||||||
|
if self.source.source_type == Source.SOURCE_TYPE_YOUTUBE_PLAYLIST:
|
||||||
|
sorted_media = Media.objects.filter(source=self.source)
|
||||||
|
else:
|
||||||
|
self_year = self.upload_date.year if self.upload_date else self.created.year
|
||||||
|
filtered_media = Media.objects.filter(source=self.source, published__year=self_year)
|
||||||
|
sorted_media = sorted(filtered_media, key=lambda x: (x.upload_date, x.key))
|
||||||
|
position_counter = 1
|
||||||
|
for media in sorted_media:
|
||||||
|
if media == self:
|
||||||
|
return position_counter
|
||||||
|
position_counter += 1
|
||||||
|
|
||||||
|
|
||||||
class MediaServer(models.Model):
|
class MediaServer(models.Model):
|
||||||
'''
|
'''
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
import glob
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
|
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
@ -74,6 +75,7 @@ def source_pre_delete(sender, instance, **kwargs):
|
||||||
media.delete()
|
media.delete()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=Source)
|
@receiver(post_delete, sender=Source)
|
||||||
def source_post_delete(sender, instance, **kwargs):
|
def source_post_delete(sender, instance, **kwargs):
|
||||||
# Triggered after a source is deleted
|
# Triggered after a source is deleted
|
||||||
|
@ -93,13 +95,17 @@ def task_task_failed(sender, task_id, completed_task, **kwargs):
|
||||||
|
|
||||||
@receiver(post_save, sender=Media)
|
@receiver(post_save, sender=Media)
|
||||||
def media_post_save(sender, instance, created, **kwargs):
|
def media_post_save(sender, instance, created, **kwargs):
|
||||||
|
# If the media is skipped manually, bail.
|
||||||
|
if instance.manual_skip:
|
||||||
|
return
|
||||||
# Triggered after media is saved
|
# Triggered after media is saved
|
||||||
cap_changed = False
|
cap_changed = False
|
||||||
can_download_changed = False
|
can_download_changed = False
|
||||||
# Reset the skip flag if the download cap has changed if the media has not
|
# Reset the skip flag if the download cap has changed if the media has not
|
||||||
# already been downloaded
|
# already been downloaded
|
||||||
if not instance.downloaded:
|
if not instance.downloaded and instance.metadata:
|
||||||
max_cap_age = instance.source.download_cap_date
|
max_cap_age = instance.source.download_cap_date
|
||||||
|
filter_text = instance.source.filter_text.strip()
|
||||||
published = instance.published
|
published = instance.published
|
||||||
if not published:
|
if not published:
|
||||||
if not instance.skip:
|
if not instance.skip:
|
||||||
|
@ -113,7 +119,16 @@ def media_post_save(sender, instance, created, **kwargs):
|
||||||
else:
|
else:
|
||||||
if max_cap_age:
|
if max_cap_age:
|
||||||
if published > max_cap_age and instance.skip:
|
if published > max_cap_age and instance.skip:
|
||||||
# Media was published after the cap date but is set to be skipped
|
if filter_text:
|
||||||
|
if instance.source.is_regex_match(instance.title):
|
||||||
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
|
f'publishing date and title filter, marking to be unskipped')
|
||||||
|
instance.skip = False
|
||||||
|
cap_changed = True
|
||||||
|
else:
|
||||||
|
log.debug(f'Media: {instance.source} / {instance} has a valid publishing date '
|
||||||
|
f'but failed the title filter match, already marked skipped')
|
||||||
|
else:
|
||||||
log.info(f'Media: {instance.source} / {instance} has a valid '
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
f'publishing date, marking to be unskipped')
|
f'publishing date, marking to be unskipped')
|
||||||
instance.skip = False
|
instance.skip = False
|
||||||
|
@ -126,10 +141,20 @@ def media_post_save(sender, instance, created, **kwargs):
|
||||||
else:
|
else:
|
||||||
if instance.skip:
|
if instance.skip:
|
||||||
# Media marked to be skipped but source download cap removed
|
# Media marked to be skipped but source download cap removed
|
||||||
|
if filter_text:
|
||||||
|
if instance.source.is_regex_match(instance.title):
|
||||||
log.info(f'Media: {instance.source} / {instance} has a valid '
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
f'publishing date, marking to be unskipped')
|
f'publishing date and title filter, marking to be unskipped')
|
||||||
instance.skip = False
|
instance.skip = False
|
||||||
cap_changed = True
|
cap_changed = True
|
||||||
|
else:
|
||||||
|
log.info(f'Media: {instance.source} / {instance} has a valid publishing date '
|
||||||
|
f'but failed the title filter match, already marked skipped')
|
||||||
|
else:
|
||||||
|
log.debug(f'Media: {instance.source} / {instance} has a valid publishing date and '
|
||||||
|
f'is already marked as not to be skipped')
|
||||||
|
|
||||||
|
cap_changed = False
|
||||||
# Recalculate the "can_download" flag, this may
|
# 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
|
||||||
if instance.metadata:
|
if instance.metadata:
|
||||||
|
@ -152,7 +177,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
|
||||||
)
|
)
|
||||||
|
@ -199,6 +224,16 @@ def media_pre_delete(sender, instance, **kwargs):
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
||||||
(str(instance.pk), thumbnail_url))
|
(str(instance.pk), thumbnail_url))
|
||||||
|
if instance.source.delete_files_on_disk and (instance.media_file or instance.thumb):
|
||||||
|
# Delete all media files if it contains filename
|
||||||
|
filepath = instance.media_file.path if instance.media_file else instance.thumb.path
|
||||||
|
barefilepath, fileext = os.path.splitext(filepath)
|
||||||
|
# Get all files that start with the bare file path
|
||||||
|
all_related_files = glob.glob(f'{barefilepath}.*')
|
||||||
|
for file in all_related_files:
|
||||||
|
log.info(f'Deleting file for: {instance} path: {file}')
|
||||||
|
delete_file(file)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=Media)
|
@receiver(post_delete, sender=Media)
|
||||||
|
|
|
@ -132,18 +132,25 @@ 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'(now older than {media.source.days_to_keep} days / '
|
|
||||||
f'download_date before {delta})')
|
f'download_date before {delta})')
|
||||||
# .delete() also triggers a pre_delete signal that removes the files
|
# .delete() also triggers a pre_delete signal that removes the files
|
||||||
media.delete()
|
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)
|
||||||
def index_source_task(source_id):
|
def index_source_task(source_id):
|
||||||
'''
|
'''
|
||||||
|
@ -153,7 +160,6 @@ def index_source_task(source_id):
|
||||||
source = Source.objects.get(pk=source_id)
|
source = Source.objects.get(pk=source_id)
|
||||||
except Source.DoesNotExist:
|
except Source.DoesNotExist:
|
||||||
# Task triggered but the Source has been deleted, delete the task
|
# Task triggered but the Source has been deleted, delete the task
|
||||||
delete_index_source_task(source_id)
|
|
||||||
return
|
return
|
||||||
# Reset any errors
|
# Reset any errors
|
||||||
source.has_failed = False
|
source.has_failed = False
|
||||||
|
@ -189,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)
|
||||||
|
@ -202,7 +211,6 @@ def check_source_directory_exists(source_id):
|
||||||
source = Source.objects.get(pk=source_id)
|
source = Source.objects.get(pk=source_id)
|
||||||
except Source.DoesNotExist:
|
except Source.DoesNotExist:
|
||||||
# Task triggered but the Source has been deleted, delete the task
|
# Task triggered but the Source has been deleted, delete the task
|
||||||
delete_index_source_task(source_id)
|
|
||||||
return
|
return
|
||||||
# Check the source output directory exists
|
# Check the source output directory exists
|
||||||
if not source.directory_exists():
|
if not source.directory_exists():
|
||||||
|
@ -223,6 +231,9 @@ def download_media_metadata(media_id):
|
||||||
log.error(f'Task download_media_metadata(pk={media_id}) called but no '
|
log.error(f'Task download_media_metadata(pk={media_id}) called but no '
|
||||||
f'media exists with ID: {media_id}')
|
f'media exists with ID: {media_id}')
|
||||||
return
|
return
|
||||||
|
if media.manual_skip:
|
||||||
|
log.info(f'Task for ID: {media_id} skipped, due to task being manually skipped.')
|
||||||
|
return
|
||||||
source = media.source
|
source = media.source
|
||||||
metadata = media.index_metadata()
|
metadata = media.index_metadata()
|
||||||
media.metadata = json.dumps(metadata, default=json_serial)
|
media.metadata = json.dumps(metadata, default=json_serial)
|
||||||
|
@ -241,6 +252,11 @@ def download_media_metadata(media_id):
|
||||||
log.warn(f'Media: {source} / {media} is older than cap age '
|
log.warn(f'Media: {source} / {media} is older than cap age '
|
||||||
f'{max_cap_age}, skipping')
|
f'{max_cap_age}, skipping')
|
||||||
media.skip = True
|
media.skip = True
|
||||||
|
# If the source has a search filter, check the video title matches the filter
|
||||||
|
if source.filter_text and not source.is_regex_match(media.title):
|
||||||
|
# Filter text not found in the media title. Accepts regex string, blank search filter results in this returning false
|
||||||
|
log.warn(f'Media: {source} / {media} does not match {source.filter_text}, skipping')
|
||||||
|
media.skip = True
|
||||||
# If the source has a cut-off check the upload date is within the allowed delta
|
# 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:
|
if source.delete_old_media and source.days_to_keep > 0:
|
||||||
if not isinstance(media.published, datetime):
|
if not isinstance(media.published, datetime):
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -10,15 +10,23 @@
|
||||||
<p class="truncate"><strong><a href="{{ media.url }}" target="_blank"><i class="fas fa-link"></i> {{ media.url }}</a></strong></p>
|
<p class="truncate"><strong><a href="{{ media.url }}" target="_blank"><i class="fas fa-link"></i> {{ media.url }}</a></strong></p>
|
||||||
<p class="truncate">Downloading to: <strong>{{ media.source.directory_path }}</strong></p>
|
<p class="truncate">Downloading to: <strong>{{ media.source.directory_path }}</strong></p>
|
||||||
{% if download_state == 'downloaded' %}
|
{% if download_state == 'downloaded' %}
|
||||||
|
{% if media.source.is_audio %}
|
||||||
|
<audio controls src="{% url 'sync:media-content' pk=media.pk %}"></audio>
|
||||||
|
{% else %}
|
||||||
<video controls style="width: 100%">
|
<video controls style="width: 100%">
|
||||||
<source src="{% url 'sync:media-content' pk=media.pk %}">
|
<source src="{% url 'sync:media-content' pk=media.pk %}">
|
||||||
</video>
|
</video>
|
||||||
<p class="truncate"><a href="{% url 'sync:media-content' pk=media.pk %}" download="{{ media.filename }}">Download</a></p>
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="truncate"><a href="{% url 'sync:media-content' pk=media.pk %}" download="{{ media.filename }}"><strong><i class="fas fa-download"></i> Download</strong></a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if not media.can_download %}{% include 'errorbox.html' with message='Media cannot be downloaded because it has no formats which match the source requirements.' %}{% endif %}
|
{% if media.manual_skip %}{% include 'errorbox.html' with message='Media is marked to be skipped and will not be downloaded.' %}
|
||||||
{% if media.skip %}{% include 'errorbox.html' with message='Media is marked to be skipped and will not be downloaded.' %}{% endif %}
|
{% else %}
|
||||||
|
{% if not media.can_download %}{% include 'errorbox.html' with message='Media cannot be downloaded because it has no formats which match the source requirements.' %}{% endif %}
|
||||||
|
{% if media.skip %}{% include 'errorbox.html' with message='This media may be skipped due to error(s).' %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% include 'infobox.html' with message=message %}
|
{% include 'infobox.html' with message=message %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12 m7">
|
<div class="col s12 m7">
|
||||||
|
@ -162,10 +170,10 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
{% if media.skip %}
|
{% if media.manual_skip %}
|
||||||
<a href="{% url 'sync:enable-media' pk=media.pk %}" class="btn">Enable (unskip) media <i class="fas fa-cloud-download-alt"></i></a>
|
<a href="{% url 'sync:enable-media' pk=media.pk %}" class="btn">Unskip media (manually) <i class="fas fa-cloud-download-alt"></i></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'sync:skip-media' pk=media.pk %}" class="btn delete-button">Skip media <i class="fas fa-times-circle"></i></a>
|
<a href="{% url 'sync:skip-media' pk=media.pk %}" class="btn delete-button">Manually mark media to be skipped <i class="fas fa-times-circle"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -36,8 +36,10 @@
|
||||||
{% if m.downloaded %}
|
{% if m.downloaded %}
|
||||||
<i class="fas fa-check-circle" title="Downloaded"></i> {{ m.download_date|date:'Y-m-d' }}
|
<i class="fas fa-check-circle" title="Downloaded"></i> {{ m.download_date|date:'Y-m-d' }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if m.skip %}
|
{% if m.manual_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> Manually skipped</span>
|
||||||
|
{% elif m.skip %}
|
||||||
|
<span class="error-text"><i class="fas fa-times" title="Skipping media"></i> Skipped by system</span>
|
||||||
{% elif not m.source.download_media %}
|
{% 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>
|
<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 %}
|
{% elif not m.has_metadata %}
|
||||||
|
@ -62,5 +64,5 @@
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped %}
|
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped only_skipped=only_skipped%}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to delete this source? Deleting a source is permanent.
|
Are you sure you want to delete this source? Deleting a source is permanent.
|
||||||
By default, deleting a source does not delete any saved media files. You can
|
By default, deleting a source does not delete any saved media files. You can
|
||||||
tick the "also delete downloaded media" checkbox to also remove save
|
<strong>tick the "also delete downloaded media" checkbox to also remove directory {{ source.directory_path }}
|
||||||
media when you delete the source. Deleting a source cannot be undone.
|
</strong>when you delete the source. Deleting a source cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -43,6 +43,10 @@
|
||||||
<td class="hide-on-small-only">Directory</td>
|
<td class="hide-on-small-only">Directory</td>
|
||||||
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
|
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Filter text">
|
||||||
|
<td class="hide-on-small-only">Filter text</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Filter text<br></span><strong>{{ source.filter_text }}</strong></td>
|
||||||
|
</tr>
|
||||||
<tr title="Media file name format to use for saving files">
|
<tr title="Media file name format to use for saving files">
|
||||||
<td class="hide-on-small-only">Media format</td>
|
<td class="hide-on-small-only">Media format</td>
|
||||||
<td><span class="hide-on-med-and-up">Media format<br></span><strong>{{ source.media_format }}</strong></td>
|
<td><span class="hide-on-med-and-up">Media format<br></span><strong>{{ source.media_format }}</strong></td>
|
||||||
|
@ -115,6 +119,14 @@
|
||||||
<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>
|
||||||
|
<tr title="Delete files on disk when they are removed from TubeSync?">
|
||||||
|
<td class="hide-on-small-only">Delete files on disk</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Delete files on disk<br></span><strong>{% if source.delete_files_on_disk %}<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 +142,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>
|
||||||
|
|
|
@ -24,7 +24,8 @@
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="collection">
|
<div class="collection">
|
||||||
{% for source in sources %}
|
{% for source in sources %}
|
||||||
<a href="{% url 'sync:source' pk=source.pk %}" class="collection-item">
|
<span class="collection-item flex-collection-container">
|
||||||
|
<a href="{% url 'sync:source' pk=source.pk %}" class="flex-grow">
|
||||||
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
||||||
{{ source.format_summary }}<br>
|
{{ source.format_summary }}<br>
|
||||||
{% if source.has_failed %}
|
{% if source.has_failed %}
|
||||||
|
@ -33,6 +34,8 @@
|
||||||
<strong>{{ source.media_count }}</strong> media items, <strong>{{ source.downloaded_count }}</strong> downloaded{% if source.delete_old_media and source.days_to_keep > 0 %}, keeping {{ source.days_to_keep }} days of media{% endif %}
|
<strong>{{ source.media_count }}</strong> media items, <strong>{{ source.downloaded_count }}</strong> downloaded{% if source.delete_old_media and source.days_to_keep > 0 %}, keeping {{ source.days_to_keep }} days of media{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'sync:source-sync-now' pk=source.pk %}" class="collection-item"><i class="fas fa-arrow-rotate-right"></i></a>
|
||||||
|
</span>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -66,7 +66,7 @@
|
||||||
{% for task in scheduled %}
|
{% for task in scheduled %}
|
||||||
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
||||||
<i class="fas fa-stopwatch"></i> <strong>{{ task }}</strong><br>
|
<i class="fas fa-stopwatch"></i> <strong>{{ task }}</strong><br>
|
||||||
{% if task.instance.index_schedule %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.<br>{% endif %}
|
{% if task.instance.index_schedule and task.repeat > 0 %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.<br>{% endif %}
|
||||||
<i class="fas fa-redo"></i> Task will run {% if task.run_now %}<strong>immediately</strong>{% else %}at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>{% endif %}
|
<i class="fas fa-redo"></i> Task will run {% if task.run_now %}<strong>immediately</strong>{% else %}at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
@ -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):
|
||||||
|
@ -174,6 +175,7 @@ class FrontEndTestCase(TestCase):
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
'download_cap': 0,
|
'download_cap': 0,
|
||||||
|
'filter_text':'.*',
|
||||||
'index_schedule': 3600,
|
'index_schedule': 3600,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
|
@ -182,7 +184,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)
|
||||||
|
@ -215,6 +218,7 @@ class FrontEndTestCase(TestCase):
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
'download_cap': 0,
|
'download_cap': 0,
|
||||||
|
'filter_text':'.*',
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
|
@ -223,7 +227,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)
|
||||||
|
@ -244,6 +249,7 @@ class FrontEndTestCase(TestCase):
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
'download_cap': 0,
|
'download_cap': 0,
|
||||||
|
'filter_text':'.*',
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
|
@ -252,7 +258,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 +471,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -651,6 +661,8 @@ class MediaTestCase(TestCase):
|
||||||
'<episodedetails>',
|
'<episodedetails>',
|
||||||
' <title>no fancy stuff title</title>',
|
' <title>no fancy stuff title</title>',
|
||||||
' <showtitle>testname</showtitle>',
|
' <showtitle>testname</showtitle>',
|
||||||
|
' <season>2017</season>',
|
||||||
|
' <episode></episode>',
|
||||||
' <ratings>',
|
' <ratings>',
|
||||||
' <rating default="True" max="5" name="youtube">',
|
' <rating default="True" max="5" name="youtube">',
|
||||||
' <value>1.2345</value>',
|
' <value>1.2345</value>',
|
||||||
|
@ -1397,3 +1409,118 @@ 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()
|
||||||
|
|
||||||
|
def test_is_regex_match(self):
|
||||||
|
|
||||||
|
self.media.metadata = all_test_metadata['boring']
|
||||||
|
expected_matches = {
|
||||||
|
('.*'): (True),
|
||||||
|
('no fancy stuff'): (True),
|
||||||
|
('No fancy stuff'): (False),
|
||||||
|
('(?i)No fancy stuff'): (True), #set case insensitive flag
|
||||||
|
('no'): (True),
|
||||||
|
('Foo'): (False),
|
||||||
|
('^(?!.*fancy).*$'): (False),
|
||||||
|
('^(?!.*funny).*$'): (True),
|
||||||
|
('(?=.*f.*)(?=.{0,2}|.{4,})'): (True),
|
||||||
|
('f{4,}'): (False),
|
||||||
|
('^[^A-Z]*$'): (True),
|
||||||
|
('^[^a-z]*$'): (False),
|
||||||
|
('^[^\\s]*$'): (False)
|
||||||
|
}
|
||||||
|
|
||||||
|
for params, expected in expected_matches.items():
|
||||||
|
self.source.filter_text = params
|
||||||
|
expected_match_result = expected
|
||||||
|
self.assertEqual(self.source.is_regex_match(self.media.title), expected_match_result)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
|
@ -28,6 +28,10 @@ urlpatterns = [
|
||||||
ValidateSourceView.as_view(),
|
ValidateSourceView.as_view(),
|
||||||
name='validate-source'),
|
name='validate-source'),
|
||||||
|
|
||||||
|
path('source-sync-now/<uuid:pk>',
|
||||||
|
SourcesView.as_view(),
|
||||||
|
name='source-sync-now'),
|
||||||
|
|
||||||
path('source-add',
|
path('source-add',
|
||||||
AddSourceView.as_view(),
|
AddSourceView.as_view(),
|
||||||
name='add-source'),
|
name='add-source'),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,18 +1,24 @@
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import Http404
|
from django.http import FileResponse, Http404, HttpResponseNotFound, HttpResponseRedirect
|
||||||
from django.views.generic import TemplateView, ListView, DetailView
|
from django.views.generic import TemplateView, ListView, DetailView
|
||||||
from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView,
|
from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView,
|
||||||
DeleteView)
|
DeleteView)
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from django.core.exceptions import SuspiciousFileOperation
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Q, Count, Sum, When, Case
|
from django.db.models import Q, Count, Sum, When, Case
|
||||||
from django.forms import ValidationError
|
from django.forms import Form, ValidationError
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.utils._os import safe_join
|
||||||
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.utils import append_uri_params
|
from common.utils import append_uri_params
|
||||||
|
@ -55,7 +61,7 @@ class DashboardView(TemplateView):
|
||||||
# Disk usage
|
# Disk usage
|
||||||
disk_usage = Media.objects.filter(
|
disk_usage = Media.objects.filter(
|
||||||
downloaded=True, downloaded_filesize__isnull=False
|
downloaded=True, downloaded_filesize__isnull=False
|
||||||
).aggregate(Sum('downloaded_filesize'))
|
).defer('metadata').aggregate(Sum('downloaded_filesize'))
|
||||||
data['disk_usage_bytes'] = disk_usage['downloaded_filesize__sum']
|
data['disk_usage_bytes'] = disk_usage['downloaded_filesize__sum']
|
||||||
if not data['disk_usage_bytes']:
|
if not data['disk_usage_bytes']:
|
||||||
data['disk_usage_bytes'] = 0
|
data['disk_usage_bytes'] = 0
|
||||||
|
@ -66,12 +72,12 @@ 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]
|
).defer('metadata').order_by('-download_date')[:10]
|
||||||
# Largest downloads
|
# Largest downloads
|
||||||
data['largest_downloads'] = Media.objects.filter(
|
data['largest_downloads'] = Media.objects.filter(
|
||||||
downloaded=True, downloaded_filesize__isnull=False
|
downloaded=True, downloaded_filesize__isnull=False
|
||||||
).order_by('-downloaded_filesize')[:10]
|
).defer('metadata').order_by('-downloaded_filesize')[:10]
|
||||||
# UID and GID
|
# UID and GID
|
||||||
data['uid'] = os.getuid()
|
data['uid'] = os.getuid()
|
||||||
data['gid'] = os.getgid()
|
data['gid'] = os.getgid()
|
||||||
|
@ -92,8 +98,27 @@ class SourcesView(ListView):
|
||||||
paginate_by = settings.SOURCES_PER_PAGE
|
paginate_by = settings.SOURCES_PER_PAGE
|
||||||
messages = {
|
messages = {
|
||||||
'source-deleted': _('Your selected source has been deleted.'),
|
'source-deleted': _('Your selected source has been deleted.'),
|
||||||
|
'source-refreshed': _('The source has been scheduled to be synced now.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
if args[0].path.startswith("/source-sync-now/"):
|
||||||
|
sobj = Source.objects.get(pk=kwargs["pk"])
|
||||||
|
if sobj is None:
|
||||||
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
|
verbose_name = _('Index media from source "{}" once')
|
||||||
|
index_source_task(
|
||||||
|
str(sobj.pk),
|
||||||
|
queue=str(sobj.pk),
|
||||||
|
repeat=0,
|
||||||
|
verbose_name=verbose_name.format(sobj.name))
|
||||||
|
url = reverse_lazy('sync:sources')
|
||||||
|
url = append_uri_params(url, {'message': 'source-refreshed'})
|
||||||
|
return HttpResponseRedirect(url)
|
||||||
|
else:
|
||||||
|
return super().get(self, *args, **kwargs)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.message = None
|
self.message = None
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -271,28 +296,46 @@ class ValidateSourceView(FormView):
|
||||||
|
|
||||||
class EditSourceMixin:
|
class EditSourceMixin:
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'filter_text', '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', 'delete_files_on_disk', 'days_to_keep', 'source_resolution',
|
||||||
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo', 'write_json')
|
'source_vcodec', '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 '
|
||||||
'this page for valid media name variables'),
|
'this page for valid media name variables'),
|
||||||
|
'dir_outside_dlroot': _('You cannot specify a directory outside of the '
|
||||||
|
'base directory (%BASEDIR%)')
|
||||||
}
|
}
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form: Form):
|
||||||
# Perform extra validation to make sure the media_format is valid
|
# Perform extra validation to make sure the media_format is valid
|
||||||
obj = form.save(commit=False)
|
obj = form.save(commit=False)
|
||||||
source_type = form.cleaned_data['media_format']
|
source_type = form.cleaned_data['media_format']
|
||||||
example_media_file = obj.get_example_media_format()
|
example_media_file = obj.get_example_media_format()
|
||||||
|
|
||||||
if example_media_file == '':
|
if example_media_file == '':
|
||||||
form.add_error(
|
form.add_error(
|
||||||
'media_format',
|
'media_format',
|
||||||
ValidationError(self.errors['invalid_media_format'])
|
ValidationError(self.errors['invalid_media_format'])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for suspicious file path(s)
|
||||||
|
try:
|
||||||
|
targetCheck = form.cleaned_data['directory']+"/.virt"
|
||||||
|
newdir = safe_join(settings.DOWNLOAD_ROOT,targetCheck)
|
||||||
|
except SuspiciousFileOperation:
|
||||||
|
form.add_error(
|
||||||
|
'directory',
|
||||||
|
ValidationError(self.errors['dir_outside_dlroot'].replace("%BASEDIR%",str(settings.DOWNLOAD_ROOT)))
|
||||||
|
)
|
||||||
|
|
||||||
if form.errors:
|
if form.errors:
|
||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@ -363,7 +406,7 @@ class SourceView(DetailView):
|
||||||
error_message = get_error_message(error)
|
error_message = get_error_message(error)
|
||||||
setattr(error, 'error_message', error_message)
|
setattr(error, 'error_message', error_message)
|
||||||
data['errors'].append(error)
|
data['errors'].append(error)
|
||||||
data['media'] = Media.objects.filter(source=self.object).order_by('-published')
|
data['media'] = Media.objects.filter(source=self.object).order_by('-published').defer('metadata')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@ -394,14 +437,13 @@ class DeleteSourceView(DeleteView, FormMixin):
|
||||||
source = self.get_object()
|
source = self.get_object()
|
||||||
for media in Media.objects.filter(source=source):
|
for media in Media.objects.filter(source=source):
|
||||||
if media.media_file:
|
if media.media_file:
|
||||||
# Delete the media file
|
file_path = media.media_file.path
|
||||||
delete_file(media.media_file.path)
|
matching_files = glob.glob(os.path.splitext(file_path)[0] + '.*')
|
||||||
# Delete thumbnail copy if it exists
|
for file in matching_files:
|
||||||
delete_file(media.thumbpath)
|
delete_file(file)
|
||||||
# Delete NFO file if it exists
|
directory_path = source.directory_path
|
||||||
delete_file(media.nfopath)
|
if os.path.exists(directory_path):
|
||||||
# Delete JSON file if it exists
|
shutil.rmtree(directory_path, True)
|
||||||
delete_file(media.jsonpath)
|
|
||||||
return super().post(request, *args, **kwargs)
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
|
@ -448,16 +490,16 @@ class MediaView(ListView):
|
||||||
if self.show_skipped:
|
if self.show_skipped:
|
||||||
q = Media.objects.filter(source=self.filter_source)
|
q = Media.objects.filter(source=self.filter_source)
|
||||||
elif self.only_skipped:
|
elif self.only_skipped:
|
||||||
q = Media.objects.filter(source=self.filter_source, skip=True)
|
q = Media.objects.filter(Q(source=self.filter_source) & (Q(skip=True) | Q(manual_skip=True)))
|
||||||
else:
|
else:
|
||||||
q = Media.objects.filter(source=self.filter_source, skip=False)
|
q = Media.objects.filter(Q(source=self.filter_source) & (Q(skip=False) & Q(manual_skip=False)))
|
||||||
else:
|
else:
|
||||||
if self.show_skipped:
|
if self.show_skipped:
|
||||||
q = Media.objects.all()
|
q = Media.objects.all()
|
||||||
elif self.only_skipped:
|
elif self.only_skipped:
|
||||||
q = Media.objects.filter(skip=True)
|
q = Media.objects.filter(Q(skip=True)|Q(manual_skip=True))
|
||||||
else:
|
else:
|
||||||
q = Media.objects.filter(skip=False)
|
q = Media.objects.filter(Q(skip=False)&Q(manual_skip=False))
|
||||||
return q.order_by('-published', '-created')
|
return q.order_by('-published', '-created')
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
@ -612,12 +654,13 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
||||||
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
|
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
|
||||||
# If the media file exists on disk, delete it
|
# If the media file exists on disk, delete it
|
||||||
if self.object.media_file_exists:
|
if self.object.media_file_exists:
|
||||||
delete_file(self.object.media_file.path)
|
# Delete all files which contains filename
|
||||||
self.object.media_file = None
|
filepath = self.object.media_file.path
|
||||||
# If the media has an associated thumbnail copied, also delete it
|
barefilepath, fileext = os.path.splitext(filepath)
|
||||||
delete_file(self.object.thumbpath)
|
# Get all files that start with the bare file path
|
||||||
# If the media has an associated NFO file with it, also delete it
|
all_related_files = glob.glob(f'{barefilepath}.*')
|
||||||
delete_file(self.object.nfopath)
|
for file in all_related_files:
|
||||||
|
delete_file(file)
|
||||||
# Reset all download data
|
# Reset all download data
|
||||||
self.object.metadata = None
|
self.object.metadata = None
|
||||||
self.object.downloaded = False
|
self.object.downloaded = False
|
||||||
|
@ -629,6 +672,7 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
||||||
self.object.downloaded_filesize = None
|
self.object.downloaded_filesize = None
|
||||||
# Mark it to be skipped
|
# Mark it to be skipped
|
||||||
self.object.skip = True
|
self.object.skip = True
|
||||||
|
self.object.manual_skip = True
|
||||||
self.object.save()
|
self.object.save()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
@ -657,6 +701,7 @@ class MediaEnableView(FormView, SingleObjectMixin):
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
# Mark it as not skipped
|
# Mark it as not skipped
|
||||||
self.object.skip = False
|
self.object.skip = False
|
||||||
|
self.object.manual_skip = False
|
||||||
self.object.save()
|
self.object.save()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
@ -677,8 +722,35 @@ class MediaContent(DetailView):
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
|
# development direct file stream - DO NOT USE PRODUCTIVLY
|
||||||
|
if settings.DEBUG and 'runserver' in sys.argv:
|
||||||
|
# get media URL
|
||||||
|
pth = self.object.media_file.url
|
||||||
|
# remove "/media-data/"
|
||||||
|
pth = pth.split("/media-data/",1)[1]
|
||||||
|
# remove "/" (incase of absolute path)
|
||||||
|
pth = pth.split(str(settings.DOWNLOAD_ROOT).lstrip("/"),1)
|
||||||
|
|
||||||
|
# if we do not have a "/" at the beginning, it is not a absolute path...
|
||||||
|
if len(pth) > 1:
|
||||||
|
pth = pth[1]
|
||||||
|
else:
|
||||||
|
pth = pth[0]
|
||||||
|
|
||||||
|
|
||||||
|
# build final path
|
||||||
|
filepth = pathlib.Path(str(settings.DOWNLOAD_ROOT) + pth)
|
||||||
|
|
||||||
|
if filepth.exists():
|
||||||
|
# return file
|
||||||
|
response = FileResponse(open(filepth,'rb'))
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
|
else:
|
||||||
headers = {
|
headers = {
|
||||||
|
'Content-Type': self.object.content_type,
|
||||||
'X-Accel-Redirect': self.object.media_file.url,
|
'X-Accel-Redirect': self.object.media_file.url,
|
||||||
}
|
}
|
||||||
return HttpResponse(headers=headers)
|
return HttpResponse(headers=headers)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
'''
|
'''
|
||||||
Wrapper for the youtube-dl library. Used so if there are any library interface
|
Wrapper for the yt-dlp library. Used so if there are any library interface
|
||||||
updates we only need to udpate them in one place.
|
updates we only need to udpate them in one place.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
@ -64,13 +64,20 @@ def get_media_info(url):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def download_media(url, media_format, extension, output_file, info_json):
|
def download_media(url, media_format, extension, output_file, info_json,
|
||||||
|
sponsor_categories=None,
|
||||||
|
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':
|
||||||
|
@ -99,17 +106,39 @@ def download_media(url, media_format, extension, output_file, info_json):
|
||||||
f'{total_size_str} in {elapsed_str}')
|
f'{total_size_str} in {elapsed_str}')
|
||||||
else:
|
else:
|
||||||
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
||||||
hook.download_progress = 0
|
|
||||||
|
|
||||||
opts = get_yt_opts()
|
hook.download_progress = 0
|
||||||
opts.update({
|
ytopts = {
|
||||||
'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': [],
|
||||||
|
'writesubtitles': write_subtitles,
|
||||||
|
'writeautomaticsub': auto_subtitles,
|
||||||
|
'subtitleslangs': sub_langs.split(','),
|
||||||
|
}
|
||||||
|
if not sponsor_categories:
|
||||||
|
sponsor_categories = []
|
||||||
|
sbopt = {
|
||||||
|
'key': 'SponsorBlock',
|
||||||
|
'categories': sponsor_categories
|
||||||
|
}
|
||||||
|
ffmdopt = {
|
||||||
|
'key': 'FFmpegMetadata',
|
||||||
|
'add_chapters': embed_metadata,
|
||||||
|
'add_metadata': embed_metadata
|
||||||
|
}
|
||||||
|
opts = get_yt_opts()
|
||||||
|
if embed_thumbnail:
|
||||||
|
ytopts['postprocessors'].append({'key': 'EmbedThumbnail'})
|
||||||
|
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])
|
||||||
|
|
|
@ -25,9 +25,6 @@ DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
|
||||||
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', DJANGO_URL_PREFIX)
|
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', DJANGO_URL_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
TIME_ZONE = os.getenv('TZ', 'UTC')
|
|
||||||
|
|
||||||
|
|
||||||
database_dict = {}
|
database_dict = {}
|
||||||
database_connection_env = os.getenv('DATABASE_CONNECTION', '')
|
database_connection_env = os.getenv('DATABASE_CONNECTION', '')
|
||||||
if database_connection_env:
|
if database_connection_env:
|
||||||
|
@ -67,6 +64,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:
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,7 +7,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
||||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
VERSION = '0.12.0'
|
VERSION = '0.13.3'
|
||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
|
@ -96,7 +97,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = 'en-us'
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = os.getenv('TZ', 'UTC')
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_L10N = True
|
USE_L10N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
Loading…
Reference in New Issue