Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8315efac03 | ||
|
|
35678e3be9 | ||
|
|
e75b446883 | ||
|
|
dd05595558 | ||
|
|
931aa78815 | ||
|
|
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 | ||
|
|
e8d75a79c5 | ||
|
|
ff4be7cfa0 | ||
|
|
c1cb19259e | ||
|
|
837b6c3107 | ||
|
|
ced6314a62 | ||
|
|
bb6c195ae7 | ||
|
|
c280b76777 | ||
|
|
248da767b0 | ||
|
|
1069b87295 | ||
|
|
3525a65cd6 | ||
|
|
c51a5bb365 | ||
|
|
7f4b9aff14 | ||
|
|
a59e7fe65f | ||
|
|
3e0a71f2ef | ||
|
|
3dfbca2af4 | ||
|
|
0c256f59d8 | ||
|
|
dbbae72c25 | ||
|
|
b1b852d82c | ||
|
|
437bb17f75 | ||
|
|
fdfcb5fd33 | ||
|
|
ff35f791f6 | ||
|
|
b2ea37ffec | ||
|
|
d89530d5b8 | ||
|
|
f00050008b | ||
|
|
68604d19c7 | ||
|
|
55e5b5632f | ||
|
|
5e18cb92dd | ||
|
|
6178e0baa0 | ||
|
|
8050bac507 | ||
|
|
6dcdac1647 | ||
|
|
763f6b89ef | ||
|
|
6c28292918 | ||
|
|
574fc55a5e | ||
|
|
c8fd74b3a4 | ||
|
|
6622e17a5a | ||
|
|
ea05bd0b13 | ||
|
|
019c98dc76 | ||
|
|
72dfe51a46 | ||
|
|
22cebba8ac | ||
|
|
d51d198f94 | ||
|
|
ed0c2d7dd3 | ||
|
|
5ced901ae8 | ||
|
|
afda481046 | ||
|
|
a986864f77 | ||
|
|
ad1c4ecbc9 | ||
|
|
54b7de4442 | ||
|
|
d1996aee80 | ||
|
|
326cefbec1 | ||
|
|
d6e81c6af7 | ||
|
|
a000f8f2c0 | ||
|
|
cbab09e931 | ||
|
|
414fca08ca | ||
|
|
874c71b7aa | ||
|
|
5b101825f5 | ||
|
|
0db8db4351 | ||
|
|
d4fd148089 | ||
|
|
c739d594d8 | ||
|
|
05e8ad8e89 | ||
|
|
024ab72e5f | ||
|
|
66ec3a29ec | ||
|
|
28a565737f | ||
|
|
2c7116f6ba | ||
|
|
9ccb9db6de | ||
|
|
2d992cbb90 | ||
|
|
302a3614cf | ||
|
|
ea546013de | ||
|
|
fb18610893 | ||
|
|
2364432088 | ||
|
|
655bed14fd | ||
|
|
721399f665 | ||
|
|
694ed5c581 | ||
|
|
a98f2462ed | ||
|
|
5461a5357d | ||
|
|
20df9f4044 | ||
|
|
3ec4f7c525 | ||
|
|
443fb827d0 | ||
|
|
a810303f52 | ||
|
|
9370a481f9 | ||
|
|
1478c95d59 | ||
|
|
f69fa747af | ||
|
|
a29a92893f | ||
|
|
7d471056c1 | ||
|
|
119493c181 | ||
|
|
02a0f924b4 | ||
|
|
38665eb00d | ||
|
|
c32358bcef | ||
|
|
df9316bede | ||
|
|
8525d920a0 | ||
|
|
a6e08d9a10 | ||
|
|
2e0d0385b0 | ||
|
|
972c184c70 | ||
|
|
adeafbfcb4 |
32
.github/workflows/ci.yaml
vendored
32
.github/workflows/ci.yaml
vendored
@@ -7,9 +7,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -27,7 +24,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,13 +32,24 @@ jobs:
|
|||||||
containerise:
|
containerise:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- name: Set up QEMU
|
||||||
- name: Build the container image
|
uses: docker/setup-qemu-action@v1
|
||||||
run: docker build . --tag $IMAGE_NAME
|
- name: Set up Docker Buildx
|
||||||
|
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: Push image to GitHub Container Registry
|
- name: Lowercase github username for ghcr
|
||||||
run: |
|
id: string
|
||||||
LATEST_TAG=ghcr.io/meeb/$IMAGE_NAME:latest
|
uses: ASzc/change-string-case-action@v1
|
||||||
docker tag $IMAGE_NAME $LATEST_TAG
|
with:
|
||||||
docker push $LATEST_TAG
|
string: ${{ github.actor }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
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 }}
|
||||||
|
|||||||
34
.github/workflows/release.yaml
vendored
34
.github/workflows/release.yaml
vendored
@@ -11,18 +11,28 @@ jobs:
|
|||||||
containerise:
|
containerise:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Get tag
|
- name: Get tag
|
||||||
id: vars
|
id: tag
|
||||||
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
|
uses: dawidd6/action-get-tag@v1
|
||||||
- name: Build the container image
|
- uses: docker/build-push-action@v2
|
||||||
run: docker build . --tag $IMAGE_NAME
|
- name: Set up Docker Buildx
|
||||||
|
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: Push image to GitHub Container Registry
|
- name: Lowercase github username for ghcr
|
||||||
env:
|
id: string
|
||||||
RELEASE_TAG: ${{ steps.vars.outputs.tag }}
|
uses: ASzc/change-string-case-action@v1
|
||||||
run: |
|
with:
|
||||||
REF_TAG=ghcr.io/meeb/$IMAGE_NAME:$RELEASE_TAG
|
string: ${{ github.actor }}
|
||||||
docker tag $IMAGE_NAME $REF_TAG
|
- name: Build and push
|
||||||
docker push $REF_TAG
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
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 }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -130,3 +130,5 @@ dmypy.json
|
|||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
Pipfile.lock
|
||||||
121
Dockerfile
121
Dockerfile
@@ -1,8 +1,9 @@
|
|||||||
FROM debian:buster-slim
|
FROM debian:bullseye-slim
|
||||||
|
|
||||||
ARG ARCH="amd64"
|
ARG TARGETPLATFORM
|
||||||
ARG S6_VERSION="2.1.0.2"
|
ARG S6_VERSION="3.1.2.1"
|
||||||
ARG FFMPEG_VERSION="4.3.1"
|
ARG FFMPEG_DATE="autobuild-2023-01-03-12-55"
|
||||||
|
ARG FFMPEG_VERSION="109474-gc94988a781"
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||||
HOME="/root" \
|
HOME="/root" \
|
||||||
@@ -10,63 +11,102 @@ ENV DEBIAN_FRONTEND="noninteractive" \
|
|||||||
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="52460473413601ff7a84ae690b161a074217ddc734990c2cdee9847166cf669e" \
|
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="47d95c0129fba27d051748a442a44a73ce1bd38d1e3f9fe1e9dd7258c7581fa5" \
|
|
||||||
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 \
|
||||||
|
"linux/amd64") echo "amd64" ;; \
|
||||||
|
"linux/arm64") echo "aarch64" ;; \
|
||||||
|
*) echo "" ;; esac) && \
|
||||||
|
export S6_ARCH_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
|
"linux/amd64") echo "6019b6b06cfdbb1d1cd572d46b9b158a4904fd19ca59d374de4ddaaa6a3727d5" ;; \
|
||||||
|
"linux/arm64") echo "e73f9a021b64f88278830742149c14ef8a52331102881ba025bf32a66a0e7c78" ;; \
|
||||||
|
*) echo "" ;; esac) && \
|
||||||
|
export S6_DOWNLOAD_ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
|
"linux/amd64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-x86_64.tar.xz" ;; \
|
||||||
|
"linux/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.xz" ;; \
|
||||||
|
*) echo "" ;; esac) && \
|
||||||
|
export FFMPEG_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
|
"linux/amd64") echo "ed9059668e4a6dac9bde122a775f52ad08cbb90df3658f8c1e328477c13c242e" ;; \
|
||||||
|
"linux/arm64") echo "dd1375bd351d38ea1cc3efd68a998699366e28bd9b90df65d11af2b9121746b7" ;; \
|
||||||
|
*) echo "" ;; esac) && \
|
||||||
|
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
|
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linux64-gpl.tar.xz" ;; \
|
||||||
|
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linuxarm64-gpl.tar.xz" ;; \
|
||||||
|
*) echo "" ;; esac) && \
|
||||||
|
export S6_NOARCH_EXPECTED_SHA256="cee89d3eeabdfe15239b2c5c3581d9352d2197d4fd23bba3f1e64bf916ccf496" && \
|
||||||
|
export S6_DOWNLOAD_NOARCH="https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-noarch.tar.xz" && \
|
||||||
|
echo "Building for arch: ${ARCH}|${ARCH44}, downloading S6 from: ${S6_DOWNLOAD}}, expecting S6 SHA256: ${S6_EXPECTED_SHA256}" && \
|
||||||
|
set -x && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get -y --no-install-recommends install locales && \
|
apt-get -y --no-install-recommends install locales && \
|
||||||
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
|
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
|
||||||
locale-gen en_US.UTF-8 && \
|
locale-gen en_US.UTF-8 && \
|
||||||
# Install required distro packages
|
# Install required distro packages
|
||||||
apt-get -y --no-install-recommends install curl xz-utils ca-certificates binutils && \
|
apt-get -y --no-install-recommends install curl ca-certificates binutils xz-utils && \
|
||||||
# Install s6
|
# Install s6
|
||||||
curl -L ${S6_DOWNLOAD} --output /tmp/s6-overlay-${ARCH}.tar.gz && \
|
curl -L ${S6_DOWNLOAD_NOARCH} --output /tmp/s6-overlay-noarch.tar.xz && \
|
||||||
sha256sum /tmp/s6-overlay-${ARCH}.tar.gz && \
|
echo "${S6_NOARCH_EXPECTED_SHA256} /tmp/s6-overlay-noarch.tar.xz" | sha256sum -c - && \
|
||||||
echo "${S6_EXPECTED_SHA256} /tmp/s6-overlay-${ARCH}.tar.gz" | sha256sum -c - && \
|
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && \
|
||||||
tar xzf /tmp/s6-overlay-${ARCH}.tar.gz -C / && \
|
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
|
# Install ffmpeg
|
||||||
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg-${ARCH}-static.tar.xz && \
|
echo "Building for arch: ${ARCH}|${ARCH44}, downloading FFMPEG from: ${FFMPEG_DOWNLOAD}, expecting FFMPEG SHA256: ${FFMPEG_EXPECTED_SHA256}" && \
|
||||||
echo "${FFMPEG_EXPECTED_SHA256} /tmp/ffmpeg-${ARCH}-static.tar.xz" | sha256sum -c - && \
|
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg-${ARCH}.tar.xz && \
|
||||||
xz --decompress /tmp/ffmpeg-${ARCH}-static.tar.xz && \
|
sha256sum /tmp/ffmpeg-${ARCH}.tar.xz && \
|
||||||
tar -xvf /tmp/ffmpeg-${ARCH}-static.tar -C /tmp && \
|
echo "${FFMPEG_EXPECTED_SHA256} /tmp/ffmpeg-${ARCH}.tar.xz" | sha256sum -c - && \
|
||||||
install -v -s -g root -o root -m 0755 -s /tmp/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static/ffmpeg -t /usr/local/bin && \
|
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
|
# Clean up
|
||||||
rm -rf /tmp/s6-overlay-${ARCH}.tar.gz && \
|
rm -rf /tmp/s6-overlay-${ARCH}.tar.gz && \
|
||||||
rm -rf /tmp/ffmpeg-${ARCH}-static.tar && \
|
rm -rf /tmp/ffmpeg-${ARCH}.tar.xz && \
|
||||||
rm -rf /tmp/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static && \
|
apt-get -y autoremove --purge curl binutils xz-utils
|
||||||
apt-get -y autoremove --purge curl xz-utils binutils
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Set up the app
|
# Set up the app
|
||||||
RUN set -x && \
|
RUN set -x && \
|
||||||
|
apt-get update && \
|
||||||
# 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 python3 python3-setuptools python3-pip python3-dev gcc make && \
|
apt-get -y --no-install-recommends install \
|
||||||
|
python3 \
|
||||||
|
python3-setuptools \
|
||||||
|
python3-pip \
|
||||||
|
python3-dev \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
libmariadb3 \
|
||||||
|
postgresql-common \
|
||||||
|
libpq-dev \
|
||||||
|
libpq5 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libwebp6 \
|
||||||
|
libjpeg-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
redis-server && \
|
||||||
# Install pipenv
|
# Install pipenv
|
||||||
pip3 --disable-pip-version-check install pipenv && \
|
pip3 --disable-pip-version-check install wheel pipenv && \
|
||||||
# Create a 'app' user which the application will run as
|
# Create a 'app' user which the application will run as
|
||||||
groupadd app && \
|
groupadd app && \
|
||||||
useradd -M -d /app -s /bin/false -g app app && \
|
useradd -M -d /app -s /bin/false -g app app && \
|
||||||
# Install non-distro packages
|
# Install non-distro packages
|
||||||
pipenv install --system && \
|
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
|
||||||
@@ -79,10 +119,20 @@ 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 && \
|
pip3 --disable-pip-version-check uninstall -y pipenv wheel virtualenv && \
|
||||||
apt-get -y autoremove --purge python3-pip python3-dev gcc make && \
|
apt-get -y autoremove --purge \
|
||||||
|
python3-pip \
|
||||||
|
python3-dev \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
postgresql-common \
|
||||||
|
libpq-dev \
|
||||||
|
libjpeg-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/* && \
|
||||||
@@ -92,7 +142,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 /
|
||||||
@@ -102,7 +157,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"]
|
||||||
|
|||||||
14
Makefile
14
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,5 @@ 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:
|
test: build
|
||||||
$(python) app/manage.py test --verbosity=2
|
cd tubesync && $(python) manage.py test --verbosity=2 && cd ..
|
||||||
|
|||||||
11
Pipfile
11
Pipfile
@@ -6,7 +6,7 @@ verify_ssl = true
|
|||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
django = "*"
|
django = "~=3.2"
|
||||||
django-sass-processor = "*"
|
django-sass-processor = "*"
|
||||||
libsass = "*"
|
libsass = "*"
|
||||||
pillow = "*"
|
pillow = "*"
|
||||||
@@ -14,10 +14,11 @@ whitenoise = "*"
|
|||||||
gunicorn = "*"
|
gunicorn = "*"
|
||||||
django-compressor = "*"
|
django-compressor = "*"
|
||||||
httptools = "*"
|
httptools = "*"
|
||||||
youtube-dl = "*"
|
|
||||||
django-background-tasks = "*"
|
django-background-tasks = "*"
|
||||||
requests = "*"
|
requests = "*"
|
||||||
django-basicauth = "*"
|
django-basicauth = "*"
|
||||||
|
psycopg2-binary = "*"
|
||||||
[requires]
|
mysqlclient = "*"
|
||||||
python_version = "3"
|
yt-dlp = "*"
|
||||||
|
redis = "*"
|
||||||
|
hiredis = "*"
|
||||||
|
|||||||
260
Pipfile.lock
generated
260
Pipfile.lock
generated
@@ -1,260 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"hash": {
|
|
||||||
"sha256": "f698e2853dec2d325d2d7e752620fc81d911022d394a57f2f8a9349ac2682752"
|
|
||||||
},
|
|
||||||
"pipfile-spec": 6,
|
|
||||||
"requires": {
|
|
||||||
"python_version": "3"
|
|
||||||
},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"name": "pypi",
|
|
||||||
"url": "https://pypi.org/simple",
|
|
||||||
"verify_ssl": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"default": {
|
|
||||||
"asgiref": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
|
|
||||||
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
|
|
||||||
],
|
|
||||||
"version": "==3.3.1"
|
|
||||||
},
|
|
||||||
"certifi": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
|
||||||
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
|
||||||
],
|
|
||||||
"version": "==2020.12.5"
|
|
||||||
},
|
|
||||||
"chardet": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
|
||||||
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
|
||||||
],
|
|
||||||
"version": "==4.0.0"
|
|
||||||
},
|
|
||||||
"django": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:32ce792ee9b6a0cbbec340123e229ac9f765dff8c2a4ae9247a14b2ba3a365a7",
|
|
||||||
"sha256:baf099db36ad31f970775d0be5587cc58a6256a6771a44eb795b554d45f211b8"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==3.1.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:57ac0a696d061e5fc6fbc55381d2050f353b973fb97eee5593f39247bc0f30af",
|
|
||||||
"sha256:d2ed1c6137ddaac5536233ec0a819e14009553fee0a869bea65d03e5285ba74f"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.4"
|
|
||||||
},
|
|
||||||
"django-sass-processor": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:9b46a12ca8bdcb397d46fbcc49e6a926ff9f76a93c5efeb23b495419fd01fc7a"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.8.2"
|
|
||||||
},
|
|
||||||
"gunicorn": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
|
|
||||||
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==20.0.4"
|
|
||||||
},
|
|
||||||
"httptools": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be",
|
|
||||||
"sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d",
|
|
||||||
"sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce",
|
|
||||||
"sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2",
|
|
||||||
"sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6",
|
|
||||||
"sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f",
|
|
||||||
"sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009",
|
|
||||||
"sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce",
|
|
||||||
"sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a",
|
|
||||||
"sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c",
|
|
||||||
"sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4",
|
|
||||||
"sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.1.1"
|
|
||||||
},
|
|
||||||
"idna": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
|
||||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
|
||||||
],
|
|
||||||
"version": "==2.10"
|
|
||||||
},
|
|
||||||
"libsass": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b",
|
|
||||||
"sha256:1b2d415bbf6fa7da33ef46e549db1418498267b459978eff8357e5e823962d35",
|
|
||||||
"sha256:25ebc2085f5eee574761ccc8d9cd29a9b436fc970546d5ef08c6fa41eb57dff1",
|
|
||||||
"sha256:2ae806427b28bc1bb7cb0258666d854fcf92ba52a04656b0b17ba5e190fb48a9",
|
|
||||||
"sha256:4a246e4b88fd279abef8b669206228c92534d96ddcd0770d7012088c408dff23",
|
|
||||||
"sha256:553e5096414a8d4fb48d0a48f5a038d3411abe254d79deac5e008516c019e63a",
|
|
||||||
"sha256:697f0f9fa8a1367ca9ec6869437cb235b1c537fc8519983d1d890178614a8903",
|
|
||||||
"sha256:a8fd4af9f853e8bf42b1425c5e48dd90b504fa2e70d7dac5ac80b8c0a5a5fe85",
|
|
||||||
"sha256:c9411fec76f480ffbacc97d8188322e02a5abca6fc78e70b86a2a2b421eae8a2",
|
|
||||||
"sha256:daa98a51086d92aa7e9c8871cf1a8258124b90e2abf4697852a3dca619838618",
|
|
||||||
"sha256:e0e60836eccbf2d9e24ec978a805cd6642fa92515fbd95e3493fee276af76f8a",
|
|
||||||
"sha256:e64ae2587f1a683e831409aad03ba547c245ef997e1329fffadf7a866d2510b8",
|
|
||||||
"sha256:f6852828e9e104d2ce0358b73c550d26dd86cc3a69439438c3b618811b9584f5"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.20.1"
|
|
||||||
},
|
|
||||||
"pillow": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:01bb0a34f1a6689b138c0089d670ae2e8f886d2666a9b2f2019031abdea673c4",
|
|
||||||
"sha256:07872f1d8421db5a3fe770f7480835e5e90fddb58f36c216d4a2ac0d594de474",
|
|
||||||
"sha256:1022f8f6dc3c5b0dcf928f1c49ba2ac73051f576af100d57776e2b65c1f76a8d",
|
|
||||||
"sha256:14415e9e28410232370615dbde0cf0a00e526f522f665460344a5b96973a3086",
|
|
||||||
"sha256:172acfaf00434a28dddfe592d83f2980e22e63c769ff4a448ddf7b7a38ffd165",
|
|
||||||
"sha256:1c5e3c36f02c815766ae9dd91899b1c5b4652f2a37b7a51609f3bd467c0f11fb",
|
|
||||||
"sha256:292f2aa1ae5c5c1451cb4b558addb88c257411d3fd71c6cf45562911baffc979",
|
|
||||||
"sha256:2a40d7d4b17db87f5b9a1efc0aff56000e1d0d5ece415090c102aafa0ccbe858",
|
|
||||||
"sha256:2f0d7034d5faae9a8d1019d152ede924f653df2ce77d3bba4ce62cd21b5f94ae",
|
|
||||||
"sha256:33fdbd4f5608c852d97264f9d2e3b54e9e9959083d008145175b86100b275e5b",
|
|
||||||
"sha256:3b13d89d97b551e02549d1f0edf22bed6acfd6fd2e888cd1e9a953bf215f0e81",
|
|
||||||
"sha256:3e759bcc03d6f39bc751e56d86bc87252b9a21c689a27c5ed753717a87d53a5b",
|
|
||||||
"sha256:3ec87bd1248b23a2e4e19e774367fbe30fddc73913edc5f9b37470624f55dc1f",
|
|
||||||
"sha256:436b0a2dd9fe3f7aa6a444af6bdf53c1eb8f5ced9ea3ef104daa83f0ea18e7bc",
|
|
||||||
"sha256:43b3c859912e8bf754b3c5142df624794b18eb7ae07cfeddc917e1a9406a3ef2",
|
|
||||||
"sha256:4fe74636ee71c57a7f65d7b21a9f127d842b4fb75511e5d256ace258826eb352",
|
|
||||||
"sha256:59445af66b59cc39530b4f810776928d75e95f41e945f0c32a3de4aceb93c15d",
|
|
||||||
"sha256:69da5b1d7102a61ce9b45deb2920a2012d52fd8f4201495ea9411d0071b0ec22",
|
|
||||||
"sha256:7094bbdecb95ebe53166e4c12cf5e28310c2b550b08c07c5dc15433898e2238e",
|
|
||||||
"sha256:8211cac9bf10461f9e33fe9a3af6c5131f3fdd0d10672afc2abb2c70cf95c5ca",
|
|
||||||
"sha256:8cf77e458bd996dc85455f10fe443c0c946f5b13253773439bcbec08aa1aebc2",
|
|
||||||
"sha256:924fc33cb4acaf6267b8ca3b8f1922620d57a28470d5e4f49672cea9a841eb08",
|
|
||||||
"sha256:99ce3333b40b7a4435e0a18baad468d44ab118a4b1da0af0a888893d03253f1d",
|
|
||||||
"sha256:a7d690b2c5f7e4a932374615fedceb1e305d2dd5363c1de15961725fe10e7d16",
|
|
||||||
"sha256:b9af590adc1e46898a1276527f3cfe2da8048ae43fbbf9b1bf9395f6c99d9b47",
|
|
||||||
"sha256:bb18422ad00c1fecc731d06592e99c3be2c634da19e26942ba2f13d805005cf2",
|
|
||||||
"sha256:c10af40ee2f1a99e1ae755ab1f773916e8bca3364029a042cd9161c400416bd8",
|
|
||||||
"sha256:c143c409e7bc1db784471fe9d0bf95f37c4458e879ad84cfae640cb74ee11a26",
|
|
||||||
"sha256:c448d2b335e21951416a30cd48d35588d122a912d5fe9e41900afacecc7d21a1",
|
|
||||||
"sha256:d30f30c044bdc0ab8f3924e1eeaac87e0ff8a27e87369c5cac4064b6ec78fd83",
|
|
||||||
"sha256:df534e64d4f3e84e8f1e1a37da3f541555d947c1c1c09b32178537f0f243f69d",
|
|
||||||
"sha256:f6fc18f9c9c7959bf58e6faf801d14fafb6d4717faaf6f79a68c8bb2a13dcf20",
|
|
||||||
"sha256:ff83dfeb04c98bb3e7948f876c17513a34e9a19fd92e292288649164924c1b39"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==8.1.1"
|
|
||||||
},
|
|
||||||
"pytz": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
|
||||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
|
||||||
],
|
|
||||||
"version": "==2021.1"
|
|
||||||
},
|
|
||||||
"rcssmin": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
|
|
||||||
],
|
|
||||||
"version": "==1.0.6"
|
|
||||||
},
|
|
||||||
"requests": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
|
||||||
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.25.1"
|
|
||||||
},
|
|
||||||
"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:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
|
||||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
|
||||||
],
|
|
||||||
"version": "==1.15.0"
|
|
||||||
},
|
|
||||||
"sqlparse": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
|
|
||||||
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
|
|
||||||
],
|
|
||||||
"version": "==0.4.1"
|
|
||||||
},
|
|
||||||
"urllib3": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80",
|
|
||||||
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
|
|
||||||
],
|
|
||||||
"version": "==1.26.3"
|
|
||||||
},
|
|
||||||
"whitenoise": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7",
|
|
||||||
"sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==5.2.0"
|
|
||||||
},
|
|
||||||
"youtube-dl": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:02432aa2dd0e859e64d74fca2ad624abf3bead3dba811d594100e1cb7897dce7",
|
|
||||||
"sha256:28663ce51bb35d0a0fa764aed3492b38c570da0a5a62fef3c28f4431522a6d4a"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2021.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"develop": {}
|
|
||||||
}
|
|
||||||
73
README.md
73
README.md
@@ -9,10 +9,10 @@ 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 implemenations. 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
|
||||||
so media which fails to download will be retried for an extended period making it,
|
so media which fails to download will be retried for an extended period making it,
|
||||||
@@ -22,12 +22,9 @@ hopefully, quite reliable.
|
|||||||
# Latest container image
|
# Latest container image
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ghcr.io/meeb/tubesync:v0.9.1
|
ghcr.io/meeb/tubesync:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**NOTE: the `:latest` tag does exist, but will contain in-development commits and may
|
|
||||||
be broken. Use at your own risk.**
|
|
||||||
|
|
||||||
# Screenshots
|
# Screenshots
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
@@ -72,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
|
||||||
@@ -101,8 +99,8 @@ $ mkdir /some/directory/tubesync-downloads
|
|||||||
Finally, download and run the container:
|
Finally, download and run the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull a versioned image
|
# Pull image
|
||||||
$ docker pull ghcr.io/meeb/tubesync:v0.9.1
|
$ docker pull ghcr.io/meeb/tubesync:latest
|
||||||
# Start the container using your user ID and group ID
|
# Start the container using your user ID and group ID
|
||||||
$ docker run \
|
$ docker run \
|
||||||
-d \
|
-d \
|
||||||
@@ -113,19 +111,21 @@ $ docker run \
|
|||||||
-v /some/directory/tubesync-config:/config \
|
-v /some/directory/tubesync-config:/config \
|
||||||
-v /some/directory/tubesync-downloads:/downloads \
|
-v /some/directory/tubesync-downloads:/downloads \
|
||||||
-p 4848:4848 \
|
-p 4848:4848 \
|
||||||
ghcr.io/meeb/tubesync:v0.9.1
|
ghcr.io/meeb/tubesync:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
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:v0.9.1
|
image: ghcr.io/meeb/tubesync:latest
|
||||||
container_name: tubesync
|
container_name: tubesync
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -151,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
|
||||||
...
|
...
|
||||||
@@ -236,11 +236,11 @@ $ 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)
|
||||||
|
|
||||||
|
|
||||||
# Warnings
|
# Warnings
|
||||||
@@ -282,7 +282,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?
|
||||||
@@ -294,27 +294,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/)
|
||||||
@@ -324,7 +324,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:
|
||||||
|
|
||||||
@@ -350,6 +350,10 @@ etc.). Configuration of this is beyond the scope of this README.
|
|||||||
|
|
||||||
Just `amd64` for the moment. Others may be made available if there is demand.
|
Just `amd64` for the moment. Others may be made available if there is demand.
|
||||||
|
|
||||||
|
### The pipenv install fails with "Locking failed"!
|
||||||
|
|
||||||
|
Make sure that you have `mysql_config` or `mariadb_config` available, as required by the python module `mysqlclient`. On Debian-based systems this is usually found in the package `libmysqlclient-dev`
|
||||||
|
|
||||||
|
|
||||||
# Advanced configuration
|
# Advanced configuration
|
||||||
|
|
||||||
@@ -358,23 +362,24 @@ There are a number of other environment variables you can set. These are, mostly
|
|||||||
useful if you are manually installing TubeSync in some other environment. These are:
|
useful if you are manually installing TubeSync in some other environment. These are:
|
||||||
|
|
||||||
| Name | What | Example |
|
| Name | What | Example |
|
||||||
| ------------------------ | ------------------------------------------------------------ | ---------------------------------- |
|
| ------------------------ | ------------------------------------------------------------ | ------------------------------------ |
|
||||||
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||||
| DJANGO_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 |
|
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
||||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||||
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
||||||
| HTTP_USER | Sets the username for HTTP basic authentication | some-username |
|
| HTTP_USER | Sets the username for HTTP basic authentication | some-username |
|
||||||
| HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password |
|
| 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 +402,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
|
||||||
24
config/root/etc/s6-overlay/s6-rc.d/gunicorn/run
Executable file
24
config/root/etc/s6-overlay/s6-rc.d/gunicorn/run
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/command/with-contenv bash
|
||||||
|
|
||||||
|
UMASK_SET=${UMASK_SET:-022}
|
||||||
|
umask "$UMASK_SET"
|
||||||
|
|
||||||
|
cd /app || exit
|
||||||
|
|
||||||
|
PIDFILE=/run/app/gunicorn.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/gunicorn -c /app/tubesync/gunicorn.py --capture-output tubesync.wsgi:application
|
||||||
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
|
||||||
27
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/run
Executable file
27
config/root/etc/s6-overlay/s6-rc.d/tubesync-init/run
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/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 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
|
||||||
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,9 +0,0 @@
|
|||||||
#!/usr/bin/with-contenv bash
|
|
||||||
|
|
||||||
UMASK_SET=${UMASK_SET:-022}
|
|
||||||
umask "$UMASK_SET"
|
|
||||||
|
|
||||||
cd /app || exit
|
|
||||||
|
|
||||||
exec s6-setuidgid app \
|
|
||||||
/usr/local/bin/gunicorn -c /app/tubesync/gunicorn.py --capture-output tubesync.wsgi:application
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/with-contenv bash
|
|
||||||
|
|
||||||
cd /
|
|
||||||
|
|
||||||
/usr/sbin/nginx
|
|
||||||
80
docs/other-database-backends.md
Normal file
80
docs/other-database-backends.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# TubeSync
|
||||||
|
|
||||||
|
## Advanced usage guide - using other database backends
|
||||||
|
|
||||||
|
This is a new feature in v1.0 of TubeSync and later. It allows you to use a custom
|
||||||
|
existing external database server instead of the default SQLite database. You may want
|
||||||
|
to use this if you encounter performance issues with adding very large or a large
|
||||||
|
number of channels and database write contention (as shown by errors in the log)
|
||||||
|
become an issue.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
You should start with a blank install of TubeSync. Migrating to a new database will
|
||||||
|
reset your database. If you are comfortable with Django you can export and re-import
|
||||||
|
existing database data with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ docker exec -i tubesync python3 /app/manage.py dumpdata > some-file.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change you database backend over, then use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cat some-file.json | docker exec -i tubesync python3 /app/manage.py loaddata --format=json -
|
||||||
|
```
|
||||||
|
|
||||||
|
As detailed in the Django documentation:
|
||||||
|
|
||||||
|
https://docs.djangoproject.com/en/3.1/ref/django-admin/#dumpdata
|
||||||
|
|
||||||
|
and:
|
||||||
|
|
||||||
|
https://docs.djangoproject.com/en/3.1/ref/django-admin/#loaddata
|
||||||
|
|
||||||
|
Further instructions are beyond the scope of TubeSync documenation and you should refer
|
||||||
|
to Django documentation for more details.
|
||||||
|
|
||||||
|
If you are not comfortable with the above, then skip the `dumpdata` steps, however
|
||||||
|
remember you will start again with a completely new database.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Create a database in your external database server
|
||||||
|
|
||||||
|
You need to create a database and a user with permissions to access the database in
|
||||||
|
your chosen external database server. Steps vary between PostgreSQL, MySQL and MariaDB
|
||||||
|
so this is up to you to work out.
|
||||||
|
|
||||||
|
### 2. Set the database connection string environment variable
|
||||||
|
|
||||||
|
You need to provide the database connection details to TubeSync via an environment
|
||||||
|
variable. The environment variable name is `DATABASE_CONNECTION` and the format is the
|
||||||
|
standard URL-style string. Examples are:
|
||||||
|
|
||||||
|
`postgresql://tubesync:password@localhost:5432/tubesync`
|
||||||
|
|
||||||
|
and
|
||||||
|
|
||||||
|
`mysql://tubesync:password@localhost:3306/tubesync`
|
||||||
|
|
||||||
|
*Important note:* For MySQL databases make SURE you create the tubesync database with
|
||||||
|
`utf8mb4` encoding, like:
|
||||||
|
|
||||||
|
`CREATE DATABASE tubesync CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;`
|
||||||
|
|
||||||
|
Without `utf8mb4` encoding things like emojis in video titles (or any extended UTF8
|
||||||
|
characters) can cause issues.
|
||||||
|
|
||||||
|
### 3. Start TubeSync and check the logs
|
||||||
|
|
||||||
|
Once you start TubeSync with the new database connection you should see the folling log
|
||||||
|
entry in the container or stdout logs:
|
||||||
|
|
||||||
|
`2021-04-04 22:42:17,912 [tubesync/INFO] Using database connection: django.db.backends.postgresql://tubesync:[hidden]@localhost:5432/tubesync`
|
||||||
|
|
||||||
|
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!
|
||||||
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,10 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from .third_party_versions import youtube_dl_version, ffmpeg_version
|
from .third_party_versions import yt_dlp_version, ffmpeg_version
|
||||||
|
|
||||||
|
|
||||||
def app_details(request):
|
def app_details(request):
|
||||||
return {
|
return {
|
||||||
'app_version': str(settings.VERSION),
|
'app_version': str(settings.VERSION),
|
||||||
'youtube_dl_version': youtube_dl_version,
|
'yt_dlp_version': yt_dlp_version,
|
||||||
'ffmpeg_version': ffmpeg_version,
|
'ffmpeg_version': ffmpeg_version,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,10 @@ class DownloadFailedException(Exception):
|
|||||||
exist.
|
exist.
|
||||||
'''
|
'''
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseConnectionError(Exception):
|
||||||
|
'''
|
||||||
|
Raised when parsing or initially connecting to a database.
|
||||||
|
'''
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'roboto-light';
|
font-family: 'roboto-light';
|
||||||
src: url('/static/fonts/roboto/roboto-light.woff') format('woff');
|
src: url('../fonts/roboto/roboto-light.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-regular';
|
||||||
src: url('/static/fonts/roboto/roboto-regular.woff') format('woff');
|
src: url('../fonts/roboto/roboto-regular.woff') format('woff');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'roboto-bold';
|
font-family: 'roboto-bold';
|
||||||
src: url('/static/fonts/roboto/roboto-bold.woff') format('woff');
|
src: url('../fonts/roboto/roboto-bold.woff') format('woff');
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/meeb/tubesync" class="nowrap" target="_blank"><i class="fab fa-github"></i> TubeSync</a> version <strong>{{ app_version }}</strong> with
|
<a href="https://github.com/meeb/tubesync" class="nowrap" target="_blank"><i class="fab fa-github"></i> TubeSync</a> version <strong>{{ app_version }}</strong> with
|
||||||
<a href="https://yt-dl.org/" class="nowrap" target="_blank"><i class="fas fa-link"></i> youtube-dl</a> version <strong>{{ youtube_dl_version }}</strong> and
|
<a href="https://github.com/yt-dlp/yt-dlp" class="nowrap" target="_blank"><i class="fas fa-link"></i> yt-dlp</a> version <strong>{{ yt_dlp_version }}</strong> and
|
||||||
<a href="https://ffmpeg.org/" class="nowrap" target="_blank"><i class="fas fa-link"></i> FFmpeg</a> version <strong>{{ ffmpeg_version }}</strong>.
|
<a href="https://ffmpeg.org/" class="nowrap" target="_blank"><i class="fas fa-link"></i> FFmpeg</a> version <strong>{{ ffmpeg_version }}</strong>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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, clean_filename
|
||||||
|
from .errors import DatabaseConnectionError
|
||||||
|
|
||||||
|
|
||||||
class ErrorPageTestCase(TestCase):
|
class ErrorPageTestCase(TestCase):
|
||||||
@@ -61,3 +63,75 @@ class CommonStaticTestCase(TestCase):
|
|||||||
favicon_real_path = os.path.join(os.sep.join(root_parts),
|
favicon_real_path = os.path.join(os.sep.join(root_parts),
|
||||||
os.sep.join(url_parts))
|
os.sep.join(url_parts))
|
||||||
self.assertTrue(os.path.exists(favicon_real_path))
|
self.assertTrue(os.path.exists(favicon_real_path))
|
||||||
|
|
||||||
|
|
||||||
|
class UtilsTestCase(TestCase):
|
||||||
|
|
||||||
|
def test_parse_database_connection_string(self):
|
||||||
|
database_dict = parse_database_connection_string(
|
||||||
|
'postgresql://tubesync:password@localhost:5432/tubesync')
|
||||||
|
self.assertEqual(database_dict,
|
||||||
|
{
|
||||||
|
'DRIVER': 'postgresql',
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'USER': 'tubesync',
|
||||||
|
'PASSWORD': 'password',
|
||||||
|
'HOST': 'localhost',
|
||||||
|
'PORT': 5432,
|
||||||
|
'NAME': 'tubesync',
|
||||||
|
'CONN_MAX_AGE': 300,
|
||||||
|
'OPTIONS': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
database_dict = parse_database_connection_string(
|
||||||
|
'mysql://tubesync:password@localhost:3306/tubesync')
|
||||||
|
self.assertEqual(database_dict,
|
||||||
|
{
|
||||||
|
'DRIVER': 'mysql',
|
||||||
|
'ENGINE': 'django.db.backends.mysql',
|
||||||
|
'USER': 'tubesync',
|
||||||
|
'PASSWORD': 'password',
|
||||||
|
'HOST': 'localhost',
|
||||||
|
'PORT': 3306,
|
||||||
|
'NAME': 'tubesync',
|
||||||
|
'CONN_MAX_AGE': 300,
|
||||||
|
'OPTIONS': {'charset': 'utf8mb4'}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Invalid driver
|
||||||
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
|
parse_database_connection_string(
|
||||||
|
'test://tubesync:password@localhost:5432/tubesync')
|
||||||
|
# No username
|
||||||
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
|
parse_database_connection_string(
|
||||||
|
'postgresql://password@localhost:5432/tubesync')
|
||||||
|
# No database name
|
||||||
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
|
parse_database_connection_string(
|
||||||
|
'postgresql://tubesync:password@5432')
|
||||||
|
# Invalid port
|
||||||
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
|
parse_database_connection_string(
|
||||||
|
'postgresql://tubesync:password@localhost:test/tubesync')
|
||||||
|
# Invalid port
|
||||||
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
|
parse_database_connection_string(
|
||||||
|
'postgresql://tubesync:password@localhost:65537/tubesync')
|
||||||
|
# Invalid username or password
|
||||||
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
|
parse_database_connection_string(
|
||||||
|
'postgresql://tubesync:password:test@localhost:5432/tubesync')
|
||||||
|
# Invalid database name
|
||||||
|
with self.assertRaises(DatabaseConnectionError):
|
||||||
|
parse_database_connection_string(
|
||||||
|
'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,7 +1,7 @@
|
|||||||
from youtube_dl import version as yt_version
|
from yt_dlp import version as yt_dlp_version
|
||||||
|
|
||||||
|
|
||||||
youtube_dl_version = str(yt_version.__version__)
|
yt_dlp_version = str(yt_dlp_version.__version__)
|
||||||
ffmpeg_version = '(shared install)'
|
ffmpeg_version = '(shared install)'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,96 @@
|
|||||||
from urllib.parse import urlunsplit, urlencode
|
import string
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlunsplit, urlencode, urlparse
|
||||||
|
from yt_dlp.utils import LazyList
|
||||||
|
from .errors import DatabaseConnectionError
|
||||||
|
|
||||||
|
|
||||||
|
def parse_database_connection_string(database_connection_string):
|
||||||
|
'''
|
||||||
|
Parses a connection string in a URL style format, such as:
|
||||||
|
postgresql://tubesync:password@localhost:5432/tubesync
|
||||||
|
mysql://someuser:somepassword@localhost:3306/tubesync
|
||||||
|
into a Django-compatible settings.DATABASES dict format.
|
||||||
|
'''
|
||||||
|
valid_drivers = ('postgresql', 'mysql')
|
||||||
|
default_ports = {
|
||||||
|
'postgresql': 5432,
|
||||||
|
'mysql': 3306,
|
||||||
|
}
|
||||||
|
django_backends = {
|
||||||
|
'postgresql': 'django.db.backends.postgresql',
|
||||||
|
'mysql': 'django.db.backends.mysql',
|
||||||
|
}
|
||||||
|
backend_options = {
|
||||||
|
'postgresql': {},
|
||||||
|
'mysql': {
|
||||||
|
'charset': 'utf8mb4',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
parts = urlparse(str(database_connection_string))
|
||||||
|
except Exception as e:
|
||||||
|
raise DatabaseConnectionError(f'Failed to parse "{database_connection_string}" '
|
||||||
|
f'as a database connection string: {e}') from e
|
||||||
|
driver = parts.scheme
|
||||||
|
user_pass_host_port = parts.netloc
|
||||||
|
database = parts.path
|
||||||
|
if driver not in valid_drivers:
|
||||||
|
raise DatabaseConnectionError(f'Database connection string '
|
||||||
|
f'"{database_connection_string}" specified an '
|
||||||
|
f'invalid driver, must be one of {valid_drivers}')
|
||||||
|
django_driver = django_backends.get(driver)
|
||||||
|
host_parts = user_pass_host_port.split('@')
|
||||||
|
if len(host_parts) != 2:
|
||||||
|
raise DatabaseConnectionError(f'Database connection string netloc must be in '
|
||||||
|
f'the format of user:pass@host')
|
||||||
|
user_pass, host_port = host_parts
|
||||||
|
user_pass_parts = user_pass.split(':')
|
||||||
|
if len(user_pass_parts) != 2:
|
||||||
|
raise DatabaseConnectionError(f'Database connection string netloc must be in '
|
||||||
|
f'the format of user:pass@host')
|
||||||
|
username, password = user_pass_parts
|
||||||
|
host_port_parts = host_port.split(':')
|
||||||
|
if len(host_port_parts) == 1:
|
||||||
|
# No port number, assign a default port
|
||||||
|
hostname = host_port_parts[0]
|
||||||
|
port = default_ports.get(driver)
|
||||||
|
elif len(host_port_parts) == 2:
|
||||||
|
# Host name and port number
|
||||||
|
hostname, port = host_port_parts
|
||||||
|
try:
|
||||||
|
port = int(port)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise DatabaseConnectionError(f'Database connection string contained an '
|
||||||
|
f'invalid port, ports must be integers: '
|
||||||
|
f'{e}') from e
|
||||||
|
if not 0 < port < 63336:
|
||||||
|
raise DatabaseConnectionError(f'Database connection string contained an '
|
||||||
|
f'invalid port, ports must be between 1 and '
|
||||||
|
f'65535, got {port}')
|
||||||
|
else:
|
||||||
|
# Malformed
|
||||||
|
raise DatabaseConnectionError(f'Database connection host must be a hostname or '
|
||||||
|
f'a hostname:port combination')
|
||||||
|
if database.startswith('/'):
|
||||||
|
database = database[1:]
|
||||||
|
if not database:
|
||||||
|
raise DatabaseConnectionError(f'Database connection string path must be a '
|
||||||
|
f'string in the format of /databasename')
|
||||||
|
if '/' in database:
|
||||||
|
raise DatabaseConnectionError(f'Database connection string path can only '
|
||||||
|
f'contain a single string name, got: {database}')
|
||||||
|
return {
|
||||||
|
'DRIVER': driver,
|
||||||
|
'ENGINE': django_driver,
|
||||||
|
'NAME': database,
|
||||||
|
'USER': username,
|
||||||
|
'PASSWORD': password,
|
||||||
|
'HOST': hostname,
|
||||||
|
'PORT': port,
|
||||||
|
'CONN_MAX_AGE': 300,
|
||||||
|
'OPTIONS': backend_options.get(driver),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request):
|
def get_client_ip(request):
|
||||||
@@ -22,5 +114,18 @@ 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):
|
||||||
|
if isinstance(obj, datetime):
|
||||||
|
return obj.isoformat()
|
||||||
|
if isinstance(obj, LazyList):
|
||||||
|
return list(obj)
|
||||||
|
raise TypeError(f'Type {type(obj)} is not json_serial()-able')
|
||||||
|
|||||||
51
tubesync/sync/management/commands/delete-source.py
Normal file
51
tubesync/sync/management/commands/delete-source.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db.models import signals
|
||||||
|
from common.logger import log
|
||||||
|
from sync.models import Source, Media, MediaServer
|
||||||
|
from sync.signals import media_post_delete
|
||||||
|
from sync.tasks import rescan_media_server
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = ('Deletes a source by UUID')
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--source', action='store', required=True, help='Source UUID')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
source_uuid_str = options.get('source', '')
|
||||||
|
try:
|
||||||
|
source_uuid = uuid.UUID(source_uuid_str)
|
||||||
|
except Exception as e:
|
||||||
|
raise CommandError(f'Failed to parse source UUID: {e}')
|
||||||
|
log.info(f'Deleting source with UUID: {source_uuid}')
|
||||||
|
# Fetch the source by UUID
|
||||||
|
try:
|
||||||
|
source = Source.objects.get(uuid=source_uuid)
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
raise CommandError(f'Source does not exist with '
|
||||||
|
f'UUID: {source_uuid}')
|
||||||
|
# Detach post-delete signal for Media so we don't spam media servers
|
||||||
|
signals.post_delete.disconnect(media_post_delete, sender=Media)
|
||||||
|
# Delete the source, triggering pre-delete signals for each media item
|
||||||
|
log.info(f'Found source with UUID "{source.uuid}" with name '
|
||||||
|
f'"{source.name}" and deleting it, this may take some time!')
|
||||||
|
source.delete()
|
||||||
|
# Update any media servers
|
||||||
|
for mediaserver in MediaServer.objects.all():
|
||||||
|
log.info(f'Scheduling media server updates')
|
||||||
|
verbose_name = _('Request media server rescan for "{}"')
|
||||||
|
rescan_media_server(
|
||||||
|
str(mediaserver.pk),
|
||||||
|
priority=0,
|
||||||
|
verbose_name=verbose_name.format(mediaserver),
|
||||||
|
remove_existing_tasks=True
|
||||||
|
)
|
||||||
|
# Re-attach signals
|
||||||
|
signals.post_delete.connect(media_post_delete, sender=Media)
|
||||||
|
# All done
|
||||||
|
log.info('Done')
|
||||||
15
tubesync/sync/management/commands/list-sources.py
Normal file
15
tubesync/sync/management/commands/list-sources.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import os
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from common.logger import log
|
||||||
|
from sync.models import Source, Media, MediaServer
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = ('Lists sources')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
log.info('Listing sources...')
|
||||||
|
for source in Source.objects.all():
|
||||||
|
log.info(f' - {source.uuid}: {source.name}')
|
||||||
|
log.info('Done')
|
||||||
@@ -19,6 +19,7 @@ class Command(BaseCommand):
|
|||||||
# Iter all tasks
|
# Iter all tasks
|
||||||
for source in Source.objects.all():
|
for source in Source.objects.all():
|
||||||
# Recreate the initial indexing task
|
# Recreate the initial indexing task
|
||||||
|
log.info(f'Resetting tasks for source: {source}')
|
||||||
verbose_name = _('Index media from source "{}"')
|
verbose_name = _('Index media from source "{}"')
|
||||||
index_source_task(
|
index_source_task(
|
||||||
str(source.pk),
|
str(source.pk),
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -124,7 +124,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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -21,7 +21,7 @@ from .matching import (get_best_combined_format, get_best_audio_format,
|
|||||||
from .mediaservers import PlexMediaServer
|
from .mediaservers import PlexMediaServer
|
||||||
|
|
||||||
|
|
||||||
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT))
|
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT), base_url='/media-data/')
|
||||||
|
|
||||||
|
|
||||||
class Source(models.Model):
|
class Source(models.Model):
|
||||||
@@ -158,6 +158,9 @@ class Source(models.Model):
|
|||||||
EVERY_6_HOURS = 21600, _('Every 6 hours')
|
EVERY_6_HOURS = 21600, _('Every 6 hours')
|
||||||
EVERY_12_HOURS = 43200, _('Every 12 hours')
|
EVERY_12_HOURS = 43200, _('Every 12 hours')
|
||||||
EVERY_24_HOURS = 86400, _('Every 24 hours')
|
EVERY_24_HOURS = 86400, _('Every 24 hours')
|
||||||
|
EVERY_3_DAYS = 259200, _('Every 3 days')
|
||||||
|
EVERY_7_DAYS = 604800, _('Every 7 days')
|
||||||
|
NEVER = 0, _('Never')
|
||||||
|
|
||||||
uuid = models.UUIDField(
|
uuid = models.UUIDField(
|
||||||
_('uuid'),
|
_('uuid'),
|
||||||
@@ -218,7 +221,7 @@ class Source(models.Model):
|
|||||||
_('index schedule'),
|
_('index schedule'),
|
||||||
choices=IndexSchedule.choices,
|
choices=IndexSchedule.choices,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
default=IndexSchedule.EVERY_6_HOURS,
|
default=IndexSchedule.EVERY_24_HOURS,
|
||||||
help_text=_('Schedule of how often to index the source for new media')
|
help_text=_('Schedule of how often to index the source for new media')
|
||||||
)
|
)
|
||||||
download_media = models.BooleanField(
|
download_media = models.BooleanField(
|
||||||
@@ -295,6 +298,11 @@ 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,
|
||||||
@@ -384,10 +392,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)
|
||||||
@@ -425,19 +437,19 @@ 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,
|
||||||
'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',
|
||||||
'format': '-'.join(fmt),
|
'format': '-'.join(fmt),
|
||||||
'playlist_index': 1,
|
|
||||||
'playlist_title': 'Some Playlist Title',
|
'playlist_title': 'Some Playlist Title',
|
||||||
'ext': self.extension,
|
'ext': self.extension,
|
||||||
'resolution': self.source_resolution if self.source_resolution else '',
|
'resolution': self.source_resolution if self.source_resolution else '',
|
||||||
@@ -465,7 +477,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):
|
||||||
@@ -559,11 +575,6 @@ class Media(models.Model):
|
|||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'dislike_count',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'dislike_count',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'dislike_count',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'dislike_count',
|
||||||
},
|
},
|
||||||
'playlist_index': {
|
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_index',
|
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_index',
|
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_index',
|
|
||||||
},
|
|
||||||
'playlist_title': {
|
'playlist_title': {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title',
|
||||||
@@ -658,7 +669,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,
|
||||||
@@ -668,7 +679,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'),
|
||||||
@@ -688,7 +705,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'),
|
||||||
@@ -815,6 +832,23 @@ class Media(models.Model):
|
|||||||
hdr = ''
|
hdr = ''
|
||||||
# If the download has completed use existing values
|
# If the download has completed use existing values
|
||||||
if self.downloaded:
|
if self.downloaded:
|
||||||
|
# Check if there's any stored meta data at all
|
||||||
|
if (not self.downloaded_video_codec and \
|
||||||
|
not self.downloaded_audio_codec):
|
||||||
|
# Marked as downloaded but no metadata, imported?
|
||||||
|
return {
|
||||||
|
'resolution': resolution,
|
||||||
|
'height': height,
|
||||||
|
'width': width,
|
||||||
|
'vcodec': vcodec,
|
||||||
|
'acodec': acodec,
|
||||||
|
'fps': fps,
|
||||||
|
'hdr': hdr,
|
||||||
|
'format': tuple(fmt),
|
||||||
|
}
|
||||||
|
if self.downloaded_format:
|
||||||
|
resolution = self.downloaded_format.lower()
|
||||||
|
elif self.downloaded_height:
|
||||||
resolution = f'{self.downloaded_height}p'
|
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()
|
||||||
@@ -842,7 +876,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)
|
||||||
@@ -851,7 +885,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:
|
||||||
@@ -912,7 +946,6 @@ class Media(models.Model):
|
|||||||
'title_full': clean_filename(self.title),
|
'title_full': clean_filename(self.title),
|
||||||
'key': self.key,
|
'key': self.key,
|
||||||
'format': '-'.join(display_format['format']),
|
'format': '-'.join(display_format['format']),
|
||||||
'playlist_index': self.playlist_index,
|
|
||||||
'playlist_title': self.playlist_title,
|
'playlist_title': self.playlist_title,
|
||||||
'ext': self.source.extension,
|
'ext': self.source.extension,
|
||||||
'resolution': display_format['resolution'],
|
'resolution': display_format['resolution'],
|
||||||
@@ -983,7 +1016,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 ValueError:
|
||||||
|
duration = 0
|
||||||
|
return duration
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration_formatted(self):
|
def duration_formatted(self):
|
||||||
@@ -1029,11 +1067,6 @@ class Media(models.Model):
|
|||||||
field = self.get_metadata_field('formats')
|
field = self.get_metadata_field('formats')
|
||||||
return self.loaded_metadata.get(field, [])
|
return self.loaded_metadata.get(field, [])
|
||||||
|
|
||||||
@property
|
|
||||||
def playlist_index(self):
|
|
||||||
field = self.get_metadata_field('playlist_index')
|
|
||||||
return self.loaded_metadata.get(field, 0)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def playlist_title(self):
|
def playlist_title(self):
|
||||||
field = self.get_metadata_field('playlist_title')
|
field = self.get_metadata_field('playlist_title')
|
||||||
@@ -1048,6 +1081,9 @@ class Media(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def thumbname(self):
|
def thumbname(self):
|
||||||
|
if self.downloaded and self.media_file:
|
||||||
|
filename = os.path.basename(self.media_file.path)
|
||||||
|
else:
|
||||||
filename = self.filename
|
filename = self.filename
|
||||||
prefix, ext = os.path.splitext(filename)
|
prefix, ext = os.path.splitext(filename)
|
||||||
return f'{prefix}.jpg'
|
return f'{prefix}.jpg'
|
||||||
@@ -1058,6 +1094,9 @@ class Media(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def nfoname(self):
|
def nfoname(self):
|
||||||
|
if self.downloaded and self.media_file:
|
||||||
|
filename = os.path.basename(self.media_file.path)
|
||||||
|
else:
|
||||||
filename = self.filename
|
filename = self.filename
|
||||||
prefix, ext = os.path.splitext(filename)
|
prefix, ext = os.path.splitext(filename)
|
||||||
return f'{prefix}.nfo'
|
return f'{prefix}.nfo'
|
||||||
@@ -1066,6 +1105,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
|
||||||
@@ -1090,6 +1142,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):
|
||||||
'''
|
'''
|
||||||
@@ -1214,7 +1291,7 @@ 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)
|
||||||
# Return the download paramaters
|
# Return the download paramaters
|
||||||
return format_str, self.source.extension
|
return format_str, self.source.extension
|
||||||
|
|
||||||
@@ -1224,7 +1301,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)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ def source_post_save(sender, instance, created, **kwargs):
|
|||||||
priority=0,
|
priority=0,
|
||||||
verbose_name=verbose_name.format(instance.name)
|
verbose_name=verbose_name.format(instance.name)
|
||||||
)
|
)
|
||||||
|
if instance.index_schedule > 0:
|
||||||
delete_task_by_source('sync.tasks.index_source_task', instance.pk)
|
delete_task_by_source('sync.tasks.index_source_task', instance.pk)
|
||||||
log.info(f'Scheduling media indexing for source: {instance.name}')
|
log.info(f'Scheduling media indexing for source: {instance.name}')
|
||||||
verbose_name = _('Index media from source "{}"')
|
verbose_name = _('Index media from source "{}"')
|
||||||
@@ -92,17 +93,61 @@ 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):
|
||||||
# Triggered after media is saved, Recalculate the "can_download" flag, this may
|
# If the media is skipped manually, bail.
|
||||||
|
if instance.manual_skip:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Triggered after media is saved
|
||||||
|
cap_changed = False
|
||||||
|
can_download_changed = False
|
||||||
|
# Reset the skip flag if the download cap has changed if the media has not
|
||||||
|
# already been downloaded
|
||||||
|
if not instance.downloaded:
|
||||||
|
max_cap_age = instance.source.download_cap_date
|
||||||
|
published = instance.published
|
||||||
|
if not published:
|
||||||
|
if not instance.skip:
|
||||||
|
log.warn(f'Media: {instance.source} / {instance} has no published date '
|
||||||
|
f'set, marking to be skipped')
|
||||||
|
instance.skip = True
|
||||||
|
cap_changed = True
|
||||||
|
else:
|
||||||
|
log.debug(f'Media: {instance.source} / {instance} has no published date '
|
||||||
|
f'set but is already marked to be skipped')
|
||||||
|
else:
|
||||||
|
if max_cap_age:
|
||||||
|
if published > max_cap_age and instance.skip:
|
||||||
|
# Media was published after the cap date but is set to be skipped
|
||||||
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
|
f'publishing date, marking to be unskipped')
|
||||||
|
instance.skip = False
|
||||||
|
cap_changed = True
|
||||||
|
elif published <= max_cap_age and not instance.skip:
|
||||||
|
log.info(f'Media: {instance.source} / {instance} is too old for '
|
||||||
|
f'the download cap date, marking to be skipped')
|
||||||
|
instance.skip = True
|
||||||
|
cap_changed = True
|
||||||
|
else:
|
||||||
|
if instance.skip:
|
||||||
|
# Media marked to be skipped but source download cap removed
|
||||||
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
|
f'publishing date, marking to be unskipped')
|
||||||
|
instance.skip = False
|
||||||
|
cap_changed = True
|
||||||
|
# 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:
|
||||||
post_save.disconnect(media_post_save, sender=Media)
|
|
||||||
if instance.get_format_str():
|
if instance.get_format_str():
|
||||||
if not instance.can_download:
|
if not instance.can_download:
|
||||||
instance.can_download = True
|
instance.can_download = True
|
||||||
instance.save()
|
can_download_changed = True
|
||||||
else:
|
else:
|
||||||
if instance.can_download:
|
if instance.can_download:
|
||||||
instance.can_download = False
|
instance.can_download = False
|
||||||
|
can_download_changed = True
|
||||||
|
# Save the instance if any changes were required
|
||||||
|
if cap_changed or can_download_changed:
|
||||||
|
post_save.disconnect(media_post_save, sender=Media)
|
||||||
instance.save()
|
instance.save()
|
||||||
post_save.connect(media_post_save, sender=Media)
|
post_save.connect(media_post_save, sender=Media)
|
||||||
# If the media is missing metadata schedule it to be downloaded
|
# If the media is missing metadata schedule it to be downloaded
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from background_task import background
|
|||||||
from background_task.models import Task, CompletedTask
|
from background_task.models import Task, CompletedTask
|
||||||
from common.logger import log
|
from common.logger import log
|
||||||
from common.errors import NoMediaException, DownloadFailedException
|
from common.errors import NoMediaException, DownloadFailedException
|
||||||
|
from common.utils import json_serial
|
||||||
from .models import Source, Media, MediaServer
|
from .models import Source, Media, MediaServer
|
||||||
from .utils import (get_remote_image, resize_image_to_height, delete_file,
|
from .utils import (get_remote_image, resize_image_to_height, delete_file,
|
||||||
write_text_file)
|
write_text_file)
|
||||||
@@ -152,7 +153,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
|
||||||
@@ -175,7 +175,7 @@ def index_source_task(source_id):
|
|||||||
# Video has no unique key (ID), it can't be indexed
|
# Video has no unique key (ID), it can't be indexed
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
media = Media.objects.get(key=key)
|
media = Media.objects.get(key=key, source=source)
|
||||||
except Media.DoesNotExist:
|
except Media.DoesNotExist:
|
||||||
media = Media(key=key)
|
media = Media(key=key)
|
||||||
media.source = source
|
media.source = source
|
||||||
@@ -201,7 +201,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():
|
||||||
@@ -222,9 +221,14 @@ 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)
|
media.metadata = json.dumps(metadata, default=json_serial)
|
||||||
upload_date = media.upload_date
|
upload_date = media.upload_date
|
||||||
# Media must have a valid upload date
|
# Media must have a valid upload date
|
||||||
if upload_date:
|
if upload_date:
|
||||||
@@ -234,7 +238,7 @@ 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 '
|
||||||
@@ -310,18 +314,26 @@ def download_media(media_id):
|
|||||||
return
|
return
|
||||||
if media.skip:
|
if media.skip:
|
||||||
# Media was toggled to be skipped after the task was scheduled
|
# Media was toggled to be skipped after the task was scheduled
|
||||||
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but it '
|
log.warn(f'Download task triggered for media: {media} (UUID: {media.pk}) but '
|
||||||
f'is now marked to be skipped, not downloading')
|
f'it is now marked to be skipped, not downloading')
|
||||||
return
|
return
|
||||||
if media.downloaded and media.media_file:
|
if media.downloaded and media.media_file:
|
||||||
# Media has been marked as downloaded before the download_media task was fired,
|
# Media has been marked as downloaded before the download_media task was fired,
|
||||||
# skip it
|
# skip it
|
||||||
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but it '
|
log.warn(f'Download task triggered for media: {media} (UUID: {media.pk}) but '
|
||||||
f'has already been marked as downloaded, not downloading again')
|
f'it has already been marked as downloaded, not downloading again')
|
||||||
return
|
return
|
||||||
if not media.source.download_media:
|
if not media.source.download_media:
|
||||||
log.warn(f'Download task triggeredd media: {media} (UUID: {media.pk}) but the '
|
log.warn(f'Download task triggered for media: {media} (UUID: {media.pk}) but '
|
||||||
f'source {media.source} has since been marked to not download media, '
|
f'the source {media.source} has since been marked to not download, '
|
||||||
|
f'not downloading')
|
||||||
|
return
|
||||||
|
max_cap_age = media.source.download_cap_date
|
||||||
|
published = media.published
|
||||||
|
if max_cap_age and published:
|
||||||
|
if published <= max_cap_age:
|
||||||
|
log.warn(f'Download task triggered media: {media} (UUID: {media.pk}) but '
|
||||||
|
f'the source has a download cap and the media is now too old, '
|
||||||
f'not downloading')
|
f'not downloading')
|
||||||
return
|
return
|
||||||
filepath = media.filepath
|
filepath = media.filepath
|
||||||
@@ -332,7 +344,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>
|
||||||
@@ -63,11 +63,6 @@
|
|||||||
<td>Media format string</td>
|
<td>Media format string</td>
|
||||||
<td>720p-avc1-mp4a</td>
|
<td>720p-avc1-mp4a</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>{playlist_index}</td>
|
|
||||||
<td>Playlist index of media, if it's in a playlist</td>
|
|
||||||
<td>12</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{playlist_title}</td>
|
<td>{playlist_title}</td>
|
||||||
<td>Playlist title of media, if it's in a playlist</td>
|
<td>Playlist title of media, if it's in a playlist</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">
|
||||||
@@ -123,6 +123,10 @@
|
|||||||
<td class="hide-on-small-only">Downloads directory</td>
|
<td class="hide-on-small-only">Downloads directory</td>
|
||||||
<td><span class="hide-on-med-and-up">Downloads directory<br></span><strong>{{ downloads_dir }}</strong></td>
|
<td><span class="hide-on-med-and-up">Downloads directory<br></span><strong>{{ downloads_dir }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Database connection used by TubeSync">
|
||||||
|
<td class="hide-on-small-only">Database</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Database<br></span><strong>{{ database_connection }}</strong></td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -111,6 +111,10 @@
|
|||||||
<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>
|
||||||
{% 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>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="collection">
|
<div class="collection">
|
||||||
{% for source in sources %}
|
{% for source in sources %}
|
||||||
<a href="{% url 'sync:source' pk=source.pk %}" class="collection-item">
|
<span class="collection-item flex-collection-container">
|
||||||
|
<a href="{% url 'sync:source' pk=source.pk %}" class="flex-grow">
|
||||||
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
||||||
{{ source.format_summary }}<br>
|
{{ source.format_summary }}<br>
|
||||||
{% if source.has_failed %}
|
{% if source.has_failed %}
|
||||||
@@ -33,6 +34,8 @@
|
|||||||
<strong>{{ source.media_count }}</strong> media items, <strong>{{ source.downloaded_count }}</strong> downloaded{% if source.delete_old_media and source.days_to_keep > 0 %}, keeping {{ source.days_to_keep }} days of media{% endif %}
|
<strong>{{ source.media_count }}</strong> media items, <strong>{{ source.downloaded_count }}</strong> downloaded{% if source.delete_old_media and source.days_to_keep > 0 %}, keeping {{ source.days_to_keep }} days of media{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'sync:source-sync-now' pk=source.pk %}" class="collection-item"><i class="fas fa-arrow-rotate-right"></i></a>
|
||||||
|
</span>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
{% for task in scheduled %}
|
{% for task in scheduled %}
|
||||||
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
||||||
<i class="fas fa-stopwatch"></i> <strong>{{ task }}</strong><br>
|
<i class="fas fa-stopwatch"></i> <strong>{{ task }}</strong><br>
|
||||||
{% if task.instance.index_schedule %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.<br>{% endif %}
|
{% if task.instance.index_schedule and task.repeat > 0 %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.<br>{% endif %}
|
||||||
<i class="fas fa-redo"></i> Task will run {% if task.run_now %}<strong>immediately</strong>{% else %}at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>{% endif %}
|
<i class="fas fa-redo"></i> Task will run {% if task.run_now %}<strong>immediately</strong>{% else %}at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
1
tubesync/sync/testdata/metadata.json
vendored
1
tubesync/sync/testdata/metadata.json
vendored
@@ -9,7 +9,6 @@
|
|||||||
"average_rating": 1.2345,
|
"average_rating": 1.2345,
|
||||||
"dislike_count": 123,
|
"dislike_count": 123,
|
||||||
"like_count": 456,
|
"like_count": 456,
|
||||||
"playlist_index": 789,
|
|
||||||
"playlist_title": "test playlist",
|
"playlist_title": "test playlist",
|
||||||
"uploader": "test uploader",
|
"uploader": "test uploader",
|
||||||
"categories":[
|
"categories":[
|
||||||
|
|||||||
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
@@ -36,6 +36,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 +50,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 +66,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 +78,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 +90,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 +102,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',
|
||||||
@@ -344,21 +354,25 @@ class FrontEndTestCase(TestCase):
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
'''
|
'''
|
||||||
|
past_date = timezone.make_aware(datetime(year=2000, month=1, day=1))
|
||||||
test_media1 = Media.objects.create(
|
test_media1 = Media.objects.create(
|
||||||
key='mediakey1',
|
key='mediakey1',
|
||||||
source=test_source,
|
source=test_source,
|
||||||
|
published=past_date,
|
||||||
metadata=test_minimal_metadata
|
metadata=test_minimal_metadata
|
||||||
)
|
)
|
||||||
test_media1_pk = str(test_media1.pk)
|
test_media1_pk = str(test_media1.pk)
|
||||||
test_media2 = Media.objects.create(
|
test_media2 = Media.objects.create(
|
||||||
key='mediakey2',
|
key='mediakey2',
|
||||||
source=test_source,
|
source=test_source,
|
||||||
|
published=past_date,
|
||||||
metadata=test_minimal_metadata
|
metadata=test_minimal_metadata
|
||||||
)
|
)
|
||||||
test_media2_pk = str(test_media2.pk)
|
test_media2_pk = str(test_media2.pk)
|
||||||
test_media3 = Media.objects.create(
|
test_media3 = Media.objects.create(
|
||||||
key='mediakey3',
|
key='mediakey3',
|
||||||
source=test_source,
|
source=test_source,
|
||||||
|
published=past_date,
|
||||||
metadata=test_minimal_metadata
|
metadata=test_minimal_metadata
|
||||||
)
|
)
|
||||||
test_media3_pk = str(test_media3.pk)
|
test_media3_pk = str(test_media3.pk)
|
||||||
@@ -487,7 +501,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 = ''
|
||||||
@@ -535,9 +549,6 @@ class FilepathTestCase(TestCase):
|
|||||||
self.source.media_format = 'test-{format}'
|
self.source.media_format = 'test-{format}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-1080p-vp9-opus')
|
'test-1080p-vp9-opus')
|
||||||
self.source.media_format = 'test-{playlist_index}'
|
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
|
||||||
'test-1')
|
|
||||||
self.source.media_format = 'test-{playlist_title}'
|
self.source.media_format = 'test-{playlist_title}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-Some Playlist Title')
|
'test-Some Playlist Title')
|
||||||
@@ -1161,14 +1172,14 @@ class FormatMatchingTestCase(TestCase):
|
|||||||
('2160p', 'VP9', False, True): (True, '337'), # Exact match, hdr
|
('2160p', 'VP9', False, True): (True, '337'), # Exact match, hdr
|
||||||
('2160p', 'VP9', True, False): (True, '315'), # Exact match, 60fps
|
('2160p', 'VP9', True, False): (True, '315'), # Exact match, 60fps
|
||||||
('2160p', 'VP9', True, True): (True, '337'), # Exact match, 60fps+hdr
|
('2160p', 'VP9', True, True): (True, '337'), # Exact match, 60fps+hdr
|
||||||
('4320P', 'AVC1', False, False): (False, False),
|
('4320p', 'AVC1', False, False): (False, False),
|
||||||
('4320P', 'AVC1', False, True): (False, False),
|
('4320p', 'AVC1', False, True): (False, False),
|
||||||
('4320P', 'AVC1', True, False): (False, False),
|
('4320p', 'AVC1', True, False): (False, False),
|
||||||
('4320P', 'AVC1', True, True): (False, False),
|
('4320p', 'AVC1', True, True): (False, False),
|
||||||
('4320P', 'VP9', False, False): (False, False),
|
('4320p', 'VP9', False, False): (False, False),
|
||||||
('4320P', 'VP9', False, True): (False, False),
|
('4320p', 'VP9', False, True): (False, False),
|
||||||
('4320P', 'VP9', True, False): (True, '272'), # Exact match, 60fps
|
('4320p', 'VP9', True, False): (True, '272'), # Exact match, 60fps
|
||||||
('4320P', 'VP9', True, True): (False, False),
|
('4320p', 'VP9', True, True): (False, False),
|
||||||
}
|
}
|
||||||
for params, expected in expected_matches.items():
|
for params, expected in expected_matches.items():
|
||||||
resolution, vcodec, prefer_60fps, prefer_hdr = params
|
resolution, vcodec, prefer_60fps, prefer_hdr = params
|
||||||
@@ -1367,14 +1378,14 @@ class FormatMatchingTestCase(TestCase):
|
|||||||
('2160p', 'VP9', False, True): (True, '337'), # Exact match, hdr
|
('2160p', 'VP9', False, True): (True, '337'), # Exact match, hdr
|
||||||
('2160p', 'VP9', True, False): (True, '315'), # Exact match, 60fps
|
('2160p', 'VP9', True, False): (True, '315'), # Exact match, 60fps
|
||||||
('2160p', 'VP9', True, True): (True, '337'), # Exact match, 60fps+hdr
|
('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, 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', 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, 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', '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, False): (False, '272'), # Fallback, 60fps (no other 8k streams)
|
||||||
('4320P', 'VP9', False, True): (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, False): (True, '272'), # Exact match, 60fps
|
||||||
('4320P', 'VP9', True, True): (False, '272'), # Fallback, 60fps (no other 8k streams)
|
('4320p', 'VP9', True, True): (False, '272'), # Fallback, 60fps (no other 8k streams)
|
||||||
}
|
}
|
||||||
for params, expected in expected_matches.items():
|
for params, expected in expected_matches.items():
|
||||||
resolution, vcodec, prefer_60fps, prefer_hdr = params
|
resolution, vcodec, prefer_60fps, prefer_hdr = params
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -78,6 +82,7 @@ class DashboardView(TemplateView):
|
|||||||
# Config and download locations
|
# Config and download locations
|
||||||
data['config_dir'] = str(settings.CONFIG_BASE_DIR)
|
data['config_dir'] = str(settings.CONFIG_BASE_DIR)
|
||||||
data['downloads_dir'] = str(settings.DOWNLOAD_ROOT)
|
data['downloads_dir'] = str(settings.DOWNLOAD_ROOT)
|
||||||
|
data['database_connection'] = settings.DATABASE_CONNECTION_STR
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -91,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)
|
||||||
@@ -170,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': [],
|
||||||
@@ -179,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': [],
|
||||||
@@ -188,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',),
|
||||||
@@ -268,23 +292,55 @@ 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', 'media_format',
|
||||||
|
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||||
|
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||||
|
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo', 'write_json')
|
||||||
|
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 = {}
|
||||||
@@ -311,20 +367,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'})
|
||||||
@@ -363,33 +405,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})
|
||||||
@@ -415,11 +433,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):
|
||||||
@@ -442,6 +462,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):
|
||||||
@@ -454,19 +475,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):
|
||||||
@@ -478,6 +507,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
|
||||||
|
|
||||||
|
|
||||||
@@ -627,6 +657,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
|
||||||
@@ -636,6 +667,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)
|
||||||
|
|
||||||
@@ -664,6 +696,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)
|
||||||
|
|
||||||
@@ -672,6 +705,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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from common.logger import log
|
from common.logger import log
|
||||||
import youtube_dl
|
import yt_dlp
|
||||||
|
|
||||||
|
|
||||||
_youtubedl_cachedir = getattr(settings, 'YOUTUBE_DL_CACHEDIR', None)
|
_youtubedl_cachedir = getattr(settings, 'YOUTUBE_DL_CACHEDIR', None)
|
||||||
@@ -19,20 +19,30 @@ if _youtubedl_cachedir:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeError(youtube_dl.utils.DownloadError):
|
class YouTubeError(yt_dlp.utils.DownloadError):
|
||||||
'''
|
'''
|
||||||
Generic wrapped error for all errors that could be raised by youtube-dl.
|
Generic wrapped error for all errors that could be raised by youtube-dl.
|
||||||
'''
|
'''
|
||||||
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,
|
||||||
@@ -41,10 +51,10 @@ def get_media_info(url):
|
|||||||
'extract_flat': True,
|
'extract_flat': True,
|
||||||
})
|
})
|
||||||
response = {}
|
response = {}
|
||||||
with youtube_dl.YoutubeDL(opts) as y:
|
with yt_dlp.YoutubeDL(opts) as y:
|
||||||
try:
|
try:
|
||||||
response = y.extract_info(url, download=False)
|
response = y.extract_info(url, download=False)
|
||||||
except youtube_dl.utils.DownloadError as e:
|
except yt_dlp.utils.DownloadError as e:
|
||||||
raise YouTubeError(f'Failed to extract_info for "{url}": {e}') from e
|
raise YouTubeError(f'Failed to extract_info for "{url}": {e}') from e
|
||||||
if not response:
|
if not response:
|
||||||
raise YouTubeError(f'Failed to extract_info for "{url}": No metadata was '
|
raise YouTubeError(f'Failed to extract_info for "{url}": No metadata was '
|
||||||
@@ -54,7 +64,7 @@ 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"):
|
||||||
'''
|
'''
|
||||||
Downloads a YouTube URL to a file on disk.
|
Downloads a YouTube URL to a file on disk.
|
||||||
'''
|
'''
|
||||||
@@ -90,18 +100,30 @@ def download_media(url, media_format, extension, output_file):
|
|||||||
else:
|
else:
|
||||||
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
||||||
hook.download_progress = 0
|
hook.download_progress = 0
|
||||||
|
postprocessors = []
|
||||||
opts = copy(_defaults)
|
postprocessors.append({
|
||||||
|
'key': 'FFmpegMetadata',
|
||||||
|
'add_chapters': True,
|
||||||
|
'add_metadata': True
|
||||||
|
})
|
||||||
|
# Pending configuration options from PR #338
|
||||||
|
#postprocessors.append({
|
||||||
|
# 'key': 'SponsorBlock',
|
||||||
|
# 'categories': [sponsor_categories]
|
||||||
|
#})
|
||||||
|
opts = get_yt_opts()
|
||||||
opts.update({
|
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': postprocessors,
|
||||||
})
|
})
|
||||||
with youtube_dl.YoutubeDL(opts) as y:
|
with yt_dlp.YoutubeDL(opts) as y:
|
||||||
try:
|
try:
|
||||||
return y.download([url])
|
return y.download([url])
|
||||||
except youtube_dl.utils.DownloadError as e:
|
except yt_dlp.utils.DownloadError as e:
|
||||||
raise YouTubeError(f'Failed to download for "{url}": {e}') from e
|
raise YouTubeError(f'Failed to download for "{url}": {e}') from e
|
||||||
return False
|
return False
|
||||||
|
|||||||
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': ()
|
||||||
|
},
|
||||||
|
}
|
||||||
27
tubesync/tubesync/dbutils.py
Normal file
27
tubesync/tubesync/dbutils.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import importlib
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.backends.utils import CursorWrapper
|
||||||
|
|
||||||
|
|
||||||
|
def patch_ensure_connection():
|
||||||
|
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')
|
||||||
|
|
||||||
|
def ensure_connection(self):
|
||||||
|
if self.connection is not None:
|
||||||
|
try:
|
||||||
|
with CursorWrapper(self.create_cursor(), self) as cursor:
|
||||||
|
cursor.execute('SELECT 1;')
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with self.wrap_database_errors:
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
module.DatabaseWrapper.ensure_connection = ensure_connection
|
||||||
@@ -1,32 +1,58 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from common.logger import log
|
||||||
|
from common.utils import parse_database_connection_string
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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')
|
||||||
|
|
||||||
|
|
||||||
DATABASES = {
|
database_dict = {}
|
||||||
|
database_connection_env = os.getenv('DATABASE_CONNECTION', '')
|
||||||
|
if database_connection_env:
|
||||||
|
database_dict = parse_database_connection_string(database_connection_env)
|
||||||
|
|
||||||
|
|
||||||
|
if database_dict:
|
||||||
|
log.info(f'Using database connection: {database_dict["ENGINE"]}://'
|
||||||
|
f'{database_dict["USER"]}:[hidden]@{database_dict["HOST"]}:'
|
||||||
|
f'{database_dict["PORT"]}/{database_dict["NAME"]}')
|
||||||
|
DATABASES = {
|
||||||
|
'default': database_dict,
|
||||||
|
}
|
||||||
|
DATABASE_CONNECTION_STR = (f'{database_dict["DRIVER"]} at "{database_dict["HOST"]}:'
|
||||||
|
f'{database_dict["PORT"]}" database '
|
||||||
|
f'"{database_dict["NAME"]}"')
|
||||||
|
else:
|
||||||
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': CONFIG_BASE_DIR / 'db.sqlite3',
|
'NAME': CONFIG_BASE_DIR / 'db.sqlite3',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DATABASE_CONNECTION_STR = f'sqlite at "{DATABASES["default"]["NAME"]}"'
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_THREADS = 1
|
DEFAULT_THREADS = 1
|
||||||
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8
|
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8
|
||||||
@@ -38,6 +64,7 @@ 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'
|
||||||
|
|
||||||
|
|
||||||
BASICAUTH_USERNAME = os.getenv('HTTP_USER', '').strip()
|
BASICAUTH_USERNAME = os.getenv('HTTP_USER', '').strip()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ DATABASES = {
|
|||||||
'NAME': CONFIG_BASE_DIR / 'db.sqlite3',
|
'NAME': CONFIG_BASE_DIR / 'db.sqlite3',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DATABASE_CONNECTION_STR = f'sqlite at "{DATABASES["default"]["NAME"]}"'
|
||||||
|
|
||||||
|
|
||||||
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR / 'downloads'
|
DOWNLOAD_ROOT = DOWNLOADS_BASE_DIR / 'downloads'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
|||||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
VERSION = '0.9.1'
|
VERSION = '0.12.1'
|
||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
@@ -76,6 +76,9 @@ WSGI_APPLICATION = 'tubesync.wsgi.application'
|
|||||||
DATABASES = {}
|
DATABASES = {}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||||
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
@@ -135,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
|
||||||
@@ -146,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'
|
||||||
|
|
||||||
|
|
||||||
@@ -156,7 +159,9 @@ YOUTUBE_DEFAULTS = {
|
|||||||
'age_limit': 99, # 'Age in years' to spoof
|
'age_limit': 99, # 'Age in years' to spoof
|
||||||
'ignoreerrors': True, # Skip on errors (such as unavailable videos in playlists)
|
'ignoreerrors': True, # Skip on errors (such as unavailable videos in playlists)
|
||||||
'cachedir': False, # Disable on-disk caching
|
'cachedir': False, # Disable on-disk caching
|
||||||
|
'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}'
|
||||||
@@ -168,3 +173,7 @@ except ImportError as e:
|
|||||||
import sys
|
import sys
|
||||||
sys.stderr.write(f'Unable to import local_settings: {e}\n')
|
sys.stderr.write(f'Unable to import local_settings: {e}\n')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
from .dbutils import patch_ensure_connection
|
||||||
|
patch_ensure_connection()
|
||||||
|
|||||||
@@ -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