Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2c1c45e829 | ||
|
|
c64f54bcb4 | ||
|
|
6ce55b0337 | ||
|
|
d06c4beae0 | ||
|
|
db651e16b9 | ||
|
|
86068790ed | ||
|
|
ea72671351 | ||
|
|
96b9eddf43 | ||
|
|
bceefc8b01 | ||
|
|
820cc69937 | ||
|
|
1e8711be51 | ||
|
|
e3423bc2d2 | ||
|
|
6fbf72d0e7 | ||
|
|
d6852bf828 | ||
|
|
f6f4f244d7 | ||
|
|
df35aa2a5f | ||
|
|
799c0fce39 | ||
|
|
2f324f28a9 | ||
|
|
895bfe6f87 | ||
|
|
e0669b107d | ||
|
|
0dc201b293 | ||
|
|
82fa0f6bce | ||
|
|
8b93cb4a59 | ||
|
|
647254d7f7 | ||
|
|
3567e20600 | ||
|
|
5348e25303 | ||
|
|
749df3f7bb | ||
|
|
2c2f53e5b2 | ||
|
|
06cfafb803 | ||
|
|
f5a37f2e86 | ||
|
|
36747a47e0 | ||
|
|
ffd69e8d40 | ||
|
|
eebef3371f | ||
|
|
4cd6701c8a | ||
|
|
4ebe6f2a37 | ||
|
|
d553d58fde | ||
|
|
df40a1367a | ||
|
|
607ee77e70 | ||
|
|
9af493aa8a | ||
|
|
f0c94ff789 | ||
|
|
39c7799831 | ||
|
|
da7371f830 | ||
|
|
387cfefc8f | ||
|
|
d92dbde781 | ||
|
|
e36658e1a1 | ||
|
|
51cd942717 | ||
|
|
001554db1a | ||
|
|
7cf86bb98d | ||
|
|
c28c095f48 | ||
|
|
12eac049e5 | ||
|
|
304cc153cf | ||
|
|
b45231f533 | ||
|
|
26eb9d30e8 | ||
|
|
97fa62d12b | ||
|
|
1b092fe955 | ||
|
|
18a59fe835 | ||
|
|
410906ad8e | ||
|
|
8f4b09f346 | ||
|
|
cda021cbbf | ||
|
|
ee4df99cd8 | ||
|
|
53f1873a9b | ||
|
|
9434293a84 | ||
|
|
ed69fe9dcc | ||
|
|
67af70569b | ||
|
|
68a62d8a7c | ||
|
|
55578f4de7 |
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: [meeb]
|
||||||
31
.github/workflows/ci.yaml
vendored
@@ -35,13 +35,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
|
||||||
- name: Log into GitHub Container Registry
|
uses: docker/setup-buildx-action@v1
|
||||||
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
- name: Log into GitHub Container Registry
|
||||||
- name: Push image to GitHub Container Registry
|
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
run: |
|
- name: Lowercase github username for ghcr
|
||||||
LATEST_TAG=ghcr.io/meeb/$IMAGE_NAME:latest
|
id: string
|
||||||
docker tag $IMAGE_NAME $LATEST_TAG
|
uses: ASzc/change-string-case-action@v1
|
||||||
docker push $LATEST_TAG
|
with:
|
||||||
|
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 }}
|
||||||
|
|||||||
40
.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
|
||||||
- name: Get tag
|
uses: docker/setup-qemu-action@v1
|
||||||
id: vars
|
- name: Get tag
|
||||||
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
|
id: tag
|
||||||
- name: Build the container image
|
uses: dawidd6/action-get-tag@v1
|
||||||
run: docker build . --tag $IMAGE_NAME
|
- uses: docker/build-push-action@v2
|
||||||
- name: Log into GitHub Container Registry
|
- name: Set up Docker Buildx
|
||||||
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
uses: docker/setup-buildx-action@v1
|
||||||
- name: Push image to GitHub Container Registry
|
- name: Log into GitHub Container Registry
|
||||||
env:
|
run: echo "${{ secrets.REGISTRY_ACCESS_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
RELEASE_TAG: ${{ steps.vars.outputs.tag }}
|
- name: Lowercase github username for ghcr
|
||||||
run: |
|
id: string
|
||||||
REF_TAG=ghcr.io/meeb/$IMAGE_NAME:$RELEASE_TAG
|
uses: ASzc/change-string-case-action@v1
|
||||||
docker tag $IMAGE_NAME $REF_TAG
|
with:
|
||||||
docker push $REF_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 }}:${{ 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 }}
|
||||||
|
|||||||
121
Dockerfile
@@ -1,52 +1,51 @@
|
|||||||
FROM debian:buster-slim
|
FROM debian:bullseye-slim
|
||||||
|
|
||||||
ARG ARCH="amd64"
|
ARG TARGETPLATFORM
|
||||||
ARG S6_VERSION="2.1.0.2"
|
ARG S6_VERSION="2.2.0.3"
|
||||||
ARG FFMPEG_VERSION="4.3.1"
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||||
HOME="/root" \
|
HOME="/root" \
|
||||||
LANGUAGE="en_US.UTF-8" \
|
LANGUAGE="en_US.UTF-8" \
|
||||||
LANG="en_US.UTF-8" \
|
LANG="en_US.UTF-8" \
|
||||||
LC_ALL="en_US.UTF-8" \
|
LC_ALL="en_US.UTF-8" \
|
||||||
TERM="xterm" \
|
TERM="xterm"
|
||||||
S6_EXPECTED_SHA256="52460473413601ff7a84ae690b161a074217ddc734990c2cdee9847166cf669e" \
|
|
||||||
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 \
|
||||||
apt-get update && \
|
"linux/amd64") echo "amd64" ;; \
|
||||||
apt-get -y --no-install-recommends install locales && \
|
"linux/arm64") echo "aarch64" ;; \
|
||||||
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
|
*) echo "" ;; esac) && \
|
||||||
locale-gen en_US.UTF-8 && \
|
export S6_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
# Install required distro packages
|
"linux/amd64") echo "a7076cf205b331e9f8479bbb09d9df77dbb5cd8f7d12e9b74920902e0c16dd98" ;; \
|
||||||
apt-get -y --no-install-recommends install curl xz-utils ca-certificates binutils && \
|
"linux/arm64") echo "84f585a100b610124bb80e441ef2dc2d68ac2c345fd393d75a6293e0951ccfc5" ;; \
|
||||||
# Install s6
|
*) echo "" ;; esac) && \
|
||||||
curl -L ${S6_DOWNLOAD} --output /tmp/s6-overlay-${ARCH}.tar.gz && \
|
export S6_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
sha256sum /tmp/s6-overlay-${ARCH}.tar.gz && \
|
"linux/amd64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-amd64.tar.gz" ;; \
|
||||||
echo "${S6_EXPECTED_SHA256} /tmp/s6-overlay-${ARCH}.tar.gz" | sha256sum -c - && \
|
"linux/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.gz" ;; \
|
||||||
tar xzf /tmp/s6-overlay-${ARCH}.tar.gz -C / && \
|
*) echo "" ;; esac) && \
|
||||||
# Install ffmpeg
|
echo "Building for arch: ${ARCH}|${ARCH44}, downloading S6 from: ${S6_DOWNLOAD}}, expecting S6 SHA256: ${S6_EXPECTED_SHA256}" && \
|
||||||
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg-${ARCH}-static.tar.xz && \
|
set -x && \
|
||||||
echo "${FFMPEG_EXPECTED_SHA256} /tmp/ffmpeg-${ARCH}-static.tar.xz" | sha256sum -c - && \
|
apt-get update && \
|
||||||
xz --decompress /tmp/ffmpeg-${ARCH}-static.tar.xz && \
|
apt-get -y --no-install-recommends install locales && \
|
||||||
tar -xvf /tmp/ffmpeg-${ARCH}-static.tar -C /tmp && \
|
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
|
||||||
install -v -s -g root -o root -m 0755 -s /tmp/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static/ffmpeg -t /usr/local/bin && \
|
locale-gen en_US.UTF-8 && \
|
||||||
# Clean up
|
# Install required distro packages
|
||||||
rm -rf /tmp/s6-overlay-${ARCH}.tar.gz && \
|
apt-get -y --no-install-recommends install curl ca-certificates binutils && \
|
||||||
rm -rf /tmp/ffmpeg-${ARCH}-static.tar && \
|
# Install s6
|
||||||
rm -rf /tmp/ffmpeg-${FFMPEG_VERSION}-${ARCH}-static && \
|
curl -L ${S6_DOWNLOAD} --output /tmp/s6-overlay-${ARCH}.tar.gz && \
|
||||||
apt-get -y autoremove --purge curl xz-utils binutils
|
sha256sum /tmp/s6-overlay-${ARCH}.tar.gz && \
|
||||||
|
echo "${S6_EXPECTED_SHA256} /tmp/s6-overlay-${ARCH}.tar.gz" | sha256sum -c - && \
|
||||||
|
tar xzf /tmp/s6-overlay-${ARCH}.tar.gz -C / && \
|
||||||
|
# Clean up
|
||||||
|
rm -rf /tmp/s6-overlay-${ARCH}.tar.gz && \
|
||||||
|
apt-get -y autoremove --purge curl 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
|
||||||
@@ -57,11 +56,31 @@ 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 \
|
||||||
|
ffmpeg \
|
||||||
|
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 && \
|
||||||
@@ -82,7 +101,18 @@ RUN set -x && \
|
|||||||
rm /app/Pipfile.lock && \
|
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/* && \
|
||||||
@@ -94,6 +124,11 @@ RUN set -x && \
|
|||||||
chown root:root /root && \
|
chown root:root /root && \
|
||||||
chmod 0700 /root
|
chmod 0700 /root
|
||||||
|
|
||||||
|
# Append software versions
|
||||||
|
RUN set -x && \
|
||||||
|
FFMPEG_VERSION=$(/usr/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 +137,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"]
|
||||||
|
|||||||
12
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
|
||||||
@@ -30,4 +30,4 @@ runcontainer:
|
|||||||
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(python) app/manage.py test --verbosity=2
|
cd tubesync && $(python) manage.py test --verbosity=2 && cd ..
|
||||||
|
|||||||
12
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,9 +14,11 @@ whitenoise = "*"
|
|||||||
gunicorn = "*"
|
gunicorn = "*"
|
||||||
django-compressor = "*"
|
django-compressor = "*"
|
||||||
httptools = "*"
|
httptools = "*"
|
||||||
youtube-dl = "*"
|
|
||||||
django-background-tasks = "*"
|
django-background-tasks = "*"
|
||||||
requests = "*"
|
requests = "*"
|
||||||
|
django-basicauth = "*"
|
||||||
[requires]
|
psycopg2-binary = "*"
|
||||||
python_version = "3"
|
mysqlclient = "*"
|
||||||
|
yt-dlp = "*"
|
||||||
|
redis = "*"
|
||||||
|
hiredis = "*"
|
||||||
|
|||||||
742
Pipfile.lock
generated
@@ -1,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "a4bb556fc61ee4583f9588980450b071814298ee4d1a1023fad149c14d14aaba"
|
"sha256": "a8b6cd12970bce4ea2de47aed437cf99ab5e63253a53e587e885c63b32ebc9a1"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {},
|
||||||
"python_version": "3"
|
|
||||||
},
|
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"name": "pypi",
|
"name": "pypi",
|
||||||
@@ -18,39 +16,127 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"asgiref": {
|
"asgiref": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
|
"sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4",
|
||||||
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
|
"sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"
|
||||||
],
|
],
|
||||||
"version": "==3.3.1"
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==3.5.2"
|
||||||
|
},
|
||||||
|
"async-timeout": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15",
|
||||||
|
"sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==4.0.2"
|
||||||
|
},
|
||||||
|
"brotli": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:12effe280b8ebfd389022aa65114e30407540ccb89b177d3fbc9a4f177c4bd5d",
|
||||||
|
"sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8",
|
||||||
|
"sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b",
|
||||||
|
"sha256:19598ecddd8a212aedb1ffa15763dd52a388518c4550e615aed88dc3753c0f0c",
|
||||||
|
"sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c",
|
||||||
|
"sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70",
|
||||||
|
"sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f",
|
||||||
|
"sha256:26d168aac4aaec9a4394221240e8a5436b5634adc3cd1cdf637f6645cecbf181",
|
||||||
|
"sha256:29d1d350178e5225397e28ea1b7aca3648fcbab546d20e7475805437bfb0a130",
|
||||||
|
"sha256:2aad0e0baa04517741c9bb5b07586c642302e5fb3e75319cb62087bd0995ab19",
|
||||||
|
"sha256:3496fc835370da351d37cada4cf744039616a6db7d13c430035e901443a34daa",
|
||||||
|
"sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429",
|
||||||
|
"sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126",
|
||||||
|
"sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4",
|
||||||
|
"sha256:44bb8ff420c1d19d91d79d8c3574b8954288bdff0273bf788954064d260d7ab0",
|
||||||
|
"sha256:4688c1e42968ba52e57d8670ad2306fe92e0169c6f3af0089be75bbac0c64a3b",
|
||||||
|
"sha256:495ba7e49c2db22b046a53b469bbecea802efce200dffb69b93dd47397edc9b6",
|
||||||
|
"sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438",
|
||||||
|
"sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f",
|
||||||
|
"sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389",
|
||||||
|
"sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6",
|
||||||
|
"sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26",
|
||||||
|
"sha256:5cb1e18167792d7d21e21365d7650b72d5081ed476123ff7b8cac7f45189c0c7",
|
||||||
|
"sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14",
|
||||||
|
"sha256:622a231b08899c864eb87e85f81c75e7b9ce05b001e59bbfbf43d4a71f5f32b2",
|
||||||
|
"sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430",
|
||||||
|
"sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296",
|
||||||
|
"sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12",
|
||||||
|
"sha256:6d847b14f7ea89f6ad3c9e3901d1bc4835f6b390a9c71df999b0162d9bb1e20f",
|
||||||
|
"sha256:76ffebb907bec09ff511bb3acc077695e2c32bc2142819491579a695f77ffd4d",
|
||||||
|
"sha256:7bbff90b63328013e1e8cb50650ae0b9bac54ffb4be6104378490193cd60f85a",
|
||||||
|
"sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452",
|
||||||
|
"sha256:7ee83d3e3a024a9618e5be64648d6d11c37047ac48adff25f12fa4226cf23d1c",
|
||||||
|
"sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761",
|
||||||
|
"sha256:85f7912459c67eaab2fb854ed2bc1cc25772b300545fe7ed2dc03954da638649",
|
||||||
|
"sha256:87fdccbb6bb589095f413b1e05734ba492c962b4a45a13ff3408fa44ffe6479b",
|
||||||
|
"sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea",
|
||||||
|
"sha256:8a674ac10e0a87b683f4fa2b6fa41090edfd686a6524bd8dedbd6138b309175c",
|
||||||
|
"sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a",
|
||||||
|
"sha256:9744a863b489c79a73aba014df554b0e7a0fc44ef3f8a0ef2a52919c7d155031",
|
||||||
|
"sha256:9749a124280a0ada4187a6cfd1ffd35c350fb3af79c706589d98e088c5044267",
|
||||||
|
"sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5",
|
||||||
|
"sha256:9bf919756d25e4114ace16a8ce91eb340eb57a08e2c6950c3cebcbe3dff2a5e7",
|
||||||
|
"sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d",
|
||||||
|
"sha256:9ed4c92a0665002ff8ea852353aeb60d9141eb04109e88928026d3c8a9e5433c",
|
||||||
|
"sha256:a72661af47119a80d82fa583b554095308d6a4c356b2a554fdc2799bc19f2a43",
|
||||||
|
"sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa",
|
||||||
|
"sha256:b336c5e9cf03c7be40c47b5fd694c43c9f1358a80ba384a21969e0b4e66a9b17",
|
||||||
|
"sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb",
|
||||||
|
"sha256:b83bb06a0192cccf1eb8d0a28672a1b79c74c3a8a5f2619625aeb6f28b3a82bb",
|
||||||
|
"sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b",
|
||||||
|
"sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4",
|
||||||
|
"sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3",
|
||||||
|
"sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7",
|
||||||
|
"sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1",
|
||||||
|
"sha256:e16eb9541f3dd1a3e92b89005e37b1257b157b7256df0e36bd7b33b50be73bcb",
|
||||||
|
"sha256:e23281b9a08ec338469268f98f194658abfb13658ee98e2b7f85ee9dd06caa91",
|
||||||
|
"sha256:e2d9e1cbc1b25e22000328702b014227737756f4b5bf5c485ac1d8091ada078b",
|
||||||
|
"sha256:e48f4234f2469ed012a98f4b7874e7f7e173c167bed4934912a29e03167cf6b1",
|
||||||
|
"sha256:e4c4e92c14a57c9bd4cb4be678c25369bf7a092d55fd0866f759e425b9660806",
|
||||||
|
"sha256:ec1947eabbaf8e0531e8e899fc1d9876c179fc518989461f5d24e2223395a9e3",
|
||||||
|
"sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"
|
||||||
|
],
|
||||||
|
"markers": "platform_python_implementation == 'CPython'",
|
||||||
|
"version": "==1.0.9"
|
||||||
},
|
},
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
"sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d",
|
||||||
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
"sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"
|
||||||
],
|
],
|
||||||
"version": "==2020.12.5"
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==2022.6.15"
|
||||||
},
|
},
|
||||||
"chardet": {
|
"charset-normalizer": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
|
||||||
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
|
||||||
],
|
],
|
||||||
"version": "==4.0.0"
|
"markers": "python_full_version >= '3.5.0'",
|
||||||
|
"version": "==2.0.12"
|
||||||
|
},
|
||||||
|
"deprecated": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d",
|
||||||
|
"sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==1.2.13"
|
||||||
},
|
},
|
||||||
"django": {
|
"django": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
|
"sha256:6d93497a0a9bf6ba0e0b1a29cccdc40efbfc76297255b1309b3a884a688ec4b6",
|
||||||
"sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03"
|
"sha256:b896ca61edc079eb6bbaa15cf6071eb69d6aac08cce5211583cfb41515644fdf"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.1.4"
|
"version": "==3.2.13"
|
||||||
},
|
},
|
||||||
"django-appconf": {
|
"django-appconf": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1b1d0e1069c843ebe8ae5aa48ec52403b1440402b320c3e3a206a0907e97bb06",
|
"sha256:ae9f864ee1958c815a965ed63b3fba4874eec13de10236ba063a788f9a17389d",
|
||||||
"sha256:be58deb54a43d77d2e1621fe59f787681376d3cd0b8bd8e4758ef6c3a6453380"
|
"sha256:be3db0be6c81fa84742000b89a81c016d70ae66a7ccb620cdef592b1f1a6aaa4"
|
||||||
],
|
],
|
||||||
"version": "==1.0.4"
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==1.0.5"
|
||||||
},
|
},
|
||||||
"django-background-tasks": {
|
"django-background-tasks": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -59,6 +145,14 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.2.5"
|
"version": "==1.2.5"
|
||||||
},
|
},
|
||||||
|
"django-basicauth": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:15e9e366f698f53c71b1e794dafea060f990a2ac556bae6b7330dd25324a091c",
|
||||||
|
"sha256:e5e47d1acdc1943bedcc1bf673059d6c15e257dfe9eef67a22fb824f79546c0d"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.5.3"
|
||||||
|
},
|
||||||
"django-compat": {
|
"django-compat": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3ac9a3bedc56b9365d9eb241bc5157d0c193769bf995f9a78dc1bc24e7c2331b"
|
"sha256:3ac9a3bedc56b9365d9eb241bc5157d0c193769bf995f9a78dc1bc24e7c2331b"
|
||||||
@@ -67,180 +161,562 @@
|
|||||||
},
|
},
|
||||||
"django-compressor": {
|
"django-compressor": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:57ac0a696d061e5fc6fbc55381d2050f353b973fb97eee5593f39247bc0f30af",
|
"sha256:1db91b6d04293636a68bd1328dc7bb90d636b0295f67b1cc6d4fa102b9fd25f6",
|
||||||
"sha256:d2ed1c6137ddaac5536233ec0a819e14009553fee0a869bea65d03e5285ba74f"
|
"sha256:b4fe15cc23bf39420b37cb0030572bd0971104ca1ec3764f502c0f179e576dff"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.4"
|
"version": "==4.0"
|
||||||
},
|
},
|
||||||
"django-sass-processor": {
|
"django-sass-processor": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:9b46a12ca8bdcb397d46fbcc49e6a926ff9f76a93c5efeb23b495419fd01fc7a"
|
"sha256:7631421e1bd318f8aed4b0e1d962228656cf685228120bcbb964d517cb8e9536",
|
||||||
|
"sha256:a5aeca9a1ec0a2dafb0dfbf3ec1a746861d2c2146e0171de178f4c1d7c0b472e"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.8.2"
|
"version": "==1.2"
|
||||||
},
|
},
|
||||||
"gunicorn": {
|
"gunicorn": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
|
"sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e",
|
||||||
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
|
"sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==20.0.4"
|
"version": "==20.1.0"
|
||||||
|
},
|
||||||
|
"hiredis": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e",
|
||||||
|
"sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27",
|
||||||
|
"sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163",
|
||||||
|
"sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc",
|
||||||
|
"sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26",
|
||||||
|
"sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e",
|
||||||
|
"sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579",
|
||||||
|
"sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a",
|
||||||
|
"sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048",
|
||||||
|
"sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87",
|
||||||
|
"sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63",
|
||||||
|
"sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54",
|
||||||
|
"sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05",
|
||||||
|
"sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb",
|
||||||
|
"sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea",
|
||||||
|
"sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5",
|
||||||
|
"sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e",
|
||||||
|
"sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc",
|
||||||
|
"sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99",
|
||||||
|
"sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a",
|
||||||
|
"sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581",
|
||||||
|
"sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426",
|
||||||
|
"sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db",
|
||||||
|
"sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a",
|
||||||
|
"sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a",
|
||||||
|
"sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d",
|
||||||
|
"sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443",
|
||||||
|
"sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79",
|
||||||
|
"sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d",
|
||||||
|
"sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9",
|
||||||
|
"sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d",
|
||||||
|
"sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485",
|
||||||
|
"sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5",
|
||||||
|
"sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048",
|
||||||
|
"sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0",
|
||||||
|
"sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6",
|
||||||
|
"sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41",
|
||||||
|
"sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298",
|
||||||
|
"sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce",
|
||||||
|
"sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0",
|
||||||
|
"sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.0.0"
|
||||||
},
|
},
|
||||||
"httptools": {
|
"httptools": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be",
|
"sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424",
|
||||||
"sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d",
|
"sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23",
|
||||||
"sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce",
|
"sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4",
|
||||||
"sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2",
|
"sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055",
|
||||||
"sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6",
|
"sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff",
|
||||||
"sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f",
|
"sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48",
|
||||||
"sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009",
|
"sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0",
|
||||||
"sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce",
|
"sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83",
|
||||||
"sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a",
|
"sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd",
|
||||||
"sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c",
|
"sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1",
|
||||||
"sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4",
|
"sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe",
|
||||||
"sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"
|
"sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d",
|
||||||
|
"sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777",
|
||||||
|
"sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae",
|
||||||
|
"sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409",
|
||||||
|
"sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919",
|
||||||
|
"sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d",
|
||||||
|
"sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b",
|
||||||
|
"sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e",
|
||||||
|
"sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111",
|
||||||
|
"sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855",
|
||||||
|
"sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de",
|
||||||
|
"sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c",
|
||||||
|
"sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a",
|
||||||
|
"sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c",
|
||||||
|
"sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad",
|
||||||
|
"sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af",
|
||||||
|
"sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed",
|
||||||
|
"sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe",
|
||||||
|
"sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3",
|
||||||
|
"sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722",
|
||||||
|
"sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890",
|
||||||
|
"sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5",
|
||||||
|
"sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.1.1"
|
"version": "==0.4.0"
|
||||||
},
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||||
],
|
],
|
||||||
"version": "==2.10"
|
"markers": "python_full_version >= '3.5.0'",
|
||||||
|
"version": "==3.3"
|
||||||
},
|
},
|
||||||
"libsass": {
|
"libsass": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b",
|
"sha256:06c8776417fe930714bdc930a3d7e795ae3d72be6ac883ff72a1b8f7c49e5ffb",
|
||||||
"sha256:1b2d415bbf6fa7da33ef46e549db1418498267b459978eff8357e5e823962d35",
|
"sha256:12f39712de38689a8b785b7db41d3ba2ea1d46f9379d81ea4595802d91fa6529",
|
||||||
"sha256:25ebc2085f5eee574761ccc8d9cd29a9b436fc970546d5ef08c6fa41eb57dff1",
|
"sha256:1e25dd9047a9392d3c59a0b869e0404f2b325a03871ee45285ee33b3664f5613",
|
||||||
"sha256:2ae806427b28bc1bb7cb0258666d854fcf92ba52a04656b0b17ba5e190fb48a9",
|
"sha256:659ae41af8708681fa3ec73f47b9735a6725e71c3b66ff570bfce78952f2314e",
|
||||||
"sha256:4a246e4b88fd279abef8b669206228c92534d96ddcd0770d7012088c408dff23",
|
"sha256:6b984510ed94993708c0d697b4fef2d118929bbfffc3b90037be0f5ccadf55e7",
|
||||||
"sha256:553e5096414a8d4fb48d0a48f5a038d3411abe254d79deac5e008516c019e63a",
|
"sha256:a005f298f64624f313a3ac618ab03f844c71d84ae4f4a4aec4b68d2a4ffe75eb",
|
||||||
"sha256:697f0f9fa8a1367ca9ec6869437cb235b1c537fc8519983d1d890178614a8903",
|
"sha256:abc29357ee540849faf1383e1746d40d69ed5cb6d4c346df276b258f5aa8977a",
|
||||||
"sha256:a8fd4af9f853e8bf42b1425c5e48dd90b504fa2e70d7dac5ac80b8c0a5a5fe85",
|
"sha256:c9ec490609752c1d81ff6290da33485aa7cb6d7365ac665b74464c1b7d97f7da",
|
||||||
"sha256:c9411fec76f480ffbacc97d8188322e02a5abca6fc78e70b86a2a2b421eae8a2",
|
"sha256:d5ba529d9ce668be9380563279f3ffe988f27bc5b299c5a28453df2e0b0fbaf2",
|
||||||
"sha256:daa98a51086d92aa7e9c8871cf1a8258124b90e2abf4697852a3dca619838618",
|
"sha256:e2b1a7d093f2e76dc694c17c0c285e846d0b0deb0e8b21dc852ba1a3a4e2f1d6"
|
||||||
"sha256:e0e60836eccbf2d9e24ec978a805cd6642fa92515fbd95e3493fee276af76f8a",
|
|
||||||
"sha256:e64ae2587f1a683e831409aad03ba547c245ef997e1329fffadf7a866d2510b8",
|
|
||||||
"sha256:f6852828e9e104d2ce0358b73c550d26dd86cc3a69439438c3b618811b9584f5"
|
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.20.1"
|
"version": "==0.21.0"
|
||||||
|
},
|
||||||
|
"mutagen": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1",
|
||||||
|
"sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"
|
||||||
|
],
|
||||||
|
"markers": "python_version < '4' and python_full_version >= '3.5.0'",
|
||||||
|
"version": "==1.45.1"
|
||||||
|
},
|
||||||
|
"mysqlclient": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0d1cd3a5a4d28c222fa199002810e8146cffd821410b67851af4cc80aeccd97c",
|
||||||
|
"sha256:828757e419fb11dd6c5ed2576ec92c3efaa93a0f7c39e263586d1ee779c3d782",
|
||||||
|
"sha256:996924f3483fd36a34a5812210c69e71dea5a3d5978d01199b78b7f6d485c855",
|
||||||
|
"sha256:b355c8b5a7d58f2e909acdbb050858390ee1b0e13672ae759e5e784110022994",
|
||||||
|
"sha256:c1ed71bd6244993b526113cca3df66428609f90e4652f37eb51c33496d478b37",
|
||||||
|
"sha256:c812b67e90082a840efb82a8978369e6e69fc62ce1bda4ca8f3084a9d862308b",
|
||||||
|
"sha256:dea88c8d3f5a5d9293dfe7f087c16dd350ceb175f2f6631c9cf4caf3e19b7a96"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.1.1"
|
||||||
|
},
|
||||||
|
"packaging": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
|
||||||
|
"sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==21.3"
|
||||||
},
|
},
|
||||||
"pillow": {
|
"pillow": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a",
|
"sha256:088df396b047477dd1bbc7de6e22f58400dae2f21310d9e2ec2933b2ef7dfa4f",
|
||||||
"sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae",
|
"sha256:09e67ef6e430f90caa093528bd758b0616f8165e57ed8d8ce014ae32df6a831d",
|
||||||
"sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce",
|
"sha256:0b4d5ad2cd3a1f0d1df882d926b37dbb2ab6c823ae21d041b46910c8f8cd844b",
|
||||||
"sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e",
|
"sha256:0b525a356680022b0af53385944026d3486fc8c013638cf9900eb87c866afb4c",
|
||||||
"sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140",
|
"sha256:1d4331aeb12f6b3791911a6da82de72257a99ad99726ed6b63f481c0184b6fb9",
|
||||||
"sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb",
|
"sha256:20d514c989fa28e73a5adbddd7a171afa5824710d0ab06d4e1234195d2a2e546",
|
||||||
"sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021",
|
"sha256:2b291cab8a888658d72b575a03e340509b6b050b62db1f5539dd5cd18fd50578",
|
||||||
"sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6",
|
"sha256:3f6c1716c473ebd1649663bf3b42702d0d53e27af8b64642be0dd3598c761fb1",
|
||||||
"sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302",
|
"sha256:42dfefbef90eb67c10c45a73a9bc1599d4dac920f7dfcbf4ec6b80cb620757fe",
|
||||||
"sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c",
|
"sha256:488f3383cf5159907d48d32957ac6f9ea85ccdcc296c14eca1a4e396ecc32098",
|
||||||
"sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271",
|
"sha256:4d45dbe4b21a9679c3e8b3f7f4f42a45a7d3ddff8a4a16109dff0e1da30a35b2",
|
||||||
"sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09",
|
"sha256:53c27bd452e0f1bc4bfed07ceb235663a1df7c74df08e37fd6b03eb89454946a",
|
||||||
"sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3",
|
"sha256:55e74faf8359ddda43fee01bffbc5bd99d96ea508d8a08c527099e84eb708f45",
|
||||||
"sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015",
|
"sha256:59789a7d06c742e9d13b883d5e3569188c16acb02eeed2510fd3bfdbc1bd1530",
|
||||||
"sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3",
|
"sha256:5b650dbbc0969a4e226d98a0b440c2f07a850896aed9266b6fedc0f7e7834108",
|
||||||
"sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544",
|
"sha256:66daa16952d5bf0c9d5389c5e9df562922a59bd16d77e2a276e575d32e38afd1",
|
||||||
"sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8",
|
"sha256:6e760cf01259a1c0a50f3c845f9cad1af30577fd8b670339b1659c6d0e7a41dd",
|
||||||
"sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792",
|
"sha256:7502539939b53d7565f3d11d87c78e7ec900d3c72945d4ee0e2f250d598309a0",
|
||||||
"sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0",
|
"sha256:769a7f131a2f43752455cc72f9f7a093c3ff3856bf976c5fb53a59d0ccc704f6",
|
||||||
"sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3",
|
"sha256:7c150dbbb4a94ea4825d1e5f2c5501af7141ea95825fadd7829f9b11c97aaf6c",
|
||||||
"sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8",
|
"sha256:8844217cdf66eabe39567118f229e275f0727e9195635a15e0e4b9227458daaf",
|
||||||
"sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11",
|
"sha256:8a66fe50386162df2da701b3722781cbe90ce043e7d53c1fd6bd801bca6b48d4",
|
||||||
"sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7",
|
"sha256:9370d6744d379f2de5d7fa95cdbd3a4d92f0b0ef29609b4b1687f16bc197063d",
|
||||||
"sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11",
|
"sha256:937a54e5694684f74dcbf6e24cc453bfc5b33940216ddd8f4cd8f0f79167f765",
|
||||||
"sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e",
|
"sha256:9c857532c719fb30fafabd2371ce9b7031812ff3889d75273827633bca0c4602",
|
||||||
"sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039",
|
"sha256:a4165205a13b16a29e1ac57efeee6be2dfd5b5408122d59ef2145bc3239fa340",
|
||||||
"sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5",
|
"sha256:b3fe2ff1e1715d4475d7e2c3e8dabd7c025f4410f79513b4ff2de3d51ce0fa9c",
|
||||||
"sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72"
|
"sha256:b6617221ff08fbd3b7a811950b5c3f9367f6e941b86259843eab77c8e3d2b56b",
|
||||||
|
"sha256:b761727ed7d593e49671d1827044b942dd2f4caae6e51bab144d4accf8244a84",
|
||||||
|
"sha256:baf3be0b9446a4083cc0c5bb9f9c964034be5374b5bc09757be89f5d2fa247b8",
|
||||||
|
"sha256:c17770a62a71718a74b7548098a74cd6880be16bcfff5f937f900ead90ca8e92",
|
||||||
|
"sha256:c67db410508b9de9c4694c57ed754b65a460e4812126e87f5052ecf23a011a54",
|
||||||
|
"sha256:d78ca526a559fb84faaaf84da2dd4addef5edb109db8b81677c0bb1aad342601",
|
||||||
|
"sha256:e9ed59d1b6ee837f4515b9584f3d26cf0388b742a11ecdae0d9237a94505d03a",
|
||||||
|
"sha256:f054b020c4d7e9786ae0404278ea318768eb123403b18453e28e47cdb7a0a4bf",
|
||||||
|
"sha256:f372d0f08eff1475ef426344efe42493f71f377ec52237bf153c5713de987251",
|
||||||
|
"sha256:f3f6a6034140e9e17e9abc175fc7a266a6e63652028e157750bd98e804a8ed9a",
|
||||||
|
"sha256:ffde4c6fabb52891d81606411cbfaf77756e3b561b566efd270b3ed3791fde4e"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==8.0.1"
|
"version": "==9.1.1"
|
||||||
|
},
|
||||||
|
"psycopg2-binary": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7",
|
||||||
|
"sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76",
|
||||||
|
"sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa",
|
||||||
|
"sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9",
|
||||||
|
"sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004",
|
||||||
|
"sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1",
|
||||||
|
"sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094",
|
||||||
|
"sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57",
|
||||||
|
"sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af",
|
||||||
|
"sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554",
|
||||||
|
"sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232",
|
||||||
|
"sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c",
|
||||||
|
"sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b",
|
||||||
|
"sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834",
|
||||||
|
"sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2",
|
||||||
|
"sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71",
|
||||||
|
"sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460",
|
||||||
|
"sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e",
|
||||||
|
"sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4",
|
||||||
|
"sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d",
|
||||||
|
"sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d",
|
||||||
|
"sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9",
|
||||||
|
"sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f",
|
||||||
|
"sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063",
|
||||||
|
"sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478",
|
||||||
|
"sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092",
|
||||||
|
"sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c",
|
||||||
|
"sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce",
|
||||||
|
"sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1",
|
||||||
|
"sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65",
|
||||||
|
"sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e",
|
||||||
|
"sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4",
|
||||||
|
"sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029",
|
||||||
|
"sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33",
|
||||||
|
"sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39",
|
||||||
|
"sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53",
|
||||||
|
"sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307",
|
||||||
|
"sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42",
|
||||||
|
"sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35",
|
||||||
|
"sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8",
|
||||||
|
"sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb",
|
||||||
|
"sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae",
|
||||||
|
"sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e",
|
||||||
|
"sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f",
|
||||||
|
"sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba",
|
||||||
|
"sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24",
|
||||||
|
"sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca",
|
||||||
|
"sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb",
|
||||||
|
"sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef",
|
||||||
|
"sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42",
|
||||||
|
"sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1",
|
||||||
|
"sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667",
|
||||||
|
"sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272",
|
||||||
|
"sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281",
|
||||||
|
"sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e",
|
||||||
|
"sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.9.3"
|
||||||
|
},
|
||||||
|
"pycryptodomex": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:04cc393045a8f19dd110c975e30f38ed7ab3faf21ede415ea67afebd95a22380",
|
||||||
|
"sha256:0776bfaf2c48154ab54ea45392847c1283d2fcf64e232e85565f858baedfc1fa",
|
||||||
|
"sha256:0fadb9f7fa3150577800eef35f62a8a24b9ddf1563ff060d9bd3af22d3952c8c",
|
||||||
|
"sha256:18e2ab4813883ae63396c0ffe50b13554b32bb69ec56f0afaf052e7a7ae0d55b",
|
||||||
|
"sha256:191e73bc84a8064ad1874dba0ebadedd7cce4dedee998549518f2c74a003b2e1",
|
||||||
|
"sha256:35a8f7afe1867118330e2e0e0bf759c409e28557fb1fc2fbb1c6c937297dbe9a",
|
||||||
|
"sha256:3709f13ca3852b0b07fc04a2c03b379189232b24007c466be0f605dd4723e9d4",
|
||||||
|
"sha256:4540904c09704b6f831059c0dfb38584acb82cb97b0125cd52688c1f1e3fffa6",
|
||||||
|
"sha256:463119d7d22d0fc04a0f9122e9d3e6121c6648bcb12a052b51bd1eed1b996aa2",
|
||||||
|
"sha256:46b3f05f2f7ac7841053da4e0f69616929ca3c42f238c405f6c3df7759ad2780",
|
||||||
|
"sha256:48697790203909fab02a33226fda546604f4e2653f9d47bc5d3eb40879fa7c64",
|
||||||
|
"sha256:5676a132169a1c1a3712edf25250722ebc8c9102aa9abd814df063ca8362454f",
|
||||||
|
"sha256:65204412d0c6a8e3c41e21e93a5e6054a74fea501afa03046a388cf042e3377a",
|
||||||
|
"sha256:67e1e6a92151023ccdfcfbc0afb3314ad30080793b4c27956ea06ab1fb9bcd8a",
|
||||||
|
"sha256:6f5b6ba8aefd624834bc177a2ac292734996bb030f9d1b388e7504103b6fcddf",
|
||||||
|
"sha256:7341f1bb2dadb0d1a0047f34c3a58208a92423cdbd3244d998e4b28df5eac0ed",
|
||||||
|
"sha256:78d9621cf0ea35abf2d38fa2ca6d0634eab6c991a78373498ab149953787e5e5",
|
||||||
|
"sha256:8eecdf9cdc7343001d047f951b9cc805cd68cb6cd77b20ea46af5bffc5bd3dfb",
|
||||||
|
"sha256:94c7b60e1f52e1a87715571327baea0733708ab4723346598beca4a3b6879794",
|
||||||
|
"sha256:996e1ba717077ce1e6d4849af7a1426f38b07b3d173b879e27d5e26d2e958beb",
|
||||||
|
"sha256:a07a64709e366c2041cd5cfbca592b43998bf4df88f7b0ca73dca37071ccf1bd",
|
||||||
|
"sha256:b6306403228edde6e289f626a3908a2f7f67c344e712cf7c0a508bab3ad9e381",
|
||||||
|
"sha256:b9279adc16e4b0f590ceff581f53a80179b02cba9056010d733eb4196134a870",
|
||||||
|
"sha256:c4cb9cb492ea7dcdf222a8d19a1d09002798ea516aeae8877245206d27326d86",
|
||||||
|
"sha256:dd452a5af7014e866206d41751886c9b4bf379a339fdf2dbfc7dd16c0fb4f8e0",
|
||||||
|
"sha256:e2b12968522a0358b8917fc7b28865acac002f02f4c4c6020fcb264d76bfd06d",
|
||||||
|
"sha256:e3164a18348bd53c69b4435ebfb4ac8a4076291ffa2a70b54f0c4b80c7834b1d",
|
||||||
|
"sha256:e47bf8776a7e15576887f04314f5228c6527b99946e6638cf2f16da56d260cab",
|
||||||
|
"sha256:f8be976cec59b11f011f790b88aca67b4ea2bd286578d0bd3e31bcd19afcd3e4",
|
||||||
|
"sha256:fc9bc7a9b79fe5c750fc81a307052f8daabb709bdaabb0fb18fb136b66b653b5"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
|
"version": "==3.15.0"
|
||||||
|
},
|
||||||
|
"pyparsing": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb",
|
||||||
|
"sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
|
||||||
|
],
|
||||||
|
"markers": "python_full_version >= '3.6.8'",
|
||||||
|
"version": "==3.0.9"
|
||||||
},
|
},
|
||||||
"pytz": {
|
"pytz": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
|
"sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7",
|
||||||
"sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
|
"sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"
|
||||||
],
|
],
|
||||||
"version": "==2020.4"
|
"version": "==2022.1"
|
||||||
},
|
},
|
||||||
"rcssmin": {
|
"rcssmin": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:ca87b695d3d7864157773a61263e5abb96006e9ff0e021eff90cbe0e1ba18270"
|
"sha256:0a6aae7e119509445bf7aa6da6ca0f285cc198273c20f470ad999ff83bbadcf9",
|
||||||
],
|
"sha256:1512223b6a687bb747e4e531187bd49a56ed71287e7ead9529cbaa1ca4718a0a",
|
||||||
"version": "==1.0.6"
|
"sha256:1d7c2719d014e4e4df4e33b75ae8067c7e246cf470eaec8585e06e2efac7586c",
|
||||||
},
|
"sha256:2211a5c91ea14a5937b57904c9121f8bfef20987825e55368143da7d25446e3b",
|
||||||
"requests": {
|
"sha256:27fc400627fd3d328b7fe95af2a01f5d0af6b5af39731af5d071826a1f08e362",
|
||||||
"hashes": [
|
"sha256:30f5522285065cae0164d20068377d84b5d10b414156115f8729b034d0ea5e8b",
|
||||||
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
"sha256:32ccaebbbd4d56eab08cf26aed36f5d33389b9d1d3ca1fecf53eb6ab77760ddf",
|
||||||
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
"sha256:352dd3a78eb914bb1cb269ac2b66b3154f2490a52ab605558c681de3fb5194d2",
|
||||||
],
|
"sha256:37f1242e34ca273ed2c26cf778854e18dd11b31c6bfca60e23fce146c84667c1",
|
||||||
"index": "pypi",
|
"sha256:49807735f26f59404194f1e6f93254b6d5b6f7748c2a954f4470a86a40ff4c13",
|
||||||
"version": "==2.25.1"
|
"sha256:506e33ab4c47051f7deae35b6d8dbb4a5c025f016e90a830929a1ecc7daa1682",
|
||||||
},
|
"sha256:6158d0d86cd611c5304d738dc3d6cfeb23864dd78ad0d83a633f443696ac5d77",
|
||||||
"rjsmin": {
|
"sha256:7085d1b51dd2556f3aae03947380f6e9e1da29fb1eeadfa6766b7f105c54c9ff",
|
||||||
"hashes": [
|
"sha256:7c44002b79f3656348196005b9522ec5e04f182b466f66d72b16be0bd03c13d8",
|
||||||
"sha256:0ab825839125eaca57cc59581d72e596e58a7a56fbc0839996b7528f0343a0a8",
|
"sha256:7da63fee37edf204bbd86785edb4d7491642adbfd1d36fd230b7ccbbd8db1a6f",
|
||||||
"sha256:211c2fe8298951663bbc02acdffbf714f6793df54bfc50e1c6c9e71b3f2559a3",
|
"sha256:8b659a88850e772c84cfac4520ec223de6807875e173d8ef3248ab7f90876066",
|
||||||
"sha256:466fe70cc5647c7c51b3260c7e2e323a98b2b173564247f9c89e977720a0645f",
|
"sha256:c28b9eb20982b45ebe6adef8bd2547e5ed314dafddfff4eba806b0f8c166cfd1",
|
||||||
"sha256:585e75a84d9199b68056fd4a083d9a61e2a92dfd10ff6d4ce5bdb04bc3bdbfaf",
|
"sha256:ddff3a41611664c7f1d9e3d8a9c1669e0e155ac0458e586ffa834dc5953e7d9f",
|
||||||
"sha256:6044ca86e917cd5bb2f95e6679a4192cef812122f28ee08c677513de019629b3",
|
"sha256:f1a37bbd36b050813673e62ae6464467548628690bf4d48a938170e121e8616e",
|
||||||
"sha256:714329db774a90947e0e2086cdddb80d5e8c4ac1c70c9f92436378dedb8ae345",
|
"sha256:f31c82d06ba2dbf33c20db9550157e80bb0c4cbd24575c098f0831d1d2e3c5df"
|
||||||
"sha256:799890bd07a048892d8d3deb9042dbc20b7f5d0eb7da91e9483c561033b23ce2",
|
|
||||||
"sha256:975b69754d6a76be47c0bead12367a1ca9220d08e5393f80bab0230d4625d1f4",
|
|
||||||
"sha256:b15dc75c71f65d9493a8c7fa233fdcec823e3f1b88ad84a843ffef49b338ac32",
|
|
||||||
"sha256:dd0f4819df4243ffe4c964995794c79ca43943b5b756de84be92b445a652fb86",
|
|
||||||
"sha256:e3908b21ebb584ce74a6ac233bdb5f29485752c9d3be5e50c5484ed74169232c",
|
|
||||||
"sha256:e487a7783ac4339e79ec610b98228eb9ac72178973e3dee16eba0e3feef25924",
|
|
||||||
"sha256:ecd29f1b3e66a4c0753105baec262b331bcbceefc22fbe6f7e8bcd2067bcb4d7"
|
|
||||||
],
|
],
|
||||||
"version": "==1.1.0"
|
"version": "==1.1.0"
|
||||||
},
|
},
|
||||||
|
"redis": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:a52d5694c9eb4292770084fa8c863f79367ca19884b329ab574d5cb2036b3e54",
|
||||||
|
"sha256:ddf27071df4adf3821c4f2ca59d67525c3a82e5f268bed97b813cb4fabf87880"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==4.3.4"
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:bc7861137fbce630f17b03d3ad02ad0bf978c844f3536d0edda6499dafce2b6f",
|
||||||
|
"sha256:d568723a7ebd25875d8d1eaf5dfa068cd2fc8194b2e483d7b1f7c81918dbec6b"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.28.0"
|
||||||
|
},
|
||||||
|
"rjsmin": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:05efa485dfddb6418e3b86d8862463aa15641a61f6ae05e7e6de8f116ee77c69",
|
||||||
|
"sha256:1622fbb6c6a8daaf77da13cc83356539bfe79c1440f9664b02c7f7b150b9a18e",
|
||||||
|
"sha256:1c93b29fd725e61718299ffe57de93ff32d71b313eaabbfcc7bd32ddb82831d5",
|
||||||
|
"sha256:2ed83aca637186bafdc894b4b7fc3657e2d74014ccca7d3d69122c1e82675216",
|
||||||
|
"sha256:38a4474ed52e1575fb9da983ec8657faecd8ab3738508d36e04f87769411fd3d",
|
||||||
|
"sha256:3b14f4c2933ec194eb816b71a0854ce461b6419a3d852bf360344731ab28c0a6",
|
||||||
|
"sha256:40e7211a25d9a11ac9ff50446e41268c978555676828af86fa1866615823bfff",
|
||||||
|
"sha256:41c7c3910f7b8816e37366b293e576ddecf696c5f2197d53cf2c1526ac336646",
|
||||||
|
"sha256:4387a00777faddf853eebdece9f2e56ebaf243c3f24676a9de6a20c5d4f3d731",
|
||||||
|
"sha256:54fc30519365841b27556ccc1cb94c5b4413c384ff6d467442fddba66e2e325a",
|
||||||
|
"sha256:6c395ffc130332cca744f081ed5efd5699038dcb7a5d30c3ff4bc6adb5b30a62",
|
||||||
|
"sha256:6c529feb6c400984452494c52dd9fdf59185afeacca2afc5174a28ab37751a1b",
|
||||||
|
"sha256:86c4da7285ddafe6888cb262da563570f28e4a31146b5164a7a6947b1222196b",
|
||||||
|
"sha256:8944a8a55ac825b8e5ec29f341ecb7574697691ef416506885898d2f780fb4ca",
|
||||||
|
"sha256:993935654c1311280e69665367d7e6ff694ac9e1609168cf51cae8c0307df0db",
|
||||||
|
"sha256:99e5597a812b60058baa1457387dc79cca7d273b2a700dc98bfd20d43d60711d",
|
||||||
|
"sha256:b6a7c8c8d19e154334f640954e43e57283e87bb4a2f6e23295db14eea8e9fc1d",
|
||||||
|
"sha256:c81229ffe5b0a0d5b3b5d5e6d0431f182572de9e9a077e85dbae5757db0ab75c",
|
||||||
|
"sha256:d63e193a2f932a786ae82068aa76d1d126fcdff8582094caff9e5e66c4dcc124",
|
||||||
|
"sha256:e18fe1a610fb105273bb369f61c2b0bd9e66a3f0792e27e4cac44e42ace1968b"
|
||||||
|
],
|
||||||
|
"version": "==1.2.0"
|
||||||
|
},
|
||||||
|
"setuptools": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:990a4f7861b31532871ab72331e755b5f14efbe52d336ea7f6118144dd478741",
|
||||||
|
"sha256:c1848f654aea2e3526d17fc3ce6aeaa5e7e24e66e645b5be2171f3f6b4e5a178"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==62.6.0"
|
||||||
|
},
|
||||||
"six": {
|
"six": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
|
||||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
|
||||||
],
|
],
|
||||||
"version": "==1.15.0"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==1.16.0"
|
||||||
},
|
},
|
||||||
"sqlparse": {
|
"sqlparse": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
|
"sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae",
|
||||||
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
|
"sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"
|
||||||
],
|
],
|
||||||
"version": "==0.4.1"
|
"markers": "python_full_version >= '3.5.0'",
|
||||||
|
"version": "==0.4.2"
|
||||||
},
|
},
|
||||||
"urllib3": {
|
"urllib3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
|
"sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14",
|
||||||
"sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
|
"sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"
|
||||||
],
|
],
|
||||||
"version": "==1.26.2"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||||
|
"version": "==1.26.9"
|
||||||
|
},
|
||||||
|
"websockets": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af",
|
||||||
|
"sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c",
|
||||||
|
"sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76",
|
||||||
|
"sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47",
|
||||||
|
"sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69",
|
||||||
|
"sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079",
|
||||||
|
"sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c",
|
||||||
|
"sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55",
|
||||||
|
"sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02",
|
||||||
|
"sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559",
|
||||||
|
"sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3",
|
||||||
|
"sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e",
|
||||||
|
"sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978",
|
||||||
|
"sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98",
|
||||||
|
"sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae",
|
||||||
|
"sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755",
|
||||||
|
"sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d",
|
||||||
|
"sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991",
|
||||||
|
"sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1",
|
||||||
|
"sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680",
|
||||||
|
"sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247",
|
||||||
|
"sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f",
|
||||||
|
"sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2",
|
||||||
|
"sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7",
|
||||||
|
"sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4",
|
||||||
|
"sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667",
|
||||||
|
"sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb",
|
||||||
|
"sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094",
|
||||||
|
"sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36",
|
||||||
|
"sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79",
|
||||||
|
"sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500",
|
||||||
|
"sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e",
|
||||||
|
"sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582",
|
||||||
|
"sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442",
|
||||||
|
"sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd",
|
||||||
|
"sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6",
|
||||||
|
"sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731",
|
||||||
|
"sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4",
|
||||||
|
"sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d",
|
||||||
|
"sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8",
|
||||||
|
"sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f",
|
||||||
|
"sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677",
|
||||||
|
"sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8",
|
||||||
|
"sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9",
|
||||||
|
"sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e",
|
||||||
|
"sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b",
|
||||||
|
"sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916",
|
||||||
|
"sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==10.3"
|
||||||
},
|
},
|
||||||
"whitenoise": {
|
"whitenoise": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7",
|
"sha256:8e9c600a5c18bd17655ef668ad55b5edf6c24ce9bdca5bf607649ca4b1e8e2c2",
|
||||||
"sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d"
|
"sha256:8fa943c6d4cd9e27673b70c21a07b0aa120873901e099cd46cab40f7cc96d567"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==5.2.0"
|
"version": "==6.2.0"
|
||||||
},
|
},
|
||||||
"youtube-dl": {
|
"wrapt": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:65968065e66966955dc79fad9251565fcc982566118756da624bd21467f3a04c",
|
"sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3",
|
||||||
"sha256:eaa859f15b6897bec21474b7787dc958118c8088e1f24d4ef1d58eab13188958"
|
"sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b",
|
||||||
|
"sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4",
|
||||||
|
"sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2",
|
||||||
|
"sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656",
|
||||||
|
"sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3",
|
||||||
|
"sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff",
|
||||||
|
"sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310",
|
||||||
|
"sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a",
|
||||||
|
"sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57",
|
||||||
|
"sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069",
|
||||||
|
"sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383",
|
||||||
|
"sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe",
|
||||||
|
"sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87",
|
||||||
|
"sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d",
|
||||||
|
"sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b",
|
||||||
|
"sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907",
|
||||||
|
"sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f",
|
||||||
|
"sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0",
|
||||||
|
"sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28",
|
||||||
|
"sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1",
|
||||||
|
"sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853",
|
||||||
|
"sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc",
|
||||||
|
"sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3",
|
||||||
|
"sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3",
|
||||||
|
"sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164",
|
||||||
|
"sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1",
|
||||||
|
"sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c",
|
||||||
|
"sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1",
|
||||||
|
"sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7",
|
||||||
|
"sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1",
|
||||||
|
"sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320",
|
||||||
|
"sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed",
|
||||||
|
"sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1",
|
||||||
|
"sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248",
|
||||||
|
"sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c",
|
||||||
|
"sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456",
|
||||||
|
"sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77",
|
||||||
|
"sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef",
|
||||||
|
"sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1",
|
||||||
|
"sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7",
|
||||||
|
"sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86",
|
||||||
|
"sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4",
|
||||||
|
"sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d",
|
||||||
|
"sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d",
|
||||||
|
"sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8",
|
||||||
|
"sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5",
|
||||||
|
"sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471",
|
||||||
|
"sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00",
|
||||||
|
"sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68",
|
||||||
|
"sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3",
|
||||||
|
"sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d",
|
||||||
|
"sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735",
|
||||||
|
"sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d",
|
||||||
|
"sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569",
|
||||||
|
"sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7",
|
||||||
|
"sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59",
|
||||||
|
"sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5",
|
||||||
|
"sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb",
|
||||||
|
"sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b",
|
||||||
|
"sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f",
|
||||||
|
"sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462",
|
||||||
|
"sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015",
|
||||||
|
"sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
|
"version": "==1.14.1"
|
||||||
|
},
|
||||||
|
"yt-dlp": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:5fbfac72fd035d11bc2693e5d1cd6933b1bc0712f742f5082a261703810bb5c9",
|
||||||
|
"sha256:a688f5cbc4a824456983774ccdd4a12befd379f6c92e25074fa85e7b8ce31704"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2020.12.14"
|
"version": "==2022.6.29"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"develop": {}
|
"develop": {}
|
||||||
|
|||||||
132
README.md
@@ -9,42 +9,43 @@ 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,
|
||||||
hopefully, quite reliable.
|
hopefully, quite reliable.
|
||||||
|
|
||||||
|
|
||||||
# Latest container image
|
# Latest container image
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ghcr.io/meeb/tubesync:v0.4
|
ghcr.io/meeb/tubesync:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
# Screenshots
|
# Screenshots
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Sources overview
|
### Sources overview
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Source details
|
### Source details
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Media overview
|
### Media overview
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Media details
|
### Media details
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
# Requirements
|
# Requirements
|
||||||
@@ -68,7 +69,8 @@ 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):
|
||||||
|
|
||||||
@@ -97,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.4
|
$ 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 \
|
||||||
@@ -109,7 +111,7 @@ $ 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.4
|
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
|
||||||
@@ -121,7 +123,7 @@ Alternatively, for Docker Compose, you can use something like:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
tubesync:
|
tubesync:
|
||||||
image: ghcr.io/meeb/tubesync:v0.4
|
image: ghcr.io/meeb/tubesync:latest
|
||||||
container_name: tubesync
|
container_name: tubesync
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -135,6 +137,41 @@ Alternatively, for Docker Compose, you can use something like:
|
|||||||
- PGID=1000
|
- PGID=1000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Optional authentication
|
||||||
|
|
||||||
|
Available in `v1.0` (or `:latest`)and later. If you want to enable a basic username and
|
||||||
|
password to be required to access the TubeSync dashboard you can set them with the
|
||||||
|
following environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HTTP_USER
|
||||||
|
HTTP_PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
For example in the `docker run ...` line add in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
...
|
||||||
|
-e HTTP_USER=some-username \
|
||||||
|
-e HTTP_PASS=some-secure-password \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in your Docker Compose file you would add in:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
- HTTP_USER=some-username
|
||||||
|
- HTTP_PASS=some-secure-password
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
When BOTH `HTTP_USER` and `HTTP_PASS` are set then basic HTTP authentication will be
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
|
||||||
# Updating
|
# Updating
|
||||||
|
|
||||||
To update, you can just pull a new version of the container image as they are released.
|
To update, you can just pull a new version of the container image as they are released.
|
||||||
@@ -192,14 +229,26 @@ $ docker logs --follow tubesync
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Advanced usage guides
|
||||||
|
|
||||||
|
Once you're happy using TubeSync there are some advanced usage guides for more complex
|
||||||
|
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
|
||||||
|
|
||||||
### 1. Index frequency
|
### 1. Index frequency
|
||||||
|
|
||||||
It's a good idea to add sources with as low an index frequency as possible. This is the
|
It's a good idea to add sources with as long of an index frequency as possible. This is
|
||||||
duration between indexes of the source. An index is when TubeSync checks to see
|
the duration between indexes of the source. An index is when TubeSync checks to see
|
||||||
what videos available on a channel or playlist to find new media. Try and keep this as
|
what videos available on a channel or playlist to find new media. Try and keep this as
|
||||||
long as possible, 24 hours if possible.
|
long as possible, up to 24 hours.
|
||||||
|
|
||||||
|
|
||||||
### 2. Indexing massive channels
|
### 2. Indexing massive channels
|
||||||
@@ -209,6 +258,14 @@ every hour" or similar short interval it's entirely possible your TubeSync insta
|
|||||||
spend its entire time just indexing the massive channel over and over again without
|
spend its entire time just indexing the massive channel over and over again without
|
||||||
downloading any media. Check your tasks for the status of your TubeSync install.
|
downloading any media. Check your tasks for the status of your TubeSync install.
|
||||||
|
|
||||||
|
If you add a significant amount of "work" due to adding many large channels you may
|
||||||
|
need to increase the number of background workers by setting the `TUBESYNC_WORKERS`
|
||||||
|
environment variable. Try around ~4 at most, although the absolute maximum allowed is 8.
|
||||||
|
|
||||||
|
**Be nice.** it's likely entirely possible your IP address could get throttled by the
|
||||||
|
source if you try and crawl extremely large amounts very quickly. **Try and be polite
|
||||||
|
with the smallest amount of indexing and concurrent downloads possible for your needs.**
|
||||||
|
|
||||||
|
|
||||||
# FAQ
|
# FAQ
|
||||||
|
|
||||||
@@ -222,8 +279,8 @@ automatically.
|
|||||||
|
|
||||||
### Does TubeSync support any other video platforms?
|
### Does TubeSync support any other video platforms?
|
||||||
|
|
||||||
At the moment, no. This is a first 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?
|
||||||
@@ -236,7 +293,7 @@ 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 execelent
|
||||||
[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's errors in my "tasks" tab!
|
||||||
@@ -249,13 +306,13 @@ media available because you got a channel name wrong) will be shown as errors on
|
|||||||
|
|
||||||
### 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/)
|
||||||
@@ -278,7 +335,9 @@ can log in at http://localhost:4848/admin
|
|||||||
|
|
||||||
### Are there user accounts or multi-user support?
|
### Are there user accounts or multi-user support?
|
||||||
|
|
||||||
No not at the moment. This could be added later if there is demand for it.
|
There is support for basic HTTP authentication by setting the `HTTP_USER` and
|
||||||
|
`HTTP_PASS` environment variables. There is not support for multi-user or user
|
||||||
|
management.
|
||||||
|
|
||||||
### Does TubeSync support HTTPS?
|
### Does TubeSync support HTTPS?
|
||||||
|
|
||||||
@@ -293,23 +352,28 @@ Just `amd64` for the moment. Others may be made available if there is demand.
|
|||||||
# Advanced configuration
|
# Advanced configuration
|
||||||
|
|
||||||
There are a number of other environment variables you can set. These are, mostly,
|
There are a number of other environment variables you can set. These are, mostly,
|
||||||
**NOT** required to be set in the default container installation, they are mostly
|
**NOT** required to be set in the default container installation, they are really only
|
||||||
useful if you are manually installing TubeSync in some other environment. These are:
|
useful if you are manually installing TubeSync in some other environment. These are:
|
||||||
|
|
||||||
| Name | What | Example |
|
| Name | What | Example |
|
||||||
| ----------------- | ------------------------------------- | ---------------------------------- |
|
| ------------------------ | ------------------------------------------------------------ | ------------------------------------ |
|
||||||
| DJANGO_SECRET_KEY | Django secret key | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
| DJANGO_SECRET_KEY | Django's SECRET_KEY | YJySXnQLB7UVZw2dXKDWxI5lEZaImK6l |
|
||||||
| TUBESYNC_DEBUG | Enable debugging | True |
|
| DJANGO_FORCE_SCRIPT_NAME | Django's FORCE_SCRIPT_NAME | /somepath |
|
||||||
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS | tubesync.example.com,otherhost.com |
|
| TUBESYNC_DEBUG | Enable debugging | True |
|
||||||
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
| TUBESYNC_WORKERS | Number of background workers, default is 2, max allowed is 8 | 2 |
|
||||||
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
| TUBESYNC_HOSTS | Django's ALLOWED_HOSTS, defaults to `*` | tubesync.example.com,otherhost.com |
|
||||||
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
| GUNICORN_WORKERS | Number of gunicorn workers to spawn | 3 |
|
||||||
|
| LISTEN_HOST | IP address for gunicorn to listen on | 127.0.0.1 |
|
||||||
|
| LISTEN_PORT | Port number for gunicorn to listen on | 8080 |
|
||||||
|
| HTTP_USER | Sets the username for HTTP basic authentication | some-username |
|
||||||
|
| HTTP_PASS | Sets the password for HTTP basic authentication | some-secure-password |
|
||||||
|
| DATABASE_CONNECTION | Optional external database connection details | mysql://user:pass@host:port/database |
|
||||||
|
|
||||||
|
|
||||||
# Manual, non-containerised, installation
|
# 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
|
||||||
the following 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
|
||||||
@@ -320,7 +384,7 @@ installing and running WSGI-based Python web applications before attempting this
|
|||||||
`tubesync/tubesync/local_settings.py` and edit it as appropriate
|
`tubesync/tubesync/local_settings.py` and edit it as appropriate
|
||||||
5. Run migrations with `./manage.py migrate`
|
5. Run migrations with `./manage.py migrate`
|
||||||
6. Collect static files with `./manage.py collectstatic`
|
6. Collect static files with `./manage.py collectstatic`
|
||||||
6. Set up your prefered WSGI server, such as `gunicorn` poiting it to the application
|
6. Set up your prefered WSGI server, such as `gunicorn` pointing it to the application
|
||||||
in `tubesync/tubesync/wsgi.py`
|
in `tubesync/tubesync/wsgi.py`
|
||||||
7. Set up your proxy server such as `nginx` and forward it to the WSGI server
|
7. Set up your proxy server such as `nginx` and forward it to the WSGI server
|
||||||
8. Check the web interface is working
|
8. Check the web interface is working
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ chown -R app:app /app/common/static && \
|
|||||||
chmod -R 0750 /app/common/static && \
|
chmod -R 0750 /app/common/static && \
|
||||||
chown -R app:app /app/static && \
|
chown -R app:app /app/static && \
|
||||||
chmod -R 0750 /app/static && \
|
chmod -R 0750 /app/static && \
|
||||||
find /app -type f -exec chmod 640 {} \; && \
|
find /app -type f ! -iname healthcheck.py -exec chmod 640 {} \; && \
|
||||||
chmod +x /app/healthcheck.py
|
chmod 0755 /app/healthcheck.py
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
exec s6-setuidgid app \
|
exec s6-setuidgid app \
|
||||||
|
|||||||
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
|
||||||
@@ -5,5 +5,20 @@ umask "$UMASK_SET"
|
|||||||
|
|
||||||
cd /app || exit
|
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 \
|
exec s6-setuidgid app \
|
||||||
/usr/local/bin/gunicorn -c /app/tubesync/gunicorn.py --capture-output tubesync.wsgi:application
|
/usr/local/bin/gunicorn -c /app/tubesync/gunicorn.py --capture-output tubesync.wsgi:application
|
||||||
|
|||||||
4
config/root/etc/services.d/redis/run
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/with-contenv bash
|
||||||
|
|
||||||
|
exec s6-setuidgid redis \
|
||||||
|
/usr/bin/redis-server /etc/redis/redis.conf
|
||||||
37
docs/create-missing-metadata.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# TubeSync
|
||||||
|
|
||||||
|
## Advanced usage guide - creating missing metadata
|
||||||
|
|
||||||
|
This is a new feature in v0.9 of TubeSync and later. It allows you to create or
|
||||||
|
re-create missing metadata in your TubeSync download directories for missing `nfo`
|
||||||
|
files and thumbnails.
|
||||||
|
|
||||||
|
If you add a source with "write NFO files" or "copy thumbnails" disabled, download
|
||||||
|
some media and then update the source to write NFO files or copy thumbnails then
|
||||||
|
TubeSync will not automatically retroactively attempt to copy or create your missing
|
||||||
|
metadata files. You can use a special one-off command to manually write missing
|
||||||
|
metadata files to the correct locations.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
You have added a source without metadata writing enabled, downloaded some media, then
|
||||||
|
updated the source to enable metadata writing.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Run the batch metadata sync command
|
||||||
|
|
||||||
|
Execute the following Django command:
|
||||||
|
|
||||||
|
`./manage.py sync-missing-metadata`
|
||||||
|
|
||||||
|
When deploying TubeSync inside a container, you can execute this with:
|
||||||
|
|
||||||
|
`docker exec -ti tubesync python3 /app/manage.py sync-missing-metadata`
|
||||||
|
|
||||||
|
This command will log what its doing to the terminal when you run it.
|
||||||
|
|
||||||
|
Internally, this command loops over all your sources which have been saved with
|
||||||
|
"write NFO files" or "copy thumbnails" enabled. Then, loops over all media saved to
|
||||||
|
that source and confirms that the appropriate thumbnail files have been copied over and
|
||||||
|
the NFO file has been written if enabled.
|
||||||
BIN
docs/dashboard-v0.5.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 170 KiB |
81
docs/import-existing-media.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# TubeSync
|
||||||
|
|
||||||
|
## Advanced usage guide - importing existing media
|
||||||
|
|
||||||
|
This is a new feature in v0.9 of TubeSync and later. It allows you to mark existing
|
||||||
|
downloaded media as "downloaded" in TubeSync. You can use this feature if, for example,
|
||||||
|
you already have an extensive catalogue of downloaded media which you want to mark
|
||||||
|
as downloaded into TubeSync so TubeSync doesn't re-download media you already have.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Your existing downloaded media MUST contain the unique ID. For YouTube videos, this is
|
||||||
|
means the YouTube video ID MUST be in the filename.
|
||||||
|
|
||||||
|
Supported extensions to be imported are .m4a, .ogg, .mkv, .mp3, .mp4 and .avi. Your
|
||||||
|
media you want to import must end in one of these file extensions.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
As TubeSync does not probe media and your existing media may be re-encoded or in
|
||||||
|
different formats to what is available in the current media metadata there is no way
|
||||||
|
for TubeSync to know what codecs, resolution, bitrate etc. your imported media is in.
|
||||||
|
Any manually imported existing local media will display blank boxes for this
|
||||||
|
information on the TubeSync interface as it's unavailable.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Add your source to TubeSync
|
||||||
|
|
||||||
|
Add your source to TubeSync, such as a YouTube channel. **Make sure you untick the
|
||||||
|
"download media" checkbox.**
|
||||||
|
|
||||||
|
This will allow TubeSync to index all the available media on your source, but won't
|
||||||
|
start downloading any media.
|
||||||
|
|
||||||
|
### 2. Wait
|
||||||
|
|
||||||
|
Wait for all the media on your source to be indexed. This may take some time.
|
||||||
|
|
||||||
|
### 3. Move your existing media into TubeSync
|
||||||
|
|
||||||
|
You now need to move your existing media into TubeSync. You need to move the media
|
||||||
|
files into the correct download directories created by TubeSync. For example, if you
|
||||||
|
have downloaded videos for a YouTube channel "TestChannel", you would have added this
|
||||||
|
as a source called TestChannel and in a directory called test-channel in Tubesync. It
|
||||||
|
would have a download directory created on disk at:
|
||||||
|
|
||||||
|
`/path/to/downloads/test-channel`
|
||||||
|
|
||||||
|
You would move all of your pre-existing videos you downloaded outside of TubeSync for
|
||||||
|
this channel into this directory.
|
||||||
|
|
||||||
|
In short, your existing media needs to be moved into the correct TubeSync source
|
||||||
|
directory to be detected.
|
||||||
|
|
||||||
|
This is required so TubeSync can known which Source to link the media to.
|
||||||
|
|
||||||
|
### 4. Run the batch import command
|
||||||
|
|
||||||
|
Execute the following Django command:
|
||||||
|
|
||||||
|
`./manage.py import-existing-media`
|
||||||
|
|
||||||
|
When deploying TubeSync inside a container, you can execute this with:
|
||||||
|
|
||||||
|
`docker exec -ti tubesync python3 /app/manage.py import-existing-media`
|
||||||
|
|
||||||
|
This command will log what its doing to the terminal when you run it.
|
||||||
|
|
||||||
|
Internally, `import-existing-media` looks for the unique media key (for YouTube, this
|
||||||
|
is the YouTube video ID) in the filename and detects the source to link it to based
|
||||||
|
on the directory the media file is inside.
|
||||||
|
|
||||||
|
|
||||||
|
### 5. Re-enable downloading at the source
|
||||||
|
|
||||||
|
Edit your source and re-enable / tick the "download media" option. This will allow
|
||||||
|
TubeSync to download any missing media you did not manually import.
|
||||||
|
|
||||||
|
Note that TubeSync will still get screenshots write `nfo` files etc. for files you
|
||||||
|
manually import if enabled at the source level.
|
||||||
BIN
docs/media-item-v0.5.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 420 KiB |
BIN
docs/media-v0.5.png
Normal file
|
After Width: | Height: | Size: 666 KiB |
BIN
docs/media.png
|
Before Width: | Height: | Size: 530 KiB |
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!
|
||||||
33
docs/reset-tasks.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# TubeSync
|
||||||
|
|
||||||
|
## Advanced usage guide - reset tasks from the command line
|
||||||
|
|
||||||
|
This is a new feature in v1.0 of TubeSync and later. It allows you to reset all
|
||||||
|
scheduled tasks from the command line as well as the "reset tasks" button in the
|
||||||
|
"tasks" tab of the dashboard.
|
||||||
|
|
||||||
|
This is useful for TubeSync installations where you may have a lot of media and
|
||||||
|
sources added and the "reset tasks" button may take too long to the extent where
|
||||||
|
the page times out (with a 502 error or similar issue).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
You have added some sources and media
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Run the reset tasks command
|
||||||
|
|
||||||
|
Execute the following Django command:
|
||||||
|
|
||||||
|
`./manage.py reset-tasks`
|
||||||
|
|
||||||
|
When deploying TubeSync inside a container, you can execute this with:
|
||||||
|
|
||||||
|
`docker exec -ti tubesync python3 /app/manage.py reset-tasks`
|
||||||
|
|
||||||
|
This command will log what its doing to the terminal when you run it.
|
||||||
|
|
||||||
|
When this is run, new tasks will be immediately created so all your sources will be
|
||||||
|
indexed again straight away, any missing information such as thumbnails will be
|
||||||
|
redownloaded, etc.
|
||||||
BIN
docs/source-v0.5.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
docs/source.png
|
Before Width: | Height: | Size: 137 KiB |
BIN
docs/sources-v0.5.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/sources.png
|
Before Width: | Height: | Size: 51 KiB |
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
@@ -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,4 +1,6 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.forms import BaseForm
|
from django.forms import BaseForm
|
||||||
|
from basicauth.middleware import BasicAuthMiddleware as BaseBasicAuthMiddleware
|
||||||
|
|
||||||
|
|
||||||
class MaterializeDefaultFieldsMiddleware:
|
class MaterializeDefaultFieldsMiddleware:
|
||||||
@@ -19,3 +21,12 @@ class MaterializeDefaultFieldsMiddleware:
|
|||||||
for _, field in v.fields.items():
|
for _, field in v.fields.items():
|
||||||
field.widget.attrs.update({'class':'browser-default'})
|
field.widget.attrs.update({'class':'browser-default'})
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuthMiddleware(BaseBasicAuthMiddleware):
|
||||||
|
|
||||||
|
def process_request(self, request):
|
||||||
|
bypass_uris = getattr(settings, 'BASICAUTH_ALWAYS_ALLOW_URIS', [])
|
||||||
|
if request.path in bypass_uris:
|
||||||
|
return None
|
||||||
|
return super().process_request(request)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
}
|
}
|
||||||
.help-text {
|
.help-text {
|
||||||
color: $form-help-text-colour;
|
color: $form-help-text-colour;
|
||||||
padding: 1rem 0 1rem 0;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ html {
|
|||||||
color: $text-colour;
|
color: $text-colour;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
|
|
||||||
background-color: $header-background-colour;
|
background-color: $header-background-colour;
|
||||||
@@ -174,8 +181,10 @@ main {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 5px 10px 5px 10px;
|
padding: 5px 8px 4px 8px;
|
||||||
margin: 0 3px 0 3px;
|
margin: 0 3px 6px 3px;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
background-color: $pagination-background-colour;
|
background-color: $pagination-background-colour;
|
||||||
color: $pagination-text-colour;
|
color: $pagination-text-colour;
|
||||||
border: 2px $pagination-border-colour solid;
|
border: 2px $pagination-border-colour solid;
|
||||||
|
|||||||
@@ -16,32 +16,36 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<div class="app">
|
||||||
<div class="container">
|
|
||||||
<a href="{% url 'sync:dashboard' %}">
|
|
||||||
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
|
||||||
<h1>TubeSync</h1>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ul>
|
<a href="{% url 'sync:dashboard' %}">
|
||||||
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
{% include 'tubesync.svg' with width='3rem' height='3rem' %}
|
||||||
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
<h1>TubeSync</h1>
|
||||||
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
</a>
|
||||||
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
</div>
|
||||||
<li><a href="{% url 'sync:mediaservers' %}"><i class="fas fa-fw fa-stream"></i><span class="hide-on-med-and-down"> Media Servers</span></a></li>
|
</header>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
<nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
<ul>
|
||||||
</div>
|
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
||||||
</main>
|
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
||||||
|
<li><a href="{% url 'sync:mediaservers' %}"><i class="fas fa-fw fa-stream"></i><span class="hide-on-med-and-down"> Media Servers</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -53,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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% for i in paginator.page_range %}
|
{% for i in paginator.page_range %}
|
||||||
<a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}">{{ i }}</a>
|
<a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}{% if show_skipped %}&show_skipped=yes{% endif %}">{{ i }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
|
from .errors import DatabaseConnectionError
|
||||||
|
|
||||||
|
|
||||||
class ErrorPageTestCase(TestCase):
|
class ErrorPageTestCase(TestCase):
|
||||||
@@ -61,3 +63,66 @@ 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 DatabaseConnectionTestCase(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')
|
||||||
|
|||||||
@@ -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,95 @@
|
|||||||
from urllib.parse import urlunsplit, urlencode
|
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):
|
||||||
@@ -14,3 +105,21 @@ def append_uri_params(uri, params):
|
|||||||
uri = str(uri)
|
uri = str(uri)
|
||||||
qs = urlencode(params)
|
qs = urlencode(params)
|
||||||
return urlunsplit(('', '', uri, qs, ''))
|
return urlunsplit(('', '', uri, qs, ''))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename(filename):
|
||||||
|
if not isinstance(filename, str):
|
||||||
|
raise ValueError(f'filename must be a str, got {type(filename)}')
|
||||||
|
to_scrub = '<>\/:*?"|%'
|
||||||
|
for char in to_scrub:
|
||||||
|
filename = filename.replace(char, '')
|
||||||
|
filename = ''.join([c for c in filename if ord(c) > 30])
|
||||||
|
return ' '.join(filename.split())
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
|||||||
0
tubesync/healthcheck.py
Normal file → Executable file
@@ -7,7 +7,7 @@ class SourceAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
ordering = ('-created',)
|
ordering = ('-created',)
|
||||||
list_display = ('uuid', 'name', 'source_type', 'last_crawl',
|
list_display = ('uuid', 'name', 'source_type', 'last_crawl',
|
||||||
'has_failed')
|
'download_media', 'has_failed')
|
||||||
readonly_fields = ('uuid', 'created')
|
readonly_fields = ('uuid', 'created')
|
||||||
search_fields = ('uuid', 'key', 'name')
|
search_fields = ('uuid', 'key', 'name')
|
||||||
|
|
||||||
|
|||||||
0
tubesync/sync/management/__init__.py
Normal file
0
tubesync/sync/management/commands/__init__.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')
|
||||||
55
tubesync/sync/management/commands/import-existing-media.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from common.logger import log
|
||||||
|
from sync.models import Source, Media
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = ('Scans download media directories for media not yet downloaded and ',
|
||||||
|
'marks them as downloaded')
|
||||||
|
extra_extensions = ['mp3', 'mp4', 'avi']
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
log.info('Building directory to Source map...')
|
||||||
|
dirmap = {}
|
||||||
|
for s in Source.objects.all():
|
||||||
|
dirmap[s.directory_path] = s
|
||||||
|
log.info(f'Scanning sources...')
|
||||||
|
file_extensions = list(Source.EXTENSIONS) + self.extra_extensions
|
||||||
|
for sourceroot, source in dirmap.items():
|
||||||
|
media = list(Media.objects.filter(source=source, downloaded=False,
|
||||||
|
skip=False))
|
||||||
|
if not media:
|
||||||
|
log.info(f'Source "{source}" has no missing media')
|
||||||
|
continue
|
||||||
|
log.info(f'Scanning Source "{source}" directory for media to '
|
||||||
|
f'import: {sourceroot}, looking for {len(media)} '
|
||||||
|
f'undownloaded and unskipped items')
|
||||||
|
on_disk = []
|
||||||
|
for (root, dirs, files) in os.walk(sourceroot):
|
||||||
|
rootpath = Path(root)
|
||||||
|
for filename in files:
|
||||||
|
filepart, ext = os.path.splitext(filename)
|
||||||
|
if ext.startswith('.'):
|
||||||
|
ext = ext[1:]
|
||||||
|
ext = ext.strip().lower()
|
||||||
|
if ext not in file_extensions:
|
||||||
|
continue
|
||||||
|
on_disk.append(str(rootpath / filename))
|
||||||
|
filemap = {}
|
||||||
|
for item in media:
|
||||||
|
for filepath in on_disk:
|
||||||
|
if item.key in filepath:
|
||||||
|
# The unique item key is in the file name on disk, map it to
|
||||||
|
# the undownloaded media item
|
||||||
|
filemap[filepath] = item
|
||||||
|
continue
|
||||||
|
for filepath, item in filemap.items():
|
||||||
|
log.info(f'Matched on-disk file: {filepath} '
|
||||||
|
f'to media item: {item.source} / {item}')
|
||||||
|
item.media_file.name = filepath
|
||||||
|
item.downloaded = True
|
||||||
|
item.save()
|
||||||
|
log.info('Done')
|
||||||
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')
|
||||||
33
tubesync/sync/management/commands/reset-tasks.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from background_task.models import Task
|
||||||
|
from sync.models import Source
|
||||||
|
from sync.tasks import index_source_task
|
||||||
|
|
||||||
|
|
||||||
|
from common.logger import log
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = 'Resets all tasks'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
log.info('Resettings all tasks...')
|
||||||
|
# Delete all tasks
|
||||||
|
Task.objects.all().delete()
|
||||||
|
# Iter all tasks
|
||||||
|
for source in Source.objects.all():
|
||||||
|
# Recreate the initial indexing task
|
||||||
|
log.info(f'Resetting tasks for source: {source}')
|
||||||
|
verbose_name = _('Index media from source "{}"')
|
||||||
|
index_source_task(
|
||||||
|
str(source.pk),
|
||||||
|
repeat=source.index_schedule,
|
||||||
|
queue=str(source.pk),
|
||||||
|
priority=5,
|
||||||
|
verbose_name=verbose_name.format(source.name)
|
||||||
|
)
|
||||||
|
# This also chains down to call each Media objects .save() as well
|
||||||
|
source.save()
|
||||||
|
log.info('Done')
|
||||||
34
tubesync/sync/management/commands/sync-missing-metadata.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import os
|
||||||
|
from shutil import copyfile
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db.models import Q
|
||||||
|
from common.logger import log
|
||||||
|
from sync.models import Source, Media
|
||||||
|
from sync.utils import write_text_file
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = 'Syncs missing metadata (such as nfo files) if source settings are updated'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
log.info('Syncing missing metadata...')
|
||||||
|
sources = Source.objects.filter(Q(copy_thumbnails=True) | Q(write_nfo=True))
|
||||||
|
for source in sources.order_by('name'):
|
||||||
|
log.info(f'Finding media for source: {source}')
|
||||||
|
for item in Media.objects.filter(source=source, downloaded=True):
|
||||||
|
log.info(f'Checking media for missing metadata: {source} / {item}')
|
||||||
|
thumbpath = item.thumbpath
|
||||||
|
if not thumbpath.is_file():
|
||||||
|
if item.thumb:
|
||||||
|
log.info(f'Copying missing thumbnail from: {item.thumb.path} '
|
||||||
|
f'to: {thumbpath}')
|
||||||
|
copyfile(item.thumb.path, thumbpath)
|
||||||
|
else:
|
||||||
|
log.error(f'Tried to copy missing thumbnail for {item} but '
|
||||||
|
f'the thumbnail has not been downloaded')
|
||||||
|
nfopath = item.nfopath
|
||||||
|
if not nfopath.is_file():
|
||||||
|
log.info(f'Writing missing NFO file: {nfopath}')
|
||||||
|
write_text_file(nfopath, item.nfoxml)
|
||||||
|
log.info('Done')
|
||||||
20
tubesync/sync/management/commands/youtube-dl-info.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import json
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from sync.youtube import get_media_info
|
||||||
|
from common.utils import json_serial
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = 'Displays information obtained by youtube-dl in JSON to the console'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('url', type=str)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
url = options['url']
|
||||||
|
self.stdout.write(f'Showing information for URL: {url}')
|
||||||
|
info = get_media_info(url)
|
||||||
|
d = json.dumps(info, indent=4, sort_keys=True, default=json_serial)
|
||||||
|
self.stdout.write(d)
|
||||||
|
self.stdout.write('Done')
|
||||||
@@ -66,7 +66,7 @@ def get_best_audio_format(media):
|
|||||||
# No codecs matched
|
# No codecs matched
|
||||||
if media.source.can_fallback:
|
if media.source.can_fallback:
|
||||||
# Can fallback, find the next highest bitrate non-matching codec
|
# Can fallback, find the next highest bitrate non-matching codec
|
||||||
return False, audio_formats[0]
|
return False, audio_formats[0]['id']
|
||||||
else:
|
else:
|
||||||
# Can't fallback
|
# Can't fallback
|
||||||
return False, False
|
return False, False
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
18
tubesync/sync/migrations/0005_auto_20201219_0312.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 03:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0004_source_media_format'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='source_type',
|
||||||
|
field=models.CharField(choices=[('c', 'YouTube channel'), ('i', 'YouTube channel by ID'), ('p', 'YouTube playlist')], db_index=True, default='c', help_text='Source type', max_length=1, verbose_name='source type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
tubesync/sync/migrations/0006_source_write_nfo.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 03:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0005_auto_20201219_0312'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='write_nfo',
|
||||||
|
field=models.BooleanField(default=False, help_text='Write an NFO file with the media, these may be detected and used by some media servers', verbose_name='write nfo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
tubesync/sync/migrations/0007_auto_20201219_0645.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 06:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0006_source_write_nfo'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='write_nfo',
|
||||||
|
field=models.BooleanField(default=False, help_text='Write an NFO file in XML with the media info, these may be detected and used by some media servers', verbose_name='write nfo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
tubesync/sync/migrations/0008_source_download_cap.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.4 on 2020-12-19 06:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0007_auto_20201219_0645'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='download_cap',
|
||||||
|
field=models.IntegerField(choices=[(0, 'No cap'), (604800, '1 week (7 days)'), (2592000, '1 month (30 days)'), (7776000, '3 months (90 days)'), (15552000, '6 months (180 days)'), (31536000, '1 year (365 days)'), (63072000, '2 years (730 days)'), (94608000, '3 years (1095 days)'), (157680000, '5 years (1825 days)'), (315360000, '10 years (3650 days)')], default=0, help_text='Do not download media older than this capped date', verbose_name='download cap'),
|
||||||
|
),
|
||||||
|
]
|
||||||
30
tubesync/sync/migrations/0009_auto_20210218_0442.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 3.1.6 on 2021-02-18 04:42
|
||||||
|
|
||||||
|
import django.core.files.storage
|
||||||
|
from django.db import migrations, models
|
||||||
|
import sync.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0008_source_download_cap'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='download_media',
|
||||||
|
field=models.BooleanField(default=True, help_text='Download media from this source, if not selected the source will only be indexed', verbose_name='download media'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='media_file',
|
||||||
|
field=models.FileField(blank=True, help_text='Media file', max_length=200, 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='media_format',
|
||||||
|
field=models.CharField(default='{yyyymmdd}_{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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
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
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from xml.etree import ElementTree
|
||||||
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -10,6 +12,7 @@ from django.utils.text import slugify
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from common.errors import NoFormatException
|
from common.errors import NoFormatException
|
||||||
|
from common.utils import clean_filename
|
||||||
from .youtube import (get_media_info as get_youtube_media_info,
|
from .youtube import (get_media_info as get_youtube_media_info,
|
||||||
download_media as download_youtube_media)
|
download_media as download_youtube_media)
|
||||||
from .utils import seconds_to_timestr, parse_media_format
|
from .utils import seconds_to_timestr, parse_media_format
|
||||||
@@ -28,10 +31,13 @@ class Source(models.Model):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
SOURCE_TYPE_YOUTUBE_CHANNEL = 'c'
|
SOURCE_TYPE_YOUTUBE_CHANNEL = 'c'
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID = 'i'
|
||||||
SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p'
|
SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p'
|
||||||
SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_PLAYLIST)
|
SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
|
||||||
|
SOURCE_TYPE_YOUTUBE_PLAYLIST)
|
||||||
SOURCE_TYPE_CHOICES = (
|
SOURCE_TYPE_CHOICES = (
|
||||||
(SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')),
|
(SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')),
|
||||||
|
(SOURCE_TYPE_YOUTUBE_CHANNEL_ID, _('YouTube channel by ID')),
|
||||||
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
|
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,27 +101,54 @@ class Source(models.Model):
|
|||||||
(FALLBACK_NEXT_BEST_HD, _('Get next best resolution but at least HD'))
|
(FALLBACK_NEXT_BEST_HD, _('Get next best resolution but at least HD'))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
EXTENSION_M4A = 'm4a'
|
||||||
|
EXTENSION_OGG = 'ogg'
|
||||||
|
EXTENSION_MKV = 'mkv'
|
||||||
|
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV)
|
||||||
|
|
||||||
# Fontawesome icons used for the source on the front end
|
# Fontawesome icons used for the source on the front end
|
||||||
ICONS = {
|
ICONS = {
|
||||||
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
|
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '<i class="fab fa-youtube"></i>',
|
||||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: '<i class="fab fa-youtube"></i>',
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: '<i class="fab fa-youtube"></i>',
|
||||||
}
|
}
|
||||||
# Format to use to display a URL for the source
|
# Format to use to display a URL for the source
|
||||||
URLS = {
|
URLS = {
|
||||||
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}',
|
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}',
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}',
|
||||||
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
||||||
|
}
|
||||||
|
# Format used to create indexable URLs
|
||||||
|
INDEX_URLS = {
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}/videos',
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}/videos',
|
||||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
|
||||||
}
|
}
|
||||||
# Callback functions to get a list of media from the source
|
# Callback functions to get a list of media from the source
|
||||||
INDEXERS = {
|
INDEXERS = {
|
||||||
SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
|
SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: get_youtube_media_info,
|
||||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info,
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info,
|
||||||
}
|
}
|
||||||
# Field names to find the media ID used as the key when storing media
|
# Field names to find the media ID used as the key when storing media
|
||||||
KEY_FIELD = {
|
KEY_FIELD = {
|
||||||
SOURCE_TYPE_YOUTUBE_CHANNEL: 'id',
|
SOURCE_TYPE_YOUTUBE_CHANNEL: 'id',
|
||||||
|
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'id',
|
||||||
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id',
|
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CapChoices(models.IntegerChoices):
|
||||||
|
CAP_NOCAP = 0, _('No cap')
|
||||||
|
CAP_7DAYS = 604800, _('1 week (7 days)')
|
||||||
|
CAP_30DAYS = 2592000, _('1 month (30 days)')
|
||||||
|
CAP_90DAYS = 7776000, _('3 months (90 days)')
|
||||||
|
CAP_6MONTHS = 15552000, _('6 months (180 days)')
|
||||||
|
CAP_1YEAR = 31536000, _('1 year (365 days)')
|
||||||
|
CAP_2YEARs = 63072000, _('2 years (730 days)')
|
||||||
|
CAP_3YEARs = 94608000, _('3 years (1095 days)')
|
||||||
|
CAP_5YEARs = 157680000, _('5 years (1825 days)')
|
||||||
|
CAP_10YEARS = 315360000, _('10 years (3650 days)')
|
||||||
|
|
||||||
class IndexSchedule(models.IntegerChoices):
|
class IndexSchedule(models.IntegerChoices):
|
||||||
EVERY_HOUR = 3600, _('Every hour')
|
EVERY_HOUR = 3600, _('Every hour')
|
||||||
EVERY_2_HOURS = 7200, _('Every 2 hours')
|
EVERY_2_HOURS = 7200, _('Every 2 hours')
|
||||||
@@ -125,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'),
|
||||||
@@ -179,15 +215,26 @@ class Source(models.Model):
|
|||||||
_('media format'),
|
_('media format'),
|
||||||
max_length=200,
|
max_length=200,
|
||||||
default=settings.MEDIA_FORMATSTR_DEFAULT,
|
default=settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
help_text=_('File format to use for saving files')
|
help_text=_('File format to use for saving files, detailed options at bottom of page.')
|
||||||
)
|
)
|
||||||
index_schedule = models.IntegerField(
|
index_schedule = models.IntegerField(
|
||||||
_('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'),
|
||||||
|
default=True,
|
||||||
|
help_text=_('Download media from this source, if not selected the source will only be indexed')
|
||||||
|
)
|
||||||
|
download_cap = models.IntegerField(
|
||||||
|
_('download cap'),
|
||||||
|
choices=CapChoices.choices,
|
||||||
|
default=CapChoices.CAP_NOCAP,
|
||||||
|
help_text=_('Do not download media older than this capped date')
|
||||||
|
)
|
||||||
delete_old_media = models.BooleanField(
|
delete_old_media = models.BooleanField(
|
||||||
_('delete old media'),
|
_('delete old media'),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -246,6 +293,16 @@ class Source(models.Model):
|
|||||||
default=False,
|
default=False,
|
||||||
help_text=_('Copy thumbnails with the media, these may be detected and used by some media servers')
|
help_text=_('Copy thumbnails with the media, these may be detected and used by some media servers')
|
||||||
)
|
)
|
||||||
|
write_nfo = models.BooleanField(
|
||||||
|
_('write nfo'),
|
||||||
|
default=False,
|
||||||
|
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,
|
||||||
@@ -276,6 +333,14 @@ class Source(models.Model):
|
|||||||
def is_video(self):
|
def is_video(self):
|
||||||
return not self.is_audio
|
return not self.is_audio
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_cap_date(self):
|
||||||
|
delta = self.download_cap
|
||||||
|
if delta > 0:
|
||||||
|
return timezone.now() - timedelta(seconds=delta)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extension(self):
|
def extension(self):
|
||||||
'''
|
'''
|
||||||
@@ -287,23 +352,32 @@ class Source(models.Model):
|
|||||||
'''
|
'''
|
||||||
if self.is_audio:
|
if self.is_audio:
|
||||||
if self.source_acodec == self.SOURCE_ACODEC_MP4A:
|
if self.source_acodec == self.SOURCE_ACODEC_MP4A:
|
||||||
return 'm4a'
|
return self.EXTENSION_M4A
|
||||||
elif self.source_acodec == self.SOURCE_ACODEC_OPUS:
|
elif self.source_acodec == self.SOURCE_ACODEC_OPUS:
|
||||||
return 'ogg'
|
return self.EXTENSION_OGG
|
||||||
else:
|
else:
|
||||||
raise ValueError('Unable to choose audio extension, uknown acodec')
|
raise ValueError('Unable to choose audio extension, uknown acodec')
|
||||||
else:
|
else:
|
||||||
return 'mkv'
|
return self.EXTENSION_MKV
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_url(obj, source_type, key):
|
def create_url(obj, source_type, key):
|
||||||
url = obj.URLS.get(source_type)
|
url = obj.URLS.get(source_type)
|
||||||
return url.format(key=key)
|
return url.format(key=key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_index_url(obj, source_type, key):
|
||||||
|
url = obj.INDEX_URLS.get(source_type)
|
||||||
|
return url.format(key=key)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self):
|
def url(self):
|
||||||
return Source.create_url(self.source_type, self.key)
|
return Source.create_url(self.source_type, self.key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index_url(self):
|
||||||
|
return Source.create_index_url(self.source_type, self.key)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def format_summary(self):
|
def format_summary(self):
|
||||||
if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO:
|
if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO:
|
||||||
@@ -359,16 +433,20 @@ 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': now.strftime('%m'),
|
||||||
|
'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_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 '',
|
||||||
'height': '720' if self.source_resolution else '',
|
'height': '720' if self.source_resolution else '',
|
||||||
@@ -382,7 +460,7 @@ class Source(models.Model):
|
|||||||
def get_example_media_format(self):
|
def get_example_media_format(self):
|
||||||
try:
|
try:
|
||||||
return self.media_format.format(**self.example_media_format_dict)
|
return self.media_format.format(**self.example_media_format_dict)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def index_media(self):
|
def index_media(self):
|
||||||
@@ -392,25 +470,10 @@ class Source(models.Model):
|
|||||||
indexer = self.INDEXERS.get(self.source_type, None)
|
indexer = self.INDEXERS.get(self.source_type, None)
|
||||||
if not callable(indexer):
|
if not callable(indexer):
|
||||||
raise Exception(f'Source type f"{self.source_type}" has no indexer')
|
raise Exception(f'Source type f"{self.source_type}" has no indexer')
|
||||||
response = indexer(self.url)
|
response = indexer(self.index_url)
|
||||||
|
if not isinstance(response, dict):
|
||||||
# Account for nested playlists, such as a channel of playlists of playlists
|
return []
|
||||||
def _recurse_playlists(playlist):
|
return response.get('entries', [])
|
||||||
videos = []
|
|
||||||
if not playlist:
|
|
||||||
return videos
|
|
||||||
entries = playlist.get('entries', [])
|
|
||||||
for entry in entries:
|
|
||||||
if not entry:
|
|
||||||
continue
|
|
||||||
subentries = entry.get('entries', [])
|
|
||||||
if subentries:
|
|
||||||
videos = videos + _recurse_playlists(entry)
|
|
||||||
else:
|
|
||||||
videos.append(entry)
|
|
||||||
return videos
|
|
||||||
|
|
||||||
return _recurse_playlists(response)
|
|
||||||
|
|
||||||
|
|
||||||
def get_media_thumb_path(instance, filename):
|
def get_media_thumb_path(instance, filename):
|
||||||
@@ -433,47 +496,99 @@ class Media(models.Model):
|
|||||||
# Format to use to display a URL for the media
|
# Format to use to display a URL for the media
|
||||||
URLS = {
|
URLS = {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/watch?v={key}',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/watch?v={key}',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/watch?v={key}',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/watch?v={key}',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/watch?v={key}',
|
||||||
}
|
}
|
||||||
|
# Callback functions to get a list of media from the source
|
||||||
|
INDEXERS = {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: get_youtube_media_info,
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info,
|
||||||
|
}
|
||||||
# Maps standardised names to names used in source metdata
|
# Maps standardised names to names used in source metdata
|
||||||
METADATA_FIELDS = {
|
METADATA_FIELDS = {
|
||||||
'upload_date': {
|
'upload_date': {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date',
|
||||||
},
|
},
|
||||||
'title': {
|
'title': {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title',
|
||||||
},
|
},
|
||||||
'thumbnail': {
|
'thumbnail': {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'thumbnail',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail',
|
||||||
},
|
},
|
||||||
'description': {
|
'description': {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'description',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description',
|
||||||
},
|
},
|
||||||
'duration': {
|
'duration': {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'duration',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration',
|
||||||
},
|
},
|
||||||
'formats': {
|
'formats': {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats',
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats',
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats',
|
||||||
}
|
},
|
||||||
|
'categories': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'categories',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'categories',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'categories',
|
||||||
|
},
|
||||||
|
'rating': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'average_rating',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'average_rating',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'average_rating',
|
||||||
|
},
|
||||||
|
'age_limit': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'age_limit',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'age_limit',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'age_limit',
|
||||||
|
},
|
||||||
|
'uploader': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'uploader',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'uploader',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'uploader',
|
||||||
|
},
|
||||||
|
'upvotes': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'like_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'like_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'like_count',
|
||||||
|
},
|
||||||
|
'downvotes': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'dislike_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'dislike_count',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'dislike_count',
|
||||||
|
},
|
||||||
|
'playlist_title': {
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_title',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
STATE_UNKNOWN = 'unknown'
|
STATE_UNKNOWN = 'unknown'
|
||||||
STATE_SCHEDULED = 'scheduled'
|
STATE_SCHEDULED = 'scheduled'
|
||||||
STATE_DOWNLOADING = 'downloading'
|
STATE_DOWNLOADING = 'downloading'
|
||||||
STATE_DOWNLOADED = 'downloaded'
|
STATE_DOWNLOADED = 'downloaded'
|
||||||
|
STATE_SKIPPED = 'skipped'
|
||||||
|
STATE_DISABLED_AT_SOURCE = 'source-disabled'
|
||||||
STATE_ERROR = 'error'
|
STATE_ERROR = 'error'
|
||||||
STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED,
|
STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED,
|
||||||
STATE_ERROR)
|
STATE_SKIPPED, STATE_DISABLED_AT_SOURCE, STATE_ERROR)
|
||||||
STATE_ICONS = {
|
STATE_ICONS = {
|
||||||
STATE_UNKNOWN: '<i class="far fa-question-circle" title="Unknown download state"></i>',
|
STATE_UNKNOWN: '<i class="far fa-question-circle" title="Unknown download state"></i>',
|
||||||
STATE_SCHEDULED: '<i class="far fa-clock" title="Scheduled to download"></i>',
|
STATE_SCHEDULED: '<i class="far fa-clock" title="Scheduled to download"></i>',
|
||||||
STATE_DOWNLOADING: '<i class="fas fa-download" title="Downloading now"></i>',
|
STATE_DOWNLOADING: '<i class="fas fa-download" title="Downloading now"></i>',
|
||||||
STATE_DOWNLOADED: '<i class="far fa-check-circle" title="Downloaded"></i>',
|
STATE_DOWNLOADED: '<i class="far fa-check-circle" title="Downloaded"></i>',
|
||||||
|
STATE_SKIPPED: '<i class="fas fa-exclamation-circle" title="Skipped"></i>',
|
||||||
|
STATE_DISABLED_AT_SOURCE: '<i class="fas fa-stop-circle" title="Media downloading disabled at source"></i>',
|
||||||
STATE_ERROR: '<i class="fas fa-exclamation-triangle" title="Error downloading"></i>',
|
STATE_ERROR: '<i class="fas fa-exclamation-triangle" title="Error downloading"></i>',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +661,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,
|
||||||
@@ -576,7 +691,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'),
|
||||||
@@ -703,7 +818,24 @@ 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:
|
||||||
resolution = f'{self.downloaded_height}p'
|
# 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'
|
||||||
if self.downloaded_format != 'audio':
|
if self.downloaded_format != 'audio':
|
||||||
vcodec = self.downloaded_video_codec.lower()
|
vcodec = self.downloaded_video_codec.lower()
|
||||||
fmt.append(vcodec)
|
fmt.append(vcodec)
|
||||||
@@ -747,8 +879,9 @@ class Media(models.Model):
|
|||||||
fmt.append(resolution)
|
fmt.append(resolution)
|
||||||
vcodec = vformat['vcodec'].lower()
|
vcodec = vformat['vcodec'].lower()
|
||||||
fmt.append(vcodec)
|
fmt.append(vcodec)
|
||||||
acodec = aformat['acodec'].lower()
|
if aformat:
|
||||||
fmt.append(acodec)
|
acodec = aformat['acodec'].lower()
|
||||||
|
fmt.append(acodec)
|
||||||
if vformat:
|
if vformat:
|
||||||
if vformat['is_60fps']:
|
if vformat['is_60fps']:
|
||||||
fps = '60fps'
|
fps = '60fps'
|
||||||
@@ -791,12 +924,15 @@ class Media(models.Model):
|
|||||||
'yyyymmdd': dateobj.strftime('%Y%m%d'),
|
'yyyymmdd': dateobj.strftime('%Y%m%d'),
|
||||||
'yyyy_mm_dd': dateobj.strftime('%Y-%m-%d'),
|
'yyyy_mm_dd': dateobj.strftime('%Y-%m-%d'),
|
||||||
'yyyy': dateobj.strftime('%Y'),
|
'yyyy': dateobj.strftime('%Y'),
|
||||||
|
'mm': dateobj.strftime('%m'),
|
||||||
|
'dd': dateobj.strftime('%d'),
|
||||||
'source': self.source.slugname,
|
'source': self.source.slugname,
|
||||||
'source_full': self.source.name,
|
'source_full': self.source.name,
|
||||||
'title': self.slugtitle,
|
'title': self.slugtitle,
|
||||||
'title_full': 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_title': self.playlist_title,
|
||||||
'ext': self.source.extension,
|
'ext': self.source.extension,
|
||||||
'resolution': display_format['resolution'],
|
'resolution': display_format['resolution'],
|
||||||
'height': display_format['height'],
|
'height': display_format['height'],
|
||||||
@@ -807,10 +943,17 @@ class Media(models.Model):
|
|||||||
'hdr': display_format['hdr'],
|
'hdr': display_format['hdr'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_metadata(self):
|
||||||
|
return self.metadata is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def loaded_metadata(self):
|
def loaded_metadata(self):
|
||||||
try:
|
try:
|
||||||
return json.loads(self.metadata)
|
data = json.loads(self.metadata)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -847,7 +990,10 @@ class Media(models.Model):
|
|||||||
@property
|
@property
|
||||||
def upload_date(self):
|
def upload_date(self):
|
||||||
field = self.get_metadata_field('upload_date')
|
field = self.get_metadata_field('upload_date')
|
||||||
upload_date_str = self.loaded_metadata.get(field, '').strip()
|
try:
|
||||||
|
upload_date_str = self.loaded_metadata.get(field, '').strip()
|
||||||
|
except (AttributeError, ValueError) as e:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(upload_date_str, '%Y%m%d')
|
return datetime.strptime(upload_date_str, '%Y%m%d')
|
||||||
except (AttributeError, ValueError) as e:
|
except (AttributeError, ValueError) as e:
|
||||||
@@ -856,7 +1002,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):
|
||||||
@@ -865,26 +1016,96 @@ class Media(models.Model):
|
|||||||
return seconds_to_timestr(duration)
|
return seconds_to_timestr(duration)
|
||||||
return '??:??:??'
|
return '??:??:??'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def categories(self):
|
||||||
|
field = self.get_metadata_field('categories')
|
||||||
|
return self.loaded_metadata.get(field, [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rating(self):
|
||||||
|
field = self.get_metadata_field('rating')
|
||||||
|
return self.loaded_metadata.get(field, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def votes(self):
|
||||||
|
field = self.get_metadata_field('upvotes')
|
||||||
|
upvotes = self.loaded_metadata.get(field, 0)
|
||||||
|
if not isinstance(upvotes, int):
|
||||||
|
upvotes = 0
|
||||||
|
field = self.get_metadata_field('downvotes')
|
||||||
|
downvotes = self.loaded_metadata.get(field, 0)
|
||||||
|
if not isinstance(downvotes, int):
|
||||||
|
downvotes = 0
|
||||||
|
return upvotes + downvotes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def age_limit(self):
|
||||||
|
field = self.get_metadata_field('age_limit')
|
||||||
|
return self.loaded_metadata.get(field, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uploader(self):
|
||||||
|
field = self.get_metadata_field('uploader')
|
||||||
|
return self.loaded_metadata.get(field, '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formats(self):
|
def formats(self):
|
||||||
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_title(self):
|
||||||
|
field = self.get_metadata_field('playlist_title')
|
||||||
|
return self.loaded_metadata.get(field, '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
# If a media_file has been downloaded use its existing name
|
# Create a suitable filename from the source media_format
|
||||||
if self.media_file:
|
|
||||||
return os.path.basename(self.media_file.name)
|
|
||||||
# Otherwise, create a suitable filename from the source media_format
|
|
||||||
media_format = str(self.source.media_format)
|
media_format = str(self.source.media_format)
|
||||||
media_details = self.format_dict
|
media_details = self.format_dict
|
||||||
return media_format.format(**media_details)
|
return media_format.format(**media_details)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbname(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}.jpg'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbpath(self):
|
||||||
|
return self.source.directory_path / self.thumbname
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nfoname(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}.nfo'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nfopath(self):
|
||||||
|
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):
|
||||||
# If a media_file has been downloaded use its existing directory
|
|
||||||
if self.media_file:
|
|
||||||
return os.path.dirname(self.media_file.name)
|
|
||||||
# Otherwise, create a suitable filename from the source media_format
|
# Otherwise, create a suitable filename from the source media_format
|
||||||
media_format = str(self.source.media_format)
|
media_format = str(self.source.media_format)
|
||||||
media_details = self.format_dict
|
media_details = self.format_dict
|
||||||
@@ -907,6 +1128,103 @@ 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 nfoxml(self):
|
||||||
|
'''
|
||||||
|
Returns an NFO formatted (prettified) XML string.
|
||||||
|
'''
|
||||||
|
nfo = ElementTree.Element('episodedetails')
|
||||||
|
nfo.text = '\n '
|
||||||
|
# title = media metadata title
|
||||||
|
title = nfo.makeelement('title', {})
|
||||||
|
title.text = str(self.name).strip()
|
||||||
|
title.tail = '\n '
|
||||||
|
nfo.append(title)
|
||||||
|
# showtitle = source name
|
||||||
|
showtitle = nfo.makeelement('showtitle', {})
|
||||||
|
showtitle.text = str(self.source.name).strip()
|
||||||
|
showtitle.tail = '\n '
|
||||||
|
nfo.append(showtitle)
|
||||||
|
# ratings = media metadata youtube rating
|
||||||
|
value = nfo.makeelement('value', {})
|
||||||
|
value.text = str(self.rating)
|
||||||
|
value.tail = '\n '
|
||||||
|
votes = nfo.makeelement('votes', {})
|
||||||
|
votes.text = str(self.votes)
|
||||||
|
votes.tail = '\n '
|
||||||
|
rating_attrs = OrderedDict()
|
||||||
|
rating_attrs['name'] = 'youtube'
|
||||||
|
rating_attrs['max'] = '5'
|
||||||
|
rating_attrs['default'] = 'True'
|
||||||
|
rating = nfo.makeelement('rating', rating_attrs)
|
||||||
|
rating.text = '\n '
|
||||||
|
rating.append(value)
|
||||||
|
rating.append(votes)
|
||||||
|
rating.tail = '\n '
|
||||||
|
ratings = nfo.makeelement('ratings', {})
|
||||||
|
ratings.text = '\n '
|
||||||
|
ratings.append(rating)
|
||||||
|
ratings.tail = '\n '
|
||||||
|
nfo.append(ratings)
|
||||||
|
# plot = media metadata description
|
||||||
|
plot = nfo.makeelement('plot', {})
|
||||||
|
plot.text = str(self.description).strip()
|
||||||
|
plot.tail = '\n '
|
||||||
|
nfo.append(plot)
|
||||||
|
# thumb = local path to media thumbnail
|
||||||
|
thumb = nfo.makeelement('thumb', {})
|
||||||
|
thumb.text = self.thumbname if self.source.copy_thumbnails else ''
|
||||||
|
thumb.tail = '\n '
|
||||||
|
nfo.append(thumb)
|
||||||
|
# mpaa = media metadata age requirement
|
||||||
|
mpaa = nfo.makeelement('mpaa', {})
|
||||||
|
mpaa.text = str(self.age_limit)
|
||||||
|
mpaa.tail = '\n '
|
||||||
|
nfo.append(mpaa)
|
||||||
|
# runtime = media metadata duration in seconds
|
||||||
|
runtime = nfo.makeelement('runtime', {})
|
||||||
|
runtime.text = str(self.duration)
|
||||||
|
runtime.tail = '\n '
|
||||||
|
nfo.append(runtime)
|
||||||
|
# id = media key
|
||||||
|
idn = nfo.makeelement('id', {})
|
||||||
|
idn.text = str(self.key).strip()
|
||||||
|
idn.tail = '\n '
|
||||||
|
nfo.append(idn)
|
||||||
|
# uniqueid = media key
|
||||||
|
uniqueid_attrs = OrderedDict()
|
||||||
|
uniqueid_attrs['type'] = 'youtube'
|
||||||
|
uniqueid_attrs['default'] = 'True'
|
||||||
|
uniqueid = nfo.makeelement('uniqueid', uniqueid_attrs)
|
||||||
|
uniqueid.text = str(self.key).strip()
|
||||||
|
uniqueid.tail = '\n '
|
||||||
|
nfo.append(uniqueid)
|
||||||
|
# studio = media metadata uploader
|
||||||
|
studio = nfo.makeelement('studio', {})
|
||||||
|
studio.text = str(self.uploader).strip()
|
||||||
|
studio.tail = '\n '
|
||||||
|
nfo.append(studio)
|
||||||
|
# aired = media metadata uploaded date
|
||||||
|
aired = nfo.makeelement('aired', {})
|
||||||
|
upload_date = self.upload_date
|
||||||
|
aired.text = upload_date.strftime('%Y-%m-%d') if upload_date else ''
|
||||||
|
aired.tail = '\n '
|
||||||
|
nfo.append(aired)
|
||||||
|
# dateadded = date and time media was created in tubesync
|
||||||
|
dateadded = nfo.makeelement('dateadded', {})
|
||||||
|
dateadded.text = self.created.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
dateadded.tail = '\n '
|
||||||
|
nfo.append(dateadded)
|
||||||
|
# genre = any media metadata categories if they exist
|
||||||
|
for category_str in self.categories:
|
||||||
|
genre = nfo.makeelement('genre', {})
|
||||||
|
genre.text = str(category_str).strip()
|
||||||
|
genre.tail = '\n '
|
||||||
|
nfo.append(genre)
|
||||||
|
nfo[-1].tail = '\n'
|
||||||
|
# Return XML tree as a prettified string
|
||||||
|
return ElementTree.tostring(nfo, encoding='utf8', method='xml').decode('utf8')
|
||||||
|
|
||||||
def get_download_state(self, task=None):
|
def get_download_state(self, task=None):
|
||||||
if self.downloaded:
|
if self.downloaded:
|
||||||
return self.STATE_DOWNLOADED
|
return self.STATE_DOWNLOADED
|
||||||
@@ -917,6 +1235,10 @@ class Media(models.Model):
|
|||||||
return self.STATE_ERROR
|
return self.STATE_ERROR
|
||||||
else:
|
else:
|
||||||
return self.STATE_SCHEDULED
|
return self.STATE_SCHEDULED
|
||||||
|
if self.skip:
|
||||||
|
return self.STATE_SKIPPED
|
||||||
|
if not self.source.download_media:
|
||||||
|
return self.STATE_DISABLED_AT_SOURCE
|
||||||
return self.STATE_UNKNOWN
|
return self.STATE_UNKNOWN
|
||||||
|
|
||||||
def get_download_state_icon(self, task=None):
|
def get_download_state_icon(self, task=None):
|
||||||
@@ -930,10 +1252,20 @@ 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
|
||||||
|
|
||||||
|
def index_metadata(self):
|
||||||
|
'''
|
||||||
|
Index the media metadata returning a dict of info.
|
||||||
|
'''
|
||||||
|
indexer = self.INDEXERS.get(self.source.source_type, None)
|
||||||
|
if not callable(indexer):
|
||||||
|
raise Exception(f'Meida with source type f"{self.source.source_type}" '
|
||||||
|
f'has no indexer')
|
||||||
|
return indexer(self.url)
|
||||||
|
|
||||||
|
|
||||||
class MediaServer(models.Model):
|
class MediaServer(models.Model):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from background_task.models import Task
|
|||||||
from common.logger import log
|
from common.logger import log
|
||||||
from .models import Source, Media, MediaServer
|
from .models import Source, Media, MediaServer
|
||||||
from .tasks import (delete_task_by_source, delete_task_by_media, index_source_task,
|
from .tasks import (delete_task_by_source, delete_task_by_media, index_source_task,
|
||||||
download_media_thumbnail, map_task_to_instance,
|
download_media_thumbnail, download_media_metadata,
|
||||||
check_source_directory_exists, download_media, rescan_media_server)
|
map_task_to_instance, check_source_directory_exists,
|
||||||
|
download_media, rescan_media_server)
|
||||||
from .utils import delete_file
|
from .utils import delete_file
|
||||||
|
|
||||||
|
|
||||||
@@ -46,17 +47,18 @@ 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)
|
||||||
)
|
)
|
||||||
delete_task_by_source('sync.tasks.index_source_task', instance.pk)
|
if instance.index_schedule > 0:
|
||||||
log.info(f'Scheduling media indexing for source: {instance.name}')
|
delete_task_by_source('sync.tasks.index_source_task', instance.pk)
|
||||||
verbose_name = _('Index media from source "{}"')
|
log.info(f'Scheduling media indexing for source: {instance.name}')
|
||||||
index_source_task(
|
verbose_name = _('Index media from source "{}"')
|
||||||
str(instance.pk),
|
index_source_task(
|
||||||
repeat=instance.index_schedule,
|
str(instance.pk),
|
||||||
queue=str(instance.pk),
|
repeat=instance.index_schedule,
|
||||||
priority=5,
|
queue=str(instance.pk),
|
||||||
verbose_name=verbose_name.format(instance.name),
|
priority=5,
|
||||||
remove_existing_tasks=True
|
verbose_name=verbose_name.format(instance.name),
|
||||||
)
|
remove_existing_tasks=True
|
||||||
|
)
|
||||||
# Trigger the post_save signal for each media item linked to this source as various
|
# Trigger the post_save signal for each media item linked to this source as various
|
||||||
# flags may need to be recalculated
|
# flags may need to be recalculated
|
||||||
for media in Media.objects.filter(source=instance):
|
for media in Media.objects.filter(source=instance):
|
||||||
@@ -91,18 +93,69 @@ 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
|
# 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
|
||||||
post_save.disconnect(media_post_save, sender=Media)
|
if instance.metadata:
|
||||||
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
|
||||||
instance.save()
|
can_download_changed = True
|
||||||
post_save.connect(media_post_save, sender=Media)
|
# 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()
|
||||||
|
post_save.connect(media_post_save, sender=Media)
|
||||||
|
# If the media is missing metadata schedule it to be downloaded
|
||||||
|
if not instance.metadata:
|
||||||
|
log.info(f'Scheduling task to download metadata for: {instance.url}')
|
||||||
|
verbose_name = _('Downloading metadata for "{}"')
|
||||||
|
download_media_metadata(
|
||||||
|
str(instance.pk),
|
||||||
|
priority=10,
|
||||||
|
verbose_name=verbose_name.format(instance.pk),
|
||||||
|
remove_existing_tasks=True
|
||||||
|
)
|
||||||
# If the media is missing a thumbnail schedule it to be downloaded
|
# If the media is missing a thumbnail schedule it to be downloaded
|
||||||
if not instance.thumb_file_exists:
|
if not instance.thumb_file_exists:
|
||||||
instance.thumb = None
|
instance.thumb = None
|
||||||
@@ -124,7 +177,8 @@ def media_post_save(sender, instance, created, **kwargs):
|
|||||||
if not instance.media_file_exists:
|
if not instance.media_file_exists:
|
||||||
instance.downloaded = False
|
instance.downloaded = False
|
||||||
instance.media_file = None
|
instance.media_file = None
|
||||||
if not instance.downloaded and instance.can_download and not instance.skip:
|
if (not instance.downloaded and instance.can_download and not instance.skip
|
||||||
|
and instance.source.download_media):
|
||||||
delete_task_by_media('sync.tasks.download_media', (str(instance.pk),))
|
delete_task_by_media('sync.tasks.download_media', (str(instance.pk),))
|
||||||
verbose_name = _('Downloading media for "{}"')
|
verbose_name = _('Downloading media for "{}"')
|
||||||
download_media(
|
download_media(
|
||||||
@@ -145,20 +199,6 @@ def media_pre_delete(sender, instance, **kwargs):
|
|||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
delete_task_by_media('sync.tasks.download_media_thumbnail',
|
||||||
(str(instance.pk), thumbnail_url))
|
(str(instance.pk), thumbnail_url))
|
||||||
# Delete media thumbnail if it exists
|
|
||||||
if instance.thumb:
|
|
||||||
log.info(f'Deleting thumbnail for: {instance} path: {instance.thumb.path}')
|
|
||||||
delete_file(instance.thumb.path)
|
|
||||||
# Delete the media file if it exists
|
|
||||||
if instance.media_file:
|
|
||||||
filepath = instance.media_file.path
|
|
||||||
log.info(f'Deleting media for: {instance} path: {filepath}')
|
|
||||||
delete_file(filepath)
|
|
||||||
# Delete thumbnail copy if it exists
|
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
|
||||||
log.info(f'Deleting thumbnail for: {instance} path: {thumbpath}')
|
|
||||||
delete_file(thumbpath)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=Media)
|
@receiver(post_delete, sender=Media)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import math
|
|||||||
import uuid
|
import uuid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from hashlib import sha1
|
from hashlib import sha1
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -22,8 +22,10 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
def get_hash(task_name, pk):
|
def get_hash(task_name, pk):
|
||||||
@@ -174,26 +176,10 @@ 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
|
||||||
media.metadata = json.dumps(video)
|
|
||||||
upload_date = media.upload_date
|
|
||||||
# Media must have a valid upload date
|
|
||||||
if upload_date:
|
|
||||||
media.published = timezone.make_aware(upload_date)
|
|
||||||
else:
|
|
||||||
log.error(f'Media has no upload date, skipping: {source} / {media}')
|
|
||||||
continue
|
|
||||||
# If the source has a cut-off check the upload date is within the allowed delta
|
|
||||||
if source.delete_old_media and source.days_to_keep > 0:
|
|
||||||
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
|
||||||
if media.published < delta:
|
|
||||||
# Media was published after the cutoff date, skip it
|
|
||||||
log.warn(f'Media: {source} / {media} is older than '
|
|
||||||
f'{source.days_to_keep} days, skipping')
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
media.save()
|
media.save()
|
||||||
log.info(f'Indexed media: {source} / {media}')
|
log.info(f'Indexed media: {source} / {media}')
|
||||||
@@ -225,6 +211,61 @@ def check_source_directory_exists(source_id):
|
|||||||
source.make_directory()
|
source.make_directory()
|
||||||
|
|
||||||
|
|
||||||
|
@background(schedule=0)
|
||||||
|
def download_media_metadata(media_id):
|
||||||
|
'''
|
||||||
|
Downloads the metadata for a media item.
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
media = Media.objects.get(pk=media_id)
|
||||||
|
except Media.DoesNotExist:
|
||||||
|
# Task triggered but the media no longer exists, do nothing
|
||||||
|
log.error(f'Task download_media_metadata(pk={media_id}) called but no '
|
||||||
|
f'media exists with ID: {media_id}')
|
||||||
|
return
|
||||||
|
source = media.source
|
||||||
|
metadata = media.index_metadata()
|
||||||
|
media.metadata = json.dumps(metadata, default=json_serial)
|
||||||
|
upload_date = media.upload_date
|
||||||
|
# Media must have a valid upload date
|
||||||
|
if upload_date:
|
||||||
|
media.published = timezone.make_aware(upload_date)
|
||||||
|
else:
|
||||||
|
log.error(f'Media has no upload date, skipping: {source} / {media}')
|
||||||
|
media.skip = True
|
||||||
|
# If the source has a download cap date check the upload date is allowed
|
||||||
|
max_cap_age = source.download_cap_date
|
||||||
|
if media.published and max_cap_age:
|
||||||
|
if media.published < max_cap_age:
|
||||||
|
# Media was published after the cap date, skip it
|
||||||
|
log.warn(f'Media: {source} / {media} is older than cap age '
|
||||||
|
f'{max_cap_age}, skipping')
|
||||||
|
media.skip = True
|
||||||
|
# If the source has a cut-off check the upload date is within the allowed delta
|
||||||
|
if source.delete_old_media and source.days_to_keep > 0:
|
||||||
|
if not isinstance(media.published, datetime):
|
||||||
|
# Media has no known published date or incomplete metadata
|
||||||
|
log.warn(f'Media: {source} / {media} has no published date, skipping')
|
||||||
|
media.skip = True
|
||||||
|
else:
|
||||||
|
delta = timezone.now() - timedelta(days=source.days_to_keep)
|
||||||
|
if media.published < delta:
|
||||||
|
# Media was published after the cutoff date, skip it
|
||||||
|
log.warn(f'Media: {source} / {media} is older than '
|
||||||
|
f'{source.days_to_keep} days, skipping')
|
||||||
|
media.skip = True
|
||||||
|
# Check we can download the media item
|
||||||
|
if not media.skip:
|
||||||
|
if media.get_format_str():
|
||||||
|
media.can_download = True
|
||||||
|
else:
|
||||||
|
media.can_download = False
|
||||||
|
# Save the media
|
||||||
|
media.save()
|
||||||
|
log.info(f'Saved {len(media.metadata)} bytes of metadata for: '
|
||||||
|
f'{source} / {media_id}')
|
||||||
|
|
||||||
|
|
||||||
@background(schedule=0)
|
@background(schedule=0)
|
||||||
def download_media_thumbnail(media_id, url):
|
def download_media_thumbnail(media_id, url):
|
||||||
'''
|
'''
|
||||||
@@ -270,9 +311,28 @@ 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:
|
||||||
|
# Media has been marked as downloaded before the download_media task was fired,
|
||||||
|
# skip it
|
||||||
|
log.warn(f'Download task triggered for media: {media} (UUID: {media.pk}) but '
|
||||||
|
f'it has already been marked as downloaded, not downloading again')
|
||||||
|
return
|
||||||
|
if not media.source.download_media:
|
||||||
|
log.warn(f'Download task triggered for media: {media} (UUID: {media.pk}) but '
|
||||||
|
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')
|
||||||
|
return
|
||||||
filepath = media.filepath
|
filepath = media.filepath
|
||||||
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{filepath}"')
|
log.info(f'Downloading media: {media} (UUID: {media.pk}) to: "{filepath}"')
|
||||||
format_str, container = media.download_media()
|
format_str, container = media.download_media()
|
||||||
@@ -306,7 +366,7 @@ def download_media(media_id):
|
|||||||
media.downloaded_audio_codec = cformat['acodec']
|
media.downloaded_audio_codec = cformat['acodec']
|
||||||
if cformat['vcodec']:
|
if cformat['vcodec']:
|
||||||
# Combined
|
# Combined
|
||||||
media.downloaded_format = vformat['format']
|
media.downloaded_format = cformat['format']
|
||||||
media.downloaded_height = cformat['height']
|
media.downloaded_height = cformat['height']
|
||||||
media.downloaded_width = cformat['width']
|
media.downloaded_width = cformat['width']
|
||||||
media.downloaded_video_codec = cformat['vcodec']
|
media.downloaded_video_codec = cformat['vcodec']
|
||||||
@@ -317,11 +377,13 @@ def download_media(media_id):
|
|||||||
media.save()
|
media.save()
|
||||||
# If selected, copy the thumbnail over as well
|
# If selected, copy the thumbnail over as well
|
||||||
if media.source.copy_thumbnails and media.thumb:
|
if media.source.copy_thumbnails and media.thumb:
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
|
||||||
log.info(f'Copying media thumbnail from: {media.thumb.path} '
|
log.info(f'Copying media thumbnail from: {media.thumb.path} '
|
||||||
f'to: {thumbpath}')
|
f'to: {media.thumbpath}')
|
||||||
copyfile(media.thumb.path, thumbpath)
|
copyfile(media.thumb.path, media.thumbpath)
|
||||||
|
# If selected, write an NFO file
|
||||||
|
if media.source.write_nfo:
|
||||||
|
log.info(f'Writing media NFO file to: to: {media.nfopath}')
|
||||||
|
write_text_file(media.nfopath, media.nfoxml)
|
||||||
# Schedule a task to update media servers
|
# Schedule a task to update media servers
|
||||||
for mediaserver in MediaServer.objects.all():
|
for mediaserver in MediaServer.objects.all():
|
||||||
log.info(f'Scheduling media server updates')
|
log.info(f'Scheduling media server updates')
|
||||||
|
|||||||
@@ -11,18 +11,28 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{yyyymmdd}</td>
|
<td>{yyyymmdd}</td>
|
||||||
<td>Media publish date in YYYYMMDD</td>
|
<td>Media publish date in YYYYMMDD</td>
|
||||||
<td>20210101</td>
|
<td>20210131</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{yyyy_mm_dd}</td>
|
<td>{yyyy_mm_dd}</td>
|
||||||
<td>Media publish date in YYYY-MM-DD</td>
|
<td>Media publish date in YYYY-MM-DD</td>
|
||||||
<td>2021-01-01</td>
|
<td>2021-01-31</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{yyyy}</td>
|
<td>{yyyy}</td>
|
||||||
<td>Media publish year in YYYY</td>
|
<td>Media publish year in YYYY</td>
|
||||||
<td>2021</td>
|
<td>2021</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{mm}</td>
|
||||||
|
<td>Media publish month in MM</td>
|
||||||
|
<td>01</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{dd}</td>
|
||||||
|
<td>Media publish day in DD</td>
|
||||||
|
<td>31</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{source}</td>
|
<td>{source}</td>
|
||||||
<td>Lower case source name, max 80 chars</td>
|
<td>Lower case source name, max 80 chars</td>
|
||||||
@@ -53,6 +63,11 @@
|
|||||||
<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_title}</td>
|
||||||
|
<td>Playlist title of media, if it's in a playlist</td>
|
||||||
|
<td>Some Playlist</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ext}</td>
|
<td>{ext}</td>
|
||||||
<td>File extension</td>
|
<td>File extension</td>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
<div class="collection">
|
<div class="collection">
|
||||||
{% for media in latest_downloads %}
|
{% for media in latest_downloads %}
|
||||||
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
||||||
<div class="truncate"><strong>{{ media.name }}</strong> ({{ media.source }})</div>
|
<div class="truncate"><strong>{{ media.name }}</strong></div>
|
||||||
<div class="truncate"><strong>{{ media.download_date|timesince:now }}</strong> ago from "{{ media.source.name }}"</div>
|
<div class="truncate"><strong>{{ media.download_date|timesince:now }}</strong> ago from "{{ media.source.name }}"</div>
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
{% for media in largest_downloads %}
|
{% for media in largest_downloads %}
|
||||||
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
<a href="{% url 'sync:media-item' pk=media.pk %}" class="collection-item">
|
||||||
<div class="truncate">{{ media.name }}</div>
|
<div class="truncate">{{ media.name }}</div>
|
||||||
<div class="truncate"><strong>{{ media.downloaded_filesize|filesizeformat }}</strong>{% if media.downloaded_format %} in {{ media.downloaded_format }}{% endif %}</div>
|
<div class="truncate"><strong>{{ media.downloaded_filesize|filesizeformat }}</strong>{% if media.downloaded_format %} in {{ media.downloaded_format }}{% endif %} from "{{ media.source.name }}"</div>
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="collection-item">No media has been downloaded.</span>
|
<span class="collection-item">No media has been downloaded.</span>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -64,8 +64,14 @@
|
|||||||
<td class="hide-on-small-only">Fallback</td>
|
<td class="hide-on-small-only">Fallback</td>
|
||||||
<td><span class="hide-on-med-and-up">Fallback<br></span><strong>{{ media.source.get_fallback_display }}</strong></td>
|
<td><span class="hide-on-med-and-up">Fallback<br></span><strong>{{ media.source.get_fallback_display }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if not media.source.download_media %}
|
||||||
|
<tr title="Is media marked to be downloaded at the source?">
|
||||||
|
<td class="hide-on-small-only">Source download?</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Source download?<br></span><strong>{% if media.source.download_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if media.skip %}
|
{% if media.skip %}
|
||||||
<tr title="Has the media been downloaded?">
|
<tr title="Is the media marked to be skipped?">
|
||||||
<td class="hide-on-small-only">Skipping?</td>
|
<td class="hide-on-small-only">Skipping?</td>
|
||||||
<td><span class="hide-on-med-and-up">Skipping?<br></span><strong>{% if media.skip %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
<td><span class="hide-on-med-and-up">Skipping?<br></span><strong>{% if media.skip %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -109,7 +115,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<tr title="Can the media be downloaded?">
|
<tr title="Can the media be downloaded?">
|
||||||
<td class="hide-on-small-only">Can download?</td>
|
<td class="hide-on-small-only">Can download?</td>
|
||||||
<td><span class="hide-on-med-and-up">Can download?<br></span><strong>{% if youtube_dl_format %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
<td><span class="hide-on-med-and-up">Can download?<br></span><strong>{% if media.can_download %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr title="The available media formats">
|
<tr title="The available media formats">
|
||||||
|
|||||||
@@ -4,9 +4,23 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12 m6">
|
||||||
<h1 class="truncate">Media</h1>
|
<h1 class="truncate">Media</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col s12 m3">
|
||||||
|
{% if show_skipped %}
|
||||||
|
<a href="{% url 'sync:media' %}{% if source %}?filter={{ source.pk }}{% endif %}" class="btn"><i class="far fa-eye-slash"></i> Hide skipped media</a>
|
||||||
|
{% else %}
|
||||||
|
<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 %}
|
||||||
|
</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">
|
||||||
@@ -24,8 +38,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% if m.skip %}
|
{% if m.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> Skipped</span>
|
||||||
|
{% elif not m.source.download_media %}
|
||||||
|
<span class="error-text"><i class="fas fa-times" title="Not downloading media for this source"></i> Disabled at source</span>
|
||||||
|
{% elif not m.has_metadata %}
|
||||||
|
<i class="far fa-clock" title="Waiting for metadata"></i> Fetching metadata
|
||||||
{% elif m.can_download %}
|
{% elif m.can_download %}
|
||||||
<i class="far fa-clock" title="Waiting to download or downloading"></i> {{ m.published|date:'Y-m-d' }}
|
<i class="far fa-clock" title="Waiting to download or downloading"></i> Downloading
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="error-text"><i class="fas fa-exclamation-triangle" title="No matching formats to download"></i> No matching formats</span>
|
<span class="error-text"><i class="fas fa-exclamation-triangle" title="No matching formats to download"></i> No matching formats</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -44,5 +62,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk %}
|
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
{% include 'mediaformatvars.html' %}
|
{% include 'sync/_mediaformatvars.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
{% include 'mediaformatvars.html' %}
|
{% include 'sync/_mediaformatvars.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -51,10 +51,20 @@
|
|||||||
<td class="hide-on-small-only">Example filename</td>
|
<td class="hide-on-small-only">Example filename</td>
|
||||||
<td><span class="hide-on-med-and-up">Example filename<br></span><strong>{{ source.get_example_media_format }}</strong></td>
|
<td><span class="hide-on-med-and-up">Example filename<br></span><strong>{{ source.get_example_media_format }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if source.download_cap > 0 %}
|
||||||
|
<tr title="Do not download videos older than this cap">
|
||||||
|
<td class="hide-on-small-only">Download cap</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Download cap<br></span><strong>{{ source.get_download_cap_display }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
<tr title="Schedule of how often to index the source for new media">
|
<tr title="Schedule of how often to index the source for new media">
|
||||||
<td class="hide-on-small-only">Index schedule</td>
|
<td class="hide-on-small-only">Index schedule</td>
|
||||||
<td><span class="hide-on-med-and-up">Index schedule<br></span><strong>{{ source.get_index_schedule_display }}</strong></td>
|
<td><span class="hide-on-med-and-up">Index schedule<br></span><strong>{{ source.get_index_schedule_display }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Download media from this source">
|
||||||
|
<td class="hide-on-small-only">Download media?</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Download media?<br></span><strong>{% if source.download_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
|
</tr>
|
||||||
<tr title="When then source was created locally in TubeSync">
|
<tr title="When then source was created locally in TubeSync">
|
||||||
<td class="hide-on-small-only">Created</td>
|
<td class="hide-on-small-only">Created</td>
|
||||||
<td><span class="hide-on-med-and-up">Created<br></span><strong>{{ source.created|date:'Y-m-d H:i:s' }}</strong></td>
|
<td><span class="hide-on-med-and-up">Created<br></span><strong>{{ source.created|date:'Y-m-d H:i:s' }}</strong></td>
|
||||||
@@ -97,6 +107,14 @@
|
|||||||
<td class="hide-on-small-only">Copy thumbnails?</td>
|
<td class="hide-on-small-only">Copy thumbnails?</td>
|
||||||
<td><span class="hide-on-med-and-up">Copy thumbnails?<br></span><strong>{% if source.copy_thumbnails %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
<td><span class="hide-on-med-and-up">Copy thumbnails?<br></span><strong>{% if source.copy_thumbnails %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Should an NFO file be written with the media?">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -10,10 +10,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% include 'infobox.html' with message=message %}
|
{% include 'infobox.html' with message=message %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12 l6 margin-bottom">
|
<div class="col m12 xl4 margin-bottom">
|
||||||
<a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a>
|
<a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col s12 l6 margin-bottom">
|
<div class="col m12 xl4 margin-bottom">
|
||||||
|
<a href="{% url 'sync:validate-source' source_type='youtube-channel-id' %}" class="btn">Add a YouTube channel by ID <i class="fab fa-youtube"></i></a>
|
||||||
|
</div>
|
||||||
|
<div class="col m12 xl4 margin-bottom">
|
||||||
<a href="{% url 'sync:validate-source' source_type='youtube-playlist' %}" class="btn">Add a YouTube playlist <i class="fab fa-youtube"></i></a>
|
<a href="{% url 'sync:validate-source' source_type='youtube-playlist' %}" class="btn">Add a YouTube playlist <i class="fab fa-youtube"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +30,7 @@
|
|||||||
{% if source.has_failed %}
|
{% if source.has_failed %}
|
||||||
<span class="error-text"><i class="fas fa-exclamation-triangle"></i> <strong>Source has permanent failures</strong></span>
|
<span class="error-text"><i class="fas fa-exclamation-triangle"></i> <strong>Source has permanent failures</strong></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<strong>{{ source.media_count }}</strong> media items{% if source.delete_old_media and source.days_to_keep > 0 %}, keep {{ 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>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
16
tubesync/sync/testdata/metadata.json
vendored
@@ -3,15 +3,23 @@
|
|||||||
"upload_date":"20170911",
|
"upload_date":"20170911",
|
||||||
"license":null,
|
"license":null,
|
||||||
"creator":null,
|
"creator":null,
|
||||||
"title":"no fancy stuff",
|
"title":"no fancy stuff title",
|
||||||
"alt_title":null,
|
"alt_title":null,
|
||||||
"description":"no fancy stuff",
|
"description":"no fancy stuff desc",
|
||||||
"categories":[],
|
"average_rating": 1.2345,
|
||||||
|
"dislike_count": 123,
|
||||||
|
"like_count": 456,
|
||||||
|
"playlist_title": "test playlist",
|
||||||
|
"uploader": "test uploader",
|
||||||
|
"categories":[
|
||||||
|
"test category 1",
|
||||||
|
"test category 2"
|
||||||
|
],
|
||||||
"tags":[],
|
"tags":[],
|
||||||
"subtitles":{},
|
"subtitles":{},
|
||||||
"automatic_captions":{},
|
"automatic_captions":{},
|
||||||
"duration":401.0,
|
"duration":401.0,
|
||||||
"age_limit":0,
|
"age_limit":50,
|
||||||
"annotations":null,
|
"annotations":null,
|
||||||
"chapters":null,
|
"chapters":null,
|
||||||
"formats":[
|
"formats":[
|
||||||
|
|||||||
5026
tubesync/sync/testdata/metadata_low_formats.json
vendored
Normal file
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
from xml.etree import ElementTree
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -28,6 +30,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
def test_validate_source(self):
|
def test_validate_source(self):
|
||||||
test_source_types = {
|
test_source_types = {
|
||||||
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
||||||
|
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
|
||||||
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
|
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
|
||||||
}
|
}
|
||||||
test_sources = {
|
test_sources = {
|
||||||
@@ -36,8 +39,6 @@ class FrontEndTestCase(TestCase):
|
|||||||
'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',
|
||||||
'https://www.youtube.com/channel/testchannel',
|
|
||||||
'https://www.youtube.com/channel/testchannel/videos',
|
|
||||||
),
|
),
|
||||||
'invalid_schema': (
|
'invalid_schema': (
|
||||||
'http://www.youtube.com/c/playlist',
|
'http://www.youtube.com/c/playlist',
|
||||||
@@ -53,13 +54,37 @@ class FrontEndTestCase(TestCase):
|
|||||||
),
|
),
|
||||||
'invalid_is_playlist': (
|
'invalid_is_playlist': (
|
||||||
'https://www.youtube.com/c/playlist',
|
'https://www.youtube.com/c/playlist',
|
||||||
'https://www.youtube.com/c/playlist',
|
),
|
||||||
|
'invalid_channel_with_id': (
|
||||||
|
'https://www.youtube.com/channel/channelid',
|
||||||
|
'https://www.youtube.com/channel/channelid/videos',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
'youtube-channel-id': {
|
||||||
|
'valid': (
|
||||||
|
'https://www.youtube.com/channel/channelid',
|
||||||
|
'https://www.youtube.com/channel/channelid/videos',
|
||||||
|
),
|
||||||
|
'invalid_schema': (
|
||||||
|
'http://www.youtube.com/channel/channelid',
|
||||||
|
'ftp://www.youtube.com/channel/channelid',
|
||||||
|
),
|
||||||
|
'invalid_domain': (
|
||||||
|
'https://www.test.com/channel/channelid',
|
||||||
|
'https://www.example.com/channel/channelid',
|
||||||
|
),
|
||||||
|
'invalid_path': (
|
||||||
|
'https://www.youtube.com/test/invalid',
|
||||||
|
'https://www.youtube.com/channel/test/invalid',
|
||||||
|
),
|
||||||
|
'invalid_is_named_channel': (
|
||||||
|
'https://www.youtube.com/c/testname',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
'youtube-playlist': {
|
'youtube-playlist': {
|
||||||
'valid': (
|
'valid': (
|
||||||
'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',
|
||||||
),
|
),
|
||||||
'invalid_schema': (
|
'invalid_schema': (
|
||||||
'http://www.youtube.com/playlist?list=testplaylist',
|
'http://www.youtube.com/playlist?list=testplaylist',
|
||||||
@@ -76,6 +101,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'invalid_is_channel': (
|
'invalid_is_channel': (
|
||||||
'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/channel/testchannel',
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,19 +112,21 @@ class FrontEndTestCase(TestCase):
|
|||||||
response = c.get('/source-validate/invalid')
|
response = c.get('/source-validate/invalid')
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
for (source_type, tests) in test_sources.items():
|
for (source_type, tests) in test_sources.items():
|
||||||
for test, field in tests.items():
|
for test, urls in tests.items():
|
||||||
source_type_char = test_source_types.get(source_type)
|
for url in urls:
|
||||||
data = {'source_url': field, 'source_type': source_type_char}
|
source_type_char = test_source_types.get(source_type)
|
||||||
response = c.post(f'/source-validate/{source_type}', data)
|
data = {'source_url': url, 'source_type': source_type_char}
|
||||||
if test == 'valid':
|
response = c.post(f'/source-validate/{source_type}', data)
|
||||||
# Valid source tests should bounce to /source-add
|
if test == 'valid':
|
||||||
self.assertEqual(response.status_code, 302)
|
# Valid source tests should bounce to /source-add
|
||||||
url_parts = urlsplit(response.url)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(url_parts.path, '/source-add')
|
url_parts = urlsplit(response.url)
|
||||||
else:
|
self.assertEqual(url_parts.path, '/source-add')
|
||||||
# Invalid source tests should reload the page with an error message
|
else:
|
||||||
self.assertEqual(response.status_code, 200)
|
# Invalid source tests should reload the page with an error
|
||||||
self.assertIn('<ul class="errorlist">', response.content.decode())
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('<ul class="errorlist">',
|
||||||
|
response.content.decode())
|
||||||
|
|
||||||
def test_add_source_prepopulation(self):
|
def test_add_source_prepopulation(self):
|
||||||
c = Client()
|
c = Client()
|
||||||
@@ -135,6 +163,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'name': 'testname',
|
'name': 'testname',
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
'download_cap': 0,
|
||||||
'index_schedule': 3600,
|
'index_schedule': 3600,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -175,6 +204,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'name': 'testname',
|
'name': 'testname',
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
'download_cap': 0,
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
'index_schedule': Source.IndexSchedule.EVERY_HOUR,
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -203,6 +233,7 @@ class FrontEndTestCase(TestCase):
|
|||||||
'name': 'testname',
|
'name': 'testname',
|
||||||
'directory': 'testdirectory',
|
'directory': 'testdirectory',
|
||||||
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
'download_cap': 0,
|
||||||
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
|
||||||
'delete_old_media': False,
|
'delete_old_media': False,
|
||||||
'days_to_keep': 14,
|
'days_to_keep': 14,
|
||||||
@@ -313,21 +344,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)
|
||||||
@@ -404,7 +439,6 @@ class FrontEndTestCase(TestCase):
|
|||||||
response = c.get('/tasks-completed')
|
response = c.get('/tasks-completed')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
def test_mediasevrers(self):
|
def test_mediasevrers(self):
|
||||||
# Media servers overview page
|
# Media servers overview page
|
||||||
c = Client()
|
c = Client()
|
||||||
@@ -457,7 +491,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 = ''
|
||||||
@@ -481,6 +515,12 @@ class FilepathTestCase(TestCase):
|
|||||||
self.source.media_format = 'test-{yyyy}'
|
self.source.media_format = 'test-{yyyy}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-' + timezone.now().strftime('%Y'))
|
'test-' + timezone.now().strftime('%Y'))
|
||||||
|
self.source.media_format = 'test-{mm}'
|
||||||
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
|
'test-' + timezone.now().strftime('%m'))
|
||||||
|
self.source.media_format = 'test-{dd}'
|
||||||
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
|
'test-' + timezone.now().strftime('%d'))
|
||||||
self.source.media_format = 'test-{source}'
|
self.source.media_format = 'test-{source}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-' + self.source.slugname)
|
'test-' + self.source.slugname)
|
||||||
@@ -499,6 +539,9 @@ 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_title}'
|
||||||
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
|
'test-Some Playlist Title')
|
||||||
self.source.media_format = 'test-{ext}'
|
self.source.media_format = 'test-{ext}'
|
||||||
self.assertEqual(self.source.get_example_media_format(),
|
self.assertEqual(self.source.get_example_media_format(),
|
||||||
'test-' + self.source.extension)
|
'test-' + self.source.extension)
|
||||||
@@ -556,7 +599,79 @@ class FilepathTestCase(TestCase):
|
|||||||
self.source.media_format = ('{title}_{key}_{resolution}-{height}x{width}-'
|
self.source.media_format = ('{title}_{key}_{resolution}-{height}x{width}-'
|
||||||
'{acodec}-{vcodec}-{fps}fps-{hdr}.{ext}')
|
'{acodec}-{vcodec}-{fps}fps-{hdr}.{ext}')
|
||||||
self.assertEqual(test_media.filename,
|
self.assertEqual(test_media.filename,
|
||||||
'no-fancy-stuff_test_720p-720x1280-opus-vp9-30fps-hdr.mkv')
|
('no-fancy-stuff-title_test_720p-720x1280-opus'
|
||||||
|
'-vp9-30fps-hdr.mkv'))
|
||||||
|
|
||||||
|
|
||||||
|
class MediaTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Disable general logging for test case
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
# Add a test source
|
||||||
|
self.source = Source.objects.create(
|
||||||
|
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
||||||
|
key='testkey',
|
||||||
|
name='testname',
|
||||||
|
directory='testdirectory',
|
||||||
|
media_format=settings.MEDIA_FORMATSTR_DEFAULT,
|
||||||
|
index_schedule=3600,
|
||||||
|
delete_old_media=False,
|
||||||
|
days_to_keep=14,
|
||||||
|
source_resolution=Source.SOURCE_RESOLUTION_1080P,
|
||||||
|
source_vcodec=Source.SOURCE_VCODEC_VP9,
|
||||||
|
source_acodec=Source.SOURCE_ACODEC_OPUS,
|
||||||
|
prefer_60fps=False,
|
||||||
|
prefer_hdr=False,
|
||||||
|
fallback=Source.FALLBACK_FAIL
|
||||||
|
)
|
||||||
|
# Add some test media
|
||||||
|
self.media = Media.objects.create(
|
||||||
|
key='mediakey',
|
||||||
|
source=self.source,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
# Fix a created datetime for predictable testing
|
||||||
|
self.media.created = datetime(year=2020, month=1, day=1, hour=1,
|
||||||
|
minute=1, second=1)
|
||||||
|
|
||||||
|
def test_nfo(self):
|
||||||
|
expected_nfo = [
|
||||||
|
"<?xml version='1.0' encoding='utf8'?>",
|
||||||
|
'<episodedetails>',
|
||||||
|
' <title>no fancy stuff title</title>',
|
||||||
|
' <showtitle>testname</showtitle>',
|
||||||
|
' <ratings>',
|
||||||
|
' <rating default="True" max="5" name="youtube">',
|
||||||
|
' <value>1.2345</value>',
|
||||||
|
' <votes>579</votes>',
|
||||||
|
' </rating>',
|
||||||
|
' </ratings>',
|
||||||
|
' <plot>no fancy stuff desc</plot>',
|
||||||
|
' <thumb />', # media.thumbfile is empty without media existing
|
||||||
|
' <mpaa>50</mpaa>',
|
||||||
|
' <runtime>401</runtime>',
|
||||||
|
' <id>mediakey</id>',
|
||||||
|
' <uniqueid default="True" type="youtube">mediakey</uniqueid>',
|
||||||
|
' <studio>test uploader</studio>',
|
||||||
|
' <aired>2017-09-11</aired>',
|
||||||
|
' <dateadded>2020-01-01 01:01:01</dateadded>',
|
||||||
|
' <genre>test category 1</genre>',
|
||||||
|
' <genre>test category 2</genre>',
|
||||||
|
'</episodedetails>',
|
||||||
|
]
|
||||||
|
expected_tree = ElementTree.fromstring('\n'.join(expected_nfo))
|
||||||
|
nfo_tree = ElementTree.fromstring(self.media.nfoxml)
|
||||||
|
# Check each node with attribs in expected_tree is present in test_nfo
|
||||||
|
for expected_node in expected_tree:
|
||||||
|
# Ignore checking <genre>, only tag we may have multiple of
|
||||||
|
if expected_node.tag == 'genre':
|
||||||
|
continue
|
||||||
|
# Find the same node in the NFO XML tree
|
||||||
|
nfo_node = nfo_tree.find(expected_node.tag)
|
||||||
|
self.assertEqual(expected_node.attrib, nfo_node.attrib)
|
||||||
|
self.assertEqual(expected_node.tag, nfo_node.tag)
|
||||||
|
self.assertEqual(expected_node.text, nfo_node.text)
|
||||||
|
|
||||||
|
|
||||||
class FormatMatchingTestCase(TestCase):
|
class FormatMatchingTestCase(TestCase):
|
||||||
@@ -1047,14 +1162,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
|
||||||
@@ -1253,14 +1368,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
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ def file_is_editable(filepath):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def write_text_file(filepath, filedata):
|
||||||
|
if not isinstance(filedata, str):
|
||||||
|
raise ValueError(f'filedata must be a str, got "{type(filedata)}"')
|
||||||
|
with open(filepath, 'wt') as f:
|
||||||
|
bytes_written = f.write(filedata)
|
||||||
|
return bytes_written
|
||||||
|
|
||||||
|
|
||||||
def delete_file(filepath):
|
def delete_file(filepath):
|
||||||
if file_is_editable(filepath):
|
if file_is_editable(filepath):
|
||||||
return os.remove(filepath)
|
return os.remove(filepath)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.views.generic.detail import SingleObjectMixin
|
|||||||
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
|
from django.db.models import Q, Count, Sum, When, Case
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -78,6 +78,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
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +105,10 @@ class SourcesView(ListView):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
all_sources = Source.objects.all().order_by('name')
|
all_sources = Source.objects.all().order_by('name')
|
||||||
return all_sources.annotate(media_count=Count('media_source'))
|
return all_sources.annotate(
|
||||||
|
media_count=Count('media_source'),
|
||||||
|
downloaded_count=Count(Case(When(media_source__downloaded=True, then=1)))
|
||||||
|
)
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
data = super().get_context_data(*args, **kwargs)
|
data = super().get_context_data(*args, **kwargs)
|
||||||
@@ -128,10 +132,12 @@ class ValidateSourceView(FormView):
|
|||||||
}
|
}
|
||||||
source_types = {
|
source_types = {
|
||||||
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
|
||||||
|
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
|
||||||
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
|
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
|
||||||
}
|
}
|
||||||
help_item = {
|
help_item = {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'),
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'),
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _('YouTube channel ID'),
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'),
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'),
|
||||||
}
|
}
|
||||||
help_texts = {
|
help_texts = {
|
||||||
@@ -141,6 +147,13 @@ class ValidateSourceView(FormView):
|
|||||||
'where <strong>CHANNELNAME</strong> is the name of the channel you want '
|
'where <strong>CHANNELNAME</strong> is the name of the channel you want '
|
||||||
'to add.'
|
'to add.'
|
||||||
),
|
),
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _(
|
||||||
|
'Enter a YouTube channel URL by channel ID into the box below. A channel '
|
||||||
|
'URL by channel ID will be in the format of <strong>'
|
||||||
|
'https://www.youtube.com/channel/BiGLoNgUnIqUeId</strong> '
|
||||||
|
'where <strong>BiGLoNgUnIqUeId</strong> is the ID of the channel you want '
|
||||||
|
'to add.'
|
||||||
|
),
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _(
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _(
|
||||||
'Enter a YouTube playlist URL into the box below. A playlist URL will be '
|
'Enter a YouTube playlist URL into the box below. A playlist URL will be '
|
||||||
'in the format of <strong>https://www.youtube.com/playlist?list='
|
'in the format of <strong>https://www.youtube.com/playlist?list='
|
||||||
@@ -150,6 +163,8 @@ class ValidateSourceView(FormView):
|
|||||||
}
|
}
|
||||||
help_examples = {
|
help_examples = {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/google',
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/google',
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('https://www.youtube.com/channel/'
|
||||||
|
'UCK8sQmJBp8GCxrOtXWBpyEA'),
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('https://www.youtube.com/playlist?list='
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('https://www.youtube.com/playlist?list='
|
||||||
'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r')
|
'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r')
|
||||||
}
|
}
|
||||||
@@ -157,12 +172,21 @@ class ValidateSourceView(FormView):
|
|||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: {
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: {
|
||||||
'scheme': 'https',
|
'scheme': 'https',
|
||||||
'domain': 'www.youtube.com',
|
'domain': 'www.youtube.com',
|
||||||
'path_regex': '^\/(c\/|channel\/)?([^\/]+)(\/videos)?$',
|
'path_regex': '^\/(c\/)?([^\/]+)(\/videos)?$',
|
||||||
'path_must_not_match': ('/playlist', '/c/playlist'),
|
'path_must_not_match': ('/playlist', '/c/playlist'),
|
||||||
'qs_args': [],
|
'qs_args': [],
|
||||||
'extract_key': ('path_regex', 1),
|
'extract_key': ('path_regex', 1),
|
||||||
'example': 'https://www.youtube.com/SOMECHANNEL'
|
'example': 'https://www.youtube.com/SOMECHANNEL'
|
||||||
},
|
},
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: {
|
||||||
|
'scheme': 'https',
|
||||||
|
'domain': 'www.youtube.com',
|
||||||
|
'path_regex': '^\/channel\/([^\/]+)(\/videos)?$',
|
||||||
|
'path_must_not_match': ('/playlist', '/c/playlist'),
|
||||||
|
'qs_args': [],
|
||||||
|
'extract_key': ('path_regex', 0),
|
||||||
|
'example': 'https://www.youtube.com/channel/CHANNELID'
|
||||||
|
},
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: {
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: {
|
||||||
'scheme': 'https',
|
'scheme': 'https',
|
||||||
'domain': 'www.youtube.com',
|
'domain': 'www.youtube.com',
|
||||||
@@ -175,6 +199,7 @@ class ValidateSourceView(FormView):
|
|||||||
}
|
}
|
||||||
prepopulate_fields = {
|
prepopulate_fields = {
|
||||||
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'),
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'),
|
||||||
|
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('source_type', 'key'),
|
||||||
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'),
|
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,9 +278,9 @@ class AddSourceView(CreateView):
|
|||||||
template_name = 'sync/source-add.html'
|
template_name = 'sync/source-add.html'
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||||
'index_schedule', 'delete_old_media', 'days_to_keep',
|
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||||
'prefer_hdr', 'fallback', 'copy_thumbnails')
|
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo', 'write_json')
|
||||||
errors = {
|
errors = {
|
||||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||||
'errors or is empty. Check the table at the end of '
|
'errors or is empty. Check the table at the end of '
|
||||||
@@ -313,8 +338,8 @@ class SourceView(DetailView):
|
|||||||
messages = {
|
messages = {
|
||||||
'source-created': _('Your new source has been created. If you have added a '
|
'source-created': _('Your new source has been created. If you have added a '
|
||||||
'very large source such as a channel with hundreds of '
|
'very large source such as a channel with hundreds of '
|
||||||
'videos it can take several minutes for media to start '
|
'videos it can take several minutes or up to an hour '
|
||||||
'to appear.'),
|
'for media to start to appear.'),
|
||||||
'source-updated': _('Your source has been updated.'),
|
'source-updated': _('Your source has been updated.'),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,9 +369,9 @@ class UpdateSourceView(UpdateView):
|
|||||||
template_name = 'sync/source-update.html'
|
template_name = 'sync/source-update.html'
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
||||||
'index_schedule', 'delete_old_media', 'days_to_keep',
|
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||||
'source_resolution', 'source_vcodec', 'source_acodec', 'prefer_60fps',
|
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||||
'prefer_hdr', 'fallback', 'copy_thumbnails')
|
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo', 'write_json')
|
||||||
errors = {
|
errors = {
|
||||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||||
'errors or is empty. Check the table at the end of '
|
'errors or is empty. Check the table at the end of '
|
||||||
@@ -390,7 +415,14 @@ class DeleteSourceView(DeleteView, FormMixin):
|
|||||||
source = self.get_object()
|
source = self.get_object()
|
||||||
for media in Media.objects.filter(source=source):
|
for media in Media.objects.filter(source=source):
|
||||||
if media.media_file:
|
if media.media_file:
|
||||||
|
# Delete the media file
|
||||||
delete_file(media.media_file.name)
|
delete_file(media.media_file.name)
|
||||||
|
# Delete thumbnail copy if it exists
|
||||||
|
delete_file(media.thumbpath)
|
||||||
|
# Delete NFO file if it exists
|
||||||
|
delete_file(media.nfopath)
|
||||||
|
# Delete JSON file if it exists
|
||||||
|
delete_file(media.jsonpath)
|
||||||
return super().post(request, *args, **kwargs)
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
@@ -412,6 +444,8 @@ 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.only_skipped = False
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
@@ -421,13 +455,30 @@ class MediaView(ListView):
|
|||||||
self.filter_source = Source.objects.get(pk=filter_by)
|
self.filter_source = Source.objects.get(pk=filter_by)
|
||||||
except Source.DoesNotExist:
|
except Source.DoesNotExist:
|
||||||
self.filter_source = None
|
self.filter_source = None
|
||||||
|
show_skipped = request.GET.get('show_skipped', '').strip()
|
||||||
|
if show_skipped == 'yes':
|
||||||
|
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:
|
||||||
q = Media.objects.filter(source=self.filter_source)
|
if self.show_skipped:
|
||||||
|
q = Media.objects.filter(source=self.filter_source)
|
||||||
|
elif self.only_skipped:
|
||||||
|
q = Media.objects.filter(source=self.filter_source, skip=True)
|
||||||
|
else:
|
||||||
|
q = Media.objects.filter(source=self.filter_source, skip=False)
|
||||||
else:
|
else:
|
||||||
q = Media.objects.all()
|
if self.show_skipped:
|
||||||
|
q = Media.objects.all()
|
||||||
|
elif self.only_skipped:
|
||||||
|
q = Media.objects.filter(skip=True)
|
||||||
|
else:
|
||||||
|
q = Media.objects.filter(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):
|
||||||
@@ -438,6 +489,8 @@ class MediaView(ListView):
|
|||||||
message = str(self.messages.get('filter', ''))
|
message = str(self.messages.get('filter', ''))
|
||||||
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['only_skipped'] = self.only_skipped
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -535,13 +588,12 @@ class MediaRedownloadView(FormView, SingleObjectMixin):
|
|||||||
self.object.thumb = None
|
self.object.thumb = None
|
||||||
# If the media file exists on disk, delete it
|
# If the media file exists on disk, delete it
|
||||||
if self.object.media_file_exists:
|
if self.object.media_file_exists:
|
||||||
filepath = self.object.media_file.path
|
delete_file(self.object.media_file.path)
|
||||||
delete_file(filepath)
|
|
||||||
self.object.media_file = None
|
self.object.media_file = None
|
||||||
# If the media has an associated thumbnail copied, also delete it
|
# If the media has an associated thumbnail copied, also delete it
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
delete_file(self.object.thumbpath)
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
# If the media has an associated NFO file with it, also delete it
|
||||||
delete_file(thumbpath)
|
delete_file(self.object.nfopath)
|
||||||
# Reset all download data
|
# Reset all download data
|
||||||
self.object.downloaded = False
|
self.object.downloaded = False
|
||||||
self.object.downloaded_audio_codec = None
|
self.object.downloaded_audio_codec = None
|
||||||
@@ -581,14 +633,14 @@ class MediaSkipView(FormView, SingleObjectMixin):
|
|||||||
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
|
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
|
||||||
# If the media file exists on disk, delete it
|
# If the media file exists on disk, delete it
|
||||||
if self.object.media_file_exists:
|
if self.object.media_file_exists:
|
||||||
filepath = self.object.media_file.path
|
|
||||||
delete_file(self.object.media_file.path)
|
delete_file(self.object.media_file.path)
|
||||||
self.object.media_file = None
|
self.object.media_file = None
|
||||||
# If the media has an associated thumbnail copied, also delete it
|
# If the media has an associated thumbnail copied, also delete it
|
||||||
barefilepath, fileext = os.path.splitext(filepath)
|
delete_file(self.object.thumbpath)
|
||||||
thumbpath = f'{barefilepath}.jpg'
|
# If the media has an associated NFO file with it, also delete it
|
||||||
delete_file(thumbpath)
|
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
|
||||||
@@ -987,7 +1039,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
|
|||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
data = super().get_context_data(*args, **kwargs)
|
data = super().get_context_data(*args, **kwargs)
|
||||||
data['server_help'] = self.object.help_html
|
data['server_help'] = self.object.get_help_html
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
|
|||||||
@@ -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,36 +19,52 @@ 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,
|
||||||
'simulate': True,
|
'simulate': True,
|
||||||
'logger': log
|
'logger': log,
|
||||||
|
'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:
|
||||||
|
raise YouTubeError(f'Failed to extract_info for "{url}": No metadata was '
|
||||||
|
f'returned by youtube-dl, check for error messages in the '
|
||||||
|
f'logs above. This task will be retried later with an '
|
||||||
|
f'exponential backoff.')
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def download_media(url, media_format, extension, output_file):
|
def download_media(url, media_format, extension, output_file, info_json):
|
||||||
'''
|
'''
|
||||||
Downloads a YouTube URL to a file on disk.
|
Downloads a YouTube URL to a file on disk.
|
||||||
'''
|
'''
|
||||||
@@ -85,17 +101,18 @@ def download_media(url, media_format, extension, output_file):
|
|||||||
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
log.warn(f'[youtube-dl] unknown event: {str(event)}')
|
||||||
hook.download_progress = 0
|
hook.download_progress = 0
|
||||||
|
|
||||||
opts = copy(_defaults)
|
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
|
||||||
})
|
})
|
||||||
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
|
||||||
|
|||||||
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,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
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
|
||||||
@@ -12,22 +14,60 @@ DOWNLOADS_BASE_DIR = ROOT_DIR / 'downloads'
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
TIME_ZONE = os.getenv('TZ', 'UTC')
|
TIME_ZONE = os.getenv('TZ', 'UTC')
|
||||||
|
|
||||||
|
|
||||||
DATABASES = {
|
database_dict = {}
|
||||||
'default': {
|
database_connection_env = os.getenv('DATABASE_CONNECTION', '')
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
if database_connection_env:
|
||||||
'NAME': CONFIG_BASE_DIR / 'db.sqlite3',
|
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': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': CONFIG_BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DATABASE_CONNECTION_STR = f'sqlite at "{DATABASES["default"]["NAME"]}"'
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_THREADS = 1
|
||||||
|
MAX_BACKGROUND_TASK_ASYNC_THREADS = 8
|
||||||
|
BACKGROUND_TASK_ASYNC_THREADS = int(os.getenv('TUBESYNC_WORKERS', DEFAULT_THREADS))
|
||||||
|
if BACKGROUND_TASK_ASYNC_THREADS > MAX_BACKGROUND_TASK_ASYNC_THREADS:
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
BASICAUTH_USERNAME = os.getenv('HTTP_USER', '').strip()
|
||||||
|
BASICAUTH_PASSWORD = os.getenv('HTTP_PASS', '').strip()
|
||||||
|
if BASICAUTH_USERNAME and BASICAUTH_PASSWORD:
|
||||||
|
BASICAUTH_DISABLE = False
|
||||||
|
BASICAUTH_USERS = {
|
||||||
|
BASICAUTH_USERNAME: BASICAUTH_PASSWORD,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
BASICAUTH_DISABLE = True
|
||||||
|
BASICAUTH_USERS = {}
|
||||||
|
|||||||
@@ -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.4
|
VERSION = '0.11.0'
|
||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
@@ -37,10 +37,12 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'common.middleware.MaterializeDefaultFieldsMiddleware',
|
'common.middleware.MaterializeDefaultFieldsMiddleware',
|
||||||
|
'common.middleware.BasicAuthMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'tubesync.urls'
|
ROOT_URLCONF = 'tubesync.urls'
|
||||||
|
FORCE_SCRIPT_NAME = None
|
||||||
|
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
@@ -74,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',
|
||||||
@@ -113,20 +118,30 @@ Disallow: /
|
|||||||
'''.strip()
|
'''.strip()
|
||||||
|
|
||||||
|
|
||||||
|
X_FRAME_OPTIONS = 'SAMEORIGIN'
|
||||||
|
|
||||||
|
|
||||||
|
BASICAUTH_DISABLE = True
|
||||||
|
BASICAUTH_REALM = 'Authenticate to TubeSync'
|
||||||
|
BASICAUTH_ALWAYS_ALLOW_URIS = ('/healthcheck',)
|
||||||
|
BASICAUTH_USERS = {}
|
||||||
|
|
||||||
|
|
||||||
HEALTHCHECK_FIREWALL = True
|
HEALTHCHECK_FIREWALL = True
|
||||||
HEALTHCHECK_ALLOWED_IPS = ('127.0.0.1',)
|
HEALTHCHECK_ALLOWED_IPS = ('127.0.0.1',)
|
||||||
|
|
||||||
|
|
||||||
MAX_ATTEMPTS = 10 # Number of times tasks will be retried
|
MAX_ATTEMPTS = 15 # Number of times tasks will be retried
|
||||||
MAX_RUN_TIME = 1800 # Maximum amount of time in seconds a task can run
|
MAX_RUN_TIME = 1800 # Maximum amount of time in seconds a task can run
|
||||||
BACKGROUND_TASK_RUN_ASYNC = False # Run tasks async in the background
|
BACKGROUND_TASK_RUN_ASYNC = True # Run tasks async in the background
|
||||||
BACKGROUND_TASK_ASYNC_THREADS = 1 # Number of async tasks to run at once
|
BACKGROUND_TASK_ASYNC_THREADS = 1 # Number of async tasks to run at once
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
SOURCES_PER_PAGE = 100
|
SOURCES_PER_PAGE = 100
|
||||||
MEDIA_PER_PAGE = 72
|
MEDIA_PER_PAGE = 144
|
||||||
TASKS_PER_PAGE = 100
|
TASKS_PER_PAGE = 100
|
||||||
|
|
||||||
|
|
||||||
@@ -134,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'
|
||||||
|
|
||||||
|
|
||||||
@@ -144,10 +159,12 @@ 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 = '{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}'
|
MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -156,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()
|
||||||
|
|||||||