Compare commits
223 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa9c0ec8a | ||
|
|
e54a762a7b | ||
|
|
512b70adad | ||
|
|
6c21ff15ab | ||
|
|
adf26cb4e3 | ||
|
|
45c12561ba | ||
|
|
2d6f485a5d | ||
|
|
33b471175a | ||
|
|
7f4e8586b7 | ||
|
|
bab4b9b056 | ||
|
|
30c2127271 | ||
|
|
d1cb7ef76c | ||
|
|
1fd4f87c53 | ||
|
|
cf06f4cbc2 | ||
|
|
0523f481d2 | ||
|
|
aa4bd4ec26 | ||
|
|
96d9ee93ef | ||
|
|
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 | ||
|
|
2431f8775a | ||
|
|
438316953a | ||
|
|
85637fecba | ||
|
|
f9dfffe91a | ||
|
|
0845a6662d | ||
|
|
419c4c5a9f | ||
|
|
2f475bf2a8 | ||
|
|
7d16a1714c | ||
|
|
a7100a0f53 | ||
|
|
5a4e6cee58 | ||
|
|
e69adafcec | ||
|
|
f9908a4d3b | ||
|
|
bf99241ad2 | ||
|
|
0e278bc8c4 | ||
|
|
57921ca6b9 | ||
|
|
fb23fdeae1 | ||
|
|
433a7792d5 | ||
|
|
e198cc011b | ||
|
|
296a790af5 | ||
|
|
e190821b7b | ||
|
|
1ba865cf0d | ||
|
|
05d50c958e | ||
|
|
8426c7309a | ||
|
|
0450d47d81 | ||
|
|
e8d899d273 | ||
|
|
25d5768f6e | ||
|
|
e9a3f2dd59 | ||
|
|
7832282545 | ||
|
|
d161aef112 | ||
|
|
8901aea8d7 | ||
|
|
227cae4cdb | ||
|
|
5e57abe86a | ||
|
|
c04c1b3cfb | ||
|
|
a94541a354 | ||
|
|
84a368aa09 | ||
|
|
6d2fb86e7d | ||
|
|
67a3998aac | ||
|
|
e3ca39b5db | ||
|
|
872bfc5124 | ||
|
|
ae5550a28d | ||
|
|
153ca032b1 | ||
|
|
95e727b0a8 | ||
|
|
f1c6fc3086 | ||
|
|
a3559526cb | ||
|
|
a0ca2b3061 | ||
|
|
120a19d2ba | ||
|
|
4735e72f12 | ||
|
|
5954dba48d | ||
|
|
3f699c82ec | ||
|
|
cb39ece21b | ||
|
|
3943115b18 | ||
|
|
97183fff97 | ||
|
|
b4a247bf37 | ||
|
|
3bee755eb5 | ||
|
|
9957639be5 | ||
|
|
a5a8e37a20 | ||
|
|
7a1b2adc59 | ||
|
|
7668466bc3 | ||
|
|
ceb8cbc442 | ||
|
|
8b0d1b3397 | ||
|
|
77fb4963f9 | ||
|
|
538b3cb319 | ||
|
|
2335ceb2dc | ||
|
|
0c347d523d | ||
|
|
d0a214e21b | ||
|
|
2d8e6ed9b8 | ||
|
|
d0fcc07656 | ||
|
|
5bf53b3d3a | ||
|
|
280112beae | ||
|
|
367d41f2be | ||
|
|
61cd63bcc1 | ||
|
|
62e2e2f9e6 | ||
|
|
aa90a1afb0 | ||
|
|
238c0b5911 | ||
|
|
4d7e9133e0 | ||
|
|
709b7b44d5 | ||
|
|
425b011054 | ||
|
|
b1b3c99726 | ||
|
|
02212b8fad | ||
|
|
70e541dea0 | ||
|
|
cc7b7727c2 | ||
|
|
0757c99f01 | ||
|
|
61d97201a5 | ||
|
|
a58aef29fb | ||
|
|
56c882fa79 | ||
|
|
9a3030543f | ||
|
|
4eca23d88b | ||
|
|
aa6df98927 | ||
|
|
f3cac1908c | ||
|
|
d9a519ffde | ||
|
|
185823b040 | ||
|
|
4774a35d44 | ||
|
|
b4a89968d0 | ||
|
|
5056419aa4 | ||
|
|
a8488026d0 | ||
|
|
6459e273f1 | ||
|
|
42e4ee775f | ||
|
|
b3d9e74818 | ||
|
|
c396821cb1 | ||
|
|
f9858a4d1a | ||
|
|
3c1d64a089 | ||
|
|
00fbd53b11 | ||
|
|
99825c9a08 | ||
|
|
4f163f2f2c | ||
|
|
936800992c | ||
|
|
2e9ee04c97 | ||
|
|
8d60629034 | ||
|
|
f54adab213 | ||
|
|
6618409f9c | ||
|
|
8d08027024 | ||
|
|
9a543b1496 | ||
|
|
b70703b7a7 | ||
|
|
6ac0c6e9de | ||
|
|
ecb1aaf5b5 | ||
|
|
4c5027e0c4 |
43
.github/workflows/ci.yaml
vendored
43
.github/workflows/ci.yaml
vendored
@@ -4,12 +4,10 @@ env:
|
|||||||
IMAGE_NAME: tubesync
|
IMAGE_NAME: tubesync
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -27,7 +25,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pipenv
|
pip install pipenv
|
||||||
pipenv install --system
|
pipenv install --system --skip-lock
|
||||||
- name: Set up Django environment
|
- name: Set up Django environment
|
||||||
run: cp tubesync/tubesync/local_settings.py.example tubesync/tubesync/local_settings.py
|
run: cp tubesync/tubesync/local_settings.py.example tubesync/tubesync/local_settings.py
|
||||||
- name: Run Django tests
|
- name: Run Django tests
|
||||||
@@ -35,19 +33,24 @@ jobs:
|
|||||||
containerise:
|
containerise:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
- name: Log into GitHub Container Registry
|
- name: Log into GitHub Container Registry
|
||||||
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
- name: Build and push
|
- name: Lowercase github username for ghcr
|
||||||
uses: docker/build-push-action@v2
|
id: string
|
||||||
with:
|
uses: ASzc/change-string-case-action@v1
|
||||||
platforms: linux/amd64
|
with:
|
||||||
push: true
|
string: ${{ github.actor }}
|
||||||
tags: ghcr.io/meeb/${{ env.IMAGE_NAME }}:latest
|
- name: Build and push
|
||||||
cache-from: type=registry,ref=ghcr.io/meeb/${{ env.IMAGE_NAME }}:latest
|
uses: docker/build-push-action@v2
|
||||||
cache-to: type=inline
|
with:
|
||||||
build-args: |
|
platforms: linux/amd64,linux/arm64
|
||||||
IMAGE_NAME=${{ env.IMAGE_NAME }}
|
push: true
|
||||||
|
tags: ghcr.io/${{ steps.string.outputs.lowercase }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
cache-from: type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
build-args: |
|
||||||
|
IMAGE_NAME=${{ env.IMAGE_NAME }}
|
||||||
|
|||||||
45
.github/workflows/release.yaml
vendored
45
.github/workflows/release.yaml
vendored
@@ -11,23 +11,28 @@ jobs:
|
|||||||
containerise:
|
containerise:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Get tag
|
- name: Get tag
|
||||||
id: tag
|
id: tag
|
||||||
uses: dawidd6/action-get-tag@v1
|
uses: dawidd6/action-get-tag@v1
|
||||||
- uses: docker/build-push-action@v2
|
- uses: docker/build-push-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
- name: Log into GitHub Container Registry
|
- name: Log into GitHub Container Registry
|
||||||
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
- name: Build and push
|
- name: Lowercase github username for ghcr
|
||||||
uses: docker/build-push-action@v2
|
id: string
|
||||||
with:
|
uses: ASzc/change-string-case-action@v1
|
||||||
platforms: linux/amd64
|
with:
|
||||||
push: true
|
string: ${{ github.actor }}
|
||||||
tags: ghcr.io/meeb/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
- name: Build and push
|
||||||
cache-from: type=registry,ref=ghcr.io/meeb/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
uses: docker/build-push-action@v2
|
||||||
cache-to: type=inline
|
with:
|
||||||
build-args: |
|
platforms: linux/amd64,linux/arm64
|
||||||
IMAGE_NAME=${{ env.IMAGE_NAME }}
|
push: true
|
||||||
|
tags: ghcr.io/${{ steps.string.outputs.lowercase }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||||
|
cache-from: type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||||
|
cache-to: type=inline
|
||||||
|
build-args: |
|
||||||
|
IMAGE_NAME=${{ env.IMAGE_NAME }}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
@@ -130,3 +131,6 @@ dmypy.json
|
|||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
Pipfile.lock
|
||||||
|
.vscode/launch.json
|
||||||
|
|||||||
173
Dockerfile
173
Dockerfile
@@ -1,56 +1,77 @@
|
|||||||
FROM debian:buster-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
ARG ARCH="amd64"
|
ARG TARGETPLATFORM
|
||||||
ARG S6_VERSION="2.2.0.3"
|
ARG S6_VERSION="3.1.5.0"
|
||||||
ARG FFMPEG_VERSION="4.3.2"
|
ARG FFMPEG_DATE="autobuild-2023-11-29-14-19"
|
||||||
|
ARG FFMPEG_VERSION="112875-g47e214245b"
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||||
HOME="/root" \
|
HOME="/root" \
|
||||||
LANGUAGE="en_US.UTF-8" \
|
LANGUAGE="en_US.UTF-8" \
|
||||||
LANG="en_US.UTF-8" \
|
LANG="en_US.UTF-8" \
|
||||||
LC_ALL="en_US.UTF-8" \
|
LC_ALL="en_US.UTF-8" \
|
||||||
TERM="xterm" \
|
TERM="xterm" \
|
||||||
S6_EXPECTED_SHA256="a7076cf205b331e9f8479bbb09d9df77dbb5cd8f7d12e9b74920902e0c16dd98" \
|
S6_CMD_WAIT_FOR_SERVICES_MAXTIME="0"
|
||||||
S6_DOWNLOAD="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-${ARCH}.tar.gz" \
|
|
||||||
FFMPEG_EXPECTED_SHA256="34bffcd0b58695e3ee5eba2573b37f06cb5088050733ca96265815f58bd61d35" \
|
|
||||||
FFMPEG_DOWNLOAD="https://tubesync.sfo2.digitaloceanspaces.com/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static.tar.xz"
|
|
||||||
|
|
||||||
|
|
||||||
# Install third party software
|
# Install third party software
|
||||||
RUN set -x && \
|
RUN export ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
apt-get update && \
|
"linux/amd64") echo "amd64" ;; \
|
||||||
apt-get -y --no-install-recommends install locales && \
|
"linux/arm64") echo "aarch64" ;; \
|
||||||
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
|
*) echo "" ;; esac) && \
|
||||||
locale-gen en_US.UTF-8 && \
|
export S6_ARCH_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
# Install required distro packages
|
"linux/amd64") echo "65d0d0f353d2ff9d0af202b268b4bf53a9948a5007650854855c729289085739" ;; \
|
||||||
apt-get -y --no-install-recommends install curl xz-utils ca-certificates binutils && \
|
"linux/arm64") echo "3fbd14201473710a592b2189e81f00f3c8998e96d34f16bd2429c35d1bc36d00" ;; \
|
||||||
# Install s6
|
*) echo "" ;; esac) && \
|
||||||
curl -L ${S6_DOWNLOAD} --output /tmp/s6-overlay-${ARCH}.tar.gz && \
|
export S6_DOWNLOAD_ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
sha256sum /tmp/s6-overlay-${ARCH}.tar.gz && \
|
"linux/amd64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-x86_64.tar.xz" ;; \
|
||||||
echo "${S6_EXPECTED_SHA256} /tmp/s6-overlay-${ARCH}.tar.gz" | sha256sum -c - && \
|
"linux/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.xz" ;; \
|
||||||
tar xzf /tmp/s6-overlay-${ARCH}.tar.gz -C / && \
|
*) echo "" ;; esac) && \
|
||||||
# Install ffmpeg
|
export FFMPEG_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg-${ARCH}-static.tar.xz && \
|
"linux/amd64") echo "36bac8c527bf390603416f749ab0dd860142b0a66f0865b67366062a9c286c8b" ;; \
|
||||||
echo "${FFMPEG_EXPECTED_SHA256} /tmp/ffmpeg-${ARCH}-static.tar.xz" | sha256sum -c - && \
|
"linux/arm64") echo "8f36e45d99d2367a5c0c220ee3164fa48f4f0cec35f78204ccced8dc303bfbdc" ;; \
|
||||||
xz --decompress /tmp/ffmpeg-${ARCH}-static.tar.xz && \
|
*) echo "" ;; esac) && \
|
||||||
tar -xvf /tmp/ffmpeg-${ARCH}-static.tar -C /tmp && \
|
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
install -v -s -g root -o root -m 0755 -s /tmp/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static/ffmpeg -t /usr/local/bin && \
|
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linux64-gpl.tar.xz" ;; \
|
||||||
# Clean up
|
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linuxarm64-gpl.tar.xz" ;; \
|
||||||
rm -rf /tmp/s6-overlay-${ARCH}.tar.gz && \
|
*) echo "" ;; esac) && \
|
||||||
rm -rf /tmp/ffmpeg-${ARCH}-static.tar && \
|
export S6_NOARCH_EXPECTED_SHA256="fd80c231e8ae1a0667b7ae2078b9ad0e1269c4d117bf447a4506815a700dbff3" && \
|
||||||
rm -rf /tmp/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static && \
|
export S6_DOWNLOAD_NOARCH="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-noarch.tar.xz" && \
|
||||||
apt-get -y autoremove --purge curl xz-utils binutils
|
echo "Building for arch: ${ARCH}|${ARCH44}, downloading S6 from: ${S6_DOWNLOAD}}, expecting S6 SHA256: ${S6_EXPECTED_SHA256}" && \
|
||||||
|
set -x && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get -y --no-install-recommends install locales && \
|
||||||
|
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
|
||||||
|
locale-gen en_US.UTF-8 && \
|
||||||
|
# Install required distro packages
|
||||||
|
apt-get -y --no-install-recommends install curl ca-certificates binutils xz-utils && \
|
||||||
|
# Install s6
|
||||||
|
curl -L ${S6_DOWNLOAD_NOARCH} --output /tmp/s6-overlay-noarch.tar.xz && \
|
||||||
|
echo "${S6_NOARCH_EXPECTED_SHA256} /tmp/s6-overlay-noarch.tar.xz" | sha256sum -c - && \
|
||||||
|
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && \
|
||||||
|
curl -L ${S6_DOWNLOAD_ARCH} --output /tmp/s6-overlay-${ARCH}.tar.xz && \
|
||||||
|
echo "${S6_ARCH_EXPECTED_SHA256} /tmp/s6-overlay-${ARCH}.tar.xz" | sha256sum -c - && \
|
||||||
|
tar -C / -Jxpf /tmp/s6-overlay-${ARCH}.tar.xz && \
|
||||||
|
# Install ffmpeg
|
||||||
|
echo "Building for arch: ${ARCH}|${ARCH44}, downloading FFMPEG from: ${FFMPEG_DOWNLOAD}, expecting FFMPEG SHA256: ${FFMPEG_EXPECTED_SHA256}" && \
|
||||||
|
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg-${ARCH}.tar.xz && \
|
||||||
|
sha256sum /tmp/ffmpeg-${ARCH}.tar.xz && \
|
||||||
|
echo "${FFMPEG_EXPECTED_SHA256} /tmp/ffmpeg-${ARCH}.tar.xz" | sha256sum -c - && \
|
||||||
|
tar -xf /tmp/ffmpeg-${ARCH}.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \
|
||||||
|
tar -xf /tmp/ffmpeg-${ARCH}.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffprobe" && \
|
||||||
|
# Clean up
|
||||||
|
rm -rf /tmp/s6-overlay-${ARCH}.tar.gz && \
|
||||||
|
rm -rf /tmp/ffmpeg-${ARCH}.tar.xz && \
|
||||||
|
apt-get -y autoremove --purge curl binutils xz-utils
|
||||||
|
|
||||||
# Copy app
|
# Copy app
|
||||||
COPY tubesync /app
|
COPY tubesync /app
|
||||||
COPY tubesync/tubesync/local_settings.py.container /app/tubesync/local_settings.py
|
COPY tubesync/tubesync/local_settings.py.container /app/tubesync/local_settings.py
|
||||||
|
|
||||||
# Append container bundled software versions
|
# Copy over pip.conf to use piwheels
|
||||||
RUN echo "ffmpeg_version = '${FFMPEG_VERSION}-static'" >> /app/common/third_party_versions.py
|
COPY pip.conf /etc/pip.conf
|
||||||
|
|
||||||
# Add Pipfile
|
# Add Pipfile
|
||||||
COPY Pipfile /app/Pipfile
|
COPY Pipfile /app/Pipfile
|
||||||
COPY Pipfile.lock /app/Pipfile.lock
|
|
||||||
|
|
||||||
# Switch workdir to the the app
|
# Switch workdir to the the app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -61,29 +82,31 @@ RUN set -x && \
|
|||||||
# Install required distro packages
|
# Install required distro packages
|
||||||
apt-get -y install nginx-light && \
|
apt-get -y install nginx-light && \
|
||||||
apt-get -y --no-install-recommends install \
|
apt-get -y --no-install-recommends install \
|
||||||
python3 \
|
python3 \
|
||||||
python3-setuptools \
|
python3-dev \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
python3-dev \
|
python3-wheel \
|
||||||
gcc \
|
pipenv \
|
||||||
make \
|
gcc \
|
||||||
default-libmysqlclient-dev \
|
g++ \
|
||||||
libmariadb3 \
|
make \
|
||||||
postgresql-common \
|
pkgconf \
|
||||||
libpq-dev \
|
default-libmysqlclient-dev \
|
||||||
libpq5 \
|
libmariadb3 \
|
||||||
libjpeg62-turbo \
|
postgresql-common \
|
||||||
libwebp6 \
|
libpq-dev \
|
||||||
libjpeg-dev \
|
libpq5 \
|
||||||
zlib1g-dev \
|
libjpeg62-turbo \
|
||||||
libwebp-dev && \
|
libwebp7 \
|
||||||
# Install pipenv
|
libjpeg-dev \
|
||||||
pip3 --disable-pip-version-check install wheel pipenv && \
|
zlib1g-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
redis-server && \
|
||||||
# Create a 'app' user which the application will run as
|
# Create a 'app' user which the application will run as
|
||||||
groupadd app && \
|
groupadd app && \
|
||||||
useradd -M -d /app -s /bin/false -g app app && \
|
useradd -M -d /app -s /bin/false -g app app && \
|
||||||
# Install non-distro packages
|
# Install non-distro packages
|
||||||
pipenv install --system && \
|
PIPENV_VERBOSITY=64 pipenv install --system --skip-lock && \
|
||||||
# Make absolutely sure we didn't accidentally bundle a SQLite dev database
|
# Make absolutely sure we didn't accidentally bundle a SQLite dev database
|
||||||
rm -rf /app/db.sqlite3 && \
|
rm -rf /app/db.sqlite3 && \
|
||||||
# Run any required app commands
|
# Run any required app commands
|
||||||
@@ -96,20 +119,19 @@ RUN set -x && \
|
|||||||
mkdir -p /downloads/video && \
|
mkdir -p /downloads/video && \
|
||||||
# Clean up
|
# Clean up
|
||||||
rm /app/Pipfile && \
|
rm /app/Pipfile && \
|
||||||
rm /app/Pipfile.lock && \
|
|
||||||
pipenv --clear && \
|
pipenv --clear && \
|
||||||
pip3 --disable-pip-version-check uninstall -y pipenv wheel virtualenv && \
|
|
||||||
apt-get -y autoremove --purge \
|
apt-get -y autoremove --purge \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
gcc \
|
gcc \
|
||||||
make \
|
g++ \
|
||||||
default-libmysqlclient-dev \
|
make \
|
||||||
postgresql-common \
|
default-libmysqlclient-dev \
|
||||||
libpq-dev \
|
postgresql-common \
|
||||||
libjpeg-dev \
|
libpq-dev \
|
||||||
zlib1g-dev \
|
libjpeg-dev \
|
||||||
libwebp-dev && \
|
zlib1g-dev \
|
||||||
|
libwebp-dev && \
|
||||||
apt-get -y autoremove && \
|
apt-get -y autoremove && \
|
||||||
apt-get -y autoclean && \
|
apt-get -y autoclean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
@@ -119,7 +141,12 @@ RUN set -x && \
|
|||||||
rm -rf /root && \
|
rm -rf /root && \
|
||||||
mkdir -p /root && \
|
mkdir -p /root && \
|
||||||
chown root:root /root && \
|
chown root:root /root && \
|
||||||
chmod 0700 /root
|
chmod 0755 /root
|
||||||
|
|
||||||
|
# Append software versions
|
||||||
|
RUN set -x && \
|
||||||
|
FFMPEG_VERSION=$(/usr/local/bin/ffmpeg -version | head -n 1 | awk '{ print $3 }') && \
|
||||||
|
echo "ffmpeg_version = '${FFMPEG_VERSION}'" >> /app/common/third_party_versions.py
|
||||||
|
|
||||||
# Copy root
|
# Copy root
|
||||||
COPY config/root /
|
COPY config/root /
|
||||||
@@ -129,7 +156,7 @@ HEALTHCHECK --interval=1m --timeout=10s CMD /app/healthcheck.py http://127.0.0.1
|
|||||||
|
|
||||||
# ENVS and ports
|
# ENVS and ports
|
||||||
ENV PYTHONPATH "/app:${PYTHONPATH}"
|
ENV PYTHONPATH "/app:${PYTHONPATH}"
|
||||||
EXPOSE 8080
|
EXPOSE 4848
|
||||||
|
|
||||||
# Volumes
|
# Volumes
|
||||||
VOLUME ["/config", "/downloads"]
|
VOLUME ["/config", "/downloads"]
|
||||||
|
|||||||
20
Makefile
20
Makefile
@@ -8,17 +8,17 @@ all: clean build
|
|||||||
|
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
$(python) app/manage.py runserver
|
$(python) tubesync/manage.py runserver
|
||||||
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
mkdir -p app/media
|
mkdir -p tubesync/media
|
||||||
mkdir -p app/static
|
mkdir -p tubesync/static
|
||||||
$(python) app/manage.py collectstatic --noinput
|
$(python) tubesync/manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf app/static
|
rm -rf tubesync/static
|
||||||
|
|
||||||
|
|
||||||
container: clean
|
container: clean
|
||||||
@@ -29,5 +29,13 @@ runcontainer:
|
|||||||
$(docker) run --rm --name $(name) --env-file dev.env --log-opt max-size=50m -ti -p 4848:4848 $(image)
|
$(docker) run --rm --name $(name) --env-file dev.env --log-opt max-size=50m -ti -p 4848:4848 $(image)
|
||||||
|
|
||||||
|
|
||||||
test:
|
stopcontainer:
|
||||||
|
$(docker) stop $(name)
|
||||||
|
|
||||||
|
|
||||||
|
test: build
|
||||||
cd tubesync && $(python) manage.py test --verbosity=2 && cd ..
|
cd tubesync && $(python) manage.py test --verbosity=2 && cd ..
|
||||||
|
|
||||||
|
|
||||||
|
shell:
|
||||||
|
cd tubesync && $(python) manage.py shell
|
||||||
|
|||||||
10
Pipfile
10
Pipfile
@@ -4,9 +4,10 @@ url = "https://pypi.org/simple"
|
|||||||
verify_ssl = true
|
verify_ssl = true
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
autopep8 = "*"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
django = "*"
|
django = "~=3.2"
|
||||||
django-sass-processor = "*"
|
django-sass-processor = "*"
|
||||||
libsass = "*"
|
libsass = "*"
|
||||||
pillow = "*"
|
pillow = "*"
|
||||||
@@ -15,11 +16,10 @@ gunicorn = "*"
|
|||||||
django-compressor = "*"
|
django-compressor = "*"
|
||||||
httptools = "*"
|
httptools = "*"
|
||||||
django-background-tasks = "*"
|
django-background-tasks = "*"
|
||||||
requests = "*"
|
|
||||||
django-basicauth = "*"
|
django-basicauth = "*"
|
||||||
psycopg2-binary = "*"
|
psycopg2-binary = "*"
|
||||||
mysqlclient = "*"
|
mysqlclient = "*"
|
||||||
yt-dlp = "*"
|
yt-dlp = "*"
|
||||||
|
redis = "*"
|
||||||
[requires]
|
hiredis = "*"
|
||||||
python_version = "3"
|
requests = {extras = ["socks"], version = "*"}
|
||||||
|
|||||||
416
Pipfile.lock
generated
416
Pipfile.lock
generated
@@ -1,416 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"hash": {
|
|
||||||
"sha256": "ac12e45a1719945b2e19d4a12b03136225f1f5e81affd1adf44a7b3c8dd36b8a"
|
|
||||||
},
|
|
||||||
"pipfile-spec": 6,
|
|
||||||
"requires": {
|
|
||||||
"python_version": "3"
|
|
||||||
},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"name": "pypi",
|
|
||||||
"url": "https://pypi.org/simple",
|
|
||||||
"verify_ssl": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"default": {
|
|
||||||
"asgiref": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9",
|
|
||||||
"sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.6'",
|
|
||||||
"version": "==3.4.1"
|
|
||||||
},
|
|
||||||
"certifi": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee",
|
|
||||||
"sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"
|
|
||||||
],
|
|
||||||
"version": "==2021.5.30"
|
|
||||||
},
|
|
||||||
"charset-normalizer": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6",
|
|
||||||
"sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3'",
|
|
||||||
"version": "==2.0.6"
|
|
||||||
},
|
|
||||||
"django": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:95b318319d6997bac3595517101ad9cc83fe5672ac498ba48d1a410f47afecd2",
|
|
||||||
"sha256:e93c93565005b37ddebf2396b4dc4b6913c1838baa82efdfb79acedd5816c240"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==3.2.7"
|
|
||||||
},
|
|
||||||
"django-appconf": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1b1d0e1069c843ebe8ae5aa48ec52403b1440402b320c3e3a206a0907e97bb06",
|
|
||||||
"sha256:be58deb54a43d77d2e1621fe59f787681376d3cd0b8bd8e4758ef6c3a6453380"
|
|
||||||
],
|
|
||||||
"version": "==1.0.4"
|
|
||||||
},
|
|
||||||
"django-background-tasks": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:e1b19e8d495a276c9d64c5a1ff8b41132f75d2f58e45be71b78650dad59af9de"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==1.2.5"
|
|
||||||
},
|
|
||||||
"django-basicauth": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:15e9e366f698f53c71b1e794dafea060f990a2ac556bae6b7330dd25324a091c",
|
|
||||||
"sha256:e5e47d1acdc1943bedcc1bf673059d6c15e257dfe9eef67a22fb824f79546c0d"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.5.3"
|
|
||||||
},
|
|
||||||
"django-compat": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:3ac9a3bedc56b9365d9eb241bc5157d0c193769bf995f9a78dc1bc24e7c2331b"
|
|
||||||
],
|
|
||||||
"version": "==1.0.15"
|
|
||||||
},
|
|
||||||
"django-compressor": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:3358077605c146fdcca5f9eaffb50aa5dbe15f238f8854679115ebf31c0415e0",
|
|
||||||
"sha256:f8313f59d5e65712fc28787d084fe834997c9dfa92d064a1a3ec3d3366594d04"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.4.1"
|
|
||||||
},
|
|
||||||
"django-sass-processor": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1f043180c47754018e803a77da003377f5ea6558de57cd6946eb27a32e9c16a2",
|
|
||||||
"sha256:dcaad47c591a2d52689c1bd209259e922e902d886293f0d5c9e0d1a4eb85eda2"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==1.0.1"
|
|
||||||
},
|
|
||||||
"gunicorn": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e",
|
|
||||||
"sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==20.1.0"
|
|
||||||
},
|
|
||||||
"httptools": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:04114db99605c9b56ea22a8ec4d7b1485b908128ed4f4a8f6438489c428da794",
|
|
||||||
"sha256:074afd8afdeec0fa6786cd4a1676e0c0be23dc9a017a86647efa6b695168104f",
|
|
||||||
"sha256:113816f9af7dcfc4aa71ebb5354d77365f666ecf96ac7ff2aa1d24b6bca44165",
|
|
||||||
"sha256:1a8f26327023fa1a947d36e60a0582149e182fbbc949c8a65ec8665754dbbe69",
|
|
||||||
"sha256:2119fa619a4c53311f594f25c0205d619350fcb32140ec5057f861952e9b2b4f",
|
|
||||||
"sha256:21e948034f70e47c8abfa2d5e6f1a5661f87a2cddc7bcc70f61579cc87897c70",
|
|
||||||
"sha256:32a10a5903b5bc0eb647d01cd1e95bec3bb614a9bf53f0af1e01360b2debdf81",
|
|
||||||
"sha256:3787c1f46e9722ef7f07ea5c76b0103037483d1b12e34a02c53ceca5afa4e09a",
|
|
||||||
"sha256:3f82eb106e1474c63dba36a176067e65b48385f4cecddf3616411aa5d1fbdfec",
|
|
||||||
"sha256:3f9b4856d46ba1f0c850f4e84b264a9a8b4460acb20e865ec00978ad9fbaa4cf",
|
|
||||||
"sha256:4137137de8976511a392e27bfdcf231bd926ac13d375e0414e927b08217d779e",
|
|
||||||
"sha256:4687dfc116a9f1eb22a7d797f0dc6f6e17190d406ca4e729634b38aa98044b17",
|
|
||||||
"sha256:47dba2345aaa01b87e4981e8756af441349340708d5b60712c98c55a4d28f4af",
|
|
||||||
"sha256:5a836bd85ae1fb4304f674808488dae403e136d274aa5bafd0e6ee456f11c371",
|
|
||||||
"sha256:6e676bc3bb911b11f3d7e2144b9a53600bf6b9b21e0e4437aa308e1eef094d97",
|
|
||||||
"sha256:72ee0e3fb9c6437ab3ae34e9abee67fcee6876f4f58504e3f613dd5882aafdb7",
|
|
||||||
"sha256:79717080dc3f8b1eeb7f820b9b81528acbc04be6041f323fdd97550da2062575",
|
|
||||||
"sha256:8ac842df4fc3952efa7820b277961ea55e068bbc54cb59a0820400de7ae358d8",
|
|
||||||
"sha256:9f475b642c48b1b78584bdd12a5143e2c512485664331eade9c29ef769a17598",
|
|
||||||
"sha256:b8ac7dee63af4346e02b1e6d32202e3b5b3706a9928bec6da6d7a5b066217422",
|
|
||||||
"sha256:c0ac2e0ce6733c55858932e7d37fcc7b67ba6bb23e9648593c55f663de031b93",
|
|
||||||
"sha256:c14576b737d9e6e4f2a86af04918dbe9b62f57ce8102a8695c9a382dbe405c7f",
|
|
||||||
"sha256:cdc3975db86c29817e6d13df14e037c931fc893a710fb71097777a4147090068",
|
|
||||||
"sha256:eda95634027200f4b2a6d499e7c2e7fa9b8ee57e045dfda26958ea0af27c070b"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.3.0"
|
|
||||||
},
|
|
||||||
"idna": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
|
|
||||||
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3'",
|
|
||||||
"version": "==3.2"
|
|
||||||
},
|
|
||||||
"libsass": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:06c8776417fe930714bdc930a3d7e795ae3d72be6ac883ff72a1b8f7c49e5ffb",
|
|
||||||
"sha256:12f39712de38689a8b785b7db41d3ba2ea1d46f9379d81ea4595802d91fa6529",
|
|
||||||
"sha256:1e25dd9047a9392d3c59a0b869e0404f2b325a03871ee45285ee33b3664f5613",
|
|
||||||
"sha256:659ae41af8708681fa3ec73f47b9735a6725e71c3b66ff570bfce78952f2314e",
|
|
||||||
"sha256:6b984510ed94993708c0d697b4fef2d118929bbfffc3b90037be0f5ccadf55e7",
|
|
||||||
"sha256:a005f298f64624f313a3ac618ab03f844c71d84ae4f4a4aec4b68d2a4ffe75eb",
|
|
||||||
"sha256:abc29357ee540849faf1383e1746d40d69ed5cb6d4c346df276b258f5aa8977a",
|
|
||||||
"sha256:d5ba529d9ce668be9380563279f3ffe988f27bc5b299c5a28453df2e0b0fbaf2",
|
|
||||||
"sha256:e2b1a7d093f2e76dc694c17c0c285e846d0b0deb0e8b21dc852ba1a3a4e2f1d6"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.21.0"
|
|
||||||
},
|
|
||||||
"mutagen": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1",
|
|
||||||
"sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.5' and python_version < '4'",
|
|
||||||
"version": "==1.45.1"
|
|
||||||
},
|
|
||||||
"mysqlclient": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0ac0dd759c4ca02c35a9fedc24bc982cf75171651e8187c2495ec957a87dfff7",
|
|
||||||
"sha256:3381ca1a4f37ff1155fcfde20836b46416d66531add8843f6aa6d968982731c3",
|
|
||||||
"sha256:71c4b330cf2313bbda0307fc858cc9055e64493ba9bf28454d25cf8b3ee8d7f5",
|
|
||||||
"sha256:f6ebea7c008f155baeefe16c56cd3ee6239f7a5a9ae42396c2f1860f08a7c432",
|
|
||||||
"sha256:fc575093cf81b6605bed84653e48b277318b880dc9becf42dd47fa11ffd3e2b6"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.0.3"
|
|
||||||
},
|
|
||||||
"pillow": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0412516dcc9de9b0a1e0ae25a280015809de8270f134cc2c1e32c4eeb397cf30",
|
|
||||||
"sha256:04835e68ef12904bc3e1fd002b33eea0779320d4346082bd5b24bec12ad9c3e9",
|
|
||||||
"sha256:06d1adaa284696785375fa80a6a8eb309be722cf4ef8949518beb34487a3df71",
|
|
||||||
"sha256:085a90a99404b859a4b6c3daa42afde17cb3ad3115e44a75f0d7b4a32f06a6c9",
|
|
||||||
"sha256:0b9911ec70731711c3b6ebcde26caea620cbdd9dcb73c67b0730c8817f24711b",
|
|
||||||
"sha256:10e00f7336780ca7d3653cf3ac26f068fa11b5a96894ea29a64d3dc4b810d630",
|
|
||||||
"sha256:11c27e74bab423eb3c9232d97553111cc0be81b74b47165f07ebfdd29d825875",
|
|
||||||
"sha256:11eb7f98165d56042545c9e6db3ce394ed8b45089a67124298f0473b29cb60b2",
|
|
||||||
"sha256:13654b521fb98abdecec105ea3fb5ba863d1548c9b58831dd5105bb3873569f1",
|
|
||||||
"sha256:15ccb81a6ffc57ea0137f9f3ac2737ffa1d11f786244d719639df17476d399a7",
|
|
||||||
"sha256:18a07a683805d32826c09acfce44a90bf474e6a66ce482b1c7fcd3757d588df3",
|
|
||||||
"sha256:19ec4cfe4b961edc249b0e04b5618666c23a83bc35842dea2bfd5dfa0157f81b",
|
|
||||||
"sha256:1c3ff00110835bdda2b1e2b07f4a2548a39744bb7de5946dc8e95517c4fb2ca6",
|
|
||||||
"sha256:27a330bf7014ee034046db43ccbb05c766aa9e70b8d6c5260bfc38d73103b0ba",
|
|
||||||
"sha256:2b11c9d310a3522b0fd3c35667914271f570576a0e387701f370eb39d45f08a4",
|
|
||||||
"sha256:2c661542c6f71dfd9dc82d9d29a8386287e82813b0375b3a02983feac69ef864",
|
|
||||||
"sha256:2cde7a4d3687f21cffdf5bb171172070bb95e02af448c4c8b2f223d783214056",
|
|
||||||
"sha256:2d5e9dc0bf1b5d9048a94c48d0813b6c96fccfa4ccf276d9c36308840f40c228",
|
|
||||||
"sha256:2f23b2d3079522fdf3c09de6517f625f7a964f916c956527bed805ac043799b8",
|
|
||||||
"sha256:35d27687f027ad25a8d0ef45dd5208ef044c588003cdcedf05afb00dbc5c2deb",
|
|
||||||
"sha256:35d409030bf3bd05fa66fb5fdedc39c521b397f61ad04309c90444e893d05f7d",
|
|
||||||
"sha256:4326ea1e2722f3dc00ed77c36d3b5354b8fb7399fb59230249ea6d59cbed90da",
|
|
||||||
"sha256:4abc247b31a98f29e5224f2d31ef15f86a71f79c7f4d2ac345a5d551d6393073",
|
|
||||||
"sha256:4d89a2e9219a526401015153c0e9dd48319ea6ab9fe3b066a20aa9aee23d9fd3",
|
|
||||||
"sha256:4e59e99fd680e2b8b11bbd463f3c9450ab799305d5f2bafb74fefba6ac058616",
|
|
||||||
"sha256:548794f99ff52a73a156771a0402f5e1c35285bd981046a502d7e4793e8facaa",
|
|
||||||
"sha256:56fd98c8294f57636084f4b076b75f86c57b2a63a8410c0cd172bc93695ee979",
|
|
||||||
"sha256:59697568a0455764a094585b2551fd76bfd6b959c9f92d4bdec9d0e14616303a",
|
|
||||||
"sha256:6bff50ba9891be0a004ef48828e012babaaf7da204d81ab9be37480b9020a82b",
|
|
||||||
"sha256:6cb3dd7f23b044b0737317f892d399f9e2f0b3a02b22b2c692851fb8120d82c6",
|
|
||||||
"sha256:7dbfbc0020aa1d9bc1b0b8bcf255a7d73f4ad0336f8fd2533fcc54a4ccfb9441",
|
|
||||||
"sha256:838eb85de6d9307c19c655c726f8d13b8b646f144ca6b3771fa62b711ebf7624",
|
|
||||||
"sha256:8b68f565a4175e12e68ca900af8910e8fe48aaa48fd3ca853494f384e11c8bcd",
|
|
||||||
"sha256:8f284dc1695caf71a74f24993b7c7473d77bc760be45f776a2c2f4e04c170550",
|
|
||||||
"sha256:963ebdc5365d748185fdb06daf2ac758116deecb2277ec5ae98139f93844bc09",
|
|
||||||
"sha256:a048dad5ed6ad1fad338c02c609b862dfaa921fcd065d747194a6805f91f2196",
|
|
||||||
"sha256:a1bd983c565f92779be456ece2479840ec39d386007cd4ae83382646293d681b",
|
|
||||||
"sha256:a66566f8a22561fc1a88dc87606c69b84fa9ce724f99522cf922c801ec68f5c1",
|
|
||||||
"sha256:bcb04ff12e79b28be6c9988f275e7ab69f01cc2ba319fb3114f87817bb7c74b6",
|
|
||||||
"sha256:bd24054aaf21e70a51e2a2a5ed1183560d3a69e6f9594a4bfe360a46f94eba83",
|
|
||||||
"sha256:be25cb93442c6d2f8702c599b51184bd3ccd83adebd08886b682173e09ef0c3f",
|
|
||||||
"sha256:c691b26283c3a31594683217d746f1dad59a7ae1d4cfc24626d7a064a11197d4",
|
|
||||||
"sha256:cc9d0dec711c914ed500f1d0d3822868760954dce98dfb0b7382a854aee55d19",
|
|
||||||
"sha256:ce2e5e04bb86da6187f96d7bab3f93a7877830981b37f0287dd6479e27a10341",
|
|
||||||
"sha256:ce651ca46d0202c302a535d3047c55a0131a720cf554a578fc1b8a2aff0e7d96",
|
|
||||||
"sha256:d0c8ebbfd439c37624db98f3877d9ed12c137cadd99dde2d2eae0dab0bbfc355",
|
|
||||||
"sha256:d675a876b295afa114ca8bf42d7f86b5fb1298e1b6bb9a24405a3f6c8338811c",
|
|
||||||
"sha256:dde3f3ed8d00c72631bc19cbfff8ad3b6215062a5eed402381ad365f82f0c18c",
|
|
||||||
"sha256:e5a31c07cea5edbaeb4bdba6f2b87db7d3dc0f446f379d907e51cc70ea375629",
|
|
||||||
"sha256:f514c2717012859ccb349c97862568fdc0479aad85b0270d6b5a6509dbc142e2",
|
|
||||||
"sha256:fc0db32f7223b094964e71729c0361f93db43664dd1ec86d3df217853cedda87",
|
|
||||||
"sha256:fd4fd83aa912d7b89b4b4a1580d30e2a4242f3936882a3f433586e5ab97ed0d5",
|
|
||||||
"sha256:feb5db446e96bfecfec078b943cc07744cc759893cef045aa8b8b6d6aaa8274e"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==8.3.2"
|
|
||||||
},
|
|
||||||
"psycopg2-binary": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975",
|
|
||||||
"sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd",
|
|
||||||
"sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616",
|
|
||||||
"sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2",
|
|
||||||
"sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90",
|
|
||||||
"sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a",
|
|
||||||
"sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e",
|
|
||||||
"sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d",
|
|
||||||
"sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed",
|
|
||||||
"sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a",
|
|
||||||
"sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140",
|
|
||||||
"sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32",
|
|
||||||
"sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31",
|
|
||||||
"sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a",
|
|
||||||
"sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917",
|
|
||||||
"sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf",
|
|
||||||
"sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7",
|
|
||||||
"sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0",
|
|
||||||
"sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72",
|
|
||||||
"sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698",
|
|
||||||
"sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773",
|
|
||||||
"sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68",
|
|
||||||
"sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76",
|
|
||||||
"sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4",
|
|
||||||
"sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f",
|
|
||||||
"sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34",
|
|
||||||
"sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce",
|
|
||||||
"sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a",
|
|
||||||
"sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.9.1"
|
|
||||||
},
|
|
||||||
"pycryptodome": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:09c1555a3fa450e7eaca41ea11cd00afe7c91fef52353488e65663777d8524e0",
|
|
||||||
"sha256:12222a5edc9ca4a29de15fbd5339099c4c26c56e13c2ceddf0b920794f26165d",
|
|
||||||
"sha256:1723ebee5561628ce96748501cdaa7afaa67329d753933296321f0be55358dce",
|
|
||||||
"sha256:1c5e1ca507de2ad93474be5cfe2bfa76b7cf039a1a32fc196f40935944871a06",
|
|
||||||
"sha256:2603c98ae04aac675fefcf71a6c87dc4bb74a75e9071ae3923bbc91a59f08d35",
|
|
||||||
"sha256:2dea65df54349cdfa43d6b2e8edb83f5f8d6861e5cf7b1fbc3e34c5694c85e27",
|
|
||||||
"sha256:31c1df17b3dc5f39600a4057d7db53ac372f492c955b9b75dd439f5d8b460129",
|
|
||||||
"sha256:38661348ecb71476037f1e1f553159b80d256c00f6c0b00502acac891f7116d9",
|
|
||||||
"sha256:3e2e3a06580c5f190df843cdb90ea28d61099cf4924334d5297a995de68e4673",
|
|
||||||
"sha256:3f840c49d38986f6e17dbc0673d37947c88bc9d2d9dba1c01b979b36f8447db1",
|
|
||||||
"sha256:501ab36aae360e31d0ec370cf5ce8ace6cb4112060d099b993bc02b36ac83fb6",
|
|
||||||
"sha256:60386d1d4cfaad299803b45a5bc2089696eaf6cdd56f9fc17479a6f89595cfc8",
|
|
||||||
"sha256:6260e24d41149268122dd39d4ebd5941e9d107f49463f7e071fd397e29923b0c",
|
|
||||||
"sha256:6bbf7fee7b7948b29d7e71fcacf48bac0c57fb41332007061a933f2d996f9713",
|
|
||||||
"sha256:6d2df5223b12437e644ce0a3be7809471ffa71de44ccd28b02180401982594a6",
|
|
||||||
"sha256:758949ca62690b1540dfb24ad773c6da9cd0e425189e83e39c038bbd52b8e438",
|
|
||||||
"sha256:77997519d8eb8a4adcd9a47b9cec18f9b323e296986528186c0e9a7a15d6a07e",
|
|
||||||
"sha256:7fd519b89585abf57bf47d90166903ec7b43af4fe23c92273ea09e6336af5c07",
|
|
||||||
"sha256:98213ac2b18dc1969a47bc65a79a8fca02a414249d0c8635abb081c7f38c91b6",
|
|
||||||
"sha256:99b2f3fc51d308286071d0953f92055504a6ffe829a832a9fc7a04318a7683dd",
|
|
||||||
"sha256:9b6f711b25e01931f1c61ce0115245a23cdc8b80bf8539ac0363bdcf27d649b6",
|
|
||||||
"sha256:a3105a0eb63eacf98c2ecb0eb4aa03f77f40fbac2bdde22020bb8a536b226bb8",
|
|
||||||
"sha256:a8eb8b6ea09ec1c2535bf39914377bc8abcab2c7d30fa9225eb4fe412024e427",
|
|
||||||
"sha256:a92d5c414e8ee1249e850789052608f582416e82422502dc0ac8c577808a9067",
|
|
||||||
"sha256:d3d6958d53ad307df5e8469cc44474a75393a434addf20ecd451f38a72fe29b8",
|
|
||||||
"sha256:e0a4d5933a88a2c98bbe19c0c722f5483dc628d7a38338ac2cb64a7dbd34064b",
|
|
||||||
"sha256:e3bf558c6aeb49afa9f0c06cee7fb5947ee5a1ff3bd794b653d39926b49077fa",
|
|
||||||
"sha256:e61e363d9a5d7916f3a4ce984a929514c0df3daf3b1b2eb5e6edbb131ee771cf",
|
|
||||||
"sha256:f977cdf725b20f6b8229b0c87acb98c7717e742ef9f46b113985303ae12a99da",
|
|
||||||
"sha256:fc7489a50323a0df02378bc2fff86eb69d94cc5639914346c736be981c6a02e7"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
|
||||||
"version": "==3.10.1"
|
|
||||||
},
|
|
||||||
"pytz": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
|
||||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
|
||||||
],
|
|
||||||
"version": "==2021.1"
|
|
||||||
},
|
|
||||||
"rcssmin": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
|
|
||||||
],
|
|
||||||
"version": "==1.0.6"
|
|
||||||
},
|
|
||||||
"requests": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
|
|
||||||
"sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.26.0"
|
|
||||||
},
|
|
||||||
"rjsmin": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0ab825839125eaca57cc59581d72e596e58a7a56fbc0839996b7528f0343a0a8",
|
|
||||||
"sha256:211c2fe8298951663bbc02acdffbf714f6793df54bfc50e1c6c9e71b3f2559a3",
|
|
||||||
"sha256:466fe70cc5647c7c51b3260c7e2e323a98b2b173564247f9c89e977720a0645f",
|
|
||||||
"sha256:585e75a84d9199b68056fd4a083d9a61e2a92dfd10ff6d4ce5bdb04bc3bdbfaf",
|
|
||||||
"sha256:6044ca86e917cd5bb2f95e6679a4192cef812122f28ee08c677513de019629b3",
|
|
||||||
"sha256:714329db774a90947e0e2086cdddb80d5e8c4ac1c70c9f92436378dedb8ae345",
|
|
||||||
"sha256:799890bd07a048892d8d3deb9042dbc20b7f5d0eb7da91e9483c561033b23ce2",
|
|
||||||
"sha256:975b69754d6a76be47c0bead12367a1ca9220d08e5393f80bab0230d4625d1f4",
|
|
||||||
"sha256:b15dc75c71f65d9493a8c7fa233fdcec823e3f1b88ad84a843ffef49b338ac32",
|
|
||||||
"sha256:dd0f4819df4243ffe4c964995794c79ca43943b5b756de84be92b445a652fb86",
|
|
||||||
"sha256:e3908b21ebb584ce74a6ac233bdb5f29485752c9d3be5e50c5484ed74169232c",
|
|
||||||
"sha256:e487a7783ac4339e79ec610b98228eb9ac72178973e3dee16eba0e3feef25924",
|
|
||||||
"sha256:ecd29f1b3e66a4c0753105baec262b331bcbceefc22fbe6f7e8bcd2067bcb4d7"
|
|
||||||
],
|
|
||||||
"version": "==1.1.0"
|
|
||||||
},
|
|
||||||
"six": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
|
|
||||||
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
|
||||||
"version": "==1.16.0"
|
|
||||||
},
|
|
||||||
"sqlparse": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae",
|
|
||||||
"sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.5'",
|
|
||||||
"version": "==0.4.2"
|
|
||||||
},
|
|
||||||
"urllib3": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4",
|
|
||||||
"sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
|
||||||
"version": "==1.26.6"
|
|
||||||
},
|
|
||||||
"websockets": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:01db0ecd1a0ca6702d02a5ed40413e18b7d22f94afb3bbe0d323bac86c42c1c8",
|
|
||||||
"sha256:085bb8a6e780d30eaa1ba48ac7f3a6707f925edea787cfb761ce5a39e77ac09b",
|
|
||||||
"sha256:1ac35426fe3e7d3d0fac3d63c8965c76ed67a8fd713937be072bf0ce22808539",
|
|
||||||
"sha256:1f6b814cff6aadc4288297cb3a248614829c6e4ff5556593c44a115e9dd49939",
|
|
||||||
"sha256:2a43072e434c041a99f2e1eb9b692df0232a38c37c61d00e9f24db79474329e4",
|
|
||||||
"sha256:5b2600e01c7ca6f840c42c747ffbe0254f319594ed108db847eb3d75f4aacb80",
|
|
||||||
"sha256:62160772314920397f9d219147f958b33fa27a12c662d4455c9ccbba9a07e474",
|
|
||||||
"sha256:706e200fc7f03bed99ad0574cd1ea8b0951477dd18cc978ccb190683c69dba76",
|
|
||||||
"sha256:71358c7816e2762f3e4af3adf0040f268e219f5a38cb3487a9d0fc2e554fef6a",
|
|
||||||
"sha256:7d2e12e4f901f1bc062dfdf91831712c4106ed18a9a4cdb65e2e5f502124ca37",
|
|
||||||
"sha256:7f79f02c7f9a8320aff7d3321cd1c7e3a7dbc15d922ac996cca827301ee75238",
|
|
||||||
"sha256:82b17524b1ce6ae7f7dd93e4d18e9b9474071e28b65dbf1dfe9b5767778db379",
|
|
||||||
"sha256:82bd921885231f4a30d9bc550552495b3fc36b1235add6d374e7c65c3babd805",
|
|
||||||
"sha256:8bbf8660c3f833ddc8b1afab90213f2e672a9ddac6eecb3cde968e6b2807c1c7",
|
|
||||||
"sha256:9a4d889162bd48588e80950e07fa5e039eee9deb76a58092e8c3ece96d7ef537",
|
|
||||||
"sha256:b4ade7569b6fd17912452f9c3757d96f8e4044016b6d22b3b8391e641ca50456",
|
|
||||||
"sha256:b8176deb6be540a46695960a765a77c28ac8b2e3ef2ec95d50a4f5df901edb1c",
|
|
||||||
"sha256:c4fc9a1d242317892590abe5b61a9127f1a61740477bfb121743f290b8054002",
|
|
||||||
"sha256:c5880442f5fc268f1ef6d37b2c152c114deccca73f48e3a8c48004d2f16f4567",
|
|
||||||
"sha256:cd8c6f2ec24aedace251017bc7a414525171d4e6578f914acab9349362def4da",
|
|
||||||
"sha256:d67646ddd17a86117ae21c27005d83c1895c0cef5d7be548b7549646372f868a",
|
|
||||||
"sha256:e42a1f1e03437b017af341e9bbfdc09252cd48ef32a8c3c3ead769eab3b17368",
|
|
||||||
"sha256:eb282127e9c136f860c6068a4fba5756eb25e755baffb5940b6f1eae071928b2",
|
|
||||||
"sha256:fe83b3ec9ef34063d86dfe1029160a85f24a5a94271036e5714a57acfdd089a1",
|
|
||||||
"sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.7'",
|
|
||||||
"version": "==10.0"
|
|
||||||
},
|
|
||||||
"whitenoise": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:d234b871b52271ae7ed6d9da47ffe857c76568f11dd30e28e18c5869dbd11e12",
|
|
||||||
"sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==5.3.0"
|
|
||||||
},
|
|
||||||
"yt-dlp": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:c97716a715261657345176ab8190a19efa51db0e5b174a6629956548750245e1",
|
|
||||||
"sha256:ca7e77cdb055ba2683df5b0807aab1c1e120cbe02c8f35d9d3293d94dbdaea63"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2021.9.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"develop": {}
|
|
||||||
}
|
|
||||||
81
README.md
81
README.md
@@ -9,9 +9,9 @@ downloaded.
|
|||||||
|
|
||||||
If you want to watch YouTube videos in particular quality or settings from your local
|
If you want to watch YouTube videos in particular quality or settings from your local
|
||||||
media server, then TubeSync is for you. Internally, TubeSync is a web interface wrapper
|
media server, then TubeSync is for you. Internally, TubeSync is a web interface wrapper
|
||||||
on `youtube-dl` and `ffmpeg` with a task scheduler.
|
on `yt-dlp` and `ffmpeg` with a task scheduler.
|
||||||
|
|
||||||
There are several other web interfaces to YouTube and `youtube-dl` all with varying
|
There are several other web interfaces to YouTube and `yt-dlp` all with varying
|
||||||
features and implementations. TubeSync's largest difference is full PVR experience of
|
features and implementations. TubeSync's largest difference is full PVR experience of
|
||||||
updating media servers and better selection of media formats. Additionally, to be as
|
updating media servers and better selection of media formats. Additionally, to be as
|
||||||
hands-free as possible, TubeSync has gradual retrying of failures with back-off timers
|
hands-free as possible, TubeSync has gradual retrying of failures with back-off timers
|
||||||
@@ -69,11 +69,12 @@ currently just Plex, to complete the PVR experience.
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
TubeSync is designed to be run in a container, such as via Docker or Podman. It also
|
TubeSync is designed to be run in a container, such as via Docker or Podman. It also
|
||||||
works in a Docker Compose stack. Only `amd64` is initially supported.
|
works in a Docker Compose stack. `amd64` (most desktop PCs and servers) and `arm64`
|
||||||
|
(modern ARM computers, such as the Rasperry Pi 3 or later) are supported.
|
||||||
|
|
||||||
Example (with Docker on *nix):
|
Example (with Docker on *nix):
|
||||||
|
|
||||||
First find your the user ID and group ID you want to run TubeSync as, if you're not
|
First find the user ID and group ID you want to run TubeSync as, if you're not
|
||||||
sure what this is it's probably your current user ID and group ID:
|
sure what this is it's probably your current user ID and group ID:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -116,11 +117,13 @@ $ docker run \
|
|||||||
Once running, open `http://localhost:4848` in your browser and you should see the
|
Once running, open `http://localhost:4848` in your browser and you should see the
|
||||||
TubeSync dashboard. If you do, you can proceed to adding some sources (YouTube channels
|
TubeSync dashboard. If you do, you can proceed to adding some sources (YouTube channels
|
||||||
and playlists). If not, check `docker logs tubesync` to see what errors might be
|
and playlists). If not, check `docker logs tubesync` to see what errors might be
|
||||||
occuring, typical ones are file permission issues.
|
occurring, typical ones are file permission issues.
|
||||||
|
|
||||||
Alternatively, for Docker Compose, you can use something like:
|
Alternatively, for Docker Compose, you can use something like:
|
||||||
|
|
||||||
```yaml
|
```yml
|
||||||
|
version: '3.7'
|
||||||
|
services:
|
||||||
tubesync:
|
tubesync:
|
||||||
image: ghcr.io/meeb/tubesync:latest
|
image: ghcr.io/meeb/tubesync:latest
|
||||||
container_name: tubesync
|
container_name: tubesync
|
||||||
@@ -148,7 +151,7 @@ HTTP_USER
|
|||||||
HTTP_PASS
|
HTTP_PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
For example in the `docker run ...` line add in:
|
For example, in the `docker run ...` line add in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
...
|
...
|
||||||
@@ -233,13 +236,12 @@ $ docker logs --follow tubesync
|
|||||||
Once you're happy using TubeSync there are some advanced usage guides for more complex
|
Once you're happy using TubeSync there are some advanced usage guides for more complex
|
||||||
and less common features:
|
and less common features:
|
||||||
|
|
||||||

