109 Commits

Author SHA1 Message Date
woozu-shin
0b13065c9d [NO-ISSUE] Add lightweight option 2024-02-21 12:53:53 +09:00
meeb
7aa9c0ec8a bump to 0.13.3 2023-11-30 18:58:29 +11:00
meeb
e54a762a7b rework skip logic check, prevent race condition between metadata downloading and upload date being checked, resolves #440, #183, related to #438 2023-11-30 18:52:32 +11:00
meeb
512b70adad toggle logging verbosity based on settings.DEBUG 2023-11-30 18:50:22 +11:00
meeb
6c21ff15ab stopcontainer helper 2023-11-30 18:49:58 +11:00
meeb
adf26cb4e3 bump ffmpeg to autobuild-2023-11-29-14-19 2023-11-30 18:49:50 +11:00
meeb
45c12561ba Merge pull request #438 from locke4/main
Fix signals.py mistake
2023-11-29 04:05:13 +11:00
locke4
2d6f485a5d Update signals.py 2023-11-28 08:48:31 +00:00
meeb
33b471175a Merge pull request #425 from locke4/main
Add support for regex video title filtering
2023-11-20 16:53:58 +11:00
meeb
7f4e8586b7 Merge pull request #435 from klinker41/patch-1
Update other-database-backends.md
2023-11-20 16:51:00 +11:00
Jake Klinker
bab4b9b056 Update other-database-backends.md
Add documentation about how to use a docker compose postgres container and connect it to tubesync. This seems like a fairly basic use case that many users would want to implement, given the large performance benefits it brings.
2023-11-19 10:23:07 -07:00
meeb
30c2127271 bump ffmpeg to 2023-11-14 and yt-dlp to 2023.11.16 2023-11-16 18:54:57 +11:00
locke4
d1cb7ef76c Delete tubesync/sync/migrations/0020_auto_20231024_1812.py 2023-10-24 19:26:50 +01:00
locke4
1fd4f87c53 Merge pull request #8 from locke4/fix-pagenums
Ran makemigrations
2023-10-24 19:25:52 +01:00
locke4
cf06f4cbc2 Merge pull request #7 from locke4/locke4-patch-2
Updated according to comments on PR
2023-10-24 18:38:17 +01:00
locke4
0523f481d2 Updated according to comments on PR
Fixed whitespace

Update tests.py

Ran makemigrations

Update models.py

Update tests.py

Update models.py

Update tests.py

Update models.py

Update models.py

Update tests.py

Update models.py

Update tests.py

Update tests.py

Update tests.py

Update models.py

Update models.py

Update tests.py

Update models.py

Update models.py

Update tests.py

Update tests.py

Update signals.py

Update tasks.py

Update signals.py

Update models.py

Update tasks.py

Update signals.py

Update tasks.py

