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
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
.DS_Store
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
@ -130,3 +131,6 @@ dmypy.json
|
|||
|
||||
# Pyre type checker
|
||||
.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 S6_VERSION="3.1.2.1"
|
||||
ARG FFMPEG_DATE="autobuild-2023-01-03-12-55"
|
||||
ARG FFMPEG_VERSION="109474-gc94988a781"
|
||||
ARG S6_VERSION="3.1.5.0"
|
||||
ARG FFMPEG_DATE="autobuild-2023-11-29-14-19"
|
||||
ARG FFMPEG_VERSION="112875-g47e214245b"
|
||||
|
||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||
HOME="/root" \
|
||||
LANGUAGE="en_US.UTF-8" \
|
||||
LANG="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
|
||||
RUN export ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||
|
@ -18,22 +19,22 @@ RUN export ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
|||
"linux/arm64") echo "aarch64" ;; \
|
||||
*) echo "" ;; esac) && \
|
||||
export S6_ARCH_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||
"linux/amd64") echo "6019b6b06cfdbb1d1cd572d46b9b158a4904fd19ca59d374de4ddaaa6a3727d5" ;; \
|
||||
"linux/arm64") echo "e73f9a021b64f88278830742149c14ef8a52331102881ba025bf32a66a0e7c78" ;; \
|
||||
"linux/amd64") echo "65d0d0f353d2ff9d0af202b268b4bf53a9948a5007650854855c729289085739" ;; \
|
||||
"linux/arm64") echo "3fbd14201473710a592b2189e81f00f3c8998e96d34f16bd2429c35d1bc36d00" ;; \
|
||||
*) echo "" ;; esac) && \
|
||||
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/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.xz" ;; \
|
||||
*) echo "" ;; esac) && \
|
||||
export FFMPEG_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||
"linux/amd64") echo "ed9059668e4a6dac9bde122a775f52ad08cbb90df3658f8c1e328477c13c242e" ;; \
|
||||
"linux/arm64") echo "dd1375bd351d38ea1cc3efd68a998699366e28bd9b90df65d11af2b9121746b7" ;; \
|
||||
"linux/amd64") echo "36bac8c527bf390603416f749ab0dd860142b0a66f0865b67366062a9c286c8b" ;; \
|
||||
"linux/arm64") echo "8f36e45d99d2367a5c0c220ee3164fa48f4f0cec35f78204ccced8dc303bfbdc" ;; \
|
||||
*) echo "" ;; esac) && \
|
||||
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/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linuxarm64-gpl.tar.xz" ;; \
|
||||
*) 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" && \
|
||||
echo "Building for arch: ${ARCH}|${ARCH44}, downloading S6 from: ${S6_DOWNLOAD}}, expecting S6 SHA256: ${S6_EXPECTED_SHA256}" && \
|
||||
set -x && \
|
||||
|
@ -82,30 +83,30 @@ RUN set -x && \
|
|||
apt-get -y install nginx-light && \
|
||||
apt-get -y --no-install-recommends install \
|
||||
python3 \
|
||||
python3-setuptools \
|
||||
python3-pip \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-wheel \
|
||||
pipenv \
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
pkgconf \
|
||||
default-libmysqlclient-dev \
|
||||
libmariadb3 \
|
||||
postgresql-common \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
libjpeg62-turbo \
|
||||
libwebp6 \
|
||||
libwebp7 \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
libwebp-dev \
|
||||
redis-server && \
|
||||
# Install pipenv
|
||||
pip3 --disable-pip-version-check install wheel pipenv && \
|
||||
# Create a 'app' user which the application will run as
|
||||
groupadd app && \
|
||||
useradd -M -d /app -s /bin/false -g app app && \
|
||||
# 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
|
||||
rm -rf /app/db.sqlite3 && \
|
||||
# Run any required app commands
|
||||
|
@ -119,7 +120,6 @@ RUN set -x && \
|
|||
# Clean up
|
||||
rm /app/Pipfile && \
|
||||
pipenv --clear && \
|
||||
pip3 --disable-pip-version-check uninstall -y pipenv wheel virtualenv && \
|
||||
apt-get -y autoremove --purge \
|
||||
python3-pip \
|
||||
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)
|
||||
|
||||
|
||||
stopcontainer:
|
||||
$(docker) stop $(name)
|
||||
|
||||
|
||||
test: build
|
||||
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
|
||||
|
||||
[dev-packages]
|
||||
autopep8 = "*"
|
||||
|
||||
[packages]
|
||||
django = "~=3.2"
|
||||
|
@ -15,10 +16,10 @@ gunicorn = "*"
|
|||
django-compressor = "*"
|
||||
httptools = "*"
|
||||
django-background-tasks = "*"
|
||||
requests = "*"
|
||||
django-basicauth = "*"
|
||||
psycopg2-binary = "*"
|
||||
mysqlclient = "*"
|
||||
yt-dlp = "*"
|
||||
redis = "*"
|
||||
hiredis = "*"
|
||||
requests = {extras = ["socks"], version = "*"}
|
||||
|
|
32
README.md
32
README.md
|
@ -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)
|
||||
* [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)
|
||||
* [Reset metadata](https://github.com/meeb/tubesync/blob/main/docs/reset-metadata.md)
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
### 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
|
||||
|
||||
|
@ -357,19 +362,20 @@ There are a number of other environment variables you can set. These are, mostly
|
|||
**NOT** required to be set in the default container installation, they are really only
|
||||
useful if you are manually installing TubeSync in some other environment. These are:
|
||||
|
||||
| Name | What | Example |
|
||||
| ------------------------ | ------------------------------------------------------------ | ------------------------------------ |
|
||||
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||
| DJANGO_URL_PREFIX | Run TubeSync in a sub-URL on the web server | /somepath/ |
|
||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS, defaults to `*` | tubesync.example.com,otherhost.com |
|
||||
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
||||
| HTTP_USER | Sets the username for HTTP basic authentication | some-username |
|
||||
| HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password |
|
||||
| DATABASE_CONNECTION | Optional external database connection details | mysql://user:pass@host:port/database |
|
||||
| Name | What | Example |
|
||||
| --------------------------- | ------------------------------------------------------------ | ------------------------------------ |
|
||||
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||
| DJANGO_URL_PREFIX | Run TubeSync in a sub-URL on the web server | /somepath/ |
|
||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS, 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 |
|
||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
||||
| HTTP_USER | Sets the username for HTTP basic authentication | some-username |
|
||||
| HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password |
|
||||
| DATABASE_CONNECTION | Optional external database connection details | mysql://user:pass@host:port/database |
|
||||
|
||||
|
||||
# Manual, non-containerised, installation
|
||||
|
|
|
@ -11,8 +11,6 @@ chown -R app:app /run/app
|
|||
chmod -R 0700 /run/app
|
||||
chown -R app:app /config
|
||||
chmod -R 0755 /config
|
||||
chown -R app:app /downloads
|
||||
chmod -R 0755 /downloads
|
||||
chown -R root:app /app
|
||||
chmod -R 0750 /app
|
||||
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 {} \;
|
||||
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
|
||||
exec s6-setuidgid app \
|
||||
/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
|
||||
|
||||
```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:
|
||||
|
@ -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,
|
||||
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
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
logging_level = logging.DEBUG if settings.DEBUG else logging.INFO
|
||||
|
||||
|
||||
log = logging.getLogger('tubesync')
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.setLevel(logging_level)
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG)
|
||||
ch.setLevel(logging_level)
|
||||
formatter = logging.Formatter('%(asctime)s [%(name)s/%(levelname)s] %(message)s')
|
||||
ch.setFormatter(formatter)
|
||||
log.addHandler(ch)
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
@font-face {
|
||||
font-family: 'roboto-light';
|
||||
font-family: 'roboto';
|
||||
src: url('../fonts/roboto/roboto-light.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-weight: lighter;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'roboto-regular';
|
||||
font-family: 'roboto';
|
||||
src: url('../fonts/roboto/roboto-regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'roboto-bold';
|
||||
font-family: 'roboto';
|
||||
src: url('../fonts/roboto/roboto-bold.woff') format('woff');
|
||||
font-weight: bold;
|
||||
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;
|
||||
|
|
|
@ -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-v:before { content: fa-content($fa-var-arrows-alt-v); }
|
||||
.#{$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}-asterisk:before { content: fa-content($fa-var-asterisk); }
|
||||
.#{$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-arrows-alt: \f0b2;
|
||||
$fa-var-arrows-alt-h: \f337;
|
||||
$fa-var-arrow-rotate-right: \f01e;
|
||||
$fa-var-arrows-alt-v: \f338;
|
||||
$fa-var-artstation: \f77a;
|
||||
$fa-var-assistive-listening-systems: \f2a2;
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// Text Label Style
|
||||
+ span:not(.lever) {
|
||||
position: relative;
|
||||
padding-left: 35px;
|
||||
padding-left: 27px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
height: 25px;
|
||||
|
|
|
@ -17,3 +17,16 @@ html {
|
|||
visibility: visible;
|
||||
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="pagination">
|
||||
{% 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 %}
|
||||
</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 fmt['vcodec'] is not None:
|
||||
continue
|
||||
if not fmt['acodec']:
|
||||
continue
|
||||
audio_formats.append(fmt)
|
||||
audio_formats = list(reversed(sorted(audio_formats, key=lambda k: k['abr'])))
|
||||
if not audio_formats:
|
||||
|
@ -88,6 +90,8 @@ def get_best_video_format(media):
|
|||
# If the format has an audio stream, skip it
|
||||
if fmt['acodec'] is not None:
|
||||
continue
|
||||
if not fmt['vcodec']:
|
||||
continue
|
||||
if media.source.source_resolution.strip().upper() == fmt['format']:
|
||||
video_formats.append(fmt)
|
||||
# Check we matched some streams
|
||||
|
|
|
@ -44,7 +44,9 @@ class PlexMediaServer(MediaServer):
|
|||
'<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 '
|
||||
'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={}):
|
||||
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 uuid
|
||||
import json
|
||||
import re
|
||||
from xml.etree import ElementTree
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
|
@ -8,6 +9,7 @@ from pathlib import Path
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -16,14 +18,13 @@ from common.utils import clean_filename
|
|||
from .youtube import (get_media_info as get_youtube_media_info,
|
||||
download_media as download_youtube_media)
|
||||
from .utils import seconds_to_timestr, parse_media_format
|
||||
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)
|
||||
from .mediaservers import PlexMediaServer
|
||||
|
||||
from .fields import CommaSepChoiceField
|
||||
|
||||
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT), base_url='/media-data/')
|
||||
|
||||
|
||||
class Source(models.Model):
|
||||
'''
|
||||
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'
|
||||
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
|
||||
ICONS = {
|
||||
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 '
|
||||
'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'),
|
||||
max_length=8,
|
||||
|
@ -309,6 +364,30 @@ class Source(models.Model):
|
|||
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):
|
||||
return self.name
|
||||
|
||||
|
@ -446,6 +525,7 @@ class Source(models.Model):
|
|||
'dd': now.strftime('%d'),
|
||||
'source': self.slugname,
|
||||
'source_full': self.name,
|
||||
'uploader': 'Some Channel Name',
|
||||
'title': 'some-media-title-name',
|
||||
'title_full': 'Some Media Title Name',
|
||||
'key': 'SoMeUnIqUiD',
|
||||
|
@ -467,6 +547,11 @@ class Source(models.Model):
|
|||
except Exception as e:
|
||||
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):
|
||||
'''
|
||||
Index the media source returning a list of media metadata as dicts.
|
||||
|
@ -679,7 +764,13 @@ class Media(models.Model):
|
|||
_('skip'),
|
||||
db_index=True,
|
||||
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'),
|
||||
|
@ -949,6 +1040,7 @@ class Media(models.Model):
|
|||
'acodec': display_format['acodec'],
|
||||
'fps': display_format['fps'],
|
||||
'hdr': display_format['hdr'],
|
||||
'uploader': self.uploader,
|
||||
}
|
||||
|
||||
@property
|
||||
|
@ -1013,7 +1105,7 @@ class Media(models.Model):
|
|||
duration = self.loaded_metadata.get(field, 0)
|
||||
try:
|
||||
duration = int(duration)
|
||||
except ValueError:
|
||||
except (TypeError, ValueError):
|
||||
duration = 0
|
||||
return duration
|
||||
|
||||
|
@ -1136,6 +1228,29 @@ class Media(models.Model):
|
|||
return False
|
||||
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
|
||||
def nfoxml(self):
|
||||
'''
|
||||
|
@ -1153,6 +1268,22 @@ class Media(models.Model):
|
|||
showtitle.text = str(self.source.name).strip()
|
||||
showtitle.tail = '\n '
|
||||
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
|
||||
value = nfo.makeelement('value', {})
|
||||
value.text = str(self.rating)
|
||||
|
@ -1260,7 +1391,10 @@ class Media(models.Model):
|
|||
f'no valid format available')
|
||||
# Download the media with youtube-dl
|
||||
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 format_str, self.source.extension
|
||||
|
||||
|
@ -1274,6 +1408,19 @@ class Media(models.Model):
|
|||
f'has no indexer')
|
||||
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):
|
||||
'''
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import glob
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
|
||||
from django.dispatch import receiver
|
||||
|
@ -74,6 +75,7 @@ def source_pre_delete(sender, instance, **kwargs):
|
|||
media.delete()
|
||||
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Source)
|
||||
def source_post_delete(sender, instance, **kwargs):
|
||||
# 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)
|
||||
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
|
||||
cap_changed = False
|
||||
can_download_changed = False
|
||||
# Reset the skip flag if the download cap has changed if the media has not
|
||||
# already been downloaded
|
||||
if not instance.downloaded:
|
||||
if not instance.downloaded and instance.metadata:
|
||||
max_cap_age = instance.source.download_cap_date
|
||||
filter_text = instance.source.filter_text.strip()
|
||||
published = instance.published
|
||||
if not published:
|
||||
if not instance.skip:
|
||||
|
@ -113,11 +119,20 @@ def media_post_save(sender, instance, created, **kwargs):
|
|||
else:
|
||||
if max_cap_age:
|
||||
if published > max_cap_age and instance.skip:
|
||||
# Media was published after the cap date but is set to be skipped
|
||||
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||
f'publishing date, marking to be unskipped')
|
||||
instance.skip = False
|
||||
cap_changed = True
|
||||
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 '
|
||||
f'publishing date, marking to be unskipped')
|
||||
instance.skip = False
|
||||
cap_changed = True
|
||||
elif published <= max_cap_age and not instance.skip:
|
||||
log.info(f'Media: {instance.source} / {instance} is too old for '
|
||||
f'the download cap date, marking to be skipped')
|
||||
|
@ -126,10 +141,20 @@ def media_post_save(sender, instance, created, **kwargs):
|
|||
else:
|
||||
if instance.skip:
|
||||
# Media marked to be skipped but source download cap removed
|
||||
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||
f'publishing date, marking to be unskipped')
|
||||
instance.skip = False
|
||||
cap_changed = True
|
||||
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.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
|
||||
# need to change if the source specifications have been changed
|
||||
if instance.metadata:
|
||||
|
@ -152,7 +177,7 @@ def media_post_save(sender, instance, created, **kwargs):
|
|||
verbose_name = _('Downloading metadata for "{}"')
|
||||
download_media_metadata(
|
||||
str(instance.pk),
|
||||
priority=10,
|
||||
priority=5,
|
||||
verbose_name=verbose_name.format(instance.pk),
|
||||
remove_existing_tasks=True
|
||||
)
|
||||
|
@ -199,6 +224,16 @@ def media_pre_delete(sender, instance, **kwargs):
|
|||
if thumbnail_url:
|
||||
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
||||
(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)
|
||||
|
|
|
@ -132,16 +132,23 @@ def cleanup_completed_tasks():
|
|||
|
||||
|
||||
def cleanup_old_media():
|
||||
for media in Media.objects.filter(download_date__isnull=False):
|
||||
if media.source.delete_old_media and media.source.days_to_keep > 0:
|
||||
delta = timezone.now() - timedelta(days=media.source.days_to_keep)
|
||||
if media.downloaded and media.download_date < delta:
|
||||
# Media was downloaded after the cutoff date, delete it
|
||||
log.info(f'Deleting expired media: {media.source} / {media} '
|
||||
f'(now older than {media.source.days_to_keep} days / '
|
||||
f'download_date before {delta})')
|
||||
# .delete() also triggers a pre_delete signal that removes the files
|
||||
media.delete()
|
||||
for source in Source.objects.filter(delete_old_media=True, days_to_keep__gt=0):
|
||||
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
||||
for media in source.media_source.filter(downloaded=True, download_date__lt=delta):
|
||||
log.info(f'Deleting expired media: {source} / {media} '
|
||||
f'(now older than {source.days_to_keep} days / '
|
||||
f'download_date before {delta})')
|
||||
# .delete() also triggers a pre_delete signal that removes the files
|
||||
media.delete()
|
||||
|
||||
|
||||
def cleanup_removed_media(source, videos):
|
||||
media_objects = Media.objects.filter(source=source, downloaded=True)
|
||||
for item in media_objects:
|
||||
matching_source_item = [video['id'] for video in videos if video['id'] == item.key]
|
||||
if not matching_source_item:
|
||||
log.info(f'{item.title} is no longer in source, removing')
|
||||
item.delete()
|
||||
|
||||
|
||||
@background(schedule=0)
|
||||
|
@ -153,7 +160,6 @@ def index_source_task(source_id):
|
|||
source = Source.objects.get(pk=source_id)
|
||||
except Source.DoesNotExist:
|
||||
# Task triggered but the Source has been deleted, delete the task
|
||||
delete_index_source_task(source_id)
|
||||
return
|
||||
# Reset any errors
|
||||
source.has_failed = False
|
||||
|
@ -189,6 +195,9 @@ def index_source_task(source_id):
|
|||
cleanup_completed_tasks()
|
||||
# Tack on a cleanup of 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)
|
||||
|
@ -202,7 +211,6 @@ def check_source_directory_exists(source_id):
|
|||
source = Source.objects.get(pk=source_id)
|
||||
except Source.DoesNotExist:
|
||||
# Task triggered but the Source has been deleted, delete the task
|
||||
delete_index_source_task(source_id)
|
||||
return
|
||||
# Check the source output 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 '
|
||||
f'media exists with ID: {media_id}')
|
||||
return
|
||||
if media.manual_skip:
|
||||
log.info(f'Task for ID: {media_id} skipped, due to task being manually skipped.')
|
||||
return
|
||||
source = media.source
|
||||
metadata = media.index_metadata()
|
||||
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 '
|
||||
f'{max_cap_age}, skipping')
|
||||
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 source.delete_old_media and source.days_to_keep > 0:
|
||||
if not isinstance(media.published, datetime):
|
||||
|
|
|
@ -43,6 +43,11 @@
|
|||
<td>Full source name</td>
|
||||
<td>My Source</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{uploader}</td>
|
||||
<td>Uploader name</td>
|
||||
<td>Some Channel Name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{title}</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">Downloading to: <strong>{{ media.source.directory_path }}</strong></p>
|
||||
{% 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%">
|
||||
<source src="{% url 'sync:media-content' pk=media.pk %}">
|
||||
</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 %}
|
||||
</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.skip %}{% include 'errorbox.html' with message='Media is marked to be skipped and will not be downloaded.' %}{% endif %}
|
||||
{% if media.manual_skip %}{% include 'errorbox.html' with message='Media is marked to be skipped and will not be downloaded.' %}
|
||||
{% 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 %}
|
||||
<div class="row">
|
||||
<div class="col s12 m7">
|
||||
|
@ -162,10 +170,10 @@
|
|||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
{% if media.skip %}
|
||||
<a href="{% url 'sync:enable-media' pk=media.pk %}" class="btn">Enable (unskip) media <i class="fas fa-cloud-download-alt"></i></a>
|
||||
{% if media.manual_skip %}
|
||||
<a href="{% url 'sync:enable-media' pk=media.pk %}" class="btn">Unskip media (manually) <i class="fas fa-cloud-download-alt"></i></a>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -36,8 +36,10 @@
|
|||
{% if m.downloaded %}
|
||||
<i class="fas fa-check-circle" title="Downloaded"></i> {{ m.download_date|date:'Y-m-d' }}
|
||||
{% else %}
|
||||
{% if m.skip %}
|
||||
<span class="error-text"><i class="fas fa-times" title="Skipping media"></i> Skipped</span>
|
||||
{% if m.manual_skip %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
|
@ -62,5 +64,5 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
<p>
|
||||
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
|
||||
tick the "also delete downloaded media" checkbox to also remove save
|
||||
media when you delete the source. Deleting a source cannot be undone.
|
||||
<strong>tick the "also delete downloaded media" checkbox to also remove directory {{ source.directory_path }}
|
||||
</strong>when you delete the source. Deleting a source cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -43,6 +43,10 @@
|
|||
<td class="hide-on-small-only">Directory</td>
|
||||
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
|
||||
</tr>
|
||||
<tr title="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">
|
||||
<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>
|
||||
|
@ -115,6 +119,14 @@
|
|||
<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>
|
||||
</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 %}
|
||||
<tr title="Days after which your media from this source will be locally deleted">
|
||||
<td class="hide-on-small-only">Delete old media</td>
|
||||
|
@ -130,6 +142,55 @@
|
|||
<td class="hide-on-small-only">UUID</td>
|
||||
<td><span class="hide-on-med-and-up">UUID<br></span><strong>{{ source.uuid }}</strong></td>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -24,15 +24,18 @@
|
|||
<div class="col s12">
|
||||
<div class="collection">
|
||||
{% for source in sources %}
|
||||
<a href="{% url 'sync:source' pk=source.pk %}" class="collection-item">
|
||||
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
||||
{{ source.format_summary }}<br>
|
||||
{% if source.has_failed %}
|
||||
<span class="error-text"><i class="fas fa-exclamation-triangle"></i> <strong>Source has permanent failures</strong></span>
|
||||
{% else %}
|
||||
<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 %}
|
||||
</a>
|
||||
<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.format_summary }}<br>
|
||||
{% if source.has_failed %}
|
||||
<span class="error-text"><i class="fas fa-exclamation-triangle"></i> <strong>Source has permanent failures</strong></span>
|
||||
{% else %}
|
||||
<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 %}
|
||||
</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 %}
|
||||
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
||||
{% endfor %}
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
{% for task in scheduled %}
|
||||
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
||||
<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 %}
|
||||
</a>
|
||||
{% 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
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlsplit
|
||||
from xml.etree import ElementTree
|
||||
from django.conf import settings
|
||||
|
@ -14,6 +14,7 @@ from django.test import TestCase, Client
|
|||
from django.utils import timezone
|
||||
from background_task.models import Task
|
||||
from .models import Source, Media
|
||||
from .tasks import cleanup_old_media
|
||||
|
||||
|
||||
class FrontEndTestCase(TestCase):
|
||||
|
@ -174,6 +175,7 @@ class FrontEndTestCase(TestCase):
|
|||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'filter_text':'.*',
|
||||
'index_schedule': 3600,
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
|
@ -182,7 +184,8 @@ class FrontEndTestCase(TestCase):
|
|||
'source_acodec': 'OPUS',
|
||||
'prefer_60fps': False,
|
||||
'prefer_hdr': False,
|
||||
'fallback': 'f'
|
||||
'fallback': 'f',
|
||||
'sub_langs': 'en',
|
||||
}
|
||||
response = c.post('/source-add', data)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
@ -215,6 +218,7 @@ class FrontEndTestCase(TestCase):
|
|||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'filter_text':'.*',
|
||||
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
|
@ -223,7 +227,8 @@ class FrontEndTestCase(TestCase):
|
|||
'source_acodec': Source.SOURCE_ACODEC_OPUS,
|
||||
'prefer_60fps': False,
|
||||
'prefer_hdr': False,
|
||||
'fallback': Source.FALLBACK_FAIL
|
||||
'fallback': Source.FALLBACK_FAIL,
|
||||
'sub_langs': 'en',
|
||||
}
|
||||
response = c.post(f'/source-update/{source_uuid}', data)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
@ -244,6 +249,7 @@ class FrontEndTestCase(TestCase):
|
|||
'directory': 'testdirectory',
|
||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||
'download_cap': 0,
|
||||
'filter_text':'.*',
|
||||
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
||||
'delete_old_media': False,
|
||||
'days_to_keep': 14,
|
||||
|
@ -252,7 +258,8 @@ class FrontEndTestCase(TestCase):
|
|||
'source_acodec': Source.SOURCE_ACODEC_OPUS,
|
||||
'prefer_60fps': False,
|
||||
'prefer_hdr': False,
|
||||
'fallback': Source.FALLBACK_FAIL
|
||||
'fallback': Source.FALLBACK_FAIL,
|
||||
'sub_langs': 'en',
|
||||
}
|
||||
response = c.post(f'/source-update/{source_uuid}', data)
|
||||
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_hdr_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_60fps_hdr.json'
|
||||
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 = {
|
||||
'boring': metadata,
|
||||
'hdr': metadata_hdr,
|
||||
'60fps': metadata_60fps,
|
||||
'60fps+hdr': metadata_60fps_hdr,
|
||||
'20230629': metadata_20230629,
|
||||
}
|
||||
|
||||
|
||||
|
@ -651,6 +661,8 @@ class MediaTestCase(TestCase):
|
|||
'<episodedetails>',
|
||||
' <title>no fancy stuff title</title>',
|
||||
' <showtitle>testname</showtitle>',
|
||||
' <season>2017</season>',
|
||||
' <episode></episode>',
|
||||
' <ratings>',
|
||||
' <rating default="True" max="5" name="youtube">',
|
||||
' <value>1.2345</value>',
|
||||
|
@ -1397,3 +1409,118 @@ class FormatMatchingTestCase(TestCase):
|
|||
match_type, format_code = self.media.get_best_video_format()
|
||||
self.assertEqual(format_code, expected_format_code)
|
||||
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(),
|
||||
name='validate-source'),
|
||||
|
||||
path('source-sync-now/<uuid:pk>',
|
||||
SourcesView.as_view(),
|
||||
name='source-sync-now'),
|
||||
|
||||
path('source-add',
|
||||
AddSourceView.as_view(),
|
||||
name='add-source'),
|
||||
|
|
|
@ -78,7 +78,7 @@ def resize_image_to_height(image, width, height):
|
|||
if scaled_width < width:
|
||||
# Width too small, stretch it
|
||||
scaled_width = width
|
||||
image = image.resize((scaled_width, height), Image.ANTIALIAS)
|
||||
image = image.resize((scaled_width, height), Image.LANCZOS)
|
||||
if scaled_width > width:
|
||||
# Width too large, crop it
|
||||
delta = scaled_width - width
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
import glob
|
||||
import os
|
||||
import json
|
||||
from base64 import b64decode
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
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.edit import (FormView, FormMixin, CreateView, UpdateView,
|
||||
DeleteView)
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.core.exceptions import SuspiciousFileOperation
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.db import IntegrityError
|
||||
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._os import safe_join
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from common.utils import append_uri_params
|
||||
|
@ -55,7 +61,7 @@ class DashboardView(TemplateView):
|
|||
# Disk usage
|
||||
disk_usage = Media.objects.filter(
|
||||
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']
|
||||
if not data['disk_usage_bytes']:
|
||||
data['disk_usage_bytes'] = 0
|
||||
|
@ -66,12 +72,12 @@ class DashboardView(TemplateView):
|
|||
data['average_bytes_per_media'] = 0
|
||||
# Latest downloads
|
||||
data['latest_downloads'] = Media.objects.filter(
|
||||
downloaded=True
|
||||
).order_by('-download_date')[:10]
|
||||
downloaded=True, downloaded_filesize__isnull=False
|
||||
).defer('metadata').order_by('-download_date')[:10]
|
||||
# Largest downloads
|
||||
data['largest_downloads'] = Media.objects.filter(
|
||||
downloaded=True, downloaded_filesize__isnull=False
|
||||
).order_by('-downloaded_filesize')[:10]
|
||||
).defer('metadata').order_by('-downloaded_filesize')[:10]
|
||||
# UID and GID
|
||||
data['uid'] = os.getuid()
|
||||
data['gid'] = os.getgid()
|
||||
|
@ -92,8 +98,27 @@ class SourcesView(ListView):
|
|||
paginate_by = settings.SOURCES_PER_PAGE
|
||||
messages = {
|
||||
'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):
|
||||
self.message = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -271,28 +296,46 @@ class ValidateSourceView(FormView):
|
|||
|
||||
class EditSourceMixin:
|
||||
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',
|
||||
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo', 'write_json')
|
||||
'delete_removed_media', 'delete_files_on_disk', 'days_to_keep', 'source_resolution',
|
||||
'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 = {
|
||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||
'errors or is empty. Check the table at the end of '
|
||||
'this page for valid media name variables'),
|
||||
'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
|
||||
obj = form.save(commit=False)
|
||||
source_type = form.cleaned_data['media_format']
|
||||
example_media_file = obj.get_example_media_format()
|
||||
|
||||
if example_media_file == '':
|
||||
form.add_error(
|
||||
'media_format',
|
||||
ValidationError(self.errors['invalid_media_format'])
|
||||
)
|
||||
|
||||
# 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:
|
||||
return super().form_invalid(form)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
|
@ -363,7 +406,7 @@ class SourceView(DetailView):
|
|||
error_message = get_error_message(error)
|
||||
setattr(error, 'error_message', error_message)
|
||||
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
|
||||
|
||||
|
||||
|
@ -394,14 +437,13 @@ class DeleteSourceView(DeleteView, FormMixin):
|
|||
source = self.get_object()
|
||||
for media in Media.objects.filter(source=source):
|
||||
if media.media_file:
|
||||
# Delete the media file
|
||||
delete_file(media.media_file.path)
|
||||
# Delete thumbnail copy if it exists
|
||||
delete_file(media.thumbpath)
|
||||
# Delete NFO file if it exists
|
||||
delete_file(media.nfopath)
|
||||
# Delete JSON file if it exists
|
||||
delete_file(media.jsonpath)
|
||||
file_path = media.media_file.path
|
||||
matching_files = glob.glob(os.path.splitext(file_path)[0] + '.*')
|
||||
for file in matching_files:
|
||||
delete_file(file)
|
||||
directory_path = source.directory_path
|
||||
if os.path.exists(directory_path):
|
||||
shutil.rmtree(directory_path, True)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
|
@ -448,16 +490,16 @@ class MediaView(ListView):
|
|||
if self.show_skipped:
|
||||
q = Media.objects.filter(source=self.filter_source)
|
||||
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:
|
||||
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:
|
||||
if self.show_skipped:
|
||||
q = Media.objects.all()
|
||||
elif self.only_skipped:
|
||||
q = Media.objects.filter(skip=True)
|
||||
q = Media.objects.filter(Q(skip=True)|Q(manual_skip=True))
|
||||
else:
|
||||
q = Media.objects.filter(skip=False)
|
||||
q = Media.objects.filter(Q(skip=False)&Q(manual_skip=False))
|
||||
return q.order_by('-published', '-created')
|
||||
|
||||
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),))
|
||||
# If the media file exists on disk, delete it
|
||||
if self.object.media_file_exists:
|
||||
delete_file(self.object.media_file.path)
|
||||
self.object.media_file = None
|
||||
# If the media has an associated thumbnail copied, also delete it
|
||||
delete_file(self.object.thumbpath)
|
||||
# If the media has an associated NFO file with it, also delete it
|
||||
delete_file(self.object.nfopath)
|
||||
# Delete all files which contains filename
|
||||
filepath = self.object.media_file.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:
|
||||
delete_file(file)
|
||||
# Reset all download data
|
||||
self.object.metadata = None
|
||||
self.object.downloaded = False
|
||||
|
@ -629,6 +672,7 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
|||
self.object.downloaded_filesize = None
|
||||
# Mark it to be skipped
|
||||
self.object.skip = True
|
||||
self.object.manual_skip = True
|
||||
self.object.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
@ -657,6 +701,7 @@ class MediaEnableView(FormView, SingleObjectMixin):
|
|||
def form_valid(self, form):
|
||||
# Mark it as not skipped
|
||||
self.object.skip = False
|
||||
self.object.manual_skip = False
|
||||
self.object.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
@ -677,11 +722,38 @@ class MediaContent(DetailView):
|
|||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
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)
|
||||
|
||||
headers = {
|
||||
'X-Accel-Redirect': self.object.media_file.url,
|
||||
}
|
||||
return HttpResponse(headers=headers)
|
||||
# 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 = {
|
||||
'Content-Type': self.object.content_type,
|
||||
'X-Accel-Redirect': self.object.media_file.url,
|
||||
}
|
||||
return HttpResponse(headers=headers)
|
||||
|
||||
|
||||
class TasksView(ListView):
|
||||
|
|
|
@ -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.
|
||||
'''
|
||||
|
||||
|
@ -64,13 +64,20 @@ def get_media_info(url):
|
|||
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.
|
||||
'''
|
||||
|
||||
def hook(event):
|
||||
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':
|
||||
log.error(f'[youtube-dl] error occured downloading: {filename}')
|
||||
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}')
|
||||
else:
|
||||
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
||||
hook.download_progress = 0
|
||||
|
||||
opts = get_yt_opts()
|
||||
opts.update({
|
||||
hook.download_progress = 0
|
||||
ytopts = {
|
||||
'format': media_format,
|
||||
'merge_output_format': extension,
|
||||
'outtmpl': output_file,
|
||||
'quiet': True,
|
||||
'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:
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
TIME_ZONE = os.getenv('TZ', 'UTC')
|
||||
|
||||
|
||||
database_dict = {}
|
||||
database_connection_env = os.getenv('DATABASE_CONNECTION', '')
|
||||
if database_connection_env:
|
||||
|
@ -67,6 +64,12 @@ YOUTUBE_DL_CACHEDIR = CONFIG_BASE_DIR / 'cache'
|
|||
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_PASSWORD = os.getenv('HTTP_PASS', '').strip()
|
||||
if BASICAUTH_USERNAME and BASICAUTH_PASSWORD:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
@ -6,7 +7,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
|||
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||
|
||||
|
||||
VERSION = '0.12.0'
|
||||
VERSION = '0.13.3'
|
||||
SECRET_KEY = ''
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = []
|
||||
|
@ -96,7 +97,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = os.getenv('TZ', 'UTC')
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
|
Loading…
Reference in New Issue