|
* [Import existing media into TubeSync](https://github.com/meeb/tubesync/blob/main/docs/import-existing-media.md)
|
||||||
|
* [Sync or create missing metadata files](https://github.com/meeb/tubesync/blob/main/docs/create-missing-metadata.md)
|
||||||

|
* [Reset tasks from the command line](https://github.com/meeb/tubesync/blob/main/docs/reset-tasks.md)
|
||||||
|
* [Using PostgreSQL, MySQL or MariaDB as database backends](https://github.com/meeb/tubesync/blob/main/docs/other-database-backends.md)
|
||||||

|
* [Using cookies](https://github.com/meeb/tubesync/blob/main/docs/using-cookies.md)
|
||||||
|
* [Reset metadata](https://github.com/meeb/tubesync/blob/main/docs/reset-metadata.md)
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
# Warnings
|
# Warnings
|
||||||
@@ -281,7 +283,7 @@ automatically.
|
|||||||
### Does TubeSync support any other video platforms?
|
### Does TubeSync support any other video platforms?
|
||||||
|
|
||||||
At the moment, no. This is a pre-release. The library TubeSync uses that does most
|
At the moment, no. This is a pre-release. The library TubeSync uses that does most
|
||||||
of the downloading work, `youtube-dl`, supports many hundreds of video sources so it's
|
of the downloading work, `yt-dlp`, supports many hundreds of video sources so it's
|
||||||
likely more will be added to TubeSync if there is demand for it.
|
likely more will be added to TubeSync if there is demand for it.
|
||||||
|
|
||||||
### Is there a progress bar?
|
### Is there a progress bar?
|
||||||
@@ -293,27 +295,27 @@ your install is doing check the container logs.
|
|||||||
|
|
||||||
### Are there alerts when a download is complete?
|
### Are there alerts when a download is complete?
|
||||||
|
|
||||||
No, this feature is best served by existing services such as the execelent
|
No, this feature is best served by existing services such as the excellent
|
||||||
[Tautulli](https://tautulli.com/) which can monitor your Plex server and send alerts
|
[Tautulli](https://tautulli.com/) which can monitor your Plex server and send alerts
|
||||||
that way.
|
that way.
|
||||||
|
|
||||||
### There's errors in my "tasks" tab!
|
### There are errors in my "tasks" tab!
|
||||||
|
|
||||||
You only really need to worry about these if there is a permanent failure. Some errors
|
You only really need to worry about these if there is a permanent failure. Some errors
|
||||||
are temproary and will be retried for you automatically, such as a download got
|
are temporary and will be retried for you automatically, such as a download got
|
||||||
interrupted and will be tried again later. Sources with permanet errors (such as no
|
interrupted and will be tried again later. Sources with permanent errors (such as no
|
||||||
media available because you got a channel name wrong) will be shown as errors on the
|
media available because you got a channel name wrong) will be shown as errors on the
|
||||||
"sources" tab.
|
"sources" tab.
|
||||||
|
|
||||||
### What is TubeSync written in?
|
### What is TubeSync written in?
|
||||||
|
|
||||||
Python3 using Django, embedding youtube-dl. It's pretty much glue between other much
|
Python3 using Django, embedding yt-dlp. It's pretty much glue between other much
|
||||||
larger libraries.
|
larger libraries.
|
||||||
|
|
||||||
Notable libraries and software used:
|
Notable libraries and software used:
|
||||||
|
|
||||||
* [Django](https://www.djangoproject.com/)
|
* [Django](https://www.djangoproject.com/)
|
||||||
* [youtube-dl](https://yt-dl.org/)
|
* [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||||
* [ffmpeg](https://ffmpeg.org/)
|
* [ffmpeg](https://ffmpeg.org/)
|
||||||
* [Django Background Tasks](https://github.com/arteria/django-background-tasks/)
|
* [Django Background Tasks](https://github.com/arteria/django-background-tasks/)
|
||||||
* [django-sass](https://github.com/coderedcorp/django-sass/)
|
* [django-sass](https://github.com/coderedcorp/django-sass/)
|
||||||
@@ -323,7 +325,7 @@ See the [Pipefile](https://github.com/meeb/tubesync/blob/main/Pipfile) for a ful
|
|||||||
|
|
||||||
### Can I get access to the full Django admin?
|
### Can I get access to the full Django admin?
|
||||||
|
|
||||||
Yes, although pretty much all operations are available through the front end interface
|
Yes, although pretty much all operations are available through the front-end interface
|
||||||
and you can probably break things by playing in the admin. If you still want to access
|
and you can probably break things by playing in the admin. If you still want to access
|
||||||
it you can run:
|
it you can run:
|
||||||
|
|
||||||
@@ -349,6 +351,10 @@ etc.). Configuration of this is beyond the scope of this README.
|
|||||||
|
|
||||||
Just `amd64` for the moment. Others may be made available if there is demand.
|
Just `amd64` for the moment. Others may be made available if there is demand.
|
||||||
|
|
||||||
|
### The pipenv install fails with "Locking failed"!
|
||||||
|
|
||||||
|
Make sure that you have `mysql_config` or `mariadb_config` available, as required by the python module `mysqlclient`. On Debian-based systems this is usually found in the package `libmysqlclient-dev`
|
||||||
|
|
||||||
|
|
||||||
# Advanced configuration
|
# Advanced configuration
|
||||||
|
|
||||||
@@ -356,25 +362,26 @@ There are a number of other environment variables you can set. These are, mostly
|
|||||||
**NOT** required to be set in the default container installation, they are really only
|
**NOT** required to be set in the default container installation, they are really only
|
||||||
useful if you are manually installing TubeSync in some other environment. These are:
|
useful if you are manually installing TubeSync in some other environment. These are:
|
||||||
|
|
||||||
| Name | What | Example |
|
| Name | What | Example |
|
||||||
| ------------------------ | ------------------------------------------------------------ | ------------------------------------ |
|
| --------------------------- | ------------------------------------------------------------ | ------------------------------------ |
|
||||||
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||||
| DJANGO_FORCE_SCRIPT_NAME | Django's FORCE_SCRIPT_NAME | /somepath |
|
| DJANGO_URL_PREFIX | Run TubeSync in a sub-URL on the web server | /somepath/ |
|
||||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||||
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
||||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS | tubesync.example.com,otherhost.com |
|
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS, defaults to `*` | tubesync.example.com,otherhost.com |
|
||||||
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
| TUBESYNC_RESET_DOWNLOAD_DIR | Toggle resetting `/downloads` permissions, defaults to True | True
|
||||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
||||||
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||||
| HTTP_USER | Sets the username for HTTP basic authentication | some-username |
|
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
||||||
| HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password |
|
| HTTP_USER | Sets the username for HTTP basic authentication | some-username |
|
||||||
| DATABASE_CONNECTION | Optional external database connection details | mysql://user:pass@host:port/database |
|
| HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password |
|
||||||
|
| DATABASE_CONNECTION | Optional external database connection details | mysql://user:pass@host:port/database |
|
||||||
|
|
||||||
|
|
||||||
# Manual, non-containerised, installation
|
# Manual, non-containerised, installation
|
||||||
|
|
||||||
As a relatively normal Django app you can run TubeSync without the container. Beyond
|
As a relatively normal Django app you can run TubeSync without the container. Beyond
|
||||||
following this rough guide you are on your own and should be knowledgeable about
|
following this rough guide, you are on your own and should be knowledgeable about
|
||||||
installing and running WSGI-based Python web applications before attempting this.
|
installing and running WSGI-based Python web applications before attempting this.
|
||||||
|
|
||||||
1. Clone or download this repo
|
1. Clone or download this repo
|
||||||
@@ -397,7 +404,7 @@ installing and running WSGI-based Python web applications before attempting this
|
|||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
|
|
||||||
There is a moderately comprehensive test suite focussing on the custom media format
|
There is a moderately comprehensive test suite focusing on the custom media format
|
||||||
matching logic and that the front-end interface works. You can run it via Django:
|
matching logic and that the front-end interface works. You can run it via Django:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/with-contenv bash
|
|
||||||
|
|
||||||
# Change runtime user UID and GID
|
|
||||||
PUID=${PUID:-911}
|
|
||||||
PGID=${PGID:-911}
|
|
||||||
groupmod -o -g "$PGID" app
|
|
||||||
usermod -o -u "$PUID" app
|
|
||||||
|
|
||||||
# Reset permissions
|
|
||||||
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 && \
|
|
||||||
chmod -R 0750 /app/common/static && \
|
|
||||||
chown -R app:app /app/static && \
|
|
||||||
chmod -R 0750 /app/static && \
|
|
||||||
find /app -type f ! -iname healthcheck.py -exec chmod 640 {} \; && \
|
|
||||||
chmod 0755 /app/healthcheck.py
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
exec s6-setuidgid app \
|
|
||||||
/usr/bin/python3 /app/manage.py migrate
|
|
||||||
@@ -79,6 +79,11 @@ http {
|
|||||||
proxy_connect_timeout 10;
|
proxy_connect_timeout 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# File dwnload and streaming
|
||||||
|
location /media-data/ {
|
||||||
|
internal;
|
||||||
|
alias /downloads/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
46
config/root/etc/redis/redis.conf
Normal file
46
config/root/etc/redis/redis.conf
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
bind 127.0.0.1
|
||||||
|
protected-mode yes
|
||||||
|
port 6379
|
||||||
|
tcp-backlog 511
|
||||||
|
timeout 0
|
||||||
|
tcp-keepalive 300
|
||||||
|
daemonize no
|
||||||
|
supervised no
|
||||||
|
loglevel notice
|
||||||
|
logfile ""
|
||||||
|
databases 1
|
||||||
|
always-show-logo no
|
||||||
|
save ""
|
||||||
|
dir /var/lib/redis
|
||||||
|
maxmemory 64mb
|
||||||
|
maxmemory-policy noeviction
|
||||||
|
lazyfree-lazy-eviction no
|
||||||
|
lazyfree-lazy-expire no
|
||||||
|
lazyfree-lazy-server-del no
|
||||||
|
replica-lazy-flush no
|
||||||
|
lazyfree-lazy-user-del no
|
||||||
|
oom-score-adj no
|
||||||
|
oom-score-adj-values 0 200 800
|
||||||
|
appendonly no
|
||||||
|
appendfsync no
|
||||||
|
lua-time-limit 5000
|
||||||
|
slowlog-log-slower-than 10000
|
||||||
|
slowlog-max-len 128
|
||||||
|
latency-monitor-threshold 0
|
||||||
|
notify-keyspace-events ""
|
||||||
|
hash-max-ziplist-entries 512
|
||||||
|
hash-max-ziplist-value 64
|
||||||
|
list-max-ziplist-size -2
|
||||||
|
list-compress-depth 0
|
||||||
|
set-max-intset-entries 512
|
||||||
|
zset-max-ziplist-entries 128
|
||||||
|
zset-max-ziplist-value 64
|
||||||
|
hll-sparse-max-bytes 3000
|
||||||
|
stream-node-max-bytes 4096
|
||||||
|
stream-node-max-entries 100
|
||||||
|
activerehashing yes
|
||||||
|
client-output-buffer-limit normal 0 0 0
|
||||||
|
client-output-buffer-limit replica 256mb 64mb 60
|
||||||
|
client-output-buffer-limit pubsub 32mb 8mb 60
|
||||||
|
hz 10
|
||||||
|
dynamic-hz yes
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gunicorn
|
||||||
25
config/root/etc/s6-overlay/s6-rc.d/celery-beat/run
Executable file
25
config/root/etc/s6-overlay/s6-rc.d/celery-beat/run
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/with-contenv bash
|
||||||
|
|
||||||
|
UMASK_SET=${UMASK_SET:-022}
|
||||||
|
umask "$UMASK_SET"
|
||||||
|
|
||||||
|
cd /app || exit
|
||||||
|
|
||||||
|
PIDFILE=/run/app/celery-beat.pid
|
||||||
|
SCHEDULE=/tmp/tubesync-celerybeat-schedule
|
||||||
|
|
||||||
|
if [ -f "${PIDFILE}" ]
|
||||||
|
then
|
||||||
|
PID=$(cat $PIDFILE)
|
||||||
|
echo "Unexpected PID file exists at ${PIDFILE} with PID: ${PID}"
|
||||||
|
if kill -0 $PID
|
||||||
|
then
|
||||||
|
echo "Killing old gunicorn process with PID: ${PID}"
|
||||||
|
kill -9 $PID
|
||||||
|
fi
|
||||||
|
echo "Removing stale PID file: ${PIDFILE}"
|
||||||
|
rm ${PIDFILE}
|
||||||
|
fi
|
||||||
|
|
||||||
|
#exec s6-setuidgid app \
|
||||||
|
# /usr/local/bin/celery --workdir /app -A tubesync beat --pidfile ${PIDFILE} -s ${SCHEDULE}
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/celery-beat/type
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/celery-beat/type
Normal file
@@ -0,0 +1 @@
|
|||||||
|
longrun
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gunicorn
|
||||||
24
config/root/etc/s6-overlay/s6-rc.d/celery-worker/run
Executable file
24
config/root/etc/s6-overlay/s6-rc.d/celery-worker/run
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/with-contenv bash
|
||||||
|
|
||||||
|
UMASK_SET=${UMASK_SET:-022}
|
||||||
|
umask "$UMASK_SET"
|
||||||
|
|
||||||
|
cd /app || exit
|
||||||
|
|
||||||
|
PIDFILE=/run/app/celery-worker.pid
|
||||||
|
|
||||||
|
if [ -f "${PIDFILE}" ]
|
||||||
|
then
|
||||||
|
PID=$(cat $PIDFILE)
|
||||||
|
echo "Unexpected PID file exists at ${PIDFILE} with PID: ${PID}"
|
||||||
|
if kill -0 $PID
|
||||||
|
then
|
||||||
|
echo "Killing old gunicorn process with PID: ${PID}"
|
||||||
|
kill -9 $PID
|
||||||
|
fi
|
||||||
|
echo "Removing stale PID file: ${PIDFILE}"
|
||||||
|
rm ${PIDFILE}
|
||||||
|
fi
|
||||||
|
|
||||||
|
#exec s6-setuidgid app \
|
||||||
|
# /usr/local/bin/celery --workdir /app -A tubesync worker --pidfile ${PIDFILE} -l INFO
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/celery-worker/type
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/celery-worker/type
Normal file
@@ -0,0 +1 @@
|
|||||||
|
longrun
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/gunicorn/dependencies
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/gunicorn/dependencies
Normal file
@@ -0,0 +1 @@
|
|||||||
|
tubesync-init
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
|
|
||||||
UMASK_SET=${UMASK_SET:-022}
|
UMASK_SET=${UMASK_SET:-022}
|
||||||
umask "$UMASK_SET"
|
umask "$UMASK_SET"
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/gunicorn/type
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/gunicorn/type
Normal file
@@ -0,0 +1 @@
|
|||||||
|
longrun
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/nginx/dependencies
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/nginx/dependencies
Normal file
@@ -0,0 +1 @@
|
|||||||
|
gunicorn
|
||||||
5
config/root/etc/s6-overlay/s6-rc.d/nginx/run
Executable file
5
config/root/etc/s6-overlay/s6-rc.d/nginx/run
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/command/with-contenv bash
|
||||||
|
|
||||||
|
cd /
|
||||||
|
|
||||||
|
/usr/sbin/nginx
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/nginx/type
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/nginx/type
Normal file
@@ -0,0 +1 @@
|
|||||||
|
longrun
|
||||||
4
config/root/etc/s6-overlay/s6-rc.d/redis/run
Executable file
4
config/root/etc/s6-overlay/s6-rc.d/redis/run
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/command/with-contenv bash
|
||||||
|
|
||||||
|
exec s6-setuidgid redis \
|
||||||
|
/usr/bin/redis-server /etc/redis/redis.conf
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/redis/type
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/redis/type
Normal file
@@ -0,0 +1 @@
|
|||||||
|
longrun
|
||||||
34
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/run
Executable file
34
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/run
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/command/with-contenv bash
|
||||||
|
|
||||||
|
# Change runtime user UID and GID
|
||||||
|
PUID="${PUID:-911}"
|
||||||
|
PUID="${PUID:-911}"
|
||||||
|
groupmod -o -g "$PGID" app
|
||||||
|
usermod -o -u "$PUID" app
|
||||||
|
|
||||||
|
# Reset permissions
|
||||||
|
chown -R app:app /run/app
|
||||||
|
chmod -R 0700 /run/app
|
||||||
|
chown -R app:app /config
|
||||||
|
chmod -R 0755 /config
|
||||||
|
chown -R root:app /app
|
||||||
|
chmod -R 0750 /app
|
||||||
|
chown -R app:app /app/common/static
|
||||||
|
chmod -R 0750 /app/common/static
|
||||||
|
chown -R app:app /app/static
|
||||||
|
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
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/type
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/type
Normal file
@@ -0,0 +1 @@
|
|||||||
|
oneshot
|
||||||
3
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/up
Executable file
3
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/up
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/command/execlineb -P
|
||||||
|
|
||||||
|
/etc/s6-overlay/s6-rc.d/tubesync-init/run
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gunicorn
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
|
|
||||||
exec s6-setuidgid app \
|
exec s6-setuidgid app \
|
||||||
/usr/bin/python3 /app/manage.py process_tasks
|
/usr/bin/python3 /app/manage.py process_tasks
|
||||||
1
config/root/etc/s6-overlay/s6-rc.d/tubesync-worker/type
Normal file
1
config/root/etc/s6-overlay/s6-rc.d/tubesync-worker/type
Normal file
@@ -0,0 +1 @@
|
|||||||
|
longrun
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/with-contenv bash
|
|
||||||
|
|
||||||
cd /
|
|
||||||
|
|
||||||
/usr/sbin/nginx
|
|
||||||
@@ -13,18 +13,18 @@ become an issue.
|
|||||||
TubeSync supports SQLite (the automatic default) as well as PostgreSQL, MySQL and
|
TubeSync supports SQLite (the automatic default) as well as PostgreSQL, MySQL and
|
||||||
MariaDB. For MariaDB just follow the MySQL instructions as the driver is the same.
|
MariaDB. For MariaDB just follow the MySQL instructions as the driver is the same.
|
||||||
|
|
||||||
You should a blank install of TubeSync. Migrating to a new database will reset your
|
You should start with a blank install of TubeSync. Migrating to a new database will
|
||||||
database. If you are comfortable with Django you can export and re-import existing
|
reset your database. If you are comfortable with Django you can export and re-import
|
||||||
database data with:
|
existing database data with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ docker exec -ti tubesync python3 /app/manage.py dumpdata > some-file.json
|
$ docker exec -i tubesync python3 /app/manage.py dumpdata > some-file.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Then change you database backend over, then use
|
Then change you database backend over, then use
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cat some-file.json | docker exec -ti tubesync python3 /app/manage.py loaddata --format=json -
|
$ cat some-file.json | docker exec -i tubesync python3 /app/manage.py loaddata - --format=json
|
||||||
```
|
```
|
||||||
|
|
||||||
As detailed in the Django documentation:
|
As detailed in the Django documentation:
|
||||||
@@ -78,3 +78,46 @@ entry in the container or stdout logs:
|
|||||||
|
|
||||||
If you see a line similar to the above and the web interface loads, congratulations,
|
If you see a line similar to the above and the web interface loads, congratulations,
|
||||||
you are now using an external database server for your TubeSync data!
|
you are now using an external database server for your TubeSync data!
|
||||||
|
|
||||||
|
## 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
30
docs/reset-metadata.md
Normal 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.
|
||||||
50
docs/using-cookies.md
Normal file
50
docs/using-cookies.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# TubeSync
|
||||||
|
|
||||||
|
## Advanced usage guide - using exported cookies
|
||||||
|
|
||||||
|
This is a new feature in v0.10 of TubeSync and later. It allows you to use the cookies
|
||||||
|
file exported from your browser in "Netscape" format with TubeSync to authenticate
|
||||||
|
to YouTube. This can bypass some throttling, age restrictions and other blocks at
|
||||||
|
YouTube.
|
||||||
|
|
||||||
|
**IMPORTANT NOTE**: Using cookies exported from your browser that is authenticated
|
||||||
|
to YouTube identifes your Google account as using TubeSync. This may result in
|
||||||
|
potential account impacts and is entirely at your own risk. Do not use this
|
||||||
|
feature unless you really know what you're doing.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Have a browser that supports exporting your cookies and be logged into YouTube.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Export your cookies
|
||||||
|
|
||||||
|
You need to export cookies for youtube.com from your browser, you can either do
|
||||||
|
this manually or there are plug-ins to automate this for you. This file must be
|
||||||
|
in the "Netscape" cookie export format.
|
||||||
|
|
||||||
|
Save your cookies as a `cookies.txt` file.
|
||||||
|
|
||||||
|
### 2. Import into TubeSync
|
||||||
|
|
||||||
|
Drop the `cookies.txt` file into your TubeSync `config` directory.
|
||||||
|
|
||||||
|
If detected correctly, you will see something like this in the worker or container
|
||||||
|
logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
YYYY-MM-DD HH:MM:SS,mmm [tubesync/INFO] [youtube-dl] using cookies.txt from: /config/cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see that line it's working correctly.
|
||||||
|
|
||||||
|
If you see errors in your logs like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
http.cookiejar.LoadError: '/config/cookies.txt' does not look like a Netscape format cookies file
|
||||||
|
```
|
||||||
|
|
||||||
|
Then your `cookies.txt` file was not generated or created correctly as it's not
|
||||||
|
in the required "Netscape" format. You can fix this by exporting your `cookies.txt`
|
||||||
|
in the correct "Netscape" format.
|
||||||
2
pip.conf
Normal file
2
pip.conf
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[global]
|
||||||
|
extra-index-url=https://www.piwheels.org/simple
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
logging_level = logging.DEBUG if settings.DEBUG else logging.INFO
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('tubesync')
|
log = logging.getLogger('tubesync')
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging_level)
|
||||||
ch = logging.StreamHandler()
|
ch = logging.StreamHandler()
|
||||||
ch.setLevel(logging.DEBUG)
|
ch.setLevel(logging_level)
|
||||||
formatter = logging.Formatter('%(asctime)s [%(name)s/%(levelname)s] %(message)s')
|
formatter = logging.Formatter('%(asctime)s [%(name)s/%(levelname)s] %(message)s')
|
||||||
ch.setFormatter(formatter)
|
ch.setFormatter(formatter)
|
||||||
log.addHandler(ch)
|
log.addHandler(ch)
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'roboto-light';
|
font-family: 'roboto';
|
||||||
src: url('/static/fonts/roboto/roboto-light.woff') format('woff');
|
src: url('../fonts/roboto/roboto-light.woff') format('woff');
|
||||||
|
font-weight: lighter;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'roboto';
|
||||||
|
src: url('../fonts/roboto/roboto-regular.woff') format('woff');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'roboto-regular';
|
font-family: 'roboto';
|
||||||
src: url('/static/fonts/roboto/roboto-regular.woff') format('woff');
|
src: url('../fonts/roboto/roboto-bold.woff') format('woff');
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'roboto-bold';
|
|
||||||
src: url('/static/fonts/roboto/roboto-bold.woff') format('woff');
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
$font-family: 'roboto-regular', Arial, Helvetica, sans-serif;
|
$font-family: 'roboto', Arial, Helvetica, sans-serif;
|
||||||
$font-size: 1.05rem;
|
$font-size: 1.05rem;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ readers do not read off random characters that represent icons */
|
|||||||
.#{$fa-css-prefix}-arrows-alt-h:before { content: fa-content($fa-var-arrows-alt-h); }
|
.#{$fa-css-prefix}-arrows-alt-h:before { content: fa-content($fa-var-arrows-alt-h); }
|
||||||
.#{$fa-css-prefix}-arrows-alt-v:before { content: fa-content($fa-var-arrows-alt-v); }
|
.#{$fa-css-prefix}-arrows-alt-v:before { content: fa-content($fa-var-arrows-alt-v); }
|
||||||
.#{$fa-css-prefix}-artstation:before { content: fa-content($fa-var-artstation); }
|
.#{$fa-css-prefix}-artstation:before { content: fa-content($fa-var-artstation); }
|
||||||
|
.#{$fa-css-prefix}-arrow-rotate-right:before { content: fa-content($fa-var-arrow-rotate-right); }
|
||||||
.#{$fa-css-prefix}-assistive-listening-systems:before { content: fa-content($fa-var-assistive-listening-systems); }
|
.#{$fa-css-prefix}-assistive-listening-systems:before { content: fa-content($fa-var-assistive-listening-systems); }
|
||||||
.#{$fa-css-prefix}-asterisk:before { content: fa-content($fa-var-asterisk); }
|
.#{$fa-css-prefix}-asterisk:before { content: fa-content($fa-var-asterisk); }
|
||||||
.#{$fa-css-prefix}-asymmetrik:before { content: fa-content($fa-var-asymmetrik); }
|
.#{$fa-css-prefix}-asymmetrik:before { content: fa-content($fa-var-asymmetrik); }
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ $fa-var-arrow-right: \f061;
|
|||||||
$fa-var-arrow-up: \f062;
|
$fa-var-arrow-up: \f062;
|
||||||
$fa-var-arrows-alt: \f0b2;
|
$fa-var-arrows-alt: \f0b2;
|
||||||
$fa-var-arrows-alt-h: \f337;
|
$fa-var-arrows-alt-h: \f337;
|
||||||
|
$fa-var-arrow-rotate-right: \f01e;
|
||||||
$fa-var-arrows-alt-v: \f338;
|
$fa-var-arrows-alt-v: \f338;
|
||||||
$fa-var-artstation: \f77a;
|
$fa-var-artstation: \f77a;
|
||||||
$fa-var-assistive-listening-systems: \f2a2;
|
$fa-var-assistive-listening-systems: \f2a2;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// Text Label Style
|
// Text Label Style
|
||||||
+ span:not(.lever) {
|
+ span:not(.lever) {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 35px;
|
padding-left: 27px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
|
|||||||
@@ -17,3 +17,16 @@ html {
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-collection-container {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text > i {
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% for i in paginator.page_range %}
|
{% for i in paginator.page_range %}
|
||||||
<a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}{% if show_skipped %}&show_skipped=yes{% endif %}">{{ i }}</a>
|
<a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}{% if show_skipped %}&show_skipped=yes{% endif %}{% if only_skipped %}&only_skipped=yes{% endif %}">{{ i }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os.path
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from .testutils import prevent_request_warnings
|
from .testutils import prevent_request_warnings
|
||||||
from .utils import parse_database_connection_string
|
from .utils import parse_database_connection_string, clean_filename
|
||||||
from .errors import DatabaseConnectionError
|
from .errors import DatabaseConnectionError
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ class CommonStaticTestCase(TestCase):
|
|||||||
self.assertTrue(os.path.exists(favicon_real_path))
|
self.assertTrue(os.path.exists(favicon_real_path))
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConnectionTestCase(TestCase):
|
class UtilsTestCase(TestCase):
|
||||||
|
|
||||||
def test_parse_database_connection_string(self):
|
def test_parse_database_connection_string(self):
|
||||||
database_dict = parse_database_connection_string(
|
database_dict = parse_database_connection_string(
|
||||||
@@ -126,3 +126,12 @@ class DatabaseConnectionTestCase(TestCase):
|
|||||||
with self.assertRaises(DatabaseConnectionError):
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
parse_database_connection_string(
|
parse_database_connection_string(
|
||||||
'postgresql://tubesync:password@localhost:5432/tubesync/test')
|
'postgresql://tubesync:password@localhost:5432/tubesync/test')
|
||||||
|
|
||||||
|
def test_clean_filename(self):
|
||||||
|
self.assertEqual(clean_filename('a'), 'a')
|
||||||
|
self.assertEqual(clean_filename('a\t'), 'a')
|
||||||
|
self.assertEqual(clean_filename('a\n'), 'a')
|
||||||
|
self.assertEqual(clean_filename('a a'), 'a a')
|
||||||
|
self.assertEqual(clean_filename('a a'), 'a a')
|
||||||
|
self.assertEqual(clean_filename('a\t\t\ta'), 'a a')
|
||||||
|
self.assertEqual(clean_filename('a\t\t\ta\t\t\t'), 'a a')
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import string
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urlunsplit, urlencode, urlparse
|
from urllib.parse import urlunsplit, urlencode, urlparse
|
||||||
from yt_dlp.utils import LazyList
|
from yt_dlp.utils import LazyList
|
||||||
@@ -113,8 +114,13 @@ def clean_filename(filename):
|
|||||||
to_scrub = '<>\/:*?"|%'
|
to_scrub = '<>\/:*?"|%'
|
||||||
for char in to_scrub:
|
for char in to_scrub:
|
||||||
filename = filename.replace(char, '')
|
filename = filename.replace(char, '')
|
||||||
filename = ''.join([c for c in filename if ord(c) > 30])
|
clean_filename = ''
|
||||||
return ' '.join(filename.split())
|
for c in filename:
|
||||||
|
if c in string.whitespace:
|
||||||
|
c = ' '
|
||||||
|
if ord(c) > 30:
|
||||||
|
clean_filename += c
|
||||||
|
return clean_filename.strip()
|
||||||
|
|
||||||
|
|
||||||
def json_serial(obj):
|
def json_serial(obj):
|
||||||
|
|||||||
109
tubesync/sync/fields.py
Normal file
109
tubesync/sync/fields.py
Normal 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]
|
||||||
19
tubesync/sync/management/commands/reset-metadata.py
Normal file
19
tubesync/sync/management/commands/reset-metadata.py
Normal 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')
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from sync.youtube import get_media_info
|
from sync.youtube import get_media_info
|
||||||
|
from common.utils import json_serial
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -14,5 +15,6 @@ class Command(BaseCommand):
|
|||||||
url = options['url']
|
url = options['url']
|
||||||
self.stdout.write(f'Showing information for URL: {url}')
|
self.stdout.write(f'Showing information for URL: {url}')
|
||||||
info = get_media_info(url)
|
info = get_media_info(url)
|
||||||
self.stdout.write(json.dumps(info, indent=4, sort_keys=True))
|
d = json.dumps(info, indent=4, sort_keys=True, default=json_serial)
|
||||||
|
self.stdout.write(d)
|
||||||
self.stdout.write('Done')
|
self.stdout.write('Done')
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ def get_best_audio_format(media):
|
|||||||
# If the format has a video stream, skip it
|
# If the format has a video stream, skip it
|
||||||
if fmt['vcodec'] is not None:
|
if fmt['vcodec'] is not None:
|
||||||
continue
|
continue
|
||||||
|
if not fmt['acodec']:
|
||||||
|
continue
|
||||||
audio_formats.append(fmt)
|
audio_formats.append(fmt)
|
||||||
audio_formats = list(reversed(sorted(audio_formats, key=lambda k: k['abr'])))
|
audio_formats = list(reversed(sorted(audio_formats, key=lambda k: k['abr'])))
|
||||||
if not audio_formats:
|
if not audio_formats:
|
||||||
@@ -88,6 +90,8 @@ def get_best_video_format(media):
|
|||||||
# If the format has an audio stream, skip it
|
# If the format has an audio stream, skip it
|
||||||
if fmt['acodec'] is not None:
|
if fmt['acodec'] is not None:
|
||||||
continue
|
continue
|
||||||
|
if not fmt['vcodec']:
|
||||||
|
continue
|
||||||
if media.source.source_resolution.strip().upper() == fmt['format']:
|
if media.source.source_resolution.strip().upper() == fmt['format']:
|
||||||
video_formats.append(fmt)
|
video_formats.append(fmt)
|
||||||
# Check we matched some streams
|
# Check we matched some streams
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ class PlexMediaServer(MediaServer):
|
|||||||
'<p>The <strong>libraries</strong> is a comma-separated list of Plex '
|
'<p>The <strong>libraries</strong> is a comma-separated list of Plex '
|
||||||
'library or section IDs, you can find out how to get your library or '
|
'library or section IDs, you can find out how to get your library or '
|
||||||
'section IDs <a href="https://support.plex.tv/articles/201242707-plex-'
|
'section IDs <a href="https://support.plex.tv/articles/201242707-plex-'
|
||||||
'media-scanner-via-command-line/#toc-1" target="_blank">here</a>.</p>')
|
'media-scanner-via-command-line/#toc-1" target="_blank">here</a> or '
|
||||||
|
'<a href="https://www.plexopedia.com/plex-media-server/api/server/libraries/" '
|
||||||
|
'target="_blank">here</a></p>.')
|
||||||
|
|
||||||
def make_request(self, uri='/', params={}):
|
def make_request(self, uri='/', params={}):
|
||||||
headers = {'User-Agent': 'TubeSync'}
|
headers = {'User-Agent': 'TubeSync'}
|
||||||
@@ -124,7 +126,7 @@ class PlexMediaServer(MediaServer):
|
|||||||
# Seems we have a valid library sections page, get the library IDs
|
# Seems we have a valid library sections page, get the library IDs
|
||||||
remote_libraries = {}
|
remote_libraries = {}
|
||||||
try:
|
try:
|
||||||
for parent in parsed_response.getiterator('MediaContainer'):
|
for parent in parsed_response.iter('MediaContainer'):
|
||||||
for d in parent:
|
for d in parent:
|
||||||
library_id = d.attrib['key']
|
library_id = d.attrib['key']
|
||||||
library_name = d.attrib['title']
|
library_name = d.attrib['title']
|
||||||
|
|||||||
30
tubesync/sync/migrations/0010_auto_20210924_0554.py
Normal file
30
tubesync/sync/migrations/0010_auto_20210924_0554.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2021-09-24 05:54
|
||||||
|
|
||||||
|
import django.core.files.storage
|
||||||
|
from django.db import migrations, models
|
||||||
|
import sync.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0009_auto_20210218_0442'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='media_file',
|
||||||
|
field=models.FileField(blank=True, help_text='Media file', max_length=255, null=True, storage=django.core.files.storage.FileSystemStorage(location='/home/meeb/Repos/github.com/meeb/tubesync/tubesync/downloads'), upload_to=sync.models.get_media_file_path, verbose_name='media file'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='index_schedule',
|
||||||
|
field=models.IntegerField(choices=[(3600, 'Every hour'), (7200, 'Every 2 hours'), (10800, 'Every 3 hours'), (14400, 'Every 4 hours'), (18000, 'Every 5 hours'), (21600, 'Every 6 hours'), (43200, 'Every 12 hours'), (86400, 'Every 24 hours'), (259200, 'Every 3 days'), (604800, 'Every 7 days'), (0, 'Never')], db_index=True, default=86400, help_text='Schedule of how often to index the source for new media', verbose_name='index schedule'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='media_format',
|
||||||
|
field=models.CharField(default='{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}', help_text='File format to use for saving files, detailed options at bottom of page.', max_length=200, verbose_name='media format'),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
tubesync/sync/migrations/0011_auto_20220201_1654.py
Normal file
21
tubesync/sync/migrations/0011_auto_20220201_1654.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 3.2.11 on 2022-02-01 16:54
|
||||||
|
|
||||||
|
import django.core.files.storage
|
||||||
|
from django.db import migrations, models
|
||||||
|
import sync.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0010_auto_20210924_0554'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='write_json',
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text='Write a JSON file with the media info, these may be detected and used by some media servers', verbose_name='write json'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.12 on 2022-04-06 06:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0011_auto_20220201_1654'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='downloaded_format',
|
||||||
|
field=models.CharField(blank=True, help_text='Video format (resolution) of the downloaded media', max_length=30, null=True, verbose_name='downloaded format'),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
tubesync/sync/migrations/0013_fix_elative_media_file.py
Normal file
25
tubesync/sync/migrations/0013_fix_elative_media_file.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 3.2.12 on 2022-04-06 06:19
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def fix_media_file(apps, schema_editor):
|
||||||
|
Media = apps.get_model('sync', 'Media')
|
||||||
|
for media in Media.objects.filter(downloaded=True):
|
||||||
|
download_dir = str(settings.DOWNLOAD_ROOT)
|
||||||
|
|
||||||
|
if media.media_file.name.startswith(download_dir):
|
||||||
|
media.media_file.name = media.media_file.name[len(download_dir) + 1:]
|
||||||
|
media.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0012_alter_media_downloaded_format'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(fix_media_file)
|
||||||
|
]
|
||||||
21
tubesync/sync/migrations/0014_alter_media_media_file.py
Normal file
21
tubesync/sync/migrations/0014_alter_media_media_file.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 3.2.15 on 2022-12-28 20:33
|
||||||
|
|
||||||
|
import django.core.files.storage
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import sync.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0013_fix_elative_media_file'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='media_file',
|
||||||
|
field=models.FileField(blank=True, help_text='Media file', max_length=255, null=True, storage=django.core.files.storage.FileSystemStorage(base_url='/media-data/', location=str(settings.DOWNLOAD_ROOT)), upload_to=sync.models.get_media_file_path, verbose_name='media file'),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
tubesync/sync/migrations/0015_auto_20230213_0603.py
Normal file
23
tubesync/sync/migrations/0015_auto_20230213_0603.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
34
tubesync/sync/migrations/0016_auto_20230214_2052.py
Normal file
34
tubesync/sync/migrations/0016_auto_20230214_2052.py
Normal 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'))),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
tubesync/sync/migrations/0018_source_subtitles.py
Normal file
27
tubesync/sync/migrations/0018_source_subtitles.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
tubesync/sync/migrations/0019_add_delete_removed_media.py
Normal file
17
tubesync/sync/migrations/0019_add_delete_removed_media.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
29
tubesync/sync/migrations/0020_auto_20231024_1825.py
Normal file
29
tubesync/sync/migrations/0020_auto_20231024_1825.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -8,6 +9,7 @@ from pathlib import Path
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -19,10 +21,9 @@ from .utils import seconds_to_timestr, parse_media_format
|
|||||||
from .matching import (get_best_combined_format, get_best_audio_format,
|
from .matching import (get_best_combined_format, get_best_audio_format,
|
||||||
get_best_video_format)
|
get_best_video_format)
|
||||||
from .mediaservers import PlexMediaServer
|
from .mediaservers import PlexMediaServer
|
||||||
|
from .fields import CommaSepChoiceField
|
||||||
|
|
||||||
|
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT), base_url='/media-data/')
|
||||||
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
class Source(models.Model):
|
class Source(models.Model):
|
||||||
'''
|
'''
|
||||||
@@ -106,6 +107,47 @@ class Source(models.Model):
|
|||||||
EXTENSION_MKV = 'mkv'
|
EXTENSION_MKV = 'mkv'
|
||||||
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV)
|
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV)
|
||||||
|
|
||||||
|
|
||||||
|
# as stolen from: https://wiki.sponsor.ajay.app/w/Types / https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/postprocessor/sponsorblock.py
|
||||||
|
SPONSORBLOCK_CATEGORIES_CHOICES = (
|
||||||
|
('sponsor', 'Sponsor'),
|
||||||
|
('intro', 'Intermission/Intro Animation'),
|
||||||
|
('outro', 'Endcards/Credits'),
|
||||||
|
('selfpromo', 'Unpaid/Self Promotion'),
|
||||||
|
('preview', 'Preview/Recap'),
|
||||||
|
('filler', 'Filler Tangent'),
|
||||||
|
('interaction', 'Interaction Reminder'),
|
||||||
|
('music_offtopic', 'Non-Music Section'),
|
||||||
|
)
|
||||||
|
|
||||||
|
sponsorblock_categories = CommaSepChoiceField(
|
||||||
|
_(''),
|
||||||
|
possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES,
|
||||||
|
all_choice="all",
|
||||||
|
allow_all=True,
|
||||||
|
all_label="(all options)",
|
||||||
|
default="all",
|
||||||
|
help_text=_("Select the sponsorblocks you want to enforce")
|
||||||
|
)
|
||||||
|
|
||||||
|
embed_metadata = models.BooleanField(
|
||||||
|
_('embed metadata'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Embed metadata from source into file')
|
||||||
|
)
|
||||||
|
embed_thumbnail = models.BooleanField(
|
||||||
|
_('embed thumbnail'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Embed thumbnail into the file')
|
||||||
|
)
|
||||||
|
|
||||||
|
enable_sponsorblock = models.BooleanField(
|
||||||
|
_('enable sponsorblock'),
|
||||||
|
default=True,
|
||||||
|
help_text=_('Use SponsorBlock?')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Fontawesome icons used for the source on the front end
|
# Fontawesome icons used for the source on the front end
|
||||||
ICONS = {
|
ICONS = {
|
||||||
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
|
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
|
||||||
@@ -246,6 +288,18 @@ class Source(models.Model):
|
|||||||
help_text=_('If "delete old media" is ticked, the number of days after which '
|
help_text=_('If "delete old media" is ticked, the number of days after which '
|
||||||
'to automatically delete media')
|
'to automatically delete media')
|
||||||
)
|
)
|
||||||
|
filter_text = models.CharField(
|
||||||
|
_('filter string'),
|
||||||
|
max_length=100,
|
||||||
|
default='',
|
||||||
|
blank=True,
|
||||||
|
help_text=_('Regex compatible filter string for video titles')
|
||||||
|
)
|
||||||
|
delete_removed_media = models.BooleanField(
|
||||||
|
_('delete removed media'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Delete media that is no longer on this playlist')
|
||||||
|
)
|
||||||
source_resolution = models.CharField(
|
source_resolution = models.CharField(
|
||||||
_('source resolution'),
|
_('source resolution'),
|
||||||
max_length=8,
|
max_length=8,
|
||||||
@@ -298,12 +352,41 @@ class Source(models.Model):
|
|||||||
default=False,
|
default=False,
|
||||||
help_text=_('Write an NFO file in XML with the media info, these may be detected and used by some media servers')
|
help_text=_('Write an NFO file in XML with the media info, these may be detected and used by some media servers')
|
||||||
)
|
)
|
||||||
|
write_json = models.BooleanField(
|
||||||
|
_('write json'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Write a JSON file with the media info, these may be detected and used by some media servers')
|
||||||
|
)
|
||||||
has_failed = models.BooleanField(
|
has_failed = models.BooleanField(
|
||||||
_('has failed'),
|
_('has failed'),
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('Source has failed to index media')
|
help_text=_('Source has failed to index media')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
write_subtitles = models.BooleanField(
|
||||||
|
_('write subtitles'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Download video subtitles')
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_subtitles = models.BooleanField(
|
||||||
|
_('accept auto-generated subs'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Accept auto-generated subtitles')
|
||||||
|
)
|
||||||
|
sub_langs = models.CharField(
|
||||||
|
_('subs langs'),
|
||||||
|
max_length=30,
|
||||||
|
default='en',
|
||||||
|
help_text=_('List of subtitles langs to download, comma-separated. Example: en,fr or all,-fr,-live_chat'),
|
||||||
|
validators=[
|
||||||
|
RegexValidator(
|
||||||
|
regex=r"^(\-?[\_\.a-zA-Z]+,)*(\-?[\_\.a-zA-Z]+){1}$",
|
||||||
|
message=_('Subtitle langs must be a comma-separated list of langs. example: en,fr or all,-fr,-live_chat')
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -387,10 +470,14 @@ class Source(models.Model):
|
|||||||
@property
|
@property
|
||||||
def directory_path(self):
|
def directory_path(self):
|
||||||
download_dir = Path(media_file_storage.location)
|
download_dir = Path(media_file_storage.location)
|
||||||
|
return download_dir / self.type_directory_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type_directory_path(self):
|
||||||
if self.source_resolution == self.SOURCE_RESOLUTION_AUDIO:
|
if self.source_resolution == self.SOURCE_RESOLUTION_AUDIO:
|
||||||
return download_dir / settings.DOWNLOAD_AUDIO_DIR / self.directory
|
return Path(settings.DOWNLOAD_AUDIO_DIR) / self.directory
|
||||||
else:
|
else:
|
||||||
return download_dir / settings.DOWNLOAD_VIDEO_DIR / self.directory
|
return Path(settings.DOWNLOAD_VIDEO_DIR) / self.directory
|
||||||
|
|
||||||
def make_directory(self):
|
def make_directory(self):
|
||||||
return os.makedirs(self.directory_path, exist_ok=True)
|
return os.makedirs(self.directory_path, exist_ok=True)
|
||||||
@@ -428,14 +515,16 @@ class Source(models.Model):
|
|||||||
fmt.append('60fps')
|
fmt.append('60fps')
|
||||||
if self.prefer_hdr:
|
if self.prefer_hdr:
|
||||||
fmt.append('hdr')
|
fmt.append('hdr')
|
||||||
|
now = timezone.now()
|
||||||
return {
|
return {
|
||||||
'yyyymmdd': timezone.now().strftime('%Y%m%d'),
|
'yyyymmdd': now.strftime('%Y%m%d'),
|
||||||
'yyyy_mm_dd': timezone.now().strftime('%Y-%m-%d'),
|
'yyyy_mm_dd': now.strftime('%Y-%m-%d'),
|
||||||
'yyyy': timezone.now().strftime('%Y'),
|
'yyyy': now.strftime('%Y'),
|
||||||
'mm': timezone.now().strftime('%m'),
|
'mm': now.strftime('%m'),
|
||||||
'dd': timezone.now().strftime('%d'),
|
'dd': now.strftime('%d'),
|
||||||
'source': self.slugname,
|
'source': self.slugname,
|
||||||
'source_full': self.name,
|
'source_full': self.name,
|
||||||
|
'uploader': 'Some Channel Name',
|
||||||
'title': 'some-media-title-name',
|
'title': 'some-media-title-name',
|
||||||
'title_full': 'Some Media Title Name',
|
'title_full': 'Some Media Title Name',
|
||||||
'key': 'SoMeUnIqUiD',
|
'key': 'SoMeUnIqUiD',
|
||||||
@@ -457,6 +546,11 @@ class Source(models.Model):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def is_regex_match(self, media_item_title):
|
||||||
|
if not self.filter_text:
|
||||||
|
return True
|
||||||
|
return bool(re.search(self.filter_text, media_item_title))
|
||||||
|
|
||||||
def index_media(self):
|
def index_media(self):
|
||||||
'''
|
'''
|
||||||
Index the media source returning a list of media metadata as dicts.
|
Index the media source returning a list of media metadata as dicts.
|
||||||
@@ -467,7 +561,11 @@ class Source(models.Model):
|
|||||||
response = indexer(self.index_url)
|
response = indexer(self.index_url)
|
||||||
if not isinstance(response, dict):
|
if not isinstance(response, dict):
|
||||||
return []
|
return []
|
||||||
return response.get('entries', [])
|
entries = response.get('entries', [])
|
||||||
|
|
||||||
|
if settings.MAX_ENTRIES_PROCESSING:
|
||||||
|
entries = entries[:settings.MAX_ENTRIES_PROCESSING]
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def get_media_thumb_path(instance, filename):
|
def get_media_thumb_path(instance, filename):
|
||||||
@@ -655,7 +753,7 @@ class Media(models.Model):
|
|||||||
media_file = models.FileField(
|
media_file = models.FileField(
|
||||||
_('media file'),
|
_('media file'),
|
||||||
upload_to=get_media_file_path,
|
upload_to=get_media_file_path,
|
||||||
max_length=200,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
storage=media_file_storage,
|
storage=media_file_storage,
|
||||||
@@ -665,7 +763,13 @@ class Media(models.Model):
|
|||||||
_('skip'),
|
_('skip'),
|
||||||
db_index=True,
|
db_index=True,
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('Media will be skipped and not downloaded')
|
help_text=_('INTERNAL FLAG - Media will be skipped and not downloaded')
|
||||||
|
)
|
||||||
|
manual_skip = models.BooleanField(
|
||||||
|
_('manual_skip'),
|
||||||
|
db_index=True,
|
||||||
|
default=False,
|
||||||
|
help_text=_('Media marked as "skipped", won\' be downloaded')
|
||||||
)
|
)
|
||||||
downloaded = models.BooleanField(
|
downloaded = models.BooleanField(
|
||||||
_('downloaded'),
|
_('downloaded'),
|
||||||
@@ -685,7 +789,7 @@ class Media(models.Model):
|
|||||||
max_length=30,
|
max_length=30,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_('Audio codec of the downloaded media')
|
help_text=_('Video format (resolution) of the downloaded media')
|
||||||
)
|
)
|
||||||
downloaded_height = models.PositiveIntegerField(
|
downloaded_height = models.PositiveIntegerField(
|
||||||
_('downloaded height'),
|
_('downloaded height'),
|
||||||
@@ -826,7 +930,10 @@ class Media(models.Model):
|
|||||||
'hdr': hdr,
|
'hdr': hdr,
|
||||||
'format': tuple(fmt),
|
'format': tuple(fmt),
|
||||||
}
|
}
|
||||||
resolution = f'{self.downloaded_height}p'
|
if self.downloaded_format:
|
||||||
|
resolution = self.downloaded_format.lower()
|
||||||
|
elif self.downloaded_height:
|
||||||
|
resolution = f'{self.downloaded_height}p'
|
||||||
if self.downloaded_format != 'audio':
|
if self.downloaded_format != 'audio':
|
||||||
vcodec = self.downloaded_video_codec.lower()
|
vcodec = self.downloaded_video_codec.lower()
|
||||||
fmt.append(vcodec)
|
fmt.append(vcodec)
|
||||||
@@ -853,7 +960,7 @@ class Media(models.Model):
|
|||||||
# Otherwise, calculate from matched format codes
|
# Otherwise, calculate from matched format codes
|
||||||
vformat = None
|
vformat = None
|
||||||
aformat = None
|
aformat = None
|
||||||
if '+' in format_str:
|
if format_str and '+' in format_str:
|
||||||
# Seperate audio and video streams
|
# Seperate audio and video streams
|
||||||
vformat_code, aformat_code = format_str.split('+')
|
vformat_code, aformat_code = format_str.split('+')
|
||||||
vformat = self.get_format_by_code(vformat_code)
|
vformat = self.get_format_by_code(vformat_code)
|
||||||
@@ -862,7 +969,7 @@ class Media(models.Model):
|
|||||||
# Combined stream or audio only
|
# Combined stream or audio only
|
||||||
cformat = self.get_format_by_code(format_str)
|
cformat = self.get_format_by_code(format_str)
|
||||||
aformat = cformat
|
aformat = cformat
|
||||||
if cformat['vcodec']:
|
if cformat and cformat['vcodec']:
|
||||||
# Combined
|
# Combined
|
||||||
vformat = cformat
|
vformat = cformat
|
||||||
if vformat:
|
if vformat:
|
||||||
@@ -932,6 +1039,7 @@ class Media(models.Model):
|
|||||||
'acodec': display_format['acodec'],
|
'acodec': display_format['acodec'],
|
||||||
'fps': display_format['fps'],
|
'fps': display_format['fps'],
|
||||||
'hdr': display_format['hdr'],
|
'hdr': display_format['hdr'],
|
||||||
|
'uploader': self.uploader,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -993,7 +1101,12 @@ class Media(models.Model):
|
|||||||
@property
|
@property
|
||||||
def duration(self):
|
def duration(self):
|
||||||
field = self.get_metadata_field('duration')
|
field = self.get_metadata_field('duration')
|
||||||
return int(self.loaded_metadata.get(field, 0))
|
duration = self.loaded_metadata.get(field, 0)
|
||||||
|
try:
|
||||||
|
duration = int(duration)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = 0
|
||||||
|
return duration
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration_formatted(self):
|
def duration_formatted(self):
|
||||||
@@ -1053,7 +1166,10 @@ class Media(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def thumbname(self):
|
def thumbname(self):
|
||||||
filename = self.filename
|
if self.downloaded and self.media_file:
|
||||||
|
filename = os.path.basename(self.media_file.path)
|
||||||
|
else:
|
||||||
|
filename = self.filename
|
||||||
prefix, ext = os.path.splitext(filename)
|
prefix, ext = os.path.splitext(filename)
|
||||||
return f'{prefix}.jpg'
|
return f'{prefix}.jpg'
|
||||||
|
|
||||||
@@ -1063,7 +1179,10 @@ class Media(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def nfoname(self):
|
def nfoname(self):
|
||||||
filename = self.filename
|
if self.downloaded and self.media_file:
|
||||||
|
filename = os.path.basename(self.media_file.path)
|
||||||
|
else:
|
||||||
|
filename = self.filename
|
||||||
prefix, ext = os.path.splitext(filename)
|
prefix, ext = os.path.splitext(filename)
|
||||||
return f'{prefix}.nfo'
|
return f'{prefix}.nfo'
|
||||||
|
|
||||||
@@ -1071,6 +1190,19 @@ class Media(models.Model):
|
|||||||
def nfopath(self):
|
def nfopath(self):
|
||||||
return self.source.directory_path / self.nfoname
|
return self.source.directory_path / self.nfoname
|
||||||
|
|
||||||
|
@property
|
||||||
|
def jsonname(self):
|
||||||
|
if self.downloaded and self.media_file:
|
||||||
|
filename = os.path.basename(self.media_file.path)
|
||||||
|
else:
|
||||||
|
filename = self.filename
|
||||||
|
prefix, ext = os.path.splitext(filename)
|
||||||
|
return f'{prefix}.info.json'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def jsonpath(self):
|
||||||
|
return self.source.directory_path / self.jsonname
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def directory_path(self):
|
def directory_path(self):
|
||||||
# Otherwise, create a suitable filename from the source media_format
|
# Otherwise, create a suitable filename from the source media_format
|
||||||
@@ -1095,6 +1227,31 @@ class Media(models.Model):
|
|||||||
return False
|
return False
|
||||||
return os.path.exists(self.media_file.path)
|
return os.path.exists(self.media_file.path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_type(self):
|
||||||
|
if not self.downloaded:
|
||||||
|
return 'video/mp4'
|
||||||
|
vcodec = self.downloaded_video_codec
|
||||||
|
if vcodec is None:
|
||||||
|
acodec = self.downloaded_audio_codec
|
||||||
|
if acodec is None:
|
||||||
|
raise TypeError() # nothing here.
|
||||||
|
|
||||||
|
acodec = acodec.lower()
|
||||||
|
if acodec == "mp4a":
|
||||||
|
return "audio/mp4"
|
||||||
|
elif acodec == "opus":
|
||||||
|
return "audio/opus"
|
||||||
|
else:
|
||||||
|
# fall-fall-back.
|
||||||
|
return 'audio/ogg'
|
||||||
|
|
||||||
|
vcodec = vcodec.lower()
|
||||||
|
if vcodec == 'vp9':
|
||||||
|
return 'video/webm'
|
||||||
|
else:
|
||||||
|
return 'video/mp4'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def nfoxml(self):
|
def nfoxml(self):
|
||||||
'''
|
'''
|
||||||
@@ -1219,7 +1376,10 @@ class Media(models.Model):
|
|||||||
f'no valid format available')
|
f'no valid format available')
|
||||||
# Download the media with youtube-dl
|
# Download the media with youtube-dl
|
||||||
download_youtube_media(self.url, format_str, self.source.extension,
|
download_youtube_media(self.url, format_str, self.source.extension,
|
||||||
str(self.filepath))
|
str(self.filepath), self.source.write_json,
|
||||||
|
self.source.sponsorblock_categories, self.source.embed_thumbnail,
|
||||||
|
self.source.embed_metadata, self.source.enable_sponsorblock,
|
||||||
|
self.source.write_subtitles, self.source.auto_subtitles,self.source.sub_langs )
|
||||||
# Return the download paramaters
|
# Return the download paramaters
|
||||||
return format_str, self.source.extension
|
return format_str, self.source.extension
|
||||||
|
|
||||||
@@ -1229,7 +1389,7 @@ class Media(models.Model):
|
|||||||
'''
|
'''
|
||||||
indexer = self.INDEXERS.get(self.source.source_type, None)
|
indexer = self.INDEXERS.get(self.source.source_type, None)
|
||||||
if not callable(indexer):
|
if not callable(indexer):
|
||||||
raise Exception(f'Meida with source type f"{self.source.source_type}" '
|
raise Exception(f'Media with source type f"{self.source.source_type}" '
|
||||||
f'has no indexer')
|
f'has no indexer')
|
||||||
return indexer(self.url)
|
return indexer(self.url)
|
||||||
|
|
||||||
|
|||||||
@@ -93,13 +93,17 @@ def task_task_failed(sender, task_id, completed_task, **kwargs):
|
|||||||
|
|
||||||
@receiver(post_save, sender=Media)
|
@receiver(post_save, sender=Media)
|
||||||
def media_post_save(sender, instance, created, **kwargs):
|
def media_post_save(sender, instance, created, **kwargs):
|
||||||
|
# If the media is skipped manually, bail.
|
||||||
|
if instance.manual_skip:
|
||||||
|
return
|
||||||
# Triggered after media is saved
|
# Triggered after media is saved
|
||||||
cap_changed = False
|
cap_changed = False
|
||||||
can_download_changed = False
|
can_download_changed = False
|
||||||
# Reset the skip flag if the download cap has changed if the media has not
|
# Reset the skip flag if the download cap has changed if the media has not
|
||||||
# already been downloaded
|
# already been downloaded
|
||||||
if not instance.downloaded:
|
if not instance.downloaded and instance.metadata:
|
||||||
max_cap_age = instance.source.download_cap_date
|
max_cap_age = instance.source.download_cap_date
|
||||||
|
filter_text = instance.source.filter_text.strip()
|
||||||
published = instance.published
|
published = instance.published
|
||||||
if not published:
|
if not published:
|
||||||
if not instance.skip:
|
if not instance.skip:
|
||||||
@@ -113,11 +117,20 @@ def media_post_save(sender, instance, created, **kwargs):
|
|||||||
else:
|
else:
|
||||||
if max_cap_age:
|
if max_cap_age:
|
||||||
if published > max_cap_age and instance.skip:
|
if published > max_cap_age and instance.skip:
|
||||||
# Media was published after the cap date but is set to be skipped
|
if filter_text:
|
||||||
log.info(f'Media: {instance.source} / {instance} has a valid '
|
if instance.source.is_regex_match(instance.title):
|
||||||
f'publishing date, marking to be unskipped')
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
instance.skip = False
|
f'publishing date and title filter, marking to be unskipped')
|
||||||
cap_changed = True
|
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:
|
elif published <= max_cap_age and not instance.skip:
|
||||||
log.info(f'Media: {instance.source} / {instance} is too old for '
|
log.info(f'Media: {instance.source} / {instance} is too old for '
|
||||||
f'the download cap date, marking to be skipped')
|
f'the download cap date, marking to be skipped')
|
||||||
@@ -126,10 +139,20 @@ def media_post_save(sender, instance, created, **kwargs):
|
|||||||
else:
|
else:
|
||||||
if instance.skip:
|
if instance.skip:
|
||||||
# Media marked to be skipped but source download cap removed
|
# Media marked to be skipped but source download cap removed
|
||||||
log.info(f'Media: {instance.source} / {instance} has a valid '
|
if filter_text:
|
||||||
f'publishing date, marking to be unskipped')
|
if instance.source.is_regex_match(instance.title):
|
||||||
instance.skip = False
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
cap_changed = True
|
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
|
# Recalculate the "can_download" flag, this may
|
||||||
# need to change if the source specifications have been changed
|
# need to change if the source specifications have been changed
|
||||||
if instance.metadata:
|
if instance.metadata:
|
||||||
@@ -152,7 +175,7 @@ def media_post_save(sender, instance, created, **kwargs):
|
|||||||
verbose_name = _('Downloading metadata for "{}"')
|
verbose_name = _('Downloading metadata for "{}"')
|
||||||
download_media_metadata(
|
download_media_metadata(
|
||||||
str(instance.pk),
|
str(instance.pk),
|
||||||
priority=10,
|
priority=5,
|
||||||
verbose_name=verbose_name.format(instance.pk),
|
verbose_name=verbose_name.format(instance.pk),
|
||||||
remove_existing_tasks=True
|
remove_existing_tasks=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def map_task_to_instance(task):
|
|||||||
Source: 'sync:source',
|
Source: 'sync:source',
|
||||||
Media: 'sync:media-item',
|
Media: 'sync:media-item',
|
||||||
}
|
}
|
||||||
# Unpack
|
# Unpack
|
||||||
task_func, task_args_str = task.task_name, task.task_params
|
task_func, task_args_str = task.task_name, task.task_params
|
||||||
model = TASK_MAP.get(task_func, None)
|
model = TASK_MAP.get(task_func, None)
|
||||||
if not model:
|
if not model:
|
||||||
@@ -132,16 +132,23 @@ def cleanup_completed_tasks():
|
|||||||
|
|
||||||
|
|
||||||
def cleanup_old_media():
|
def cleanup_old_media():
|
||||||
for media in Media.objects.filter(download_date__isnull=False):
|
for source in Source.objects.filter(delete_old_media=True, days_to_keep__gt=0):
|
||||||
if media.source.delete_old_media and media.source.days_to_keep > 0:
|
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
||||||
delta = timezone.now() - timedelta(days=media.source.days_to_keep)
|
for media in source.media_source.filter(downloaded=True, download_date__lt=delta):
|
||||||
if media.downloaded and media.download_date < delta:
|
log.info(f'Deleting expired media: {source} / {media} '
|
||||||
# Media was downloaded after the cutoff date, delete it
|
f'(now older than {source.days_to_keep} days / '
|
||||||
log.info(f'Deleting expired media: {media.source} / {media} '
|
f'download_date before {delta})')
|
||||||
f'(now older than {media.source.days_to_keep} days / '
|
# .delete() also triggers a pre_delete signal that removes the files
|
||||||
f'download_date before {delta})')
|
media.delete()
|
||||||
# .delete() also triggers a pre_delete signal that removes the files
|
|
||||||
media.delete()
|
|
||||||
|
def cleanup_removed_media(source, videos):
|
||||||
|
media_objects = Media.objects.filter(source=source, downloaded=True)
|
||||||
|
for item in media_objects:
|
||||||
|
matching_source_item = [video['id'] for video in videos if video['id'] == item.key]
|
||||||
|
if not matching_source_item:
|
||||||
|
log.info(f'{item.title} is no longer in source, removing')
|
||||||
|
item.delete()
|
||||||
|
|
||||||
|
|
||||||
@background(schedule=0)
|
@background(schedule=0)
|
||||||
@@ -153,7 +160,6 @@ def index_source_task(source_id):
|
|||||||
source = Source.objects.get(pk=source_id)
|
source = Source.objects.get(pk=source_id)
|
||||||
except Source.DoesNotExist:
|
except Source.DoesNotExist:
|
||||||
# Task triggered but the Source has been deleted, delete the task
|
# Task triggered but the Source has been deleted, delete the task
|
||||||
delete_index_source_task(source_id)
|
|
||||||
return
|
return
|
||||||
# Reset any errors
|
# Reset any errors
|
||||||
source.has_failed = False
|
source.has_failed = False
|
||||||
@@ -189,6 +195,9 @@ def index_source_task(source_id):
|
|||||||
cleanup_completed_tasks()
|
cleanup_completed_tasks()
|
||||||
# Tack on a cleanup of old media
|
# Tack on a cleanup of old media
|
||||||
cleanup_old_media()
|
cleanup_old_media()
|
||||||
|
if source.delete_removed_media:
|
||||||
|
log.info(f'Cleaning up media no longer in source {source}')
|
||||||
|
cleanup_removed_media(source, videos)
|
||||||
|
|
||||||
|
|
||||||
@background(schedule=0)
|
@background(schedule=0)
|
||||||
@@ -202,7 +211,6 @@ def check_source_directory_exists(source_id):
|
|||||||
source = Source.objects.get(pk=source_id)
|
source = Source.objects.get(pk=source_id)
|
||||||
except Source.DoesNotExist:
|
except Source.DoesNotExist:
|
||||||
# Task triggered but the Source has been deleted, delete the task
|
# Task triggered but the Source has been deleted, delete the task
|
||||||
delete_index_source_task(source_id)
|
|
||||||
return
|
return
|
||||||
# Check the source output directory exists
|
# Check the source output directory exists
|
||||||
if not source.directory_exists():
|
if not source.directory_exists():
|
||||||
@@ -223,6 +231,9 @@ def download_media_metadata(media_id):
|
|||||||
log.error(f'Task download_media_metadata(pk={media_id}) called but no '
|
log.error(f'Task download_media_metadata(pk={media_id}) called but no '
|
||||||
f'media exists with ID: {media_id}')
|
f'media exists with ID: {media_id}')
|
||||||
return
|
return
|
||||||
|
if media.manual_skip:
|
||||||
|
log.info(f'Task for ID: {media_id} skipped, due to task being manually skipped.')
|
||||||
|
return
|
||||||
source = media.source
|
source = media.source
|
||||||
metadata = media.index_metadata()
|
metadata = media.index_metadata()
|
||||||
media.metadata = json.dumps(metadata, default=json_serial)
|
media.metadata = json.dumps(metadata, default=json_serial)
|
||||||
@@ -235,12 +246,17 @@ def download_media_metadata(media_id):
|
|||||||
media.skip = True
|
media.skip = True
|
||||||
# If the source has a download cap date check the upload date is allowed
|
# If the source has a download cap date check the upload date is allowed
|
||||||
max_cap_age = source.download_cap_date
|
max_cap_age = source.download_cap_date
|
||||||
if max_cap_age:
|
if media.published and max_cap_age:
|
||||||
if media.published < max_cap_age:
|
if media.published < max_cap_age:
|
||||||
# Media was published after the cap date, skip it
|
# Media was published after the cap date, skip it
|
||||||
log.warn(f'Media: {source} / {media} is older than cap age '
|
log.warn(f'Media: {source} / {media} is older than cap age '
|
||||||
f'{max_cap_age}, skipping')
|
f'{max_cap_age}, skipping')
|
||||||
media.skip = True
|
media.skip = True
|
||||||
|
# If the source has a search filter, check the video title matches the filter
|
||||||
|
if source.filter_text and not source.is_regex_match(media.title):
|
||||||
|
# Filter text not found in the media title. Accepts regex string, blank search filter results in this returning false
|
||||||
|
log.warn(f'Media: {source} / {media} does not match {source.filter_text}, skipping')
|
||||||
|
media.skip = True
|
||||||
# If the source has a cut-off check the upload date is within the allowed delta
|
# If the source has a cut-off check the upload date is within the allowed delta
|
||||||
if source.delete_old_media and source.days_to_keep > 0:
|
if source.delete_old_media and source.days_to_keep > 0:
|
||||||
if not isinstance(media.published, datetime):
|
if not isinstance(media.published, datetime):
|
||||||
@@ -341,7 +357,7 @@ def download_media(media_id):
|
|||||||
log.info(f'Successfully downloaded media: {media} (UUID: {media.pk}) to: '
|
log.info(f'Successfully downloaded media: {media} (UUID: {media.pk}) to: '
|
||||||
f'"{filepath}"')
|
f'"{filepath}"')
|
||||||
# Link the media file to the object and update info about the download
|
# Link the media file to the object and update info about the download
|
||||||
media.media_file.name = str(filepath)
|
media.media_file.name = str(media.source.type_directory_path / media.filename)
|
||||||
media.downloaded = True
|
media.downloaded = True
|
||||||
media.download_date = timezone.now()
|
media.download_date = timezone.now()
|
||||||
media.downloaded_filesize = os.path.getsize(filepath)
|
media.downloaded_filesize = os.path.getsize(filepath)
|
||||||
|
|||||||
@@ -25,12 +25,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{mm}</td>
|
<td>{mm}</td>
|
||||||
<td>Media publish year in MM</td>
|
<td>Media publish month in MM</td>
|
||||||
<td>01</td>
|
<td>01</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{dd}</td>
|
<td>{dd}</td>
|
||||||
<td>Media publish year in DD</td>
|
<td>Media publish day in DD</td>
|
||||||
<td>31</td>
|
<td>31</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -43,6 +43,11 @@
|
|||||||
<td>Full source name</td>
|
<td>Full source name</td>
|
||||||
<td>My Source</td>
|
<td>My Source</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{uploader}</td>
|
||||||
|
<td>Uploader name</td>
|
||||||
|
<td>Some Channel Name</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{title}</td>
|
<td>{title}</td>
|
||||||
<td>Lower case media title, max 80 chars</td>
|
<td>Lower case media title, max 80 chars</td>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<h2 class="truncate">Runtime infomation</h2>
|
<h2 class="truncate">Runtime information</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -9,10 +9,24 @@
|
|||||||
{% if media.title %}<h2 class="truncate"><strong>{{ media.title }}</strong></h2>{% endif %}
|
{% if media.title %}<h2 class="truncate"><strong>{{ media.title }}</strong></h2>{% endif %}
|
||||||
<p class="truncate"><strong><a href="{{ media.url }}" target="_blank"><i class="fas fa-link"></i> {{ media.url }}</a></strong></p>
|
<p class="truncate"><strong><a href="{{ media.url }}" target="_blank"><i class="fas fa-link"></i> {{ media.url }}</a></strong></p>
|
||||||
<p class="truncate">Downloading to: <strong>{{ media.source.directory_path }}</strong></p>
|
<p class="truncate">Downloading to: <strong>{{ media.source.directory_path }}</strong></p>
|
||||||
|
{% if download_state == 'downloaded' %}
|
||||||
|
{% if 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>
|
||||||
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% if not media.can_download %}{% include 'errorbox.html' with message='Media cannot be downloaded because it has no formats which match the source requirements.' %}{% endif %}
|
{% if media.manual_skip %}{% include 'errorbox.html' with message='Media is marked to be skipped and will not be downloaded.' %}
|
||||||
{% if media.skip %}{% include 'errorbox.html' with message='Media is marked to be skipped and will not be downloaded.' %}{% endif %}
|
{% else %}
|
||||||
|
{% if not media.can_download %}{% include 'errorbox.html' with message='Media cannot be downloaded because it has no formats which match the source requirements.' %}{% endif %}
|
||||||
|
{% if media.skip %}{% include 'errorbox.html' with message='This media may be skipped due to error(s).' %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% include 'infobox.html' with message=message %}
|
{% include 'infobox.html' with message=message %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12 m7">
|
<div class="col s12 m7">
|
||||||
@@ -156,10 +170,10 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
{% if media.skip %}
|
{% if media.manual_skip %}
|
||||||
<a href="{% url 'sync:enable-media' pk=media.pk %}" class="btn">Enable (unskip) media <i class="fas fa-cloud-download-alt"></i></a>
|
<a href="{% url 'sync:enable-media' pk=media.pk %}" class="btn">Unskip media (manually) <i class="fas fa-cloud-download-alt"></i></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'sync:skip-media' pk=media.pk %}" class="btn delete-button">Skip media <i class="fas fa-times-circle"></i></a>
|
<a href="{% url 'sync:skip-media' pk=media.pk %}" class="btn delete-button">Manually mark media to be skipped <i class="fas fa-times-circle"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12 m9">
|
<div class="col s12 m6">
|
||||||
<h1 class="truncate">Media</h1>
|
<h1 class="truncate">Media</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="col s12 m3">
|
<div class="col s12 m3">
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
<a href="{% url 'sync:media' %}?show_skipped=yes{% if source %}&filter={{ source.pk }}{% endif %}" class="btn"><i class="far fa-eye"></i> Show skipped media</a>
|
<a href="{% url 'sync:media' %}?show_skipped=yes{% if source %}&filter={{ source.pk }}{% endif %}" class="btn"><i class="far fa-eye"></i> Show skipped media</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col s12 m3">
|
||||||
|
{% if only_skipped %}
|
||||||
|
<a href="{% url 'sync:media' %}{% if source %}?filter={{ source.pk }}{% endif %}" class="btn"><i class="far fa-eye-slash"></i> Only skipped media</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'sync:media' %}?only_skipped=yes{% if source %}&filter={{ source.pk }}{% endif %}" class="btn"><i class="far fa-eye"></i> Only skipped media</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'infobox.html' with message=message %}
|
{% include 'infobox.html' with message=message %}
|
||||||
<div class="row no-margin-bottom">
|
<div class="row no-margin-bottom">
|
||||||
@@ -29,8 +36,10 @@
|
|||||||
{% if m.downloaded %}
|
{% if m.downloaded %}
|
||||||
<i class="fas fa-check-circle" title="Downloaded"></i> {{ m.download_date|date:'Y-m-d' }}
|
<i class="fas fa-check-circle" title="Downloaded"></i> {{ m.download_date|date:'Y-m-d' }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if m.skip %}
|
{% if m.manual_skip %}
|
||||||
<span class="error-text"><i class="fas fa-times" title="Skipping media"></i> Skipped</span>
|
<span class="error-text"><i class="fas fa-times" title="Skipping media"></i> Manually skipped</span>
|
||||||
|
{% elif m.skip %}
|
||||||
|
<span class="error-text"><i class="fas fa-times" title="Skipping media"></i> Skipped by system</span>
|
||||||
{% elif not m.source.download_media %}
|
{% elif not m.source.download_media %}
|
||||||
<span class="error-text"><i class="fas fa-times" title="Not downloading media for this source"></i> Disabled at source</span>
|
<span class="error-text"><i class="fas fa-times" title="Not downloading media for this source"></i> Disabled at source</span>
|
||||||
{% elif not m.has_metadata %}
|
{% elif not m.has_metadata %}
|
||||||
@@ -55,5 +64,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped %}
|
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped only_skipped=only_skipped%}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
<td class="hide-on-small-only">Directory</td>
|
<td class="hide-on-small-only">Directory</td>
|
||||||
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
|
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Filter text">
|
||||||
|
<td class="hide-on-small-only">Filter text</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Filter text<br></span><strong>{{ source.filter_text }}</strong></td>
|
||||||
|
</tr>
|
||||||
<tr title="Media file name format to use for saving files">
|
<tr title="Media file name format to use for saving files">
|
||||||
<td class="hide-on-small-only">Media format</td>
|
<td class="hide-on-small-only">Media format</td>
|
||||||
<td><span class="hide-on-med-and-up">Media format<br></span><strong>{{ source.media_format }}</strong></td>
|
<td><span class="hide-on-med-and-up">Media format<br></span><strong>{{ source.media_format }}</strong></td>
|
||||||
@@ -111,6 +115,14 @@
|
|||||||
<td class="hide-on-small-only">Write NFO?</td>
|
<td class="hide-on-small-only">Write NFO?</td>
|
||||||
<td><span class="hide-on-med-and-up">Write NFO?<br></span><strong>{% if source.write_nfo %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
<td><span class="hide-on-med-and-up">Write NFO?<br></span><strong>{% if source.write_nfo %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Should a JSON file be written with the media?">
|
||||||
|
<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 %}
|
{% if source.delete_old_media and source.days_to_keep > 0 %}
|
||||||
<tr title="Days after which your media from this source will be locally deleted">
|
<tr title="Days after which your media from this source will be locally deleted">
|
||||||
<td class="hide-on-small-only">Delete old media</td>
|
<td class="hide-on-small-only">Delete old media</td>
|
||||||
@@ -126,6 +138,55 @@
|
|||||||
<td class="hide-on-small-only">UUID</td>
|
<td class="hide-on-small-only">UUID</td>
|
||||||
<td><span class="hide-on-med-and-up">UUID<br></span><strong>{{ source.uuid }}</strong></td>
|
<td><span class="hide-on-med-and-up">UUID<br></span><strong>{{ source.uuid }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="{{ _('Embedding thumbnail?') }}">
|
||||||
|
<td class="hide-on-small-only">{{ _("Embed thumbnail?") }}:</td>
|
||||||
|
<td><span class="hide-on-med-and-up">{{ _("Embed thumbnail?") }}<br></span><strong><i class="fas {% if source.embed_thumbnail %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr title="{{ _('Embedding metadata?') }}">
|
||||||
|
<td class="hide-on-small-only">{{ _("Embed metadata?") }}:</td>
|
||||||
|
<td><span class="hide-on-med-and-up">{{ _("Embed metadata?") }}<br></span><strong><i class="fas {% if source.embed_metadata %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr title="{{ _('Is sponsorblock enabled?') }}">
|
||||||
|
<td class="hide-on-small-only">{{ _("SponsorBlock?") }}:</td>
|
||||||
|
<td><span class="hide-on-med-and-up">{{ _("Sponsorblock enabled?") }}<br></span><strong><i class="fas {% if source.enable_sponsorblock %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% if source.enable_sponsorblock %}
|
||||||
|
<tr title="{{ _('SponsorBlock: What to block?') }}">
|
||||||
|
<td class="hide-on-small-only">{{ _("What blocked?") }}:</td>
|
||||||
|
<td><span class="hide-on-med-and-up">{{ _("What blocked?") }}<br></span><strong>
|
||||||
|
{% if source.sponsorblock_categories.all_choice in source.sponsorblock_categories.selected_choices %}
|
||||||
|
{% for k,v in source.sponsorblock_categories.possible_choices %}
|
||||||
|
{{ v }}: <i class="fas fa-check"></i><BR>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for c in source.sponsorblock_categories.selected_choices %}
|
||||||
|
{% for k,v in source.sponsorblock_categories.possible_choices %}
|
||||||
|
{% if k == c %} {{ v }}: <i class="fas fa-check"></i><BR>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<tr title="{{ _('Are Subtitles downloaded?') }}">
|
||||||
|
<td class="hide-on-small-only">{{ _("Download subtitles?") }}:</td>
|
||||||
|
<td><span class="hide-on-med-and-up">{{ _("Download subtitles?") }}:</span><strong><i class="fas {% if source.write_subtitles %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% if source.write_subtitles %}
|
||||||
|
<tr title="{{ _('Are auto subs accepted?') }}">
|
||||||
|
<td class="hide-on-small-only">{{ _("Auto-generated subtitles?") }}:</td>
|
||||||
|
<td><span class="hide-on-med-and-up">{{ _("Auto-generated subtitles?") }}:</span><strong><i class="fas {% if source.auto_subtitles %}fa-check{% else %}fa-times{% endif %}"></i></strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr title="{{ _('Subs langs?') }}">
|
||||||
|
<td class="hide-on-small-only">{{ _("Subs langs?") }}:</td>
|
||||||
|
<td><span class="hide-on-med-and-up">{{ _("Subs langs?") }}:</span><strong>{{source.sub_langs}}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,15 +24,18 @@
|
|||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="collection">
|
<div class="collection">
|
||||||
{% for source in sources %}
|
{% for source in sources %}
|
||||||
<a href="{% url 'sync:source' pk=source.pk %}" class="collection-item">
|
<span class="collection-item flex-collection-container">
|
||||||
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
<a href="{% url 'sync:source' pk=source.pk %}" class="flex-grow">
|
||||||
{{ source.format_summary }}<br>
|
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
||||||
{% if source.has_failed %}
|
{{ source.format_summary }}<br>
|
||||||
<span class="error-text"><i class="fas fa-exclamation-triangle"></i> <strong>Source has permanent failures</strong></span>
|
{% if source.has_failed %}
|
||||||
{% else %}
|
<span class="error-text"><i class="fas fa-exclamation-triangle"></i> <strong>Source has permanent failures</strong></span>
|
||||||
<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 %}
|
{% else %}
|
||||||
{% endif %}
|
<strong>{{ source.media_count }}</strong> media items, <strong>{{ source.downloaded_count }}</strong> downloaded{% if source.delete_old_media and source.days_to_keep > 0 %}, keeping {{ source.days_to_keep }} days of media{% endif %}
|
||||||
</a>
|
{% 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 %}
|
{% empty %}
|
||||||
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
{% for task in scheduled %}
|
{% for task in scheduled %}
|
||||||
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
||||||
<i class="fas fa-stopwatch"></i> <strong>{{ task }}</strong><br>
|
<i class="fas fa-stopwatch"></i> <strong>{{ task }}</strong><br>
|
||||||
{% if task.instance.index_schedule %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.<br>{% endif %}
|
{% if task.instance.index_schedule and task.repeat > 0 %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.<br>{% endif %}
|
||||||
<i class="fas fa-redo"></i> Task will run {% if task.run_now %}<strong>immediately</strong>{% else %}at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>{% endif %}
|
<i class="fas fa-redo"></i> Task will run {% if task.run_now %}<strong>immediately</strong>{% else %}at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
7
tubesync/sync/templates/widgets/checkbox_option.html
Normal file
7
tubesync/sync/templates/widgets/checkbox_option.html
Normal 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>
|
||||||
5
tubesync/sync/templates/widgets/checkbox_select.html
Normal file
5
tubesync/sync/templates/widgets/checkbox_select.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
</label>
|
||||||
|
{% for option in widget.multipleChoiceProperties %}
|
||||||
|
{% include option.template_name with option=option %}
|
||||||
|
{% endfor %}
|
||||||
|
<label>
|
||||||
6715
tubesync/sync/testdata/metadata_2023-06-29.json
vendored
Normal file
6715
tubesync/sync/testdata/metadata_2023-06-29.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
5026
tubesync/sync/testdata/metadata_low_formats.json
vendored
Normal file
5026
tubesync/sync/testdata/metadata_low_formats.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -14,6 +14,7 @@ from django.test import TestCase, Client
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from background_task.models import Task
|
from background_task.models import Task
|
||||||
from .models import Source, Media
|
from .models import Source, Media
|
||||||
|
from .tasks import cleanup_old_media
|
||||||
|
|
||||||
|
|
||||||
class FrontEndTestCase(TestCase):
|
class FrontEndTestCase(TestCase):
|
||||||
@@ -21,7 +22,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Disable general logging for test case
|
# Disable general logging for test case
|
||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
def test_dashboard(self):
|
def test_dashboard(self):
|
||||||
c = Client()
|
c = Client()
|
||||||
response = c.get('/')
|
response = c.get('/')
|
||||||
@@ -36,6 +37,9 @@ class FrontEndTestCase(TestCase):
|
|||||||
test_sources = {
|
test_sources = {
|
||||||
'youtube-channel': {
|
'youtube-channel': {
|
||||||
'valid': (
|
'valid': (
|
||||||
|
'https://m.youtube.com/testchannel',
|
||||||
|
'https://m.youtube.com/c/testchannel',
|
||||||
|
'https://m.youtube.com/c/testchannel/videos',
|
||||||
'https://www.youtube.com/testchannel',
|
'https://www.youtube.com/testchannel',
|
||||||
'https://www.youtube.com/c/testchannel',
|
'https://www.youtube.com/c/testchannel',
|
||||||
'https://www.youtube.com/c/testchannel/videos',
|
'https://www.youtube.com/c/testchannel/videos',
|
||||||
@@ -47,6 +51,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'invalid_domain': (
|
'invalid_domain': (
|
||||||
'https://www.test.com/c/testchannel',
|
'https://www.test.com/c/testchannel',
|
||||||
'https://www.example.com/c/testchannel',
|
'https://www.example.com/c/testchannel',
|
||||||
|
'https://n.youtube.com/c/testchannel',
|
||||||
),
|
),
|
||||||
'invalid_path': (
|
'invalid_path': (
|
||||||
'https://www.youtube.com/test/invalid',
|
'https://www.youtube.com/test/invalid',
|
||||||
@@ -62,6 +67,8 @@ class FrontEndTestCase(TestCase):
|
|||||||
},
|
},
|
||||||
'youtube-channel-id': {
|
'youtube-channel-id': {
|
||||||
'valid': (
|
'valid': (
|
||||||
|
'https://m.youtube.com/channel/channelid',
|
||||||
|
'https://m.youtube.com/channel/channelid/videos',
|
||||||
'https://www.youtube.com/channel/channelid',
|
'https://www.youtube.com/channel/channelid',
|
||||||
'https://www.youtube.com/channel/channelid/videos',
|
'https://www.youtube.com/channel/channelid/videos',
|
||||||
),
|
),
|
||||||
@@ -72,6 +79,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'invalid_domain': (
|
'invalid_domain': (
|
||||||
'https://www.test.com/channel/channelid',
|
'https://www.test.com/channel/channelid',
|
||||||
'https://www.example.com/channel/channelid',
|
'https://www.example.com/channel/channelid',
|
||||||
|
'https://n.youtube.com/channel/channelid',
|
||||||
),
|
),
|
||||||
'invalid_path': (
|
'invalid_path': (
|
||||||
'https://www.youtube.com/test/invalid',
|
'https://www.youtube.com/test/invalid',
|
||||||
@@ -83,6 +91,8 @@ class FrontEndTestCase(TestCase):
|
|||||||
},
|
},
|
||||||
'youtube-playlist': {
|
'youtube-playlist': {
|
||||||
'valid': (
|
'valid': (
|
||||||
|
'https://m.youtube.com/playlist?list=testplaylist',
|
||||||
|
'https://m.youtube.com/watch?v=testvideo&list=testplaylist',
|
||||||
'https://www.youtube.com/playlist?list=testplaylist',
|
'https://www.youtube.com/playlist?list=testplaylist',
|
||||||
'https://www.youtube.com/watch?v=testvideo&list=testplaylist',
|
'https://www.youtube.com/watch?v=testvideo&list=testplaylist',
|
||||||
),
|
),
|
||||||
@@ -93,6 +103,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'invalid_domain': (
|
'invalid_domain': (
|
||||||
'https://www.test.com/playlist?list=testplaylist',
|
'https://www.test.com/playlist?list=testplaylist',
|
||||||
'https://www.example.com/playlist?list=testplaylist',
|
'https://www.example.com/playlist?list=testplaylist',
|
||||||
|
'https://n.youtube.com/playlist?list=testplaylist',
|
||||||
),
|
),
|
||||||
'invalid_path': (
|
'invalid_path': (
|
||||||
'https://www.youtube.com/notplaylist?list=testplaylist',
|
'https://www.youtube.com/notplaylist?list=testplaylist',
|
||||||
@@ -164,6 +175,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
'download_cap': 0,
|
'download_cap': 0,
|
||||||
|
'filter_text':'.*',
|
||||||
'index_schedule': 3600,
|
'index_schedule': 3600,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -172,7 +184,8 @@ class FrontEndTestCase(TestCase):
|
|||||||
'source_acodec': 'OPUS',
|
'source_acodec': 'OPUS',
|
||||||
'prefer_60fps': False,
|
'prefer_60fps': False,
|
||||||
'prefer_hdr': False,
|
'prefer_hdr': False,
|
||||||
'fallback': 'f'
|
'fallback': 'f',
|
||||||
|
'sub_langs': 'en',
|
||||||
}
|
}
|
||||||
response = c.post('/source-add', data)
|
response = c.post('/source-add', data)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
@@ -205,6 +218,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
'download_cap': 0,
|
'download_cap': 0,
|
||||||
|
'filter_text':'.*',
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -213,7 +227,8 @@ class FrontEndTestCase(TestCase):
|
|||||||
'source_acodec': Source.SOURCE_ACODEC_OPUS,
|
'source_acodec': Source.SOURCE_ACODEC_OPUS,
|
||||||
'prefer_60fps': False,
|
'prefer_60fps': False,
|
||||||
'prefer_hdr': False,
|
'prefer_hdr': False,
|
||||||
'fallback': Source.FALLBACK_FAIL
|
'fallback': Source.FALLBACK_FAIL,
|
||||||
|
'sub_langs': 'en',
|
||||||
}
|
}
|
||||||
response = c.post(f'/source-update/{source_uuid}', data)
|
response = c.post(f'/source-update/{source_uuid}', data)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
@@ -234,6 +249,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
'download_cap': 0,
|
'download_cap': 0,
|
||||||
|
'filter_text':'.*',
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -242,7 +258,8 @@ class FrontEndTestCase(TestCase):
|
|||||||
'source_acodec': Source.SOURCE_ACODEC_OPUS,
|
'source_acodec': Source.SOURCE_ACODEC_OPUS,
|
||||||
'prefer_60fps': False,
|
'prefer_60fps': False,
|
||||||
'prefer_hdr': False,
|
'prefer_hdr': False,
|
||||||
'fallback': Source.FALLBACK_FAIL
|
'fallback': Source.FALLBACK_FAIL,
|
||||||
|
'sub_langs': 'en',
|
||||||
}
|
}
|
||||||
response = c.post(f'/source-update/{source_uuid}', data)
|
response = c.post(f'/source-update/{source_uuid}', data)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
@@ -454,11 +471,14 @@ metadata_60fps_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_60
|
|||||||
metadata_60fps = open(metadata_60fps_filepath, 'rt').read()
|
metadata_60fps = open(metadata_60fps_filepath, 'rt').read()
|
||||||
metadata_60fps_hdr_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_60fps_hdr.json'
|
metadata_60fps_hdr_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_60fps_hdr.json'
|
||||||
metadata_60fps_hdr = open(metadata_60fps_hdr_filepath, 'rt').read()
|
metadata_60fps_hdr = open(metadata_60fps_hdr_filepath, 'rt').read()
|
||||||
|
metadata_20230629_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_2023-06-29.json'
|
||||||
|
metadata_20230629 = open(metadata_20230629_filepath, 'rt').read()
|
||||||
all_test_metadata = {
|
all_test_metadata = {
|
||||||
'boring': metadata,
|
'boring': metadata,
|
||||||
'hdr': metadata_hdr,
|
'hdr': metadata_hdr,
|
||||||
'60fps': metadata_60fps,
|
'60fps': metadata_60fps,
|
||||||
'60fps+hdr': metadata_60fps_hdr,
|
'60fps+hdr': metadata_60fps_hdr,
|
||||||
|
'20230629': metadata_20230629,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -491,7 +511,7 @@ class FilepathTestCase(TestCase):
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_source_dirname(self):
|
def test_source_media_format(self):
|
||||||
# Check media format validation is working
|
# Check media format validation is working
|
||||||
# Empty
|
# Empty
|
||||||
self.source.media_format = ''
|
self.source.media_format = ''
|
||||||
@@ -1387,3 +1407,118 @@ class FormatMatchingTestCase(TestCase):
|
|||||||
match_type, format_code = self.media.get_best_video_format()
|
match_type, format_code = self.media.get_best_video_format()
|
||||||
self.assertEqual(format_code, expected_format_code)
|
self.assertEqual(format_code, expected_format_code)
|
||||||
self.assertEqual(match_type, expeceted_match_type)
|
self.assertEqual(match_type, expeceted_match_type)
|
||||||
|
|
||||||
|
def test_metadata_20230629(self):
|
||||||
|
self.media.metadata = all_test_metadata['20230629']
|
||||||
|
expected_matches = {
|
||||||
|
# (format, vcodec, prefer_60fps, prefer_hdr): (match_type, code),
|
||||||
|
('360p', 'AVC1', False, True): (False, '134'), # Fallback match, no hdr
|
||||||
|
('360p', 'AVC1', True, False): (False, '134'), # Fallback match, no 60fps
|
||||||
|
('360p', 'AVC1', True, True): (False, '332'), # Fallback match, 60fps+hdr, switched to VP9
|
||||||
|
('360p', 'VP9', False, False): (True, '243'), # Exact match
|
||||||
|
('360p', 'VP9', False, True): (True, '332'), # Exact match, hdr
|
||||||
|
('360p', 'VP9', True, False): (False, '332'), # Fallback match, 60fps, extra hdr
|
||||||
|
('360p', 'VP9', True, True): (True, '332'), # Exact match, 60fps+hdr
|
||||||
|
('480p', 'AVC1', False, False): (True, '135'), # Exact match
|
||||||
|
('480p', 'AVC1', False, True): (False, '135'), # Fallback match, no hdr
|
||||||
|
('480p', 'AVC1', True, False): (False, '135'), # Fallback match, no 60fps
|
||||||
|
('480p', 'AVC1', True, True): (False, '333'), # Fallback match, 60fps+hdr, switched to VP9
|
||||||
|
('480p', 'VP9', False, False): (True, '244'), # Exact match
|
||||||
|
('480p', 'VP9', False, True): (True, '333'), # Exact match, hdr
|
||||||
|
('480p', 'VP9', True, False): (False, '333'), # Fallback match, 60fps, extra hdr
|
||||||
|
('480p', 'VP9', True, True): (True, '333'), # Exact match, 60fps+hdr
|
||||||
|
('720p', 'AVC1', False, False): (True, '136'), # Exact match
|
||||||
|
('720p', 'AVC1', False, True): (False, '136'), # Fallback match, no hdr
|
||||||
|
('720p', 'AVC1', True, False): (True, '298'), # Exact match, 60fps
|
||||||
|
('720p', 'AVC1', True, True): (False, '334'), # Fallback match, 60fps+hdr, switched to VP9
|
||||||
|
('720p', 'VP9', False, False): (True, '247'), # Exact match
|
||||||
|
('720p', 'VP9', False, True): (True, '334'), # Exact match, hdr
|
||||||
|
('720p', 'VP9', True, False): (True, '302'), # Exact match, 60fps
|
||||||
|
('720p', 'VP9', True, True): (True, '334'), # Exact match, 60fps+hdr
|
||||||
|
('1440p', 'AVC1', False, False): (False, '308'), # Fallback match, 60fps, switched to VP9 (no 1440p AVC1)
|
||||||
|
('1440p', 'AVC1', False, True): (False, '336'), # Fallback match, 60fps+hdr, switched to VP9 (no 1440p AVC1)
|
||||||
|
('1440p', 'AVC1', True, False): (False, '308'), # Fallback match, 60fps, switched to VP9 (no 1440p AVC1)
|
||||||
|
('1440p', 'AVC1', True, True): (False, '336'), # Fallback match, 60fps+hdr, switched to VP9 (no 1440p AVC1)
|
||||||
|
('1440p', 'VP9', False, False): (False, '308'), # Fallback, 60fps
|
||||||
|
('1440p', 'VP9', False, True): (True, '336'), # Exact match, hdr
|
||||||
|
('1440p', 'VP9', True, False): (True, '308'), # Exact match, 60fps
|
||||||
|
('1440p', 'VP9', True, True): (True, '336'), # Exact match, 60fps+hdr
|
||||||
|
('2160p', 'AVC1', False, False): (False, '315'), # Fallback, 60fps, switched to VP9 (no 2160p AVC1)
|
||||||
|
('2160p', 'AVC1', False, True): (False, '337'), # Fallback match, 60fps+hdr, switched to VP9 (no 2160p AVC1)
|
||||||
|
('2160p', 'AVC1', True, False): (False, '315'), # Fallback, switched to VP9 (no 2160p AVC1)
|
||||||
|
('2160p', 'AVC1', True, True): (False, '337'), # Fallback match, 60fps+hdr, switched to VP9 (no 2160p AVC1)
|
||||||
|
('2160p', 'VP9', False, False): (False, '315'), # Fallback, 60fps
|
||||||
|
('2160p', 'VP9', False, True): (True, '337'), # Exact match, hdr
|
||||||
|
('2160p', 'VP9', True, False): (True, '315'), # Exact match, 60fps
|
||||||
|
('2160p', 'VP9', True, True): (True, '337'), # Exact match, 60fps+hdr
|
||||||
|
('4320p', 'AVC1', False, False): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
|
||||||
|
('4320p', 'AVC1', False, True): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
|
||||||
|
('4320p', 'AVC1', True, False): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
|
||||||
|
('4320p', 'AVC1', True, True): (False, '272'), # Fallback, 60fps, switched to VP9 (no 4320p AVC1, no other 8k streams)
|
||||||
|
('4320p', 'VP9', False, False): (False, '272'), # Fallback, 60fps (no other 8k streams)
|
||||||
|
('4320p', 'VP9', False, True): (False, '272'), # Fallback, 60fps (no other 8k streams)
|
||||||
|
('4320p', 'VP9', True, False): (True, '272'), # Exact match, 60fps
|
||||||
|
('4320p', 'VP9', True, True): (False, '272'), # Fallback, 60fps (no other 8k streams)
|
||||||
|
}
|
||||||
|
for params, expected in expected_matches.items():
|
||||||
|
resolution, vcodec, prefer_60fps, prefer_hdr = params
|
||||||
|
expeceted_match_type, expected_format_code = expected
|
||||||
|
self.source.source_resolution = resolution
|
||||||
|
self.source.source_vcodec = vcodec
|
||||||
|
self.source.prefer_60fps = prefer_60fps
|
||||||
|
self.source.prefer_hdr = prefer_hdr
|
||||||
|
# The aim here is to execute the matching code to find error paths, specific testing isn't required
|
||||||
|
self.media.get_best_video_format()
|
||||||
|
self.media.get_best_audio_format()
|
||||||
|
|
||||||
|
def test_is_regex_match(self):
|
||||||
|
|
||||||
|
self.media.metadata = all_test_metadata['boring']
|
||||||
|
expected_matches = {
|
||||||
|
('.*'): (True),
|
||||||
|
('no fancy stuff'): (True),
|
||||||
|
('No fancy stuff'): (False),
|
||||||
|
('(?i)No fancy stuff'): (True), #set case insensitive flag
|
||||||
|
('no'): (True),
|
||||||
|
('Foo'): (False),
|
||||||
|
('^(?!.*fancy).*$'): (False),
|
||||||
|
('^(?!.*funny).*$'): (True),
|
||||||
|
('(?=.*f.*)(?=.{0,2}|.{4,})'): (True),
|
||||||
|
('f{4,}'): (False),
|
||||||
|
('^[^A-Z]*$'): (True),
|
||||||
|
('^[^a-z]*$'): (False),
|
||||||
|
('^[^\\s]*$'): (False)
|
||||||
|
}
|
||||||
|
|
||||||
|
for params, expected in expected_matches.items():
|
||||||
|
self.source.filter_text = params
|
||||||
|
expected_match_result = expected
|
||||||
|
self.assertEqual(self.source.is_regex_match(self.media.title), expected_match_result)
|
||||||
|
|
||||||
|
class TasksTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Disable general logging for test case
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
def test_delete_old_media(self):
|
||||||
|
src1 = Source.objects.create(key='aaa', name='aaa', directory='/tmp/a', delete_old_media=False, days_to_keep=14)
|
||||||
|
src2 = Source.objects.create(key='bbb', name='bbb', directory='/tmp/b', delete_old_media=True, days_to_keep=14)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
m11 = Media.objects.create(source=src1, downloaded=True, key='a11', download_date=now - timedelta(days=5))
|
||||||
|
m12 = Media.objects.create(source=src1, downloaded=True, key='a12', download_date=now - timedelta(days=25))
|
||||||
|
m13 = Media.objects.create(source=src1, downloaded=False, key='a13')
|
||||||
|
|
||||||
|
m21 = Media.objects.create(source=src2, downloaded=True, key='a21', download_date=now - timedelta(days=5))
|
||||||
|
m22 = Media.objects.create(source=src2, downloaded=True, key='a22', download_date=now - timedelta(days=25))
|
||||||
|
m23 = Media.objects.create(source=src2, downloaded=False, key='a23')
|
||||||
|
|
||||||
|
self.assertEquals(src1.media_source.all().count(), 3)
|
||||||
|
self.assertEquals(src2.media_source.all().count(), 3)
|
||||||
|
|
||||||
|
cleanup_old_media()
|
||||||
|
|
||||||
|
self.assertEquals(src1.media_source.all().count(), 3)
|
||||||
|
self.assertEquals(src2.media_source.all().count(), 2)
|
||||||
|
self.assertEquals(Media.objects.filter(pk=m22.pk).exists(), False)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.urls import path
|
|||||||
from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView,
|
from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView,
|
||||||
SourceView, UpdateSourceView, DeleteSourceView, MediaView,
|
SourceView, UpdateSourceView, DeleteSourceView, MediaView,
|
||||||
MediaThumbView, MediaItemView, MediaRedownloadView, MediaSkipView,
|
MediaThumbView, MediaItemView, MediaRedownloadView, MediaSkipView,
|
||||||
MediaEnableView, TasksView, CompletedTasksView, ResetTasks,
|
MediaEnableView, MediaContent, TasksView, CompletedTasksView, ResetTasks,
|
||||||
MediaServersView, AddMediaServerView, MediaServerView,
|
MediaServersView, AddMediaServerView, MediaServerView,
|
||||||
DeleteMediaServerView, UpdateMediaServerView)
|
DeleteMediaServerView, UpdateMediaServerView)
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ urlpatterns = [
|
|||||||
ValidateSourceView.as_view(),
|
ValidateSourceView.as_view(),
|
||||||
name='validate-source'),
|
name='validate-source'),
|
||||||
|
|
||||||
|
path('source-sync-now/<uuid:pk>',
|
||||||
|
SourcesView.as_view(),
|
||||||
|
name='source-sync-now'),
|
||||||
|
|
||||||
path('source-add',
|
path('source-add',
|
||||||
AddSourceView.as_view(),
|
AddSourceView.as_view(),
|
||||||
name='add-source'),
|
name='add-source'),
|
||||||
@@ -70,6 +74,10 @@ urlpatterns = [
|
|||||||
MediaEnableView.as_view(),
|
MediaEnableView.as_view(),
|
||||||
name='enable-media'),
|
name='enable-media'),
|
||||||
|
|
||||||
|
path('media-content/<uuid:pk>',
|
||||||
|
MediaContent.as_view(),
|
||||||
|
name='media-content'),
|
||||||
|
|
||||||
# Task URLs
|
# Task URLs
|
||||||
|
|
||||||
path('tasks',
|
path('tasks',
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ def validate_url(url, validator):
|
|||||||
Validate a URL against a dict of validation requirements. Returns an extracted
|
Validate a URL against a dict of validation requirements. Returns an extracted
|
||||||
part of the URL if the URL is valid, if invalid raises a ValidationError.
|
part of the URL if the URL is valid, if invalid raises a ValidationError.
|
||||||
'''
|
'''
|
||||||
valid_scheme, valid_netloc, valid_path, invalid_paths, valid_query, \
|
valid_scheme, valid_netlocs, valid_path, invalid_paths, valid_query, \
|
||||||
extract_parts = (
|
extract_parts = (
|
||||||
validator['scheme'], validator['domain'], validator['path_regex'],
|
validator['scheme'], validator['domains'], validator['path_regex'],
|
||||||
validator['path_must_not_match'], validator['qs_args'],
|
validator['path_must_not_match'], validator['qs_args'],
|
||||||
validator['extract_key']
|
validator['extract_key']
|
||||||
)
|
)
|
||||||
@@ -25,8 +25,8 @@ def validate_url(url, validator):
|
|||||||
if url_scheme != valid_scheme:
|
if url_scheme != valid_scheme:
|
||||||
raise ValidationError(f'invalid scheme "{url_scheme}" must be "{valid_scheme}"')
|
raise ValidationError(f'invalid scheme "{url_scheme}" must be "{valid_scheme}"')
|
||||||
url_netloc = str(url_parts.netloc).strip().lower()
|
url_netloc = str(url_parts.netloc).strip().lower()
|
||||||
if url_netloc != valid_netloc:
|
if url_netloc not in valid_netlocs:
|
||||||
raise ValidationError(f'invalid domain "{url_netloc}" must be "{valid_netloc}"')
|
raise ValidationError(f'invalid domain "{url_netloc}" must be one of "{valid_netlocs}"')
|
||||||
url_path = str(url_parts.path).strip()
|
url_path = str(url_parts.path).strip()
|
||||||
matches = re.findall(valid_path, url_path)
|
matches = re.findall(valid_path, url_path)
|
||||||
if not matches:
|
if not matches:
|
||||||
@@ -78,7 +78,7 @@ def resize_image_to_height(image, width, height):
|
|||||||
if scaled_width < width:
|
if scaled_width < width:
|
||||||
# Width too small, stretch it
|
# Width too small, stretch it
|
||||||
scaled_width = width
|
scaled_width = width
|
||||||
image = image.resize((scaled_width, height), Image.ANTIALIAS)
|
image = image.resize((scaled_width, height), Image.LANCZOS)
|
||||||
if scaled_width > width:
|
if scaled_width > width:
|
||||||
# Width too large, crop it
|
# Width too large, crop it
|
||||||
delta = scaled_width - width
|
delta = scaled_width - width
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import Http404
|
from django.http import FileResponse, Http404, HttpResponseNotFound, HttpResponseRedirect
|
||||||
from django.views.generic import TemplateView, ListView, DetailView
|
from django.views.generic import TemplateView, ListView, DetailView
|
||||||
from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView,
|
from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView,
|
||||||
DeleteView)
|
DeleteView)
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from django.core.exceptions import SuspiciousFileOperation
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Q, Count, Sum, When, Case
|
from django.db.models import Q, Count, Sum, When, Case
|
||||||
from django.forms import ValidationError
|
from django.forms import Form, ValidationError
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.utils._os import safe_join
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from common.utils import append_uri_params
|
from common.utils import append_uri_params
|
||||||
@@ -66,7 +70,7 @@ class DashboardView(TemplateView):
|
|||||||
data['average_bytes_per_media'] = 0
|
data['average_bytes_per_media'] = 0
|
||||||
# Latest downloads
|
# Latest downloads
|
||||||
data['latest_downloads'] = Media.objects.filter(
|
data['latest_downloads'] = Media.objects.filter(
|
||||||
downloaded=True
|
downloaded=True, downloaded_filesize__isnull=False
|
||||||
).order_by('-download_date')[:10]
|
).order_by('-download_date')[:10]
|
||||||
# Largest downloads
|
# Largest downloads
|
||||||
data['largest_downloads'] = Media.objects.filter(
|
data['largest_downloads'] = Media.objects.filter(
|
||||||
@@ -92,8 +96,27 @@ class SourcesView(ListView):
|
|||||||
paginate_by = settings.SOURCES_PER_PAGE
|
paginate_by = settings.SOURCES_PER_PAGE
|
||||||
messages = {
|
messages = {
|
||||||
'source-deleted': _('Your selected source has been deleted.'),
|
'source-deleted': _('Your selected source has been deleted.'),
|
||||||
|
'source-refreshed': _('The source has been scheduled to be synced now.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
if args[0].path.startswith("/source-sync-now/"):
|
||||||
|
sobj = Source.objects.get(pk=kwargs["pk"])
|
||||||
|
if sobj is None:
|
||||||
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
|
verbose_name = _('Index media from source "{}" once')
|
||||||
|
index_source_task(
|
||||||
|
str(sobj.pk),
|
||||||
|
queue=str(sobj.pk),
|
||||||
|
repeat=0,
|
||||||
|
verbose_name=verbose_name.format(sobj.name))
|
||||||
|
url = reverse_lazy('sync:sources')
|
||||||
|
url = append_uri_params(url, {'message': 'source-refreshed'})
|
||||||
|
return HttpResponseRedirect(url)
|
||||||
|
else:
|
||||||
|
return super().get(self, *args, **kwargs)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.message = None
|
self.message = None
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -171,7 +194,7 @@ class ValidateSourceView(FormView):
|
|||||||
validation_urls = {
|
validation_urls = {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: {
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: {
|
||||||
'scheme': 'https',
|
'scheme': 'https',
|
||||||
'domain': 'www.youtube.com',
|
'domains': ('m.youtube.com', 'www.youtube.com'),
|
||||||
'path_regex': '^\/(c\/)?([^\/]+)(\/videos)?$',
|
'path_regex': '^\/(c\/)?([^\/]+)(\/videos)?$',
|
||||||
'path_must_not_match': ('/playlist', '/c/playlist'),
|
'path_must_not_match': ('/playlist', '/c/playlist'),
|
||||||
'qs_args': [],
|
'qs_args': [],
|
||||||
@@ -180,7 +203,7 @@ class ValidateSourceView(FormView):
|
|||||||
},
|
},
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: {
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: {
|
||||||
'scheme': 'https',
|
'scheme': 'https',
|
||||||
'domain': 'www.youtube.com',
|
'domains': ('m.youtube.com', 'www.youtube.com'),
|
||||||
'path_regex': '^\/channel\/([^\/]+)(\/videos)?$',
|
'path_regex': '^\/channel\/([^\/]+)(\/videos)?$',
|
||||||
'path_must_not_match': ('/playlist', '/c/playlist'),
|
'path_must_not_match': ('/playlist', '/c/playlist'),
|
||||||
'qs_args': [],
|
'qs_args': [],
|
||||||
@@ -189,7 +212,7 @@ class ValidateSourceView(FormView):
|
|||||||
},
|
},
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: {
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: {
|
||||||
'scheme': 'https',
|
'scheme': 'https',
|
||||||
'domain': 'www.youtube.com',
|
'domains': ('m.youtube.com', 'www.youtube.com'),
|
||||||
'path_regex': '^\/(playlist|watch)$',
|
'path_regex': '^\/(playlist|watch)$',
|
||||||
'path_must_not_match': (),
|
'path_must_not_match': (),
|
||||||
'qs_args': ('list',),
|
'qs_args': ('list',),
|
||||||
@@ -269,23 +292,58 @@ class ValidateSourceView(FormView):
|
|||||||
return append_uri_params(url, fields)
|
return append_uri_params(url, fields)
|
||||||
|
|
||||||
|
|
||||||
class AddSourceView(CreateView):
|
class EditSourceMixin:
|
||||||
|
model = Source
|
||||||
|
fields = ('source_type', 'key', 'name', 'directory', 'filter_text', 'media_format',
|
||||||
|
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||||
|
'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')
|
||||||
|
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: 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)
|
||||||
|
|
||||||
|
|
||||||
|
class AddSourceView(EditSourceMixin, CreateView):
|
||||||
'''
|
'''
|
||||||
Adds a new source, optionally takes some initial data querystring values to
|
Adds a new source, optionally takes some initial data querystring values to
|
||||||
prepopulate some of the more unclear values.
|
prepopulate some of the more unclear values.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
template_name = 'sync/source-add.html'
|
template_name = 'sync/source-add.html'
|
||||||
model = Source
|
|
||||||
fields = ('source_type', 'key', 'name', 'directory', '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')
|
|
||||||
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'),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.prepopulated_data = {}
|
self.prepopulated_data = {}
|
||||||
@@ -312,20 +370,6 @@ class AddSourceView(CreateView):
|
|||||||
initial[k] = v
|
initial[k] = v
|
||||||
return initial
|
return initial
|
||||||
|
|
||||||
def form_valid(self, 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'])
|
|
||||||
)
|
|
||||||
if form.errors:
|
|
||||||
return super().form_invalid(form)
|
|
||||||
return super().form_valid(form)
|
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
|
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
|
||||||
return append_uri_params(url, {'message': 'source-created'})
|
return append_uri_params(url, {'message': 'source-created'})
|
||||||
@@ -364,33 +408,9 @@ class SourceView(DetailView):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class UpdateSourceView(UpdateView):
|
class UpdateSourceView(EditSourceMixin, UpdateView):
|
||||||
|
|
||||||
template_name = 'sync/source-update.html'
|
template_name = 'sync/source-update.html'
|
||||||
model = Source
|
|
||||||
fields = ('source_type', 'key', 'name', 'directory', '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')
|
|
||||||
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'),
|
|
||||||
}
|
|
||||||
|
|
||||||
def form_valid(self, 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'])
|
|
||||||
)
|
|
||||||
if form.errors:
|
|
||||||
return super().form_invalid(form)
|
|
||||||
return super().form_valid(form)
|
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
|
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
|
||||||
@@ -416,11 +436,13 @@ class DeleteSourceView(DeleteView, FormMixin):
|
|||||||
for media in Media.objects.filter(source=source):
|
for media in Media.objects.filter(source=source):
|
||||||
if media.media_file:
|
if media.media_file:
|
||||||
# Delete the media file
|
# Delete the media file
|
||||||
delete_file(media.media_file.name)
|
delete_file(media.media_file.path)
|
||||||
# Delete thumbnail copy if it exists
|
# Delete thumbnail copy if it exists
|
||||||
delete_file(media.thumbpath)
|
delete_file(media.thumbpath)
|
||||||
# Delete NFO file if it exists
|
# Delete NFO file if it exists
|
||||||
delete_file(media.nfopath)
|
delete_file(media.nfopath)
|
||||||
|
# Delete JSON file if it exists
|
||||||
|
delete_file(media.jsonpath)
|
||||||
return super().post(request, *args, **kwargs)
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
@@ -443,6 +465,7 @@ class MediaView(ListView):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.filter_source = None
|
self.filter_source = None
|
||||||
self.show_skipped = False
|
self.show_skipped = False
|
||||||
|
self.only_skipped = False
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
@@ -455,19 +478,27 @@ class MediaView(ListView):
|
|||||||
show_skipped = request.GET.get('show_skipped', '').strip()
|
show_skipped = request.GET.get('show_skipped', '').strip()
|
||||||
if show_skipped == 'yes':
|
if show_skipped == 'yes':
|
||||||
self.show_skipped = True
|
self.show_skipped = True
|
||||||
|
if not self.show_skipped:
|
||||||
|
only_skipped = request.GET.get('only_skipped', '').strip()
|
||||||
|
if only_skipped == 'yes':
|
||||||
|
self.only_skipped = True
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.filter_source:
|
if self.filter_source:
|
||||||
if self.show_skipped:
|
if self.show_skipped:
|
||||||
q = Media.objects.filter(source=self.filter_source)
|
q = Media.objects.filter(source=self.filter_source)
|
||||||
|
elif self.only_skipped:
|
||||||
|
q = Media.objects.filter(Q(source=self.filter_source) & (Q(skip=True) | Q(manual_skip=True)))
|
||||||
else:
|
else:
|
||||||
q = Media.objects.filter(source=self.filter_source, skip=False)
|
q = Media.objects.filter(Q(source=self.filter_source) & (Q(skip=False) & Q(manual_skip=False)))
|
||||||
else:
|
else:
|
||||||
if self.show_skipped:
|
if self.show_skipped:
|
||||||
q = Media.objects.all()
|
q = Media.objects.all()
|
||||||
|
elif self.only_skipped:
|
||||||
|
q = Media.objects.filter(Q(skip=True)|Q(manual_skip=True))
|
||||||
else:
|
else:
|
||||||
q = Media.objects.filter(skip=False)
|
q = Media.objects.filter(Q(skip=False)&Q(manual_skip=False))
|
||||||
return q.order_by('-published', '-created')
|
return q.order_by('-published', '-created')
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
@@ -479,6 +510,7 @@ class MediaView(ListView):
|
|||||||
data['message'] = message.format(name=self.filter_source.name)
|
data['message'] = message.format(name=self.filter_source.name)
|
||||||
data['source'] = self.filter_source
|
data['source'] = self.filter_source
|
||||||
data['show_skipped'] = self.show_skipped
|
data['show_skipped'] = self.show_skipped
|
||||||
|
data['only_skipped'] = self.only_skipped
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -628,6 +660,7 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
|||||||
# If the media has an associated NFO file with it, also delete it
|
# If the media has an associated NFO file with it, also delete it
|
||||||
delete_file(self.object.nfopath)
|
delete_file(self.object.nfopath)
|
||||||
# Reset all download data
|
# Reset all download data
|
||||||
|
self.object.metadata = None
|
||||||
self.object.downloaded = False
|
self.object.downloaded = False
|
||||||
self.object.downloaded_audio_codec = None
|
self.object.downloaded_audio_codec = None
|
||||||
self.object.downloaded_video_codec = None
|
self.object.downloaded_video_codec = None
|
||||||
@@ -637,6 +670,7 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
|||||||
self.object.downloaded_filesize = None
|
self.object.downloaded_filesize = None
|
||||||
# Mark it to be skipped
|
# Mark it to be skipped
|
||||||
self.object.skip = True
|
self.object.skip = True
|
||||||
|
self.object.manual_skip = True
|
||||||
self.object.save()
|
self.object.save()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
@@ -665,6 +699,7 @@ class MediaEnableView(FormView, SingleObjectMixin):
|
|||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
# Mark it as not skipped
|
# Mark it as not skipped
|
||||||
self.object.skip = False
|
self.object.skip = False
|
||||||
|
self.object.manual_skip = False
|
||||||
self.object.save()
|
self.object.save()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
@@ -673,6 +708,52 @@ class MediaEnableView(FormView, SingleObjectMixin):
|
|||||||
return append_uri_params(url, {'message': 'enabled'})
|
return append_uri_params(url, {'message': 'enabled'})
|
||||||
|
|
||||||
|
|
||||||
|
class MediaContent(DetailView):
|
||||||
|
'''
|
||||||
|
Redirect to nginx to download the file
|
||||||
|
'''
|
||||||
|
model = Media
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.object = None
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class TasksView(ListView):
|
class TasksView(ListView):
|
||||||
'''
|
'''
|
||||||
A list of tasks queued to be completed. This is, for example, scraping for new
|
A list of tasks queued to be completed. This is, for example, scraping for new
|
||||||
@@ -1031,4 +1112,4 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
|
|||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
url = reverse_lazy('sync:mediaserver', kwargs={'pk': self.object.pk})
|
url = reverse_lazy('sync:mediaserver', kwargs={'pk': self.object.pk})
|
||||||
return append_uri_params(url, {'message': 'updated'})
|
return append_uri_params(url, {'message': 'updated'})
|
||||||
|
|||||||
@@ -26,13 +26,23 @@ class YouTubeError(yt_dlp.utils.DownloadError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_yt_opts():
|
||||||
|
opts = copy(_defaults)
|
||||||
|
cookie_file = settings.COOKIES_FILE
|
||||||
|
if cookie_file.is_file():
|
||||||
|
cookie_file_path = str(cookie_file.resolve())
|
||||||
|
log.info(f'[youtube-dl] using cookies.txt from: {cookie_file_path}')
|
||||||
|
opts.update({'cookiefile': cookie_file_path})
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
def get_media_info(url):
|
def get_media_info(url):
|
||||||
'''
|
'''
|
||||||
Extracts information from a YouTube URL and returns it as a dict. For a channel
|
Extracts information from a YouTube URL and returns it as a dict. For a channel
|
||||||
or playlist this returns a dict of all the videos on the channel or playlist
|
or playlist this returns a dict of all the videos on the channel or playlist
|
||||||
as well as associated metadata.
|
as well as associated metadata.
|
||||||
'''
|
'''
|
||||||
opts = copy(_defaults)
|
opts = get_yt_opts()
|
||||||
opts.update({
|
opts.update({
|
||||||
'skip_download': True,
|
'skip_download': True,
|
||||||
'forcejson': True,
|
'forcejson': True,
|
||||||
@@ -54,13 +64,20 @@ def get_media_info(url):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def download_media(url, media_format, extension, output_file):
|
def download_media(url, media_format, extension, output_file, info_json,
|
||||||
|
sponsor_categories="all",
|
||||||
|
embed_thumbnail=False, embed_metadata=False, skip_sponsors=True,
|
||||||
|
write_subtitles=False, auto_subtitles=False, sub_langs='en'):
|
||||||
'''
|
'''
|
||||||
Downloads a YouTube URL to a file on disk.
|
Downloads a YouTube URL to a file on disk.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def hook(event):
|
def hook(event):
|
||||||
filename = os.path.basename(event['filename'])
|
filename = os.path.basename(event['filename'])
|
||||||
|
|
||||||
|
if event.get('downloaded_bytes') is None or event.get('total_bytes') is None:
|
||||||
|
return None
|
||||||
|
|
||||||
if event['status'] == 'error':
|
if event['status'] == 'error':
|
||||||
log.error(f'[youtube-dl] error occured downloading: {filename}')
|
log.error(f'[youtube-dl] error occured downloading: {filename}')
|
||||||
elif event['status'] == 'downloading':
|
elif event['status'] == 'downloading':
|
||||||
@@ -91,14 +108,41 @@ def download_media(url, media_format, extension, output_file):
|
|||||||
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
||||||
hook.download_progress = 0
|
hook.download_progress = 0
|
||||||
|
|
||||||
opts = copy(_defaults)
|
ytopts = {
|
||||||
opts.update({
|
|
||||||
'format': media_format,
|
'format': media_format,
|
||||||
'merge_output_format': extension,
|
'merge_output_format': extension,
|
||||||
'outtmpl': output_file,
|
'outtmpl': output_file,
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'progress_hooks': [hook],
|
'progress_hooks': [hook],
|
||||||
})
|
'writeinfojson': info_json,
|
||||||
|
'postprocessors': [],
|
||||||
|
'writesubtitles': write_subtitles,
|
||||||
|
'writeautomaticsub': auto_subtitles,
|
||||||
|
'subtitleslangs': sub_langs.split(','),
|
||||||
|
}
|
||||||
|
|
||||||
|
sbopt = {
|
||||||
|
'key': 'SponsorBlock',
|
||||||
|
'categories': [sponsor_categories]
|
||||||
|
}
|
||||||
|
ffmdopt = {
|
||||||
|
'key': 'FFmpegMetadata',
|
||||||
|
'add_chapters': True,
|
||||||
|
'add_metadata': True
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = get_yt_opts()
|
||||||
|
if embed_thumbnail:
|
||||||
|
ytopts['postprocessors'].append({'key': 'EmbedThumbnail'})
|
||||||
|
if embed_metadata:
|
||||||
|
ffmdopt["add_metadata"] = True
|
||||||
|
if skip_sponsors:
|
||||||
|
ytopts['postprocessors'].append(sbopt)
|
||||||
|
|
||||||
|
ytopts['postprocessors'].append(ffmdopt)
|
||||||
|
|
||||||
|
opts.update(ytopts)
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(opts) as y:
|
with yt_dlp.YoutubeDL(opts) as y:
|
||||||
try:
|
try:
|
||||||
return y.download([url])
|
return y.download([url])
|
||||||
|
|||||||
19
tubesync/tubesync/celery.py
Normal file
19
tubesync/tubesync/celery.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tubesync.settings')
|
||||||
|
REDIS_CONNECTION = os.getenv('REDIS_CONNECTION', 'redis://localhost:6379/0')
|
||||||
|
|
||||||
|
|
||||||
|
app = Celery('tubesync')
|
||||||
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
app.autodiscover_tasks()
|
||||||
|
app.conf.broker_url = REDIS_CONNECTION
|
||||||
|
app.conf.beat_schedule = {
|
||||||
|
'10-second-beat': {
|
||||||
|
'task': 'sync.tasks.housekeeping_task',
|
||||||
|
'schedule': 60.0,
|
||||||
|
'args': ()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ from django.db.backends.utils import CursorWrapper
|
|||||||
|
|
||||||
def patch_ensure_connection():
|
def patch_ensure_connection():
|
||||||
for name, config in settings.DATABASES.items():
|
for name, config in settings.DATABASES.items():
|
||||||
|
|
||||||
|
# Don't patch for PostgreSQL, it doesn't need it and can cause issues
|
||||||
|
if config['ENGINE'] == 'django.db.backends.postgresql':
|
||||||
|
continue
|
||||||
|
|
||||||
module = importlib.import_module(config['ENGINE'] + '.base')
|
module = importlib.import_module(config['ENGINE'] + '.base')
|
||||||
|
|
||||||
def ensure_connection(self):
|
def ensure_connection(self):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
from common.logger import log
|
from common.logger import log
|
||||||
from common.utils import parse_database_connection_string
|
from common.utils import parse_database_connection_string
|
||||||
|
|
||||||
@@ -8,16 +9,20 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
ROOT_DIR = Path('/')
|
ROOT_DIR = Path('/')
|
||||||
CONFIG_BASE_DIR = ROOT_DIR / 'config'
|
CONFIG_BASE_DIR = ROOT_DIR / 'config'
|
||||||
DOWNLOADS_BASE_DIR = ROOT_DIR / 'downloads'
|
DOWNLOADS_BASE_DIR = ROOT_DIR / 'downloads'
|
||||||
|
DJANGO_URL_PREFIX = os.getenv('DJANGO_URL_PREFIX', None)
|
||||||
|
STATIC_URL = str(os.getenv('DJANGO_STATIC_URL', '/static/'))
|
||||||
|
if DJANGO_URL_PREFIX and STATIC_URL:
|
||||||
|
STATIC_URL = urljoin(DJANGO_URL_PREFIX, STATIC_URL[1:])
|
||||||
|
|
||||||
|
|
||||||
# This is not ever meant to be a public web interface so this isn't too critical
|
# This is not ever meant to be a public web interface so this isn't too critical
|
||||||
SECRET_KEY = str(os.getenv('DJANGO_SECRET_KEY', 'tubesync-django-secret'))
|
SECRET_KEY = str(os.getenv('DJANGO_SECRET_KEY', 'tubesync-django-secret'))
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_HOSTS_STR = str(os.getenv('TUBESYNC_HOSTS', '127.0.0.1,localhost'))
|
ALLOWED_HOSTS_STR = str(os.getenv('TUBESYNC_HOSTS', '*'))
|
||||||
ALLOWED_HOSTS = ALLOWED_HOSTS_STR.split(',')
|
ALLOWED_HOSTS = ALLOWED_HOSTS_STR.split(',')
|
||||||
DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
|
DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
|
||||||
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', None)
|
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', DJANGO_URL_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
TIME_ZONE = os.getenv('TZ', 'UTC')
|
TIME_ZONE = os.getenv('TZ', 'UTC')
|
||||||
@@ -59,6 +64,13 @@ if BACKGROUND_TASK_ASYNC_THREADS > MAX_BACKGROUND_TASK_ASYNC_THREADS:
|
|||||||
MEDIA_ROOT = CONFIG_BASE_DIR / 'media'
|
MEDIA_ROOT = CONFIG_BASE_DIR / 'media'
|
||||||
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR
|
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR
|
||||||
YOUTUBE_DL_CACHEDIR = CONFIG_BASE_DIR / 'cache'
|
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_USERNAME = os.getenv('HTTP_USER', '').strip()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
|||||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
VERSION = '0.10.0'
|
VERSION = '0.13.3'
|
||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
@@ -138,7 +138,7 @@ BACKGROUND_TASK_ASYNC_THREADS = 1 # Number of async tasks to run at on
|
|||||||
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8 # For sanity reasons
|
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8 # For sanity reasons
|
||||||
BACKGROUND_TASK_PRIORITY_ORDERING = 'ASC' # Use 'niceness' task priority ordering
|
BACKGROUND_TASK_PRIORITY_ORDERING = 'ASC' # Use 'niceness' task priority ordering
|
||||||
COMPLETED_TASKS_DAYS_TO_KEEP = 7 # Number of days to keep completed tasks
|
COMPLETED_TASKS_DAYS_TO_KEEP = 7 # Number of days to keep completed tasks
|
||||||
|
MAX_ENTRIES_PROCESSING = 0 # Number of videos to process on source refresh (0 for no limit)
|
||||||
|
|
||||||
SOURCES_PER_PAGE = 100
|
SOURCES_PER_PAGE = 100
|
||||||
MEDIA_PER_PAGE = 144
|
MEDIA_PER_PAGE = 144
|
||||||
@@ -149,7 +149,7 @@ MEDIA_THUMBNAIL_WIDTH = 430 # Width in pixels to resize thumbnai
|
|||||||
MEDIA_THUMBNAIL_HEIGHT = 240 # Height in pixels to resize thumbnails to
|
MEDIA_THUMBNAIL_HEIGHT = 240 # Height in pixels to resize thumbnails to
|
||||||
|
|
||||||
|
|
||||||
VIDEO_HEIGHT_CUTOFF = 360 # Smallest resolution in pixels permitted to download
|
VIDEO_HEIGHT_CUTOFF = 240 # Smallest resolution in pixels permitted to download
|
||||||
VIDEO_HEIGHT_IS_HD = 500 # Height in pixels to count as 'HD'
|
VIDEO_HEIGHT_IS_HD = 500 # Height in pixels to count as 'HD'
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +161,7 @@ YOUTUBE_DEFAULTS = {
|
|||||||
'cachedir': False, # Disable on-disk caching
|
'cachedir': False, # Disable on-disk caching
|
||||||
'addmetadata': True, # Embed metadata during postprocessing where available
|
'addmetadata': True, # Embed metadata during postprocessing where available
|
||||||
}
|
}
|
||||||
|
COOKIES_FILE = CONFIG_BASE_DIR / 'cookies.txt'
|
||||||
|
|
||||||
|
|
||||||
MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}'
|
MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}'
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import os
|
import os
|
||||||
|
from urllib.parse import urljoin
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tubesync.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tubesync.settings')
|
||||||
application = get_wsgi_application()
|
DJANGO_URL_PREFIX = os.getenv('DJANGO_URL_PREFIX', None)
|
||||||
|
_application = get_wsgi_application()
|
||||||
|
|
||||||
|
|
||||||
|
def application(environ, start_response):
|
||||||
|
script_name = None
|
||||||
|
if DJANGO_URL_PREFIX:
|
||||||
|
if DJANGO_URL_PREFIX.endswith('/'):
|
||||||
|
script_name = DJANGO_URL_PREFIX
|
||||||
|
else:
|
||||||
|
raise Exception(f'DJANGO_URL_PREFIX must end with a /, '
|
||||||
|
f'got: {DJANGO_URL_PREFIX}')
|
||||||
|
if script_name:
|
||||||
|
static_url = urljoin(script_name, 'static/')
|
||||||
|
environ['SCRIPT_NAME'] = script_name
|
||||||
|
path_info = environ['PATH_INFO']
|
||||||
|
if path_info.startswith(script_name) and not path_info.startswith(static_url):
|
||||||
|
environ['PATH_INFO'] = path_info[len(script_name) - 1:]
|
||||||
|
return _application(environ, start_response)
|
||||||
|
|||||||
Reference in New Issue
Block a user