Update models.py
2023-10-24 18:37:09 +01:00
locke4
aa4bd4ec26 Ran makemigrations 2023-10-24 18:17:56 +01:00
locke4
96d9ee93ef Merge pull request #6 from locke4/fix-pagenums
Fix pagenums for "only_skipped" query param
2023-10-22 13:39:11 +01:00
locke4
8240c49d5c Update ci.yaml 2023-10-22 02:42:57 +01:00
locke4
0c5e3d3818 Update media.html 2023-10-22 02:30:24 +01:00
locke4
22edd1bbda Update pagination.html 2023-10-22 02:25:19 +01:00
locke4
fea0bb191e Fix typo 2023-10-21 21:23:57 +01:00
locke4
0f65a4027a Add support for regex filters on video names
Update views.py
Update tests.py
Update source.html
Update tasks.py
Update signals.py
Update 0001_initial.py
Update models.py
Update models.py
Update tests.py
2023-10-21 21:07:15 +01:00
meeb
5cac374486 Merge pull request #420 from sparklesmcfadden/delete-removed-media
Adds workflow to delete local media that no longer exists in the source
2023-10-21 14:31:39 +11:00
meeb
69efc9298d Merge pull request #423 from ltomes/patch-1
Update other-database-backends.md
2023-10-21 14:30:06 +11:00
Levi Tomes
1be8dff769 Update other-database-backends.md
django-admin only ran the loaddata for me with the - before the format flag.
2023-10-20 18:22:40 -05:00
cavanfarrell
350e544594 Fixes formatting 2023-10-20 10:25:20 -05:00
cavanfarrell
0542c734e5 Adds workflow to delete local media that no longer exists in the source 2023-10-20 10:19:57 -05:00
meeb
42b337c408 bump ffmpeg to autobuild-2023-10-11-14-20 2023-10-12 15:50:38 +11:00
meeb
2f82f8c599 fix tests 2023-10-12 15:44:51 +11:00
meeb
b57ca110b0 bump to 0.13.1 2023-10-12 15:34:33 +11:00
meeb
e3e7352600 add uploader variable, resolves #270 2023-10-12 15:33:58 +11:00
meeb
6d3a7bf859 move metadata collection to a higher priority over thumbnails, resolves #418 2023-10-12 15:27:19 +11:00
meeb
25f622311f bump to 0.13.0 2023-09-25 18:47:21 +10:00
meeb
adea4a0ecd bump ffmpeg to autobuild-2023-09-24-14-11 2023-09-25 18:45:45 +10:00
meeb
0d76f2f94e bump s6 to 3.1.5.0 2023-09-25 18:37:29 +10:00
meeb
71578d926e fix tests after subs lang pr 2023-09-25 18:31:32 +10:00
meeb
777cdb5ecc Merge pull request #406 from pacoccino/subtitles
Subtitles
2023-09-05 06:33:26 +10:00
pacoccino
3dd445bf96 Add a validator for sub_lang 2023-09-04 14:58:57 +02:00
pacoccino
86744c0510 Remove extension edits 2023-09-02 14:37:07 +02:00
pacoccino
be7454f72a Add subtitles config into sources model 2023-09-02 14:30:23 +02:00
pacoccino
e9f03cb6bf download subtitles draft 2023-08-31 22:40:29 +02:00
meeb
ddc127e6af bump libs, bump ffmpeg to autobuild-2023-08-12-14-12, resolves #399 2023-08-13 17:43:43 +10:00
meeb
63d32a1e11 replace PIL.Image.ANTIALIAS with PIL.Image.LANCZOS, resolves #392 2023-07-16 00:06:05 +10:00
meeb
2ebbb8480e bump ffmpeg to 2023-07-14-14-08 2023-07-15 14:32:16 +10:00
meeb
21785e031a add requests[socks], resolves #391 2023-07-15 14:23:23 +10:00
meeb
f12e13162f ignore media formats which do not have acodecs or vcodecs in their respective matchers, resolves #386 2023-06-29 23:48:35 +10:00
meeb
5c9c1550bf make shell helper 2023-06-29 23:30:47 +10:00
meeb
12638afb60 bump container image base to debian bookworm, update ffmpeg to 2023-06-27 and yt-dlp to 2023-06-22, rework python packages installation after bookworm update 2023-06-28 02:54:09 +10:00
meeb
b9886a3b27 Merge pull request #381 from a-kr/fix_cleanup_old_media
in cleanup_old_media, filter in database rather than in Python
2023-05-27 14:03:35 +10:00
Alexey Kryuchkov
612f78e7eb in cleanup_old_media, filter in database rather than in Python 2023-05-27 01:28:15 +03:00
meeb
0c5a9c53f8 Merge pull request #376 from gautamkrishnar/fix/dockerfile
fixing unavailable ffmpeg version
2023-05-04 12:38:27 +10:00
Gautam krishna R
d439b2f223 fixing unavailable ffmpeg version
fixing unavailable ffmpeg version
2023-05-03 21:33:16 +05:30
meeb
7116617cd2 Merge pull request #374 from garbled1/latest_dl_fix
Fix #364 by checking the filesize is not null.
2023-05-03 02:02:14 +10:00
garbled1
422d228359 Fix #364 by checking the filesize is not null. 2023-05-02 08:24:50 -07:00
meeb
1f68be5c26 update ffmpeg to 2023-04-13-12-52 2023-04-14 12:28:38 +10:00
meeb
089a487f3a add additional library ID help link, resolves #370 2023-04-14 11:45:52 +10:00
meeb
24ae70ea70 add reset-metadata command, related to #287 2023-04-05 11:02:21 +10:00
meeb
72c3242e70 add TUBESYNC_RESET_DOWNLOAD_DIR env var to toggle resetting permissions on /downloads in the container on start, resolves #354 2023-03-26 14:05:47 +11:00
meeb
f3e93c0ecf bump ffmpeg to autobuild-2023-03-23-15-58 2023-03-24 13:17:12 +11:00
meeb
fa8efb178e allow easy container env var override of HEALTHCHECK_ALLOWED_IPS, resolves #168 2023-03-24 13:02:16 +11:00
meeb
2001faea44 Merge pull request #358 from darmiel/fix/font-weight
fix: bold font weight
2023-03-11 15:49:06 +11:00
darmiel
b370e98031 fix: bold font weight 2023-03-10 14:36:29 +01:00
meeb
55bfd911b9 catch typeerrors for duration metadata, resolves #248 2023-03-10 18:23:49 +11:00
meeb
e47d0eb7be Merge pull request #357 from darmiel/fix/bump-ffpmeg
chore: bump ffmpeg to `109977-gaca7ef78cc`
2023-03-10 05:00:15 +11:00
darmiel
a95c64bc10 chore: bump ffmpeg to 109977-gaca7ef78cc 2023-03-09 14:58:41 +01:00
meeb
e9d4f89f39 fix connection kwarg to db_type() in custom field to be compatible with the postgresql backend, resolves #347 2023-02-21 13:55:03 +11:00
meeb
7876b48860 use backend agnostic text type for custom field, related to #345 and #338 2023-02-20 14:56:28 +11:00
meeb
2639d911ab change sponsorblock_categories to a textfield, fixing max charlen=255 for mysql, related to #338 2023-02-20 13:24:38 +11:00
meeb
e4c0f0e98a Merge pull request #338 from kuhnchris/embed-thumbnail
Configurations in Sources
2023-02-20 11:23:18 +11:00
KuhnChris
601449ce08 migrate migrations; split fields into fields.py 2023-02-19 23:44:48 +01:00
KuhnChris
fe4c876fdc "source" overview, fix some edge case(s) 2023-02-18 14:03:32 +01:00
KuhnChris
fbe9546a74 Merge branch 'meeb-main' into embed-thumbnail 2023-02-18 11:38:59 +01:00
KuhnChris
ce14167cee formating 2023-02-18 11:38:23 +01:00
KuhnChris
c927f32aa6 ffmpeg embed thumbnails, configuration 2023-02-18 11:37:28 +01:00
KuhnChris
1d5579aa31 Phase 1 - extend model for new fields 2023-02-18 11:35:45 +01:00
meeb
d8a9572411 bump ffmpeg, fix container build 2023-02-18 13:14:25 +11:00
meeb
8315efac03 bump to 0.12.1, resolves #340 and #341 2023-02-18 12:59:57 +11:00
meeb
35678e3be9 temporarily disable sponsorblock by default pending #338 2023-02-18 12:54:39 +11:00
meeb
e75b446883 Merge pull request #334 from kuhnchris/skip-manual
Adding new "manual_skip" field; adapt UI
2023-02-18 12:18:00 +11:00
meeb
dd05595558 Merge pull request #337 from kuhnchris/align-checkboxtext
align (i) better with text (+ checkbox less wide)
2023-02-15 11:56:56 +11:00
KuhnChris
2772e85d9f ffmpeg embed thumbnails, configuration 2023-02-15 00:01:44 +01:00
KuhnChris
931aa78815 align (i) better with text (+ checkbox less wide) 2023-02-14 22:06:15 +01:00
KuhnChris
24a49d2f14 Phase 1 - extend model for new fields 2023-02-14 21:52:50 +01:00
meeb
f14d2dd29e Merge pull request #335 from kuhnchris/delete_index_source
del `delete_index_source_task` calls - solves #333
2023-02-14 18:55:57 +11:00
KuhnChris
f4e5b6e76c del delete_index_source_task calls - solves #333 2023-02-13 14:23:41 +01:00
KuhnChris
977f996d8e Adding new "manual_skip" field; adapt UI 2023-02-13 07:46:16 +01:00
meeb
dc5491455c Merge pull request #331 from kuhnchris/sync-now-v2
"Sync now" button
2023-02-13 11:17:46 +11:00
meeb
70ef11d552 Merge pull request #332 from kuhnchris/patch-1
Remove automatic builds on pull_request
2023-02-13 11:16:11 +11:00
KuhnChris
b04e237cb8 Remove automatic builds on pull_request
Removes the automatic pull request build due to failing for the regular contributors (aside from @meeb).
2023-02-12 19:14:41 +01:00
KuhnChris
55c58b4836 "Sync now" button 2023-02-12 19:03:28 +01:00
meeb
e871983707 Merge pull request #325 from kuhnchris/allow-audio-play
Fix bug getting content_type for "audio only"
2023-02-12 23:51:37 +11:00
meeb
b3f93ddef7 Merge pull request #327 from kuhnchris/dev-file-plrovider
(dev only) allow file download directly from django
2023-02-12 23:46:24 +11:00
KuhnChris
bf7a0fcec0 content_type based on vcodec/acodec 2023-02-12 13:22:57 +01:00
KuhnChris
598ee2bd0a use pathlib; .exists() check 2023-02-12 13:09:33 +01:00
meeb
7b12fe3fad Merge pull request #326 from kuhnchris/audio-player-main
<audio>-player instead of <video> for audio only
2023-02-12 12:02:01 +11:00
meeb
7358b52184 Merge pull request #324 from kuhnchris/suspicious-file-op
Add check against folders outside of DOWNLOAD_ROOT
2023-02-12 11:54:57 +11:00
meeb
4b4b4eb58d Merge pull request #323 from kuhnchris/update-readme
Update README-FAQ regarding `Locking Failed`
2023-02-12 11:53:38 +11:00
KuhnChris
b719fd5122 Allow file fetching directly from django (dev-env) 2023-02-11 22:07:48 +01:00
KuhnChris
4696aebebc allow the use of <audio> if only audio available. 2023-02-11 22:05:36 +01:00
KuhnChris
7d333487fe Fix bug getting content_type for "audio only" 2023-02-11 22:01:01 +01:00
KuhnChris
844d17006e Add check against folders outside of DOWNLOAD_ROOT 2023-02-11 20:42:59 +01:00
KuhnChris
f9a27eb33e Update README-FAQ regarding Locking Failed 2023-02-11 19:16:25 +01:00
meeb
b8434ff444 Merge pull request #320 from ticoombs/main
feat: initial sponsorblock support
2023-02-03 17:50:48 +11:00
Tim Coombs
932eb4caf4 feat: sponsorblock support 2023-02-03 15:18:52 +11:00
meeb
812fbc5f46 remove per-init timeout 2023-01-20 13:55:54 +11:00
meeb
fdc591cc7c fix in-client video player with correct content type headers 2023-01-19 13:29:37 +11:00
meeb
4ae454a4f3 disable s6 service timeout entirely, resolves #309 2023-01-19 12:57:09 +11:00
meeb
4f6af702ae set S6_CMD_WAIT_FOR_SERVICES_MAXTIME to 60s 2023-01-19 11:38:01 +11:00
53 changed files with 7830 additions and 125 deletions

View File

@@ -4,12 +4,10 @@ env:
IMAGE_NAME: tubesync
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:

4
.gitignore vendored
View File

@@ -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

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/tubesync.iml" filepath="$PROJECT_DIR$/.idea/tubesync.iml" />
</modules>
</component>
</project>

20
.idea/tubesync.iml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/tubesync/common/templates" />
</list>
</option>
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -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 \

View File

@@ -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

View File

@@ -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 = "*"}

View File

@@ -241,6 +241,7 @@ and less common features:
* [Reset tasks from the command line](https://github.com/meeb/tubesync/blob/main/docs/reset-tasks.md)
* [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
@@ -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:
| 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 |

View File

@@ -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

View File

@@ -24,7 +24,7 @@ $ docker exec -i tubesync python3 /app/manage.py dumpdata > some-file.json
Then change you database backend over, then use
```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,46 @@ 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!
## 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.

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

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

View File

@@ -1,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)

View File

@@ -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;

View File

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

View File

@@ -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); }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>

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

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

View File

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

View File

@@ -53,6 +53,8 @@ def get_best_audio_format(media):
# If the format has a video stream, skip it
if 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

View File

@@ -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'}

View File

@@ -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'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by pac
from django.db import migrations, models
from sync.models import Source
class Migration(migrations.Migration):
dependencies = [
('sync', '0020_auto_20231024_1825'),
]
operations = [
migrations.AddField(
model_name='source',
name='lightweight_metadata',
field=models.CharField(max_length=20,
default=Source.LIGHTWEIGHT_METADATA_TYPE_RAW,
choices=Source.LIGHTWEIGHT_METADATA_TYPE_CHOICES,
help_text='Lightweight metadata',
verbose_name='lightweight metadata'),
),
]

View File

@@ -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 _
@@ -19,11 +21,10 @@ from .utils import seconds_to_timestr, parse_media_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,47 @@ 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 +288,18 @@ 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')
)
source_resolution = models.CharField(
_('source resolution'),
max_length=8,
@@ -309,6 +363,48 @@ 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')
)
]
)
LIGHTWEIGHT_METADATA_TYPE_RAW = 'RAW'
LIGHTWEIGHT_METADATA_TYPE_UNNECESSARY = 'UNNECESSARY'
LIGHTWEIGHT_METADATA_TYPE_FEATHER = 'FEATHER'
LIGHTWEIGHT_METADATA_TYPES = (LIGHTWEIGHT_METADATA_TYPE_RAW, LIGHTWEIGHT_METADATA_TYPE_UNNECESSARY, LIGHTWEIGHT_METADATA_TYPE_FEATHER)
LIGHTWEIGHT_METADATA_TYPE_CHOICES = (
(LIGHTWEIGHT_METADATA_TYPE_RAW, _("(LARGE) Save raw metadata")),
(LIGHTWEIGHT_METADATA_TYPE_UNNECESSARY, _("(MEDIUM) Treeshake unnecessary metadata json keys")),
(LIGHTWEIGHT_METADATA_TYPE_FEATHER, _("(TINY) if the capacity is large, Treeshake it event if it is in use")),
)
lightweight_metadata = models.CharField(
_('lightweight metadata'),
max_length=20,
default=LIGHTWEIGHT_METADATA_TYPE_RAW,
choices=LIGHTWEIGHT_METADATA_TYPE_CHOICES,
help_text=_('Lightweight metadata')
)
def __str__(self):
return self.name
@@ -446,6 +542,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 +564,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 +781,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 +1057,7 @@ class Media(models.Model):
'acodec': display_format['acodec'],
'fps': display_format['fps'],
'hdr': display_format['hdr'],
'uploader': self.uploader,
}
@property
@@ -1013,7 +1122,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 +1245,31 @@ 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):
'''
@@ -1260,7 +1394,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, 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

View File

@@ -93,13 +93,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,7 +117,16 @@ 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
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
@@ -126,10 +139,20 @@ def media_post_save(sender, instance, created, **kwargs):
else:
if instance.skip:
# 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 '
f'publishing date, marking to be unskipped')
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 +175,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
)

View File

@@ -132,18 +132,25 @@ 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 / '
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)
def index_source_task(source_id):
'''
@@ -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,8 +231,17 @@ 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()
if source.lightweight_metadata == Source.LIGHTWEIGHT_METADATA_TYPE_FEATHER:
del metadata["formats"]
del metadata["thumbnails"]
del metadata["automatic_captions"]
del metadata["requested_formats"]
del metadata["heatmap"]
media.metadata = json.dumps(metadata, default=json_serial)
upload_date = media.upload_date
# Media must have a valid upload date
@@ -241,6 +258,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):

View File

@@ -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>

View File

@@ -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">
@@ -124,6 +132,8 @@
<td><span class="hide-on-med-and-up">Can download?<br></span><strong>{% if media.can_download %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr>
{% endif %}
{% if media.source.lightweight_metadata == "RAW" %}
<tr title="The available media formats">
<td class="hide-on-small-only">Available formats</td>
<td><span class="hide-on-med-and-up">Available formats<br></span>
@@ -147,7 +157,10 @@
Video: <strong>{% if video_format %}{{ video_format }} {% if video_exact %}(exact match){% else %}(fallback){% endif %}{% else %}no match{% endif %}
</strong></td>
</tr>
{% endif %}
</table>
<p>{{ media.source.lightweight_metadata }}</p>
<p>{{ media.source }}</p>
</div>
</div>
{% if media.downloaded %}
@@ -162,10 +175,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>

View File

@@ -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 %}

View File

@@ -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,10 @@
<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>
{% 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 +138,65 @@
<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 %}
{% if source.lightweight_metadata %}
<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>

View File

@@ -24,7 +24,8 @@
<div class="col s12">
<div class="collection">
{% 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 }} &quot;{{ source.key }}&quot;)<br>
{{ source.format_summary }}<br>
{% 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 %}
{% 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 %}

View File

@@ -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 %}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
import logging
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,
}
@@ -1397,3 +1407,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)

View File

@@ -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'),

View File

@@ -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

View File

@@ -1,18 +1,22 @@
import os
import json
from base64 import b64decode
import pathlib
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
@@ -66,7 +70,7 @@ class DashboardView(TemplateView):
data['average_bytes_per_media'] = 0
# Latest downloads
data['latest_downloads'] = Media.objects.filter(
downloaded=True
downloaded=True, downloaded_filesize__isnull=False
).order_by('-download_date')[:10]
# Largest downloads
data['largest_downloads'] = Media.objects.filter(
@@ -92,8 +96,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 +294,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', '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', 'lightweight_metadata')
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)
@@ -448,16 +489,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):
@@ -629,6 +670,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 +699,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,8 +720,35 @@ 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)
# 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)

View File

@@ -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="all",
embed_thumbnail=False, embed_metadata=False, skip_sponsors=True,
write_subtitles=False, auto_subtitles=False, sub_langs='en'):
'''
Downloads a YouTube URL to a file on disk.
'''
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':
@@ -101,15 +108,41 @@ def download_media(url, media_format, extension, output_file, info_json):
log.warn(f'[youtube-dl] unknown event: {str(event)}')
hook.download_progress = 0
opts = get_yt_opts()
opts.update({
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(','),
}
sbopt = {
'key': 'SponsorBlock',
'categories': [sponsor_categories]
}
ffmdopt = {
'key': 'FFmpegMetadata',
'add_chapters': True,
'add_metadata': True
}
opts = get_yt_opts()
if embed_thumbnail:
ytopts['postprocessors'].append({'key': 'EmbedThumbnail'})
if embed_metadata:
ffmdopt["add_metadata"] = True
if skip_sponsors:
ytopts['postprocessors'].append(sbopt)
ytopts['postprocessors'].append(ffmdopt)
opts.update(ytopts)
with yt_dlp.YoutubeDL(opts) as y:
try:
return y.download([url])

View File

@@ -67,6 +67,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:

View File

@@ -6,7 +6,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 = []