Compare commits
338 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68646e266 | ||
|
|
3c571429e7 | ||
|
|
0ba52fa38e | ||
|
|
19f0b7819e | ||
|
|
3381ca3805 | ||
|
|
72f2373c42 | ||
|
|
44f5f2eee0 | ||
|
|
13424d1bcf | ||
|
|
3631947234 | ||
|
|
aedeaf9a0a | ||
|
|
18f54a28c6 | ||
|
|
db8d8663eb | ||
|
|
a614a48108 | ||
|
|
4f5ac8d8a0 | ||
|
|
c7fbe6b484 | ||
|
|
8c54ed6c0a | ||
|
|
9ada8b5a91 | ||
|
|
a346ed69d9 | ||
|
|
3e663bd483 | ||
|
|
4b7230843e | ||
|
|
196d2d4e32 | ||
|
|
a194b9bf28 | ||
|
|
d6318fca0f | ||
|
|
4881fabb15 | ||
|
|
7a3c41b6bc | ||
|
|
473e45c55b | ||
|
|
4319be36f6 | ||
|
|
2efc09d587 | ||
|
|
acca33a7dc | ||
|
|
e61df14ec0 | ||
|
|
0482df2fec | ||
|
|
50dd2d336c | ||
|
|
6df1250a43 | ||
|
|
c70b3a46bd | ||
|
|
10c18ff5b9 | ||
|
|
9259119227 | ||
|
|
ab56a7d8c9 | ||
|
|
fa8ba1af34 | ||
|
|
1267dbaf6b | ||
|
|
39ed3d0361 | ||
|
|
cf90db3b4c | ||
|
|
ef8642ace7 | ||
|
|
434d3e981d | ||
|
|
376e439115 | ||
|
|
64fdcac0f7 | ||
|
|
8e14258c7a | ||
|
|
2e9684de5d | ||
|
|
ca7227ffc5 | ||
|
|
3b3c8120b8 | ||
|
|
88db357b7f | ||
|
|
854f5f4b42 | ||
|
|
d18145f1c5 | ||
|
|
3bd2a7bee7 | ||
|
|
f65cdddbfe | ||
|
|
2146e51368 | ||
|
|
98dd77f004 | ||
|
|
ee8218764f | ||
|
|
73b8d867a0 | ||
|
|
734ef1c53c | ||
|
|
cee3bcf48d | ||
|
|
d9dbbb18ea | ||
|
|
e7772b361e | ||
|
|
1d52ea61da | ||
|
|
34e99951fc | ||
|
|
56dbf6fc2d | ||
|
|
a0b50ac351 | ||
|
|
12eb4de83d | ||
|
|
d3a3b5d95f | ||
|
|
928ddd5b9a | ||
|
|
522e6a75ef | ||
|
|
9b3cc8b310 | ||
|
|
ac3c666d32 | ||
|
|
691e37d279 | ||
|
|
5951837ab9 | ||
|
|
71f9a7bda4 | ||
|
|
6cf2eb053c | ||
|
|
8f436a7afb | ||
|
|
b35ac1831a | ||
|
|
f2d7e59420 | ||
|
|
b438947b0d | ||
|
|
7ac7039592 | ||
|
|
2ab0db93dc | ||
|
|
cca97d9a57 | ||
|
|
285e661667 | ||
|
|
80316df952 | ||
|
|
11bd4204aa | ||
|
|
cf4386e1a5 | ||
|
|
084f5082ad | ||
|
|
fc6bc88e61 | ||
|
|
cb1c355e58 | ||
|
|
f33dc84c57 | ||
|
|
50253c650a | ||
|
|
4ee7537733 | ||
|
|
73410f2acd | ||
|
|
30a0c30d94 | ||
|
|
7d79e0e8b0 | ||
|
|
31786ad8d9 | ||
|
|
3c1567930f | ||
|
|
29777ff928 | ||
|
|
fdc08989ae | ||
|
|
c2cb5527f5 | ||
|
|
35f38d7cee | ||
|
|
c9534bdf11 | ||
|
|
510bc2df2b | ||
|
|
ed77ea508c | ||
|
|
716fce8941 | ||
|
|
03cabe6c70 | ||
|
|
6eaa15b37a | ||
|
|
e7dfee66b1 | ||
|
|
210712cee2 | ||
|
|
fc5cf84dd2 | ||
|
|
e644ea6357 | ||
|
|
fe7c18e6c6 | ||
|
|
00ce7efb32 | ||
|
|
3f6149aaf7 | ||
|
|
9d1bde7355 | ||
|
|
00404fc6d4 | ||
|
|
70648c2253 | ||
|
|
bb246cc6f5 | ||
|
|
e7fd60010b | ||
|
|
9337e81f62 | ||
|
|
182a2e97e6 | ||
|
|
360d2f3df8 | ||
|
|
8729f030c7 | ||
|
|
dbff4c6798 | ||
|
|
c70d5340f1 | ||
|
|
1786db46cc | ||
|
|
0eabc2197b | ||
|
|
890aae8a72 | ||
|
|
f9119f5a33 | ||
|
|
ae20b518f0 | ||
|
|
28ec496430 | ||
|
|
4620d1aaed | ||
|
|
2139e20332 | ||
|
|
1fa60c3909 | ||
|
|
c3132858e7 | ||
|
|
acf1de5c69 | ||
|
|
7992e1a1c3 | ||
|
|
684f3740a8 | ||
|
|
f77cb7cff8 | ||
|
|
c552c63274 | ||
|
|
1087596486 | ||
|
|
6553d0d957 | ||
|
|
846e89f58e | ||
|
|
dbf1f59efb | ||
|
|
ca03bf716b | ||
|
|
90ebf44ad4 | ||
|
|
7d8df4f087 | ||
|
|
235438af51 | ||
|
|
cdca27d4b9 | ||
|
|
4bea8a315a | ||
|
|
218832a493 | ||
|
|
4e60f4bc8a | ||
|
|
d41f9a6f41 | ||
|
|
295c74b985 | ||
|
|
980f39d52c | ||
|
|
670744890c | ||
|
|
932f51c583 | ||
|
|
58fc5573dc | ||
|
|
e7b48d237a | ||
|
|
2f435a3a64 | ||
|
|
ee2bddcb81 | ||
|
|
b462048679 | ||
|
|
0808a055af | ||
|
|
9a86f1358e | ||
|
|
ffbd82096c | ||
|
|
dd03673e52 | ||
|
|
705973219c | ||
|
|
764b837312 | ||
|
|
633fc7d4d2 | ||
|
|
850a1b2eca | ||
|
|
befc7df583 | ||
|
|
f2e9dc97f5 | ||
|
|
09de683300 | ||
|
|
d9c9a85939 | ||
|
|
f166c418f8 | ||
|
|
5e40e2f610 | ||
|
|
0c7ebb66ed | ||
|
|
84077349c7 | ||
|
|
a0fef50248 | ||
|
|
c36295d76f | ||
|
|
90e07ce71b | ||
|
|
19d5d60de1 | ||
|
|
a3fc7f6abb | ||
|
|
ee0942b37a | ||
|
|
ecf566df84 | ||
|
|
cebc72875b | ||
|
|
1cc6172862 | ||
|
|
5844290d74 | ||
|
|
0473c86fd6 | ||
|
|
c67c9912cc | ||
|
|
435882e16b | ||
|
|
14622ae317 | ||
|
|
abf4c5cffd | ||
|
|
f7c74c0a63 | ||
|
|
dfcbd2d23d | ||
|
|
b5c3bfddc8 | ||
|
|
ed6cdf1f92 | ||
|
|
d17aa6c29e | ||
|
|
651aa14882 | ||
|
|
d8ccbe00fa | ||
|
|
dd0633c2aa | ||
|
|
f88422e92b | ||
|
|
b1a05b1d79 | ||
|
|
2c5edc64f4 | ||
|
|
3383d5d5dc | ||
|
|
8659f74a3b | ||
|
|
240a920b6c | ||
|
|
dae141a6c2 | ||
|
|
da9f082978 | ||
|
|
fe0a1c5b70 | ||
|
|
98f25027bb | ||
|
|
41bca681e5 | ||
|
|
412ad9565d | ||
|
|
fe6fe42a65 | ||
|
|
7d6dc7ae76 | ||
|
|
fd4e755938 | ||
|
|
00c3aefff1 | ||
|
|
042379035a | ||
|
|
e551ad7848 | ||
|
|
66c349d41f | ||
|
|
c3eb7091af | ||
|
|
501455029c | ||
|
|
ce7b59a8be | ||
|
|
48feca72d3 | ||
|
|
5b7d9ddbde | ||
|
|
ea1fc59e88 | ||
|
|
8a10627d6a | ||
|
|
c33827c6f7 | ||
|
|
57d582e265 | ||
|
|
6da58254bc | ||
|
|
a09343938d | ||
|
|
f98acbaa42 | ||
|
|
a85ca93d5a | ||
|
|
d663cfd77a | ||
|
|
795943196e | ||
|
|
ea572ec4c5 | ||
|
|
d87995c4b0 | ||
|
|
be6b68efdd | ||
|
|
45e27003c1 | ||
|
|
072a45efba | ||
|
|
ad54d80077 | ||
|
|
e0a47e2fe3 | ||
|
|
48e653b70a | ||
|
|
55a298e0ff | ||
|
|
d37cc6120a | ||
|
|
3b49678272 | ||
|
|
044bad41e1 | ||
|
|
2b02cd1a12 | ||
|
|
b4de0d9eb4 | ||
|
|
81a4ef0f36 | ||
|
|
b912186502 | ||
|
|
345337a1a2 | ||
|
|
2435144cb1 | ||
|
|
c8e2fc5dc9 | ||
|
|
cf2ee306d2 | ||
|
|
1189d31f5c | ||
|
|
4a3f0316ea | ||
|
|
cf8704a2aa | ||
|
|
ff9fd6a061 | ||
|
|
ffc0b45bc9 | ||
|
|
163be08803 | ||
|
|
700847f4ad | ||
|
|
0a70c02b3e | ||
|
|
f74229fdc1 | ||
|
|
3c7157542d | ||
|
|
459dee77db | ||
|
|
d7fc66a63a | ||
|
|
d5ae882df0 | ||
|
|
6f717de345 | ||
|
|
c272fa903d | ||
|
|
a6b68ed3e4 | ||
|
|
9eb78ecdc0 | ||
|
|
750322dae4 | ||
|
|
b03d4ab42e | ||
|
|
a924ddb718 | ||
|
|
0f7e4effd8 | ||
|
|
1c5b4f344d | ||
|
|
8e91eb4ac7 | ||
|
|
67f3a24580 | ||
|
|
a21fbbc2cc | ||
|
|
3c9362d763 | ||
|
|
837de278b2 | ||
|
|
e1297f3799 | ||
|
|
5dec543169 | ||
|
|
b9421f442a | ||
|
|
f32bb266bd | ||
|
|
770d7c71b6 | ||
|
|
3b55c4d677 | ||
|
|
47be76fd73 | ||
|
|
6c1421b3b4 | ||
|
|
baacf83974 | ||
|
|
b8934d40eb | ||
|
|
7b51c6f245 | ||
|
|
2f6dcbb4d4 | ||
|
|
c3f230de73 | ||
|
|
18673f0a55 | ||
|
|
f32e323309 | ||
|
|
9595a69b50 | ||
|
|
714b6d95ba | ||
|
|
f2eebbcd3d | ||
|
|
0dccfad5c8 | ||
|
|
c9ba2b0f4c | ||
|
|
450b140561 | ||
|
|
3d14a50f07 | ||
|
|
0c0fe9f5e6 | ||
|
|
b5276feba0 | ||
|
|
ac4ce99b01 | ||
|
|
633f192c8f | ||
|
|
54dedb6696 | ||
|
|
4773e73d88 | ||
|
|
4ad218e97d | ||
|
|
b6b16666f6 | ||
|
|
561f7d0387 | ||
|
|
3268cd4791 | ||
|
|
54f0f89dc8 | ||
|
|
20e6c129ba | ||
|
|
9ba68dcfaf | ||
|
|
2ae5f7e3c8 | ||
|
|
01eecbffad | ||
|
|
47ca5461f1 | ||
|
|
5a5f16c9f8 | ||
|
|
71cdcc220f | ||
|
|
d5727ca387 | ||
|
|
b36d9fd641 | ||
|
|
699e21f54d | ||
|
|
50f50bed9f | ||
|
|
7a85bcdb5c | ||
|
|
93ce7a730b | ||
|
|
8a62f053a5 | ||
|
|
e4ffa5d77a | ||
|
|
2214bc66de | ||
|
|
ddc71cc1bd | ||
|
|
b33cb0a845 | ||
|
|
7e09bf7e18 | ||
|
|
fa7292ac35 | ||
|
|
eaa433e264 | ||
|
|
2b0d8424ea |
449 changed files with 82736 additions and 83056 deletions
37
.github/workflows/build-pr.yml
vendored
37
.github/workflows/build-pr.yml
vendored
|
|
@ -14,8 +14,8 @@ on:
|
|||
- ".github/ISSUE_TEMPLATE/**"
|
||||
|
||||
env:
|
||||
PNPM_VERSION: 10
|
||||
NODE_VERSION: 20
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 22
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
jobs:
|
||||
|
|
@ -43,45 +43,56 @@ jobs:
|
|||
- name: Run Python tests
|
||||
run: uv run pytest app/tests/ -v --tb=short
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Cache bun installation
|
||||
id: cache-bun
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Prepare frontend (nuxt prepare)
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm nuxt prepare
|
||||
run: bun nuxt prepare
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ui
|
||||
run: pnpm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Run frontend type checking
|
||||
working-directory: ui
|
||||
run: pnpm run typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run frontend tsc
|
||||
working-directory: ui
|
||||
run: bun run lint:tsc
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: ui
|
||||
run: pnpm run test:ci
|
||||
run: bun run test:ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ui
|
||||
run: pnpm run generate
|
||||
run: bun run generate
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
|
|
|||
70
.github/workflows/main.yml
vendored
70
.github/workflows/main.yml
vendored
|
|
@ -28,8 +28,8 @@ env:
|
|||
BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}
|
||||
DOCKERHUB_SLUG: arabcoders/ytptube
|
||||
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||
PNPM_VERSION: 10
|
||||
NODE_VERSION: 20
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 22
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
jobs:
|
||||
|
|
@ -77,51 +77,64 @@ jobs:
|
|||
- name: Run Python tests
|
||||
run: uv run pytest app/tests/ -v --tb=short
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Cache bun installation
|
||||
id: cache-bun
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Prepare frontend (nuxt prepare)
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm nuxt prepare
|
||||
run: bun nuxt prepare
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ui
|
||||
run: pnpm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Run frontend type checking
|
||||
working-directory: ui
|
||||
run: pnpm run typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run frontend tsc
|
||||
working-directory: ui
|
||||
run: bun run lint:tsc
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: ui
|
||||
run: pnpm run test:ci
|
||||
run: bun run test:ci
|
||||
|
||||
- name: Remove dev dependencies
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: pnpm install --frozen-lockfile --prod
|
||||
run: bun install --frozen-lockfile --production --ignore-scripts
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ui
|
||||
run: pnpm run generate
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: bun run generate
|
||||
|
||||
- name: Install GitPython
|
||||
run: pip install gitpython
|
||||
|
|
@ -419,42 +432,53 @@ jobs:
|
|||
script: |
|
||||
const tag = context.ref.replace('refs/tags/', '');
|
||||
|
||||
const latestRelease = await github.rest.repos.listReleases({
|
||||
const { data: releases } = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
});
|
||||
|
||||
const release = latestRelease.data.find(r => r.tag_name === tag);
|
||||
const release = releases.find(r => r.tag_name === tag);
|
||||
if (!release) {
|
||||
core.setFailed(`Release with tag ${tag} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseTag = releases[1]?.tag_name || '';
|
||||
if (!baseTag) {
|
||||
core.setFailed('Could not determine a base tag (no previous release found)');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: comparison } = await github.rest.repos.compareCommits({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
base: latestRelease.data[1]?.tag_name || '',
|
||||
base: baseTag,
|
||||
head: tag,
|
||||
});
|
||||
|
||||
const commits = comparison.commits.filter(c => 1 === c.parents.length);
|
||||
const commits = comparison.commits.filter(c => c.parents.length === 1);
|
||||
|
||||
const changelog = commits.map(
|
||||
c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`
|
||||
).join('\n');
|
||||
const changelog = commits
|
||||
.map(c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`)
|
||||
.join('\n');
|
||||
|
||||
if (!changelog) {
|
||||
core.setFailed('No commits found for the changelog');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Changelog for ${tag}:\n${changelog}`);
|
||||
const existingBody = (release.body || '').trim();
|
||||
|
||||
const separator = `\n\n---\n\n## Commits since ${baseTag}\n\n`;
|
||||
const newBody = existingBody
|
||||
? `${existingBody}${separator}${changelog}`
|
||||
: `## Commits since ${baseTag}\n\n${changelog}`;
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
body: changelog
|
||||
body: newBody,
|
||||
});
|
||||
|
||||
dockerhub-sync-readme:
|
||||
|
|
|
|||
105
.github/workflows/native-build.yml
vendored
105
.github/workflows/native-build.yml
vendored
|
|
@ -37,9 +37,9 @@ jobs:
|
|||
contents: write
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: 3.11
|
||||
PNPM_VERSION: 10
|
||||
NODE_VERSION: 20
|
||||
PYTHON_VERSION: "3.13"
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 22
|
||||
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
steps:
|
||||
|
|
@ -60,8 +60,40 @@ jobs:
|
|||
|
||||
- name: Replace app.spec with version from workflow branch
|
||||
if: ${{ github.event.inputs.useWorkflowSpec == 'true' }}
|
||||
shell: bash
|
||||
run: cp temp-spec/app.spec ./app.spec
|
||||
|
||||
- name: Update app/library/version.py
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${TAG_NAME}"
|
||||
SHA=$(git rev-parse HEAD)
|
||||
DATE=$(date -u +"%Y%m%d")
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
if [ -z "${BRANCH}" ]; then
|
||||
BRANCH="${TAG_NAME}"
|
||||
fi
|
||||
|
||||
BRANCH=$(echo "${BRANCH}" | sed 's#/#-#g')
|
||||
|
||||
echo "APP_VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "APP_SHA=${SHA}" >> "$GITHUB_ENV"
|
||||
echo "APP_DATE=${DATE}" >> "$GITHUB_ENV"
|
||||
echo "APP_BRANCH=${BRANCH}" >> "$GITHUB_ENV"
|
||||
|
||||
sed -i.bak \
|
||||
-e "s/^APP_VERSION = \".*\"/APP_VERSION = \"${VERSION}\"/" \
|
||||
-e "s/^APP_COMMIT_SHA = \".*\"/APP_COMMIT_SHA = \"${SHA}\"/" \
|
||||
-e "s/^APP_BUILD_DATE = \".*\"/APP_BUILD_DATE = \"${DATE}\"/" \
|
||||
-e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \
|
||||
app/library/version.py
|
||||
|
||||
rm -f app/library/version.py.bak
|
||||
|
||||
echo "Updated version info:"
|
||||
cat app/library/version.py
|
||||
|
||||
- name: Cache Python venv
|
||||
id: cache-python
|
||||
uses: actions/cache@v4
|
||||
|
|
@ -78,37 +110,63 @@ jobs:
|
|||
architecture: "x64"
|
||||
|
||||
- name: Install Python dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pip install uv
|
||||
uv venv --system-site-packages --relocatable
|
||||
if [ ! -d .venv ]; then
|
||||
uv venv --system-site-packages --relocatable
|
||||
fi
|
||||
uv sync --link-mode=copy --active --extra installer
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Cache bun installation
|
||||
id: cache-bun
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ matrix.arch }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.arch }}-bun-
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ui
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --production --prefer-offline --frozen-lockfile
|
||||
pnpm run generate
|
||||
bun install --production --prefer-offline --frozen-lockfile
|
||||
bun run generate
|
||||
|
||||
- name: Clean build outputs (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: rm -rf build dist release || true
|
||||
|
||||
- name: Clean build outputs (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: powershell
|
||||
run: |
|
||||
if (Test-Path build) { Remove-Item -Recurse -Force build }
|
||||
if (Test-Path dist) { Remove-Item -Recurse -Force dist }
|
||||
if (Test-Path release) { Remove-Item -Recurse -Force release }
|
||||
|
||||
- name: Build native binary (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: powershell
|
||||
run: |
|
||||
.\.venv\Scripts\activate
|
||||
uv run pyinstaller ./app.spec
|
||||
|
||||
- name: Build native binary (Linux/macOS)
|
||||
if: matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
uv run pyinstaller ./app.spec
|
||||
|
|
@ -117,34 +175,45 @@ jobs:
|
|||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-${{ runner.os }}-${{ matrix.arch }}-${{ env.TAG_NAME }}
|
||||
path: dist/*
|
||||
path: dist/YTPTube/**
|
||||
|
||||
- name: Package artifact (Unix)
|
||||
if: startsWith(env.TAG_NAME, 'v') && runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release
|
||||
PKG_DIR="YTPTube-${{ env.TAG_NAME }}"
|
||||
ZIP_NAME="ytptube-${{ runner.os }}-${{ matrix.arch }}-${{ env.TAG_NAME }}.zip"
|
||||
|
||||
if [ ! -d dist ] || [ -z "$(ls -A dist)" ]; then
|
||||
echo "dist directory is empty, skipping packaging."
|
||||
if [ ! -f "dist/YTPTube/YTPTube" ]; then
|
||||
echo "Missing dist/YTPTube/YTPTube"
|
||||
find dist -maxdepth 3 -type f | head -n 200
|
||||
exit 1
|
||||
fi
|
||||
|
||||
zip -r "release/${ZIP_NAME}" dist/
|
||||
rm -rf "dist/${PKG_DIR}"
|
||||
mv "dist/YTPTube" "dist/${PKG_DIR}"
|
||||
|
||||
zip -yr "release/${ZIP_NAME}" "dist/${PKG_DIR}"
|
||||
|
||||
- name: Package artifact (Windows)
|
||||
if: startsWith(env.TAG_NAME, 'v') && runner.os == 'Windows'
|
||||
shell: powershell
|
||||
run: |
|
||||
$pkgDir = "YTPTube-${{ env.TAG_NAME }}"
|
||||
$zipName = "ytptube-${{ runner.os }}-${{ matrix.arch }}-${{ env.TAG_NAME }}.zip"
|
||||
New-Item -ItemType Directory -Force -Path release | Out-Null
|
||||
|
||||
if (!(Test-Path dist) -or !(Get-ChildItem -Path dist)) {
|
||||
Write-Host "dist directory is empty, skipping packaging."
|
||||
if (!(Test-Path "dist\YTPTube\YTPTube.exe")) {
|
||||
Write-Host "Missing dist\YTPTube\YTPTube.exe"
|
||||
Get-ChildItem -Recurse dist | Select-Object -First 200 | Format-Table -AutoSize
|
||||
exit 1
|
||||
}
|
||||
|
||||
Compress-Archive -Path dist\* -DestinationPath "release\$zipName"
|
||||
if (Test-Path "dist\$pkgDir") { Remove-Item -Recurse -Force "dist\$pkgDir" }
|
||||
Move-Item "dist\YTPTube" "dist\$pkgDir"
|
||||
|
||||
Compress-Archive -Path "dist\$pkgDir" -DestinationPath "release\$zipName" -Force
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: startsWith(env.TAG_NAME, 'v')
|
||||
|
|
|
|||
2
.github/workflows/update-yt-dlp.yml
vendored
2
.github/workflows/update-yt-dlp.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
VER=$(uv pip list --outdated | awk '$1 == "yt-dlp" {print $3}')
|
||||
if [ -n "$VER" ]; then
|
||||
echo "YTLDLP_VER=${VER}" >> "$GITHUB_OUTPUT"
|
||||
uv pip install --upgrade yt-dlp
|
||||
uv pip install --upgrade yt-dlp[default]
|
||||
uv sync --upgrade
|
||||
UPDATED=true
|
||||
else
|
||||
|
|
|
|||
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
|
|
@ -2,7 +2,7 @@
|
|||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Node: Nuxt3",
|
||||
"name": "UI: Dev",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
"--port",
|
||||
"8082"
|
||||
],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeExecutable": "bun",
|
||||
"type": "node",
|
||||
"cwd": "${workspaceFolder}/ui",
|
||||
"env": {
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"name": "Python: main.py ",
|
||||
"name": "Python: main.py",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "app/main.py",
|
||||
|
|
@ -33,17 +33,18 @@
|
|||
"YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads",
|
||||
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
|
||||
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
||||
"YTP_IGNORE_UI": "true"
|
||||
"YTP_IGNORE_UI": "true",
|
||||
"YTP_DEBUG": "true"
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Node: Generate UI",
|
||||
"name": "UI: Generate",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"generate"
|
||||
],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeExecutable": "bun",
|
||||
"type": "node",
|
||||
"cwd": "${workspaceFolder}/ui",
|
||||
"console": "internalConsole",
|
||||
|
|
|
|||
253
.vscode/settings.json
vendored
253
.vscode/settings.json
vendored
|
|
@ -2,200 +2,9 @@
|
|||
"files.exclude": {
|
||||
"**/__pycache__": true
|
||||
},
|
||||
"autopep8.args": [
|
||||
"--max-line-length",
|
||||
"120",
|
||||
"--experimental"
|
||||
],
|
||||
"editor.rulers": [
|
||||
120
|
||||
],
|
||||
"cSpell.words": [
|
||||
"aconvert",
|
||||
"addopts",
|
||||
"ahas",
|
||||
"ahash",
|
||||
"aiocron",
|
||||
"anyio",
|
||||
"Archiver",
|
||||
"arrowdown",
|
||||
"arrowleft",
|
||||
"arrowless",
|
||||
"arrowright",
|
||||
"arrowup",
|
||||
"asyncio",
|
||||
"attl",
|
||||
"autonumber",
|
||||
"autouse",
|
||||
"bgutil",
|
||||
"bgutilhttp",
|
||||
"bilibili",
|
||||
"brainicism",
|
||||
"brotlicffi",
|
||||
"buildcache",
|
||||
"cand",
|
||||
"caplog",
|
||||
"Cfmrc",
|
||||
"choco",
|
||||
"cifs",
|
||||
"connectionpool",
|
||||
"consoletitle",
|
||||
"contenteditable",
|
||||
"continuedl",
|
||||
"cookiesfrombrowser",
|
||||
"copyts",
|
||||
"creationflags",
|
||||
"cronsim",
|
||||
"crosshairs",
|
||||
"currsize",
|
||||
"dailymotion",
|
||||
"datas",
|
||||
"dateparser",
|
||||
"daterange",
|
||||
"defusedxml",
|
||||
"delenv",
|
||||
"denoland",
|
||||
"dlfields",
|
||||
"dotdot",
|
||||
"dotenv",
|
||||
"dpkg",
|
||||
"dunder",
|
||||
"Edes",
|
||||
"edgechromium",
|
||||
"EISGPM",
|
||||
"engineio",
|
||||
"Errno",
|
||||
"esac",
|
||||
"euuo",
|
||||
"eventbus",
|
||||
"excepthook",
|
||||
"exitcode",
|
||||
"falsey",
|
||||
"faststart",
|
||||
"Fetc",
|
||||
"filterwarnings",
|
||||
"finaldir",
|
||||
"flac",
|
||||
"forcejson",
|
||||
"forceprint",
|
||||
"Fpasswd",
|
||||
"fribidi",
|
||||
"Funsafe",
|
||||
"getpid",
|
||||
"gibibytes",
|
||||
"gitea",
|
||||
"gitpython",
|
||||
"googledrive",
|
||||
"gpac",
|
||||
"hiddenimports",
|
||||
"hookspath",
|
||||
"httpx",
|
||||
"imagetools",
|
||||
"jmespath",
|
||||
"jsonschema",
|
||||
"kibibytes",
|
||||
"Kodi",
|
||||
"kwdefaults",
|
||||
"lastgroup",
|
||||
"levelno",
|
||||
"libcurl",
|
||||
"libjavascriptcoregtk",
|
||||
"libmfx",
|
||||
"libstdc",
|
||||
"libwebkit",
|
||||
"libx",
|
||||
"LPAREN",
|
||||
"matchtitle",
|
||||
"matroska",
|
||||
"mbed",
|
||||
"mccabe",
|
||||
"mebibytes",
|
||||
"MEIPASS",
|
||||
"merch",
|
||||
"metadataparser",
|
||||
"Microformat",
|
||||
"microformats",
|
||||
"mkvtoolsnix",
|
||||
"movflags",
|
||||
"mpegts",
|
||||
"msvideo",
|
||||
"mult",
|
||||
"multidict",
|
||||
"muxdelay",
|
||||
"mweb",
|
||||
"nfsvers",
|
||||
"nodesc",
|
||||
"noninteractive",
|
||||
"noprogress",
|
||||
"onefile",
|
||||
"parsel",
|
||||
"pathex",
|
||||
"pickleable",
|
||||
"platformdirs",
|
||||
"playlistend",
|
||||
"playlistrandom",
|
||||
"playlistreverse",
|
||||
"plexmatch",
|
||||
"postprocessor",
|
||||
"preferredcodec",
|
||||
"preferredquality",
|
||||
"printtraffic",
|
||||
"procps",
|
||||
"pycryptodome",
|
||||
"pycryptodomex",
|
||||
"pyinstaller",
|
||||
"pypi",
|
||||
"pythonpath",
|
||||
"quicktime",
|
||||
"ratecontrol",
|
||||
"rejecttitle",
|
||||
"remux",
|
||||
"reqs",
|
||||
"RPAREN",
|
||||
"rtime",
|
||||
"rvfc",
|
||||
"SIGUSR",
|
||||
"smhd",
|
||||
"socketio",
|
||||
"softprops",
|
||||
"sstr",
|
||||
"startswith",
|
||||
"SUPPRESSHELP",
|
||||
"tablehead",
|
||||
"tebibytes",
|
||||
"testpaths",
|
||||
"threadsafe",
|
||||
"tiktok",
|
||||
"timespec",
|
||||
"tinyurl",
|
||||
"tmpfilename",
|
||||
"toplevel",
|
||||
"trixie",
|
||||
"ungroup",
|
||||
"unmark",
|
||||
"unnegated",
|
||||
"unpickleable",
|
||||
"Unraid",
|
||||
"upgrader",
|
||||
"urandom",
|
||||
"urlsafe",
|
||||
"usegmt",
|
||||
"usermod",
|
||||
"ustr",
|
||||
"vainfo",
|
||||
"vcodec",
|
||||
"vconvert",
|
||||
"Vitest",
|
||||
"writedescription",
|
||||
"xerror",
|
||||
"youtu",
|
||||
"youtubepot",
|
||||
"русский",
|
||||
"العربية"
|
||||
],
|
||||
"css.styleSheets": [
|
||||
"ui/app/assets/css/*.css"
|
||||
],
|
||||
"editor.rulers": [120],
|
||||
"cSpell.enabled": false,
|
||||
"css.styleSheets": ["ui/app/assets/css/*.css"],
|
||||
"search.exclude": {
|
||||
"ui/.nuxt": true,
|
||||
"ui/.output": true,
|
||||
|
|
@ -203,49 +12,23 @@
|
|||
"ui/exported": true,
|
||||
"ui/node_modules": true,
|
||||
"ui/.out": true,
|
||||
"**/.venv": true,
|
||||
"**/.venv": true
|
||||
},
|
||||
"eslint.format.enable": true,
|
||||
"eslint.format.enable": false,
|
||||
"eslint.ignoreUntitled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/tasks/*.json"
|
||||
],
|
||||
"url": "./app/schema/task_definition.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/conditions.json"
|
||||
],
|
||||
"url": "./app/schema/conditions.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/dl_fields.json"
|
||||
],
|
||||
"url": "./app/schema/dl_fields.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/notifications.json"
|
||||
],
|
||||
"url": "./app/schema/notifications.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/presets.json"
|
||||
],
|
||||
"url": "./app/schema/presets.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/tasks.json"
|
||||
],
|
||||
"url": "./app/schema/tasks.json"
|
||||
}
|
||||
],
|
||||
"python.testing.cwd": "app/tests"
|
||||
"python.testing.pytestArgs": ["app"],
|
||||
"oxc.path.oxfmt": "./ui/node_modules/.bin/oxfmt",
|
||||
"oxc.fmt.configPath": "./ui/.oxfmtrc.json",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
764
CONTRIBUTING.md
764
CONTRIBUTING.md
|
|
@ -1,715 +1,149 @@
|
|||
# Contributing to YTPTube
|
||||
|
||||
Thank you for your interest in contributing to **YTPTube**! We welcome contributions from the community to help improve this project.
|
||||
YTPTube is a **personal-first project**. While contributions are welcome, **all final decisions rest with the maintainer**.
|
||||
|
||||
This document provides guidelines and instructions for contributing to the project.
|
||||
## Core Principles
|
||||
|
||||
## Table of Contents
|
||||
* **The maintainer has final say** on all changes and project direction.
|
||||
* This project is built **for personal use first**. Community contributions are secondary.
|
||||
* **All contributions require prior discussion and approval.**
|
||||
* **All pull requests must target the `dev` branch** never `master`.
|
||||
* Contributions must align with the project's goals, architecture, and coding standards.
|
||||
|
||||
- [Contributing to YTPTube](#contributing-to-ytptube)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Backend Requirements](#backend-requirements)
|
||||
- [Frontend Requirements](#frontend-requirements)
|
||||
- [Optional Tools](#optional-tools)
|
||||
- [Installing Required Package Managers](#installing-required-package-managers)
|
||||
- [Installing uv (Python Package Manager)](#installing-uv-python-package-manager)
|
||||
- [Installing pnpm (Node.js Package Manager)](#installing-pnpm-nodejs-package-manager)
|
||||
- [Cloning the Repository](#cloning-the-repository)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Backend Setup (Python)](#backend-setup-python)
|
||||
- [Frontend Setup (Node.js)](#frontend-setup-nodejs)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Working on a Feature or Bug Fix](#working-on-a-feature-or-bug-fix)
|
||||
- [Branching Strategy](#branching-strategy)
|
||||
- [Important Rules:](#important-rules)
|
||||
- [Branch Naming Conventions:](#branch-naming-conventions)
|
||||
- [Testing Requirements](#testing-requirements)
|
||||
- [Backend Testing (Python)](#backend-testing-python)
|
||||
- [Frontend Testing (TypeScript/Vue)](#frontend-testing-typescriptvue)
|
||||
- [Test Coverage Expectations](#test-coverage-expectations)
|
||||
- [Code Quality and Linting](#code-quality-and-linting)
|
||||
- [Backend (Python)](#backend-python)
|
||||
- [Frontend (TypeScript/Vue)](#frontend-typescriptvue)
|
||||
- [Code Quality Checklist](#code-quality-checklist)
|
||||
- [Pre-commit Hooks](#pre-commit-hooks)
|
||||
- [Setting Up Pre-commit Hooks](#setting-up-pre-commit-hooks)
|
||||
- [Before Submitting](#before-submitting)
|
||||
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||
- [Creating a Pull Request](#creating-a-pull-request)
|
||||
- [PR Review Process](#pr-review-process)
|
||||
- [After Your PR is Merged](#after-your-pr-is-merged)
|
||||
- [Code Style](#code-style)
|
||||
- [Python Code Style](#python-code-style)
|
||||
- [TypeScript/Vue Code Style](#typescriptvue-code-style)
|
||||
- [Getting Help](#getting-help)
|
||||
- [Resources](#resources)
|
||||
- [Ask Questions](#ask-questions)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [License](#license)
|
||||
**Opening a PR without prior approval will result in immediate closure without review.**
|
||||
|
||||
---
|
||||
|
||||
## Code of Conduct
|
||||
## Contribution Process
|
||||
|
||||
Please be respectful and constructive in all interactions. We aim to maintain a welcoming and inclusive community.
|
||||
### 1. Start a Discussion
|
||||
|
||||
---
|
||||
Before writing any code, **propose your idea** through one of these channels:
|
||||
|
||||
## Getting Started
|
||||
* Open a **GitHub Issue** for bug fixes or small features
|
||||
* Start a **GitHub Discussion** for larger changes or questions
|
||||
|
||||
### Prerequisites
|
||||
**Include in your proposal:**
|
||||
* **What** you want to change
|
||||
* **Why** it's needed or beneficial
|
||||
* Bug fixes and functional improvements are prioritized
|
||||
* Performance enhancements with measurable impact
|
||||
* Features that align with the project's core purpose
|
||||
* **Not acceptable:** Personal UI/UX preferences, stylistic changes, or "I think it looks better" rationale
|
||||
* **How** you plan to implement it (high-level approach)
|
||||
* Any relevant context or use cases
|
||||
|
||||
Before you begin, ensure you have the following tools installed on your system:
|
||||
### 2. Wait for Approval
|
||||
|
||||
#### Backend Requirements
|
||||
* Only proceed after **explicit approval** from the maintainer to not waste effort.
|
||||
* The maintainer may suggest modifications or alternative approaches.
|
||||
* Not all proposals will be accepted this protects project coherence.
|
||||
|
||||
- **Python 3.13+**: YTPTube requires Python 3.13 or higher
|
||||
- **uv**: Modern Python package manager and virtual environment tool
|
||||
|
||||
#### Frontend Requirements
|
||||
|
||||
- **Node.js 18+**: Required for the Nuxt/Vue frontend
|
||||
- **pnpm**: Fast, disk space efficient package manager
|
||||
|
||||
#### Optional Tools
|
||||
|
||||
- **Git**: Version control
|
||||
- **Docker**: For containerized development (optional)
|
||||
- **ffmpeg**: Required for video player functionality
|
||||
|
||||
### Installing Required Package Managers
|
||||
|
||||
#### Installing uv (Python Package Manager)
|
||||
|
||||
**uv** is a fast Python package installer and resolver written in Rust. It's used for managing Python dependencies and virtual environments.
|
||||
|
||||
**On Linux/macOS:**
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**Using pip:**
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
**Verify installation:**
|
||||
|
||||
```bash
|
||||
uv --version
|
||||
```
|
||||
|
||||
For more information, visit: https://github.com/astral-sh/uv
|
||||
|
||||
#### Installing pnpm (Node.js Package Manager)
|
||||
|
||||
**pnpm** is a fast, disk space efficient package manager for Node.js.
|
||||
|
||||
**Using npm:**
|
||||
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
```
|
||||
|
||||
**Using Homebrew (macOS):**
|
||||
|
||||
```bash
|
||||
brew install pnpm
|
||||
```
|
||||
|
||||
**Using Scoop (Windows):**
|
||||
|
||||
```bash
|
||||
scoop install pnpm
|
||||
```
|
||||
|
||||
**Using standalone script:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
```
|
||||
|
||||
**Verify installation:**
|
||||
|
||||
```bash
|
||||
pnpm --version
|
||||
```
|
||||
|
||||
For more information, visit: https://pnpm.io/installation
|
||||
|
||||
### Cloning the Repository
|
||||
|
||||
Clone the repository to your local machine:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/arabcoders/ytptube.git
|
||||
cd ytptube
|
||||
```
|
||||
|
||||
**Important**: Always work on the `dev` branch, not `master`/`main`:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Backend Setup (Python)
|
||||
|
||||
1. **Navigate to the project root:**
|
||||
|
||||
```bash
|
||||
cd ytptube
|
||||
```
|
||||
|
||||
2. **Install Python dependencies using uv:**
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create a virtual environment (`.venv/`)
|
||||
- Install all dependencies from `pyproject.toml`
|
||||
- Install development dependencies
|
||||
|
||||
3. **Install development dependencies:**
|
||||
|
||||
Development dependencies are defined in `pyproject.toml` under `[dependency-groups]`:
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
4. **Verify installation:**
|
||||
|
||||
```bash
|
||||
uv run python --version
|
||||
uv run pytest --version
|
||||
```
|
||||
|
||||
5. **Run the backend development server:**
|
||||
|
||||
```bash
|
||||
uv run app/main.py
|
||||
```
|
||||
|
||||
The backend API will be available at `http://localhost:8081`
|
||||
|
||||
### Frontend Setup (Node.js)
|
||||
|
||||
1. **Navigate to the UI directory:**
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
```
|
||||
|
||||
2. **Install Node.js dependencies using pnpm:**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Create a `.env` file in the `ui/` directory based on `.env.example` to configure environment variables.
|
||||
> to link the frontend to the backend API.
|
||||
|
||||
3. **Run the frontend development server:**
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The frontend will be available at `http://localhost:8082` (or the port shown in the terminal)
|
||||
|
||||
4. **Verify the setup:**
|
||||
|
||||
```bash
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Working on a Feature or Bug Fix
|
||||
|
||||
1. **Ensure you're on the `dev` branch:**
|
||||
### 3. Develop Your Changes
|
||||
|
||||
**Branch from `dev`:**
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git checkout -b feature/descriptive-name
|
||||
```
|
||||
|
||||
2. **Create a feature branch:**
|
||||
**Follow project standards:**
|
||||
* Match existing code style and conventions
|
||||
* Add or update tests for all changes:
|
||||
* New features **MUST** include tests
|
||||
* Bug fixes **MUST** include a regression test
|
||||
* Ensure all linting and tests pass
|
||||
* Keep changes focused and atomic
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/bug-description
|
||||
```
|
||||
### 4. Submit a Pull Request
|
||||
|
||||
3. **Make your changes**
|
||||
**Target the `dev` branch:**
|
||||
* Reference the approved issue or discussion number
|
||||
* Provide a clear description of what changed and why
|
||||
* List any breaking changes or migration steps
|
||||
* Ensure CI checks pass
|
||||
|
||||
4. **Test your changes** (see [Testing Requirements](#testing-requirements))
|
||||
|
||||
5. **Run code quality checks** (see [Code Quality and Linting](#code-quality-and-linting))
|
||||
|
||||
6. **Commit your changes:**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new feature description"
|
||||
# or
|
||||
git commit -m "fix: fix bug description"
|
||||
```
|
||||
|
||||
7. **Push to your fork:**
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
8. **Create a Pull Request** targeting the `dev` branch
|
||||
**PR template checklist:**
|
||||
- [ ] Discussed and approved beforehand
|
||||
- [ ] Targets `dev` branch
|
||||
- [ ] Tests added/updated and passing
|
||||
- [ ] Linting passes
|
||||
- [ ] Documentation updated (if needed)
|
||||
|
||||
---
|
||||
|
||||
## Branching Strategy
|
||||
## Automatic Rejections
|
||||
|
||||
**YTPTube uses a two-branch strategy:**
|
||||
The following will be **closed immediately without review**:
|
||||
|
||||
- **`master`**: Production-ready code. Only maintainers merge to this branch.
|
||||
- **`dev`**: Development branch. **All contributions must target this branch.**
|
||||
* PRs opened without prior discussion and approval
|
||||
* PRs targeting `master` instead of `dev`
|
||||
* Large refactors or architectural changes without approval
|
||||
* Fully AI-generated code without meaningful human oversight
|
||||
* Changes that don't align with project goals or philosophy
|
||||
* PRs where the contributor cannot explain or justify the changes
|
||||
|
||||
### Important Rules:
|
||||
---
|
||||
|
||||
- ✅ **DO**: Create feature branches from `dev`
|
||||
- ✅ **DO**: Submit pull requests to `dev`
|
||||
- ❌ **DON'T**: Submit pull requests to `master`/`main`
|
||||
- ❌ **DON'T**: Commit directly to `dev` (use feature branches)
|
||||
## AI-Assisted Development
|
||||
|
||||
### Branch Naming Conventions:
|
||||
AI tools are **permitted** as development aids, but **you remain fully responsible** for all submitted code.
|
||||
|
||||
- `feature/description` - For new features
|
||||
- `fix/description` - For bug fixes
|
||||
- `refactor/description` - For code refactoring
|
||||
- `docs/description` - For documentation updates
|
||||
- `test/description` - For test additions/improvements
|
||||
### Acceptable Use
|
||||
|
||||
AI-assisted code is welcome **when**:
|
||||
|
||||
* You **fully understand** every line being submitted
|
||||
* The code **seamlessly integrates** with existing patterns and style
|
||||
* You have **reviewed, tested, and validated** the output yourself
|
||||
* **Comprehensive tests** are included (not AI-generated stubs)
|
||||
* The code is **indistinguishable in quality** from hand-written contributions
|
||||
* You can **explain and defend** design decisions in the PR
|
||||
|
||||
**AI is a tool, not a substitute for understanding.**
|
||||
|
||||
### Not Acceptable
|
||||
|
||||
The following will be rejected:
|
||||
|
||||
* Fully AI-generated PRs with minimal human review
|
||||
* Code that introduces new patterns or abstractions without approval
|
||||
* "Prompt-dump" output that doesn't match project conventions
|
||||
* Changes the contributor cannot explain or justify
|
||||
* AI-generated test suites that don't meaningfully validate behavior
|
||||
|
||||
### Disclosure
|
||||
|
||||
You are **not required** to disclose AI usage. However, if code quality suggests pure AI generation without human oversight, the PR will be closed.
|
||||
|
||||
**You are accountable for correctness, maintainability, and alignment regardless of how the code was created.**
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
**All code changes must include appropriate tests.** This ensures code quality and prevents regressions.
|
||||
All contributions must include appropriate tests:
|
||||
|
||||
### Backend Testing (Python)
|
||||
* **New features:** Full test coverage including edge cases
|
||||
* **Bug fixes:** Regression test that fails before the fix and passes after
|
||||
* **Refactors:** Existing tests must continue to pass
|
||||
* **Performance changes:** Benchmarks or performance tests when applicable
|
||||
|
||||
Tests are located in `app/tests/` and use **pytest**.
|
||||
|
||||
**Running tests:**
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest app/tests/test_download.py
|
||||
|
||||
# Run with verbose output
|
||||
uv run pytest -v
|
||||
|
||||
# Run with coverage
|
||||
uv run pytest --cov=app --cov-report=html
|
||||
```
|
||||
|
||||
**Creating new tests:**
|
||||
|
||||
1. Create a test file in `app/tests/` with the prefix `test_`
|
||||
2. Use pytest conventions:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.library.YourModule import YourClass
|
||||
|
||||
class TestYourClass:
|
||||
def test_feature_name(self):
|
||||
"""Test description."""
|
||||
# Arrange
|
||||
instance = YourClass()
|
||||
|
||||
# Act
|
||||
result = instance.method()
|
||||
|
||||
# Assert
|
||||
assert result == expected_value
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_feature(self):
|
||||
"""Test async functionality."""
|
||||
result = await async_function()
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
**Testing Singleton Classes:**
|
||||
|
||||
When testing singleton classes, always reset the singleton instance:
|
||||
|
||||
```python
|
||||
from app.library.Presets import Presets
|
||||
|
||||
class TestPresets:
|
||||
def setup_method(self):
|
||||
"""Reset singleton before each test."""
|
||||
Presets._reset_singleton()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Reset singleton after each test."""
|
||||
Presets._reset_singleton()
|
||||
```
|
||||
|
||||
### Frontend Testing (TypeScript/Vue)
|
||||
|
||||
Tests are located in `ui/tests/` and use **Vitest**.
|
||||
|
||||
**Running tests:**
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run in watch mode
|
||||
pnpm test:watch
|
||||
|
||||
# Run with coverage
|
||||
pnpm run test --coverage
|
||||
```
|
||||
|
||||
**Creating new tests:**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import YourComponent from '~/components/YourComponent.vue'
|
||||
|
||||
describe('YourComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
const wrapper = mount(YourComponent)
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles user interaction', async () => {
|
||||
const wrapper = mount(YourComponent)
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('event-name')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Test Coverage Expectations
|
||||
|
||||
- **New features**: Must include comprehensive tests
|
||||
- **Bug fixes**: Must include a test that reproduces the bug
|
||||
- **Refactoring**: Existing tests should still pass
|
||||
- **Coverage goal**: Aim for >80% code coverage on new code
|
||||
Tests should be clear, maintainable, and actually validate the intended behavior.
|
||||
|
||||
---
|
||||
|
||||
## Code Quality and Linting
|
||||
## Questions?
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
YTPTube uses **Ruff** for linting and formatting Python code.
|
||||
|
||||
**Running Ruff:**
|
||||
|
||||
```bash
|
||||
# Check for linting issues
|
||||
uv run ruff check app/
|
||||
|
||||
# Auto-fix linting issues
|
||||
uv run ruff check --fix app/
|
||||
|
||||
# Format code
|
||||
uv run ruff format app/
|
||||
|
||||
# Check specific file
|
||||
uv run ruff check app/library/Download.py
|
||||
uv run ruff check --fix app/library/Download.py
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Ruff configuration is in `pyproject.toml` under `[tool.ruff]`.
|
||||
|
||||
### Frontend (TypeScript/Vue)
|
||||
|
||||
The frontend uses **ESLint** for linting and **TypeScript** for type checking.
|
||||
|
||||
**Running checks:**
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
|
||||
# Type checking
|
||||
pnpm run typecheck
|
||||
|
||||
# Linting
|
||||
pnpm run lint
|
||||
|
||||
# Auto-fix linting issues
|
||||
pnpm run lint:fix
|
||||
```
|
||||
|
||||
### Code Quality Checklist
|
||||
|
||||
Before committing, ensure:
|
||||
|
||||
- ✅ All tests pass (`uv run pytest` and `pnpm test`)
|
||||
- ✅ No linting errors (`ruff check` and `pnpm run lint`)
|
||||
- ✅ TypeScript compilation succeeds (`pnpm run typecheck`)
|
||||
- ✅ Code is formatted properly
|
||||
- ✅ No unused imports or variables
|
||||
- ✅ Type hints are present (Python 3.13+)
|
||||
|
||||
---
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
YTPTube uses a custom shell script-based pre-commit hook that runs all checks in parallel for faster execution.
|
||||
|
||||
### Setting Up Pre-commit Hooks
|
||||
|
||||
1. **Create the pre-commit hook script:**
|
||||
|
||||
Create a file at `.git/hooks/pre-commit` with the following content:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
cd "$REPO_ROOT" || exit 1
|
||||
|
||||
PIDS_FILE=$(mktemp)
|
||||
trap 'rm -f "$PIDS_FILE"' EXIT
|
||||
|
||||
export CI=1
|
||||
run_step() {
|
||||
local label="$1"
|
||||
shift
|
||||
echo "🔹 Starting $label..." >&2
|
||||
(
|
||||
if ! "$@"; then
|
||||
echo "❌ [$label] failed. Commit aborted." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "✅ [$label] passed." >&2
|
||||
fi
|
||||
) &
|
||||
echo $! >>"$PIDS_FILE"
|
||||
}
|
||||
|
||||
run_step "Ruff (Python linter)" uv run ruff check app/
|
||||
run_step "PyTest" uv run pytest app/ -q
|
||||
run_step "ESLint" pnpm --dir ./ui run lint
|
||||
run_step "Vitest" pnpm --dir ./ui run test
|
||||
|
||||
fail=0
|
||||
while read -r pid; do
|
||||
if ! wait "$pid"; then
|
||||
fail=1
|
||||
fi
|
||||
done <"$PIDS_FILE"
|
||||
|
||||
if [ "$fail" -eq 1 ]; then
|
||||
echo "❌ One or more checks failed. Commit aborted." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All checks passed. Commit allowed."
|
||||
exit 0
|
||||
```
|
||||
|
||||
2. **Make the hook executable:**
|
||||
|
||||
```bash
|
||||
chmod +x .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
3. **Test the hook:**
|
||||
|
||||
```bash
|
||||
# Test manually
|
||||
.git/hooks/pre-commit
|
||||
|
||||
# Or make a test commit
|
||||
git commit --allow-empty -m "test: verify pre-commit hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Before Submitting
|
||||
|
||||
✅ **Checklist:**
|
||||
|
||||
- [ ] Code is on a feature branch created from `dev`
|
||||
- [ ] All tests pass locally
|
||||
- [ ] Code passes linting and formatting checks
|
||||
- [ ] TypeScript types are properly defined (no `any`) without justification
|
||||
- [ ] New features include tests
|
||||
- [ ] Documentation is updated (if applicable)
|
||||
- [ ] Commit messages are clear and descriptive
|
||||
- [ ] Pre-commit hooks pass
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### Creating a Pull Request
|
||||
|
||||
1. **Push your branch to GitHub:**
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
2. **Open a Pull Request on GitHub**
|
||||
|
||||
3. **Target the `dev` branch** (not `master`)
|
||||
|
||||
4. **Fill out the PR template** with:
|
||||
- Description of changes
|
||||
- Related issue numbers (if applicable)
|
||||
- Testing performed
|
||||
- Screenshots (for UI changes)
|
||||
- Breaking changes (if any)
|
||||
|
||||
### PR Review Process
|
||||
|
||||
1. **Automated Checks**: CI pipeline runs tests and linting
|
||||
2. **Code Review**: Maintainers review your code
|
||||
3. **Feedback**: Address any requested changes
|
||||
4. **Approval**: Once approved, maintainers will merge
|
||||
|
||||
### After Your PR is Merged
|
||||
|
||||
1. **Delete your feature branch** (optional but recommended)
|
||||
2. **Pull the latest `dev` branch:**
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
```
|
||||
|
||||
3. **Celebrate!** Thank you for contributing!
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python Code Style
|
||||
|
||||
- **Line length**: 120 characters
|
||||
- **Indentation**: 4 spaces
|
||||
- **Quotes**: Double quotes for strings
|
||||
- **Type hints**: Use Python 3.13+ type hints
|
||||
- **Imports**: Absolute imports from `app.library`
|
||||
- **Comparison**: Use Yoda comparisons for literals (`"value" == variable`)
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from app.library.Download import Download
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
|
||||
async def process_download(item: ItemDTO) -> bool:
|
||||
"""Process a download item."""
|
||||
try:
|
||||
if "pending" == item.status: # Yoda comparison
|
||||
result = await Download.start(item)
|
||||
return result.success
|
||||
except Exception as e:
|
||||
LOG.error(f"Download failed: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
### TypeScript/Vue Code Style
|
||||
|
||||
- **Type safety**: No `any` types without justification
|
||||
- **Explicit imports**: Import all Vue functions explicitly
|
||||
- **Component structure**: Use `<script setup lang="ts">`
|
||||
- **Props/Emits**: Always type-define
|
||||
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { DownloadItem } from '~/types/download'
|
||||
|
||||
const props = defineProps<{
|
||||
items: Array<DownloadItem>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'itemSelected', item: DownloadItem): void
|
||||
}>()
|
||||
|
||||
const selectedItem = ref<DownloadItem | null>(null)
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Resources
|
||||
|
||||
- **API Docs**: See [API.md](API.md) for API reference
|
||||
- **FAQ**: Check [FAQ.md](FAQ.md) for common questions
|
||||
- **Issues**: Browse existing [GitHub Issues](https://github.com/arabcoders/ytptube/issues)
|
||||
|
||||
### Ask Questions
|
||||
|
||||
- **GitHub Discussions**: For general questions and discussions
|
||||
- **GitHub Issues**: For bug reports and feature requests
|
||||
- **Pull Requests**: For code review and implementation questions
|
||||
- **Discord**: Join our community on Discord (link in README)
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
When reporting bugs, please include:
|
||||
|
||||
1. **Description**: Clear description of the issue
|
||||
2. **Steps to Reproduce**: Detailed steps to reproduce the bug
|
||||
3. **Expected Behavior**: What you expected to happen
|
||||
4. **Actual Behavior**: What actually happened
|
||||
5. **Environment**: OS, Python version, Node version, etc.
|
||||
6. **Logs**: Relevant error messages or logs
|
||||
7. **Screenshots**: If applicable
|
||||
* Check existing **Issues** and **Discussions** first
|
||||
* Join the project **Discord** for real-time discussion
|
||||
* Be patient, this is a personal project with limited maintenance time
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
By contributing to YTPTube, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
By contributing, you agree that your code will be licensed under the project's **MIT License**.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to YTPTube! Your efforts help make this project better for everyone.
|
||||
Thank you for respecting this contribution process. It helps maintain project quality and the maintainer's sanity.
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -1,9 +1,13 @@
|
|||
# syntax=docker/dockerfile:1.4
|
||||
FROM node:lts-alpine AS node_builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY ui ./
|
||||
RUN if [ ! -f "/app/exported/index.html" ]; then npm install --production --prefer-offline --frozen-lockfile && npm run generate; else echo "Skipping UI build, already built."; fi
|
||||
ENV NODE_ENV=production
|
||||
RUN if [ ! -f "/app/exported/index.html" ]; then \
|
||||
npm install -g bun && \
|
||||
NODE_ENV=production bun install --frozen-lockfile --production && \
|
||||
bun run generate; \
|
||||
else echo "Skipping UI build, already built."; fi
|
||||
|
||||
FROM python:3.13-bookworm AS python_builder
|
||||
|
||||
|
|
@ -17,8 +21,7 @@ ENV UV_CACHE_DIR=/root/.cache/uv
|
|||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV UV_INSTALL_DIR=/usr/bin
|
||||
|
||||
SHELL ["/bin/bash","-lc"]
|
||||
RUN echo 1 && curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
COPY --from=astral/uv:latest /uv /usr/bin/
|
||||
|
||||
WORKDIR /opt/
|
||||
|
||||
|
|
|
|||
364
FAQ.md
364
FAQ.md
|
|
@ -3,66 +3,80 @@
|
|||
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
|
||||
or the `environment:` section in `compose.yaml` file.
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------ | ------------------------------------------------------------------ | --------------------- |
|
||||
| TZ | The timezone to use for the application | `(not_set)` |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `(not_set)` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | The maximum number of workers to use for downloading | `20` |
|
||||
| YTP_MAX_WORKERS_PER_EXTRACTOR | The maximum number of concurrent downloads per extractor | `2` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `(not_set)` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `(not_set)` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
|
||||
| YTP_PORT | Which port to bind to | `8081` |
|
||||
| YTP_LOG_LEVEL | Log level | `info` |
|
||||
| YTP_STREAMER_VCODEC | The video encoding codec, default to GPU and fallback to software | `""` |
|
||||
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
|
||||
| YTP_VAAPI_DEVICE | The VAAPI device to use for hardware acceleration. | `/dev/dri/renderD128` |
|
||||
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
|
||||
| YTP_DEBUG | Whether to turn on debug mode | `false` |
|
||||
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
|
||||
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
|
||||
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
|
||||
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `(not_set)` |
|
||||
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
|
||||
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `(not_set)` |
|
||||
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
|
||||
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
|
||||
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
|
||||
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `(not_set)` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
| YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` |
|
||||
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
|
||||
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |
|
||||
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
|
||||
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
|
||||
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
|
||||
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
||||
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
||||
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
|
||||
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------- | ------------------------------------------------------------------- | --------------------- |
|
||||
| TZ | The timezone to use for the application | `(not_set)` |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `(not_set)` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | The maximum number of workers to use for downloading | `20` |
|
||||
| YTP_MAX_WORKERS_PER_EXTRACTOR | The maximum number of concurrent downloads per extractor | `2` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `(not_set)` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `(not_set)` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
|
||||
| YTP_PORT | Which port to bind to | `8081` |
|
||||
| YTP_LOG_LEVEL | Log level | `info` |
|
||||
| YTP_STREAMER_VCODEC | The video encoding codec, default to GPU and fallback to software | `""` |
|
||||
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
|
||||
| YTP_VAAPI_DEVICE | The VAAPI device to use for hardware acceleration. | `/dev/dri/renderD128` |
|
||||
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
|
||||
| YTP_DEBUG | Whether to turn on debug mode | `false` |
|
||||
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
|
||||
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
|
||||
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `(not_set)` |
|
||||
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
|
||||
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `(not_set)` |
|
||||
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
|
||||
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
|
||||
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
|
||||
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `(not_set)` |
|
||||
| YTP_BROWSER_URL | Remote browser endpoint for the bundled `browser` extractor | `(not_set)` |
|
||||
| YTP_FLARESOLVERR_URL | FlareSolverr endpoint URL. | `(not_set)` |
|
||||
| YTP_FLARESOLVERR_MAX_TIMEOUT | Max FlareSolverr challenge timeout in seconds | `120` |
|
||||
| YTP_FLARESOLVERR_CLIENT_TIMEOUT | HTTP client timeout (seconds) when calling FlareSolverr | `120` |
|
||||
| YTP_FLARESOLVERR_CACHE_TTL | The cache TTL (in seconds) for FlareSolverr solutions | `600` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
| YTP_QUEUE_DISPLAY_LIMIT | Max queued downloads returned to the UI. `0` = unlimited | `100` |
|
||||
| YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` |
|
||||
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
|
||||
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
|
||||
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
|
||||
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
||||
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
||||
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
|
||||
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
|
||||
| YTP_TASK_HANDLER_RANDOM_DELAY | The maximum random delay in seconds before starting a task handler. | `60` |
|
||||
| YTP_IGNORE_ARCHIVED_ITEMS | Don't report archived items in the download history. | `false` |
|
||||
| YTP_CHECK_FOR_UPDATES | Whether to check for application updates. | `true` |
|
||||
| YTP_EXTRACT_INFO_CONCURRENCY | The number of concurrent extract info operations. | `4` |
|
||||
| YTP_THUMB_CONCURRENCY | The number of concurrent ffmpeg thumbnail generations allowed. | `2` |
|
||||
| YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` |
|
||||
| YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` |
|
||||
| YTP_DISABLE_EXEC | Strip some dangerous yt-dlp options. | `false` |
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
||||
> The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download.
|
||||
> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page.
|
||||
|
||||
## Notes about YTP_AUTO_CLEAR_HISTORY_DAYS
|
||||
|
||||
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day.
|
||||
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
|
||||
> To raise the worker limit for a specific extractor, set an env variable using this format: `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`
|
||||
> The extractor name must be uppercase. You can find the extractor name in the download logs. This value cannot be
|
||||
> higher than `YTP_MAX_WORKERS`; higher values are ignored.
|
||||
>
|
||||
> `YTP_SIMPLE_MODE=true` only applies when the browser has no saved layout choice yet. Users can still choose a layout in
|
||||
> WebUI Settings. `/?simple=1` forces and saves Simple for that browser.
|
||||
>
|
||||
> `YTP_AUTO_CLEAR_HISTORY_DAYS` `0` days means no automatic clearing of the download history. lowest value that will
|
||||
> trigger the clearing is `1` day. This setting will **NOT** delete the downloaded files, it will only clear the
|
||||
> history from the database.
|
||||
|
||||
# Browser extensions & bookmarklets
|
||||
|
||||
|
|
@ -84,7 +98,7 @@ Change the the variable `url` and `preset` variables to match your YTPTube insta
|
|||
|
||||
## iOS Shortcuts
|
||||
|
||||
You can download [Add To YTPTube](https://www.icloud.com/shortcuts/18b8f70666a04a06aed09424f97ce951) shortcut and use it to send links to your YTPTube instance.
|
||||
You can download [Add To YTPTube](https://www.icloud.com/shortcuts/6df61c97d97b4e539c9100999ba39dd4) shortcut and use it to send links to your YTPTube instance.
|
||||
You have to edit the shortcut and replace the following:
|
||||
|
||||
- `https://ytp.example.org` with your YTPTube instance.
|
||||
|
|
@ -96,7 +110,7 @@ and use that preset to download directly from your iOS device.
|
|||
|
||||
### Advanced iOS Shortcut
|
||||
|
||||
This shortcut [YTPTube To Media](https://www.icloud.com/shortcuts/6e3db0bd532843e3aec70e6ce211be08) is more advanced, as it's parses
|
||||
This shortcut [YTPTube To Media](https://www.icloud.com/shortcuts/4dc579382f254635ad5785424055f173) is more advanced, as it's parses
|
||||
the `yt-dlp` output and attempt to download the media directly to your iOS device. It doesn't always work, but it's a good
|
||||
starting point for those who want to download media directly to their iOS device. We provide no support for this use case
|
||||
other than the shortcut itself. this shortcut missing support for parsing the http_headers, it's only parse the cookies.
|
||||
|
|
@ -109,6 +123,35 @@ As this is a simple basic authentication, if your browser doesn't show the promp
|
|||
|
||||
`http://username:password@your_ytptube_url:port`
|
||||
|
||||
# Security recommendations
|
||||
|
||||
YTPTube is designed for LAN and home-lab use behind a firewall or reverse proxy. The web interface and API are
|
||||
unauthenticated by default because in a trusted network, auth adds friction without meaningful benefit. However,
|
||||
if you expose YTPTube to the internet directly or via port forwarding **YOU MUST enable authentication**.
|
||||
|
||||
### Without auth, anyone who can reach the API can:
|
||||
|
||||
- Download arbitrary content through your IP and server.
|
||||
- Delete or modify your downloaded files and database.
|
||||
- Run arbitrary `yt-dlp` options, including `--exec`, which executes shell commands inside the container.
|
||||
|
||||
This is not a vulnerability, it's the intended design. The `cli` field passes options directly to `yt-dlp`,
|
||||
a tool that by design can execute commands. Auth is the mechanism that controls who gets to use that power.
|
||||
|
||||
**If you expose YTPTube to untrusted networks**, do one of the following:
|
||||
|
||||
1. **Enable authentication** — set both `YTP_AUTH_USERNAME` and `YTP_AUTH_PASSWORD`.
|
||||
2. **Put it behind a reverse proxy** with its own authentication layer (see [Run behind reverse proxy](#run-behind-reverse-proxy)).
|
||||
3. **Keep it on a private network** with no public exposure.
|
||||
|
||||
YTPTube already gates other powerful features behind explicit opt-in: the built-in terminal, file browser actions and internal
|
||||
URL requests for example. The `cli` field is no different, its power is by design, and access control is your responsibility.
|
||||
|
||||
> [!NOTE]
|
||||
> If you choose to run without authentication but still want to reduce at least some impact, you can set
|
||||
> `YTP_DISABLE_EXEC=true`. This strips some dangerous options at run time. However, understand that this is not a
|
||||
> substitute for auth an unauthenticated API is still fully open for all other operations.
|
||||
|
||||
# I cant download anything
|
||||
|
||||
If you are receiving errors like:
|
||||
|
|
@ -161,27 +204,49 @@ YTP_YTDLP_VERSION=2025.07.21 or master or nightly
|
|||
|
||||
Then restart the container to apply the changes.
|
||||
|
||||
# Custom output template placeholders
|
||||
|
||||
YTPTube supports custom `ytp_*` placeholders in `yt-dlp` output template via the following syntax `%(ytp_*:<args>)s`.
|
||||
|
||||
## Currently available extra placeholders are:
|
||||
|
||||
- `ytp_random`: random mixed letters and digits,
|
||||
- `N` A number is required to specify the length of the random string, for example `%(ytp_random:8)s` will generate a random string of 8 characters.
|
||||
- if the args followed by `:s` it will generate random letters only, if followed by `:d` it will generate random digits only.
|
||||
|
||||
## Examples of the custom placeholders in action:
|
||||
|
||||
- Template: `%(title)s [%(ytp_random:8)s].%(ext)s`
|
||||
- Example result: `My Video [A7k2Pq9Z].mp4`
|
||||
- Template: `%(uploader)s/%(ytp_random:6:d)s - %(title)s.%(ext)s`
|
||||
- Example result: `MyChannel/483920 - My Video.mp4`
|
||||
- Template: `%(playlist)s/%(ytp_random:10:s)s/%(title)s.%(ext)s`
|
||||
- Example result: `Favorites/QwErTyUiOp/My Video.mp4`
|
||||
|
||||
> [!NOTE]
|
||||
> `%(ytp_` placeholders are a YTPTube extension and not avaliable via console or directly via yt-dlp.
|
||||
|
||||
# How can I monitor sites without RSS feeds?
|
||||
|
||||
YTPTube includes a **generic task handler** that turns JSON definition files into site-specific scrapers. You can use it
|
||||
YTPTube includes a **generic task handler** that turns JSON definitions into site-specific scrapers. You can use it
|
||||
to watch pages that do not expose RSS or public APIs and automatically enqueue new links into the download queue.
|
||||
|
||||
1. Create definition files under `/config/tasks/*.json` (for Docker this is the mounted `config/tasks/` folder).
|
||||
2. Keep your scheduled task in `tasks.json` pointing at the page you want to monitor and make sure it uses a preset that
|
||||
enables a download archive (`--download-archive`).
|
||||
3. When the task runs, the handler scans the JSON files, picks the first definition whose `match` rule covers the task
|
||||
URL, fetches the page, extracts items, and queues the unseen ones.
|
||||
1. Create definition via the WebUI > tasks > Definitions.
|
||||
2. Create task that reference same url click on inspect to see the results. Make sure it uses a preset that enables
|
||||
a download archive (`--download-archive`).
|
||||
3. When the task handler run, the handler scans the definitions, picks the first definition whose `match` rule covers
|
||||
the task URL, fetches the page, extracts items, and queues the unseen ones.
|
||||
|
||||
### Definition schema
|
||||
|
||||
Each file must contain a single JSON object with the following keys:
|
||||
Each definition must contain a single JSON object with the following keys:
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "example", // Friendly identifier shown in logs
|
||||
"match": [
|
||||
"https://example.com/articles/*", // Glob strings, or objects with {"regex": "..."} or {"glob": "..."}
|
||||
{ "regex": "https://example.com/post/[0-9]+" }
|
||||
"match_url": [
|
||||
"https://example.com/articles/*", // Glob strings
|
||||
"https://example.com/post/[0-9]+" // Regex strings
|
||||
],
|
||||
"engine": { // Optional, defaults to HTTPX
|
||||
"type": "httpx", // "httpx" (default) or "selenium"
|
||||
|
|
@ -199,7 +264,7 @@ Each file must contain a single JSON object with the following keys:
|
|||
"headers": { "User-Agent": "MyAgent/1.0" },
|
||||
"params": { "page": 1 },
|
||||
"data": null,
|
||||
"json": null,
|
||||
"json_data": null,
|
||||
"timeout": 30
|
||||
},
|
||||
"response": { // Optional: how to interpret the body
|
||||
|
|
@ -239,6 +304,7 @@ For JSON endpoints, switch the response format and use `jsonpath` selectors:
|
|||
|
||||
```json5
|
||||
{
|
||||
...
|
||||
"response": { "type": "json" },
|
||||
"parse": {
|
||||
"items": {
|
||||
|
|
@ -274,9 +340,6 @@ For JSON endpoints, switch the response format and use `jsonpath` selectors:
|
|||
the moment. Optional keys: `arguments` (list or string), `wait_for` (type `css`/`xpath` + `expression`), `wait_timeout`,
|
||||
and `page_load_timeout`.
|
||||
|
||||
Definitions are reloaded automatically when files change, so you can tweak them without restarting YTPTube. Check
|
||||
`var/config/tasks/01-*.json` for sample files.
|
||||
|
||||
> [!NOTE]
|
||||
> A machine-readable schema is available at `app/schema/task_definition.json` if you want to validate your JSON with editors or CI tools.
|
||||
|
||||
|
|
@ -312,22 +375,13 @@ Then simply create a new preset, and in the `Command options for yt-dlp` field s
|
|||
|
||||
```bash
|
||||
--extractor-args "youtubepot-bgutilhttp:base_url=http://bgutil_provider:4416"
|
||||
--extractor-args "youtube:player-client=default,tv,mweb;formats=incomplete"
|
||||
```
|
||||
|
||||
you and also enable the fallback by using the follow extractor args
|
||||
|
||||
```bash
|
||||
--extractor-args "youtubepot-bgutilhttp:base_url=http://bgutil_provider:4416;disable_innertube=1"
|
||||
--extractor-args "youtube:player-client=default,tv,mweb;formats=incomplete"
|
||||
```
|
||||
|
||||
Use this alternative extractor args in case the extractor fails to get the pot tokens from the bgutil provider server.
|
||||
For more information please visit [bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider) project.
|
||||
|
||||
# Troubleshooting and submitting issues
|
||||
|
||||
Before asking a question or submitting an issue for YTPTube, please remember that YTPTube is only a thin wrapper for
|
||||
Before asking a question or submitting an issue for YTPTube, please remember that YTPTube is only a wrapper for
|
||||
[yt-dlp](https://github.com/yt-dlp/yt-dlp). Any issues you might be experiencing with authentication to video websites,
|
||||
postprocessing, permissions, other `yt-dlp options` configurations which seem not to work, or anything else that
|
||||
concerns the workings of the underlying yt-dlp library, need not be opened on the YTPTube project.
|
||||
|
|
@ -338,7 +392,8 @@ and once that is working, importing the options that worked for you into a new `
|
|||
## Via HTTP
|
||||
|
||||
If you have enabled the web terminal via `YTP_CONSOLE_ENABLED` environment variable, simply go to `Other > Terminal` use
|
||||
the yt-dlp command, the interface is jailed to the `yt-dlp` binary you can't access anything else.
|
||||
the yt-dlp command, the interface is jailed to the `yt-dlp` binary you can't access anything else. Or from download form
|
||||
by clicking `advanced options` button than the yellow terminal icon `Run directly in console`.
|
||||
|
||||
## Via CLI
|
||||
|
||||
|
|
@ -498,7 +553,7 @@ as we only test for GPU encoding once on first video stream.
|
|||
|
||||
# Allowing internal URLs requests
|
||||
|
||||
By default, YTPTube prevents requests to internal resources, for security reasons. However, if you want to allow requests to internal URLs, you can set the `YTP_ALLOW_INTERNAL_URLS` environment variable to `true`. This will allow requests to internal URLs.
|
||||
By default, YTPTube prevents requests to internal resources. However, if you want to allow requests to internal URLs, you can set the `YTP_ALLOW_INTERNAL_URLS` environment variable to `true`. This will allow requests to internal URLs.
|
||||
|
||||
We do not recommend enabling this option unless you know what you are doing, as it can expose your internal network to
|
||||
potential security risks. This should only be used if it's truly needed.
|
||||
|
|
@ -552,7 +607,6 @@ services:
|
|||
After making the changes, restart your container. This should resolve the "No space left on device"
|
||||
error during download.
|
||||
|
||||
|
||||
# How to prevent loading screen during YouTube premieres?
|
||||
|
||||
Depending on how you look at it, YTPTube live download implementation is rather great and fast. However, during YouTube
|
||||
|
|
@ -561,10 +615,144 @@ playing. By default we wait for 5min + the duration of the video before starting
|
|||
the loading screen. However, you can override the behavior by setting the following environment variable:
|
||||
|
||||
```env
|
||||
YTP_PREVENT_LIVE_PREMIERE=true
|
||||
YTP_LIVE_PREMIERE_BUFFER=10
|
||||
```
|
||||
|
||||
Where `YTP_LIVE_PREMIERE_BUFFER` is the buffer time in minutes to add to the video duration before the download starts.
|
||||
This will help in case the premiere has a longer loading screen than usual.
|
||||
|
||||
# How to bypass CF challenges?
|
||||
|
||||
You need to setup [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) and then set the `YTP_FLARESOLVERR_URL`
|
||||
environment variable to point to your FlareSolverr instance. For example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ytptube:
|
||||
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id, for example: "1000:1000"
|
||||
image: ghcr.io/arabcoders/ytptube:latest
|
||||
container_name: ytptube
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- YTP_FLARESOLVERR_URL=http://flaresolverr:8191/v1
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/config:rw
|
||||
- ./downloads:/downloads:rw
|
||||
tmpfs:
|
||||
- /tmp
|
||||
depends_on:
|
||||
- flaresolverr
|
||||
flaresolverr:
|
||||
image: flaresolverr/flaresolverr:latest
|
||||
container_name: flaresolverr
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
For more information please visit [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) project.
|
||||
|
||||
# How to use the browser extractor?
|
||||
|
||||
YTPTube ships with a bundled `browser` extractor plugin for `yt-dlp`. It opens the target page in a remote browser,
|
||||
waits for the page network activity, then tries to extract media urls from the captured requests and media elements.
|
||||
|
||||
This is mainly useful for sites where the regular extractor or generic page parsing does not expose the final media url,
|
||||
but a real browser session does.
|
||||
|
||||
## Environment variables
|
||||
|
||||
You can define the remote browser endpoint globally with the following environment variable.
|
||||
|
||||
### Selenium example
|
||||
|
||||
```env
|
||||
YTP_BROWSER_URL=selenium+http://selenium:4444/wd/hub
|
||||
```
|
||||
|
||||
### Playwright example
|
||||
|
||||
For a native Playwright server, point to the Playwright websocket endpoint:
|
||||
|
||||
```env
|
||||
YTP_BROWSER_URL=playwright+ws://playwright:3000/
|
||||
```
|
||||
|
||||
### Playwright CDP example
|
||||
|
||||
To connect Playwright over the Chrome DevTools Protocol, use either the shorthand `playwright+cdp://` form or an explicit transport form:
|
||||
|
||||
```env
|
||||
YTP_BROWSER_URL=playwright+cdp://chrome:9222/
|
||||
# same as playwright+cdp+http://chrome:9222/
|
||||
```
|
||||
|
||||
- `YTP_BROWSER_URL` is the full backend selector and endpoint in one value.
|
||||
- Supported forms are:
|
||||
- `selenium+http://...`
|
||||
- `selenium+https://...`
|
||||
- `playwright+ws://...`
|
||||
- `playwright+wss://...`
|
||||
- `playwright+cdp://...` which is treated as `playwright+cdp+http://...`
|
||||
- `playwright+cdp+http://...`
|
||||
- `playwright+cdp+https://...`
|
||||
- `playwright+cdp+ws://...`
|
||||
- `playwright+cdp+wss://...`
|
||||
|
||||
## yt-dlp usage
|
||||
|
||||
If you want to set the browser extractor options directly on the yt-dlp side, you can also use `--extractor-args` with `generic:url=...`:
|
||||
|
||||
### Selenium example
|
||||
|
||||
```bash
|
||||
--use-extractors "generic" --extractor-args "generic:url=selenium+http://selenium:4444/wd/hub"
|
||||
```
|
||||
|
||||
### Playwright example
|
||||
|
||||
```bash
|
||||
--use-extractors "generic" --extractor-args "generic:url=playwright+ws://playwright:3000/"
|
||||
```
|
||||
|
||||
### Playwright CDP example
|
||||
|
||||
```bash
|
||||
--use-extractors "generic" --extractor-args "generic:url=playwright+cdp://chrome:9222/"
|
||||
```
|
||||
|
||||
The explicit `--extractor-args` value takes priority over `YTP_BROWSER_URL`.
|
||||
|
||||
## Example compose setup
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ytptube:
|
||||
user: "${UID:-1000}:${UID:-1000}"
|
||||
image: ghcr.io/arabcoders/ytptube:latest
|
||||
container_name: ytptube
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- YTP_BROWSER_URL=selenium+http://selenium:4444/wd/hub # or playwright+ws://playwright:3000/ or playwright+cdp://chrome:9222/
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/config:rw
|
||||
- ./downloads:/downloads:rw
|
||||
|
||||
selenium:
|
||||
image: selenium/standalone-chrome:latest
|
||||
container_name: selenium
|
||||
restart: unless-stopped
|
||||
shm_size: 2gb
|
||||
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.59.0-noble
|
||||
container_name: playwright
|
||||
restart: unless-stopped
|
||||
command: /bin/sh -c "npx -y playwright@1.59.0 run-server --port 3000 --host 0.0.0.0"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The browser extractor is slower than the normal extractor flow and should only be used when a site actually needs a real browser session.
|
||||
> playwright require same version for both the server and the client, so make sure to use the same version in the container and in your local environment if you want to test it locally.
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -1,8 +1,9 @@
|
|||
# YTPTube
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from
|
||||
video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and
|
||||
|
|
@ -32,28 +33,39 @@ Example of the Simple mode interface.
|
|||
* Basic authentication support.
|
||||
* Supports `curl-cffi`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation). `In docker only`.
|
||||
* Bundled `pot provider plugin`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide). `In docker only`.
|
||||
* Support using [flaresolverr/flaresolverr](https://github.com/flaresolverr/flaresolverr) to bypass Cloudflare protections for yt-dlp and internal http client. See [related FAQ](FAQ.md#how-to-bypass-cf-challenges).
|
||||
* Automatic updates for `yt-dlp` and custom `pip` packages. `In docker only`.
|
||||
* Conditions feature to apply custom options based on `yt-dlp` returned info.
|
||||
* Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance.
|
||||
* A bundled executable version for Windows, macOS and Linux. `MacOS version is untested`.
|
||||
* Use playwright or selenium for extractors that require a browser. see [related FAQ](FAQ.md#how-to-use-the-browser-extractor).
|
||||
|
||||
Please read the [FAQ](FAQ.md) for more information.
|
||||
|
||||
# Installation
|
||||
|
||||
> [!IMPORTANT]
|
||||
> By default YTPTube runs without authentication. If you expose it to the internet, **enable auth**. See [security recommendations](FAQ.md#security-recommendations).
|
||||
|
||||
## Run using docker command
|
||||
|
||||
```bash
|
||||
mkdir -p ./{config,downloads} && docker run -d --rm --user "${UID}:${UID}" --name ytptube \
|
||||
mkdir -p ./{config,downloads/{files,tmp}} && docker run -itd --rm --user "${UID}:${UID}" --name ytptube \
|
||||
-e YTP_TEMP_PATH=/downloads/tmp -e YTP_DOWNLOAD_PATH=/downloads/files \
|
||||
-p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
|
||||
ghcr.io/arabcoders/ytptube:latest
|
||||
```
|
||||
|
||||
Then you can access the WebUI at `http://localhost:8081`.
|
||||
## Run using podman
|
||||
|
||||
> [!NOTE]
|
||||
> If you are using `podman` instead of `docker`, you can use the same command, but you need to change the user to `0:0`
|
||||
> it will appears to be running as root, but it will run as the user who started the container.
|
||||
```bash
|
||||
mkdir -p ./{config,downloads/{files,tmp}} && podman run -itd --rm --userns=keep-id --name ytptube \
|
||||
-e YTP_TEMP_PATH=/downloads/tmp -e YTP_DOWNLOAD_PATH=/downloads/files \
|
||||
-p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
|
||||
arabcoders/ytptube:latest
|
||||
```
|
||||
|
||||
Then you can access the WebUI at `http://localhost:8081`.
|
||||
|
||||
## Using compose file
|
||||
|
||||
|
|
@ -62,33 +74,31 @@ The following is an example of a `compose.yaml` file that can be used to run YTP
|
|||
```yaml
|
||||
services:
|
||||
ytptube:
|
||||
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id, for example: "1000:1000"
|
||||
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id.
|
||||
# comment out the above line and uncomment the below line if you are using podman-compose.
|
||||
#userns_mode: keep-id
|
||||
image: ghcr.io/arabcoders/ytptube:latest
|
||||
container_name: ytptube
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- YTP_TEMP_PATH=/downloads/tmp
|
||||
- YTP_DOWNLOAD_PATH=/downloads/files
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/config:rw
|
||||
- ./downloads:/downloads:rw
|
||||
tmpfs:
|
||||
- /tmp
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Make sure to change the `user` line to match your user id and group id
|
||||
> If you have low RAM, remove the `tmpfs` and mount a disk-based directory to `/tmp` instead. See [FAQ](FAQ.md#getting-no-space-left-on-device-error) for more information.
|
||||
> Make sure to change the `user` line to match your user id and group id in docker setups, or use `userns_mode: keep-id` in podman setups.
|
||||
|
||||
```bash
|
||||
mkdir -p ./{config,downloads} && docker compose -f compose.yaml up -d
|
||||
mkdir -p ./{config,downloads/{files,tmp}} && docker compose -f compose.yaml up -d
|
||||
```
|
||||
|
||||
Then you can access the WebUI at `http://localhost:8081`.
|
||||
|
||||
> [!NOTE]
|
||||
> you can use podman-compose instead of docker-compose, as it supports the same syntax. However, you should change the
|
||||
> user to `0:0` it will appears to be running as root, but it will run as the user who started the container.
|
||||
|
||||
## Unraid
|
||||
|
||||
For `Unraid` users You can install the `Community Applications` plugin, and search for **ytptube** it comes
|
||||
|
|
@ -102,15 +112,14 @@ For simple API documentation, you can refer to the [API documentation](API.md).
|
|||
|
||||
This project is not affiliated with yt-dlp or any other service.
|
||||
|
||||
It’s a personal project designed to make downloading videos from the internet more convenient. It’s not intended for
|
||||
piracy or any unlawful use.
|
||||
This is a personal project designed to make downloading videos from the internet more convenient for me. It is not
|
||||
intended for piracy or any unlawful use. This project was built primarily for my own use and preferences.
|
||||
|
||||
This project was built primarily for my own needs and preferences. The UI might not be the most polished or visually
|
||||
refined, but I’m happy with it as it is. You can, however, create and load your own UI for complete customization. I
|
||||
plan to refactor the UI/UX in the future using [Nuxt/ui](https://ui.nuxt.com/).
|
||||
AI-assisted tools are used in this project. If you are uncomfortable with this, you should not use this project.
|
||||
|
||||
Contributions are welcome, but I may decline changes that don’t align with my vision for the project. Unsolicited pull
|
||||
requests may be ignored. For suggestions or feature requests, please open a discussion or join the Discord server.
|
||||
Contributions are welcome, but I may decline changes that do not interest me or do not align with my vision for this
|
||||
project. Unsolicited pull requests will be closed. For suggestions or feature requests, please open a discussion or
|
||||
join the Discord server.
|
||||
|
||||
# Social contact
|
||||
|
||||
|
|
|
|||
40
app.spec
40
app.spec
|
|
@ -13,10 +13,6 @@ MANUAL_MAP = {
|
|||
|
||||
|
||||
def top_level_modules(dist_name: str) -> list[str]:
|
||||
"""
|
||||
Resolve distribution name -> importable top-level modules.
|
||||
Honors MANUAL_MAP first; falls back to metadata or normalized name.
|
||||
"""
|
||||
manual: list[str] | None = MANUAL_MAP.get(dist_name) or MANUAL_MAP.get(dist_name.replace("-", "_"))
|
||||
if manual:
|
||||
return manual
|
||||
|
|
@ -24,7 +20,7 @@ def top_level_modules(dist_name: str) -> list[str]:
|
|||
try:
|
||||
dist = importlib.metadata.distribution(dist_name)
|
||||
|
||||
top_level = []
|
||||
top_level: list[str] = []
|
||||
for f in dist.files or []:
|
||||
if f.name == "top_level.txt":
|
||||
txt = (dist.locate_file(f)).read_text().splitlines()
|
||||
|
|
@ -44,7 +40,7 @@ def top_level_modules(dist_name: str) -> list[str]:
|
|||
return [dist_name.replace("-", "_")]
|
||||
|
||||
|
||||
def parse_pyproject(path="pyproject.toml") -> set[str]:
|
||||
def parse_pyproject(path: str = "pyproject.toml") -> set[str]:
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
|
|
@ -52,12 +48,11 @@ def parse_pyproject(path="pyproject.toml") -> set[str]:
|
|||
for extra in data["project"].get("optional-dependencies", {}).values():
|
||||
reqs.update(extra)
|
||||
|
||||
# Strip env markers and versions
|
||||
return {re.split(r"[ ;<>=]", r, 1)[0] for r in reqs} # noqa: B034
|
||||
|
||||
|
||||
dist_names = parse_pyproject()
|
||||
hidden = []
|
||||
hidden: list[str] = []
|
||||
|
||||
for dist in dist_names:
|
||||
if sys.platform != "win32" and dist in ("python-magic-bin", "tzdata"):
|
||||
|
|
@ -69,14 +64,13 @@ for dist in dist_names:
|
|||
hidden += [
|
||||
"engineio.async_drivers.aiohttp",
|
||||
"socketio.async_drivers.aiohttp",
|
||||
"app",
|
||||
"dotenv"
|
||||
"socketio",
|
||||
"socketio", # python-socketio top-level
|
||||
"engineio",
|
||||
"aiohttp",
|
||||
"engineio"
|
||||
"dotenv",
|
||||
"app",
|
||||
]
|
||||
|
||||
# Deduplicate
|
||||
hidden = sorted(set(hidden))
|
||||
|
||||
a = Analysis( # noqa: F821 # type: ignore
|
||||
|
|
@ -90,23 +84,29 @@ a = Analysis( # noqa: F821 # type: ignore
|
|||
hiddenimports=hidden,
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=["libstdc++.so.6"],
|
||||
excludes=[], # do NOT exclude libstdc++.so.6
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # type: ignore # noqa: F821
|
||||
|
||||
exe = EXE( # type: ignore # noqa: F821
|
||||
exe = EXE( # noqa: F821 # type: ignore
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
name="YTPTube",
|
||||
debug=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
icon="ui/public/favicon.ico",
|
||||
onefile=True,
|
||||
exclude_binaries=True,
|
||||
)
|
||||
|
||||
coll = COLLECT( # noqa: F821 # type: ignore
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
name="YTPTube",
|
||||
)
|
||||
|
|
|
|||
25
app/conftest.py
Normal file
25
app/conftest.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from app.tests.helpers import cleanup_test_run_root, get_test_run_root, get_test_system_temp_root
|
||||
|
||||
|
||||
def pytest_configure(config) -> None:
|
||||
temp_root = get_test_system_temp_root()
|
||||
for env_name in ("TMPDIR", "TEMP", "TMP"):
|
||||
os.environ[env_name] = str(temp_root)
|
||||
|
||||
tempfile.tempdir = None
|
||||
|
||||
if getattr(config.option, "basetemp", None) is None:
|
||||
config.option.basetemp = str(get_test_run_root() / "pytest")
|
||||
|
||||
os.environ["YTP_FILE_LOGGING"] = "false"
|
||||
|
||||
|
||||
def pytest_unconfigure(config) -> None:
|
||||
del config
|
||||
os.environ.pop("YTP_FILE_LOGGING", None)
|
||||
cleanup_test_run_root()
|
||||
1
app/features/conditions/__init__.py
Normal file
1
app/features/conditions/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Conditions Feature"""
|
||||
5
app/features/conditions/deps.py
Normal file
5
app/features/conditions/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.conditions.repository import ConditionsRepository
|
||||
|
||||
|
||||
def get_conditions_repo() -> ConditionsRepository:
|
||||
return ConditionsRepository.get_instance()
|
||||
118
app/features/conditions/migration.py
Normal file
118
app/features/conditions/migration.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.conditions.models import ConditionModel
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.conditions.repository import ConditionsRepository
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "conditions"
|
||||
|
||||
def __init__(self, repo: ConditionsRepository, config: Config | None = None):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: ConditionsRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "conditions.json"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
return self._source_file.exists()
|
||||
|
||||
async def migrate(self) -> None:
|
||||
if await self._repo.count() > 0:
|
||||
LOG.warning("Conditions already exist in the database; skipping migration.")
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
try:
|
||||
items: list[dict] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to read conditions migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if items is None:
|
||||
LOG.warning("No conditions found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := await self._normalize(item, index, seen_names)):
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to insert condition '%s'.",
|
||||
normalized.name,
|
||||
extra={"condition_name": normalized.name, "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s condition(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
async def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> ConditionModel | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping condition at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping condition at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping condition at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
|
||||
extras = item.get("extras") if isinstance(item.get("extras"), dict) else {}
|
||||
extras = cast("dict[str, Any]", extras)
|
||||
filter_value: str | None = item.get("filter")
|
||||
if (not filter_value or not isinstance(filter_value, str)) and len(extras) == 0:
|
||||
LOG.warning("Skipping condition '%s' due to missing filter.", name)
|
||||
return None
|
||||
|
||||
cli: str | None = item.get("cli")
|
||||
if not isinstance(cli, str):
|
||||
cli = ""
|
||||
|
||||
enabled_value = item.get("enabled")
|
||||
enabled: bool = enabled_value if isinstance(enabled_value, bool) else True
|
||||
priority_value = item.get("priority")
|
||||
priority: int = priority_value if isinstance(priority_value, int) else 0
|
||||
if isinstance(priority, bool) or 0 > priority:
|
||||
priority = 0
|
||||
|
||||
description_value = item.get("description")
|
||||
description: str = description_value if isinstance(description_value, str) else ""
|
||||
|
||||
return ConditionModel(
|
||||
id=index,
|
||||
name=name,
|
||||
filter=filter_value,
|
||||
cli=cli,
|
||||
extras=extras,
|
||||
enabled=bool(enabled),
|
||||
priority=priority,
|
||||
description=description,
|
||||
)
|
||||
28
app/features/conditions/models.py
Normal file
28
app/features/conditions/models.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class ConditionModel(Base):
|
||||
__tablename__: str = "conditions"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_conditions_name", "name"),
|
||||
Index("ix_conditions_enabled", "enabled"),
|
||||
Index("ix_conditions_priority", "priority"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
filter: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
extras: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
192
app/features/conditions/repository.py
Normal file
192
app/features/conditions/repository.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.conditions.migration import Migration
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
|
||||
from app.features.conditions.models import ConditionModel
|
||||
from app.features.core.deps import get_session
|
||||
from app.library.log import get_logger
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model_from_payload(payload: dict[str, Any]) -> ConditionModel:
|
||||
model = ConditionModel()
|
||||
for key, value in payload.items():
|
||||
if not hasattr(model, key):
|
||||
msg = f"'{key}' is an invalid keyword argument for ConditionModel"
|
||||
raise TypeError(msg)
|
||||
setattr(model, key, value)
|
||||
return model
|
||||
|
||||
|
||||
def _coerce_model(payload: ConditionModel | dict[str, Any]) -> ConditionModel:
|
||||
if isinstance(payload, ConditionModel):
|
||||
return payload
|
||||
return _model_from_payload(payload)
|
||||
|
||||
|
||||
class ConditionsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self, config=None).run()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> ConditionsRepository:
|
||||
return ConditionsRepository()
|
||||
|
||||
async def all(self) -> list[ConditionModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[ConditionModel]] = await session.execute(
|
||||
select(ConditionModel).order_by(ConditionModel.priority.desc(), ConditionModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[ConditionModel], int, int, int]:
|
||||
async with self.session() as session:
|
||||
total: int = await self.count()
|
||||
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
if page > total_pages and total > 0:
|
||||
page = total_pages
|
||||
|
||||
query: Select[tuple[ConditionModel]] = (
|
||||
select(ConditionModel)
|
||||
.order_by(ConditionModel.priority.desc(), ConditionModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[ConditionModel]] = await session.execute(query)
|
||||
return list(result.scalars().all()), total, page, total_pages
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(ConditionModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> ConditionModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = ConditionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause: ColumnElement[bool] = or_(
|
||||
ConditionModel.id == int(identifier), ConditionModel.name == identifier
|
||||
)
|
||||
else:
|
||||
clause: ColumnElement[bool] = ConditionModel.name == identifier
|
||||
|
||||
result: Result[tuple[ConditionModel]] = await session.execute(select(ConditionModel).where(clause).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> ConditionModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[ConditionModel]] = select(ConditionModel).where(ConditionModel.name == name)
|
||||
if exclude_id is not None:
|
||||
query = query.where(ConditionModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[ConditionModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: ConditionModel | dict[str, Any]) -> ConditionModel:
|
||||
async with self.session() as session:
|
||||
model = _coerce_model(payload)
|
||||
if model.id is not None:
|
||||
model.id = None # ty: ignore
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Condition with name '{model.name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def update(self, identifier: int | str, payload: dict[str, Any]) -> ConditionModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = ConditionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause: ColumnElement[bool] = or_(
|
||||
ConditionModel.id == int(identifier), ConditionModel.name == identifier
|
||||
)
|
||||
else:
|
||||
clause: ColumnElement[bool] = ConditionModel.name == identifier
|
||||
|
||||
result: Result[tuple[ConditionModel]] = await session.execute(select(ConditionModel).where(clause).limit(1))
|
||||
if not (model := result.scalar_one_or_none()):
|
||||
msg: str = f"Condition '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
payload.pop("created_at", None)
|
||||
payload.pop("updated_at", None)
|
||||
|
||||
if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
|
||||
msg: str = f"Condition with name '{payload['name']}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
for key, value in payload.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def delete(self, identifier: int | str) -> ConditionModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = ConditionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause: ColumnElement[bool] = or_(
|
||||
ConditionModel.id == int(identifier), ConditionModel.name == identifier
|
||||
)
|
||||
else:
|
||||
clause: ColumnElement[bool] = ConditionModel.name == identifier
|
||||
|
||||
result: Result[tuple[ConditionModel]] = await session.execute(select(ConditionModel).where(clause).limit(1))
|
||||
if not (model := result.scalar_one_or_none()):
|
||||
msg: str = f"Condition '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
|
||||
async def replace_all(self, items: Iterable[dict[str, Any] | ConditionModel]) -> list[ConditionModel]:
|
||||
async with self.session() as session:
|
||||
try:
|
||||
await session.execute(delete(ConditionModel))
|
||||
models: list[ConditionModel] = [_coerce_model(item) for item in items]
|
||||
session.add_all(models)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
return models
|
||||
361
app/features/conditions/router.py
Normal file
361
app/features/conditions/router.py
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.conditions.schemas import Condition, ConditionList, ConditionPatch
|
||||
from app.features.conditions.service import Conditions
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_url
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model(model: Any) -> Condition:
|
||||
return Condition.model_validate(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/conditions/", name="conditions_list")
|
||||
async def conditions_list(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the conditions
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
repo = Conditions.get_instance()._repo
|
||||
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||
return web.json_response(
|
||||
data=ConditionList(
|
||||
items=[_model(model) for model in items],
|
||||
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||
),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/conditions/test/", name="condition_test")
|
||||
async def conditions_test(request: Request, encoder: Encoder, cache: Cache, config: Config) -> Response:
|
||||
"""
|
||||
Test condition against URL.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
cache (Cache): The cache instance.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
params = await request.json()
|
||||
|
||||
if not isinstance(params, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not (url := params.get("url")):
|
||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (cond := params.get("condition")):
|
||||
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
preset: str = params.get("preset", config.default_preset)
|
||||
key: str = cache.hash(url + str(preset))
|
||||
if not cache.has(key):
|
||||
from app.features.ytdlp.extractor import fetch_info
|
||||
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
|
||||
|
||||
(data, _) = await fetch_info(
|
||||
config=YTDLPOpts.get_instance().preset(name=preset).get_all(),
|
||||
url=url,
|
||||
debug=False,
|
||||
no_archive=True,
|
||||
follow_redirect=True,
|
||||
sanitize_info=True,
|
||||
)
|
||||
|
||||
if not data:
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to extract info from '{url!s}'."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
cache.set(key=key, value=data, ttl=600)
|
||||
else:
|
||||
data = cache.get(key)
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"Failed to extract video info for condition check '%s'.",
|
||||
cond,
|
||||
extra={
|
||||
"route": "conditions.match",
|
||||
"condition": cond,
|
||||
"url": url,
|
||||
"preset": preset,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to extract video info. '{e!s}'"},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Failed to extract video info."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.features.ytdlp.mini_filter import match_str
|
||||
|
||||
status: bool = match_str(cond, data)
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"Failed to evaluate condition '%s'.",
|
||||
cond,
|
||||
extra={
|
||||
"route": "conditions.match",
|
||||
"condition": cond,
|
||||
"url": url,
|
||||
"preset": preset,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": str(e)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"status": status,
|
||||
"condition": cond,
|
||||
"data": OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1])))),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/conditions/", name="condition_add")
|
||||
async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Add Condition.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: Condition = Condition.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate condition.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
saved = _serialize(await Conditions.get_instance().save(item=item.model_dump()))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.CREATE, data=saved)
|
||||
)
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("GET", r"api/conditions/{id:\d+}", name="condition_get")
|
||||
async def conditions_get(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the conditions
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not (id := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Conditions.get_instance().get(id)):
|
||||
return web.json_response({"error": "Condition not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/conditions/{id:\d+}", name="condition_delete")
|
||||
async def conditions_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Delete Condition.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not (id := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
deleted = _serialize(await Conditions.get_instance()._repo.delete(id))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.DELETE, data=deleted)
|
||||
)
|
||||
return web.json_response(data=deleted, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except KeyError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
|
||||
@route("PATCH", r"api/conditions/{id:\d+}", name="condition_patch")
|
||||
async def conditions_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Patch Condition.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not (id := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Conditions.get_instance().get(id)):
|
||||
return web.json_response({"error": "Condition not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
service = Conditions.get_instance()
|
||||
|
||||
try:
|
||||
validated = ConditionPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate condition.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await service._repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Condition with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", r"api/conditions/{id:\d+}", name="condition_update")
|
||||
async def conditions_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Update Condition.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not (id := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Conditions.get_instance().get(id)):
|
||||
return web.json_response({"error": "Condition not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
service = Conditions.get_instance()
|
||||
|
||||
try:
|
||||
validated = Condition.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate condition.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await service._repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Condition with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
93
app/features/conditions/schemas.py
Normal file
93
app/features/conditions/schemas.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.features.core.utils import parse_int
|
||||
from app.features.ytdlp.mini_filter import match_str
|
||||
|
||||
|
||||
class Condition(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
filter: str = ""
|
||||
cli: str = ""
|
||||
extras: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
priority: int = 0
|
||||
description: str = ""
|
||||
|
||||
@field_validator("filter")
|
||||
@classmethod
|
||||
def _validate_filter(cls, value: str) -> str:
|
||||
try:
|
||||
match_str(value, {})
|
||||
except Exception as exc:
|
||||
msg: str = f"Invalid filter. '{exc!s}'."
|
||||
raise ValueError(msg) from exc
|
||||
return value
|
||||
|
||||
@field_validator("cli")
|
||||
@classmethod
|
||||
def _validate_cli(cls, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
from app.features.ytdlp.utils import arg_converter
|
||||
|
||||
arg_converter(args=value)
|
||||
except ModuleNotFoundError:
|
||||
return value
|
||||
except Exception as exc:
|
||||
msg: str = f"Invalid command options for yt-dlp. '{exc!s}'."
|
||||
raise ValueError(msg) from exc
|
||||
return value
|
||||
|
||||
@field_validator("priority", mode="before")
|
||||
@classmethod
|
||||
def _normalize_priority(cls, value: Any) -> int:
|
||||
return parse_int(value, field="Priority", minimum=0)
|
||||
|
||||
@field_validator("enabled", mode="before")
|
||||
@classmethod
|
||||
def _validate_enabled(cls, value: Any) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
msg: str = "Enabled must be a boolean."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_cli_or_extras(self) -> Condition:
|
||||
if not self.cli and not self.extras:
|
||||
msg: str = "Either cli or extras must be set."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class ConditionPatch(Condition):
|
||||
"""
|
||||
Model for patching Condition fields. All fields are optional.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
filter: str | None = None
|
||||
cli: str | None = None
|
||||
extras: dict[str, Any] | None = None
|
||||
enabled: bool | None = None
|
||||
priority: int | None = None
|
||||
description: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_cli_or_extras(self) -> ConditionPatch:
|
||||
return self
|
||||
|
||||
|
||||
class ConditionList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[Condition] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
195
app/features/conditions/service.py
Normal file
195
app/features/conditions/service.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
from collections.abc import Iterable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.features.conditions.models import ConditionModel
|
||||
from app.features.conditions.repository import ConditionsRepository
|
||||
from app.features.conditions.schemas import Condition
|
||||
from app.features.ytdlp.mini_filter import match_str
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _ignored_identifiers(ignore_conditions: Iterable[str | int | float] | None) -> tuple[set[str], bool]:
|
||||
ignored: set[str] = set()
|
||||
ignore_all = False
|
||||
|
||||
if not ignore_conditions:
|
||||
return ignored, ignore_all
|
||||
|
||||
for value in ignore_conditions:
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||
continue
|
||||
|
||||
identifier = str(value).strip()
|
||||
if not identifier:
|
||||
continue
|
||||
|
||||
if "*" == identifier:
|
||||
ignore_all = True
|
||||
continue
|
||||
|
||||
ignored.add(identifier)
|
||||
|
||||
return ignored, ignore_all
|
||||
|
||||
|
||||
class Conditions(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
self._repo: ConditionsRepository = ConditionsRepository.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> "Conditions":
|
||||
return Conditions()
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application):
|
||||
async def handle_event(_, __):
|
||||
await self._repo.run_migrations()
|
||||
|
||||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "ConditionsRepository.run_migrations")
|
||||
|
||||
async def get_all(self) -> list[ConditionModel]:
|
||||
return await self._repo.all()
|
||||
|
||||
async def save(self, item: ConditionModel | dict) -> ConditionModel:
|
||||
"""
|
||||
Save the item.
|
||||
|
||||
Args:
|
||||
item (Condition|dict): The items to save.
|
||||
|
||||
Returns:
|
||||
ConditionModel: The current instance.
|
||||
|
||||
"""
|
||||
try:
|
||||
if not isinstance(item, ConditionModel):
|
||||
item: ConditionModel = ConditionModel(**item)
|
||||
except Exception as exc:
|
||||
msg: str = f"Failed to parse item. '{exc!s}'"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
try:
|
||||
repo = self._repo
|
||||
if item.id is None or 0 == item.id:
|
||||
model = await repo.create(item)
|
||||
else:
|
||||
model = await repo.update(item.id, Condition.model_validate(item).model_dump())
|
||||
except KeyError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
return model
|
||||
|
||||
async def has(self, identifier: str) -> bool:
|
||||
"""
|
||||
Check if the item exists by id or name.
|
||||
|
||||
Args:
|
||||
identifier (str): The id or name of the preset.
|
||||
|
||||
Returns:
|
||||
bool: True if the item exists, False otherwise.
|
||||
|
||||
"""
|
||||
return await self.get(identifier) is not None
|
||||
|
||||
async def get(self, identifier: str) -> ConditionModel | None:
|
||||
"""
|
||||
Get the item by id or name.
|
||||
|
||||
Args:
|
||||
identifier (str): The id or name of the preset.
|
||||
|
||||
Returns:
|
||||
ConditionModel|None: The item if found, None otherwise.
|
||||
|
||||
"""
|
||||
repo = self._repo
|
||||
return await repo.get(identifier)
|
||||
|
||||
async def match(
|
||||
self, info: dict, ignore_conditions: Iterable[str | int | float] | None = None
|
||||
) -> ConditionModel | None:
|
||||
"""
|
||||
Check if any condition matches the info dict.
|
||||
|
||||
Args:
|
||||
info (dict): The info dict to check.
|
||||
ignore_conditions (Iterable[str | int | float] | None): Condition ids or names to skip for this match.
|
||||
|
||||
Returns:
|
||||
Condition|None: The condition if found, None otherwise.
|
||||
|
||||
"""
|
||||
if not info or not isinstance(info, dict) or len(info) < 1:
|
||||
return None
|
||||
|
||||
ignored_identifiers, ignore_all = _ignored_identifiers(ignore_conditions)
|
||||
if ignore_all:
|
||||
return None
|
||||
|
||||
repo = self._repo
|
||||
items: list[ConditionModel] = await repo.all()
|
||||
if len(items) < 1:
|
||||
return None
|
||||
|
||||
for item in sorted(items, key=lambda x: x.priority, reverse=True):
|
||||
if not item.enabled:
|
||||
continue
|
||||
|
||||
if str(item.id) in ignored_identifiers or item.name in ignored_identifiers:
|
||||
continue
|
||||
|
||||
if not item.filter:
|
||||
LOG.error(
|
||||
"Filter is empty for '%s'.", item.name, extra={"condition_id": item.id, "condition_name": item.name}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if not match_str(item.filter, info):
|
||||
continue
|
||||
|
||||
LOG.debug(
|
||||
"Matched '%s: %s' with filter '%s'.",
|
||||
item.id,
|
||||
item.name,
|
||||
item.filter,
|
||||
extra={"condition_id": item.id, "condition_name": item.name, "filter": item.filter},
|
||||
)
|
||||
return item
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"Failed to evaluate condition '%s'.",
|
||||
item.name,
|
||||
extra={"condition_id": item.id, "condition_name": item.name, "exception_type": type(e).__name__},
|
||||
)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def single_match(self, identifier: int | str, info: dict) -> ConditionModel | None:
|
||||
"""
|
||||
Check if condition matches the info dict.
|
||||
|
||||
Args:
|
||||
identifier (int|str): The id or name of the item.
|
||||
info (dict): The info dict to check.
|
||||
|
||||
Returns:
|
||||
ConditionModel|None: The condition if found, None otherwise.
|
||||
|
||||
"""
|
||||
if not info or not isinstance(info, dict) or len(info) < 1:
|
||||
return None
|
||||
|
||||
if not (item := await self.get(str(identifier))) or not item.enabled or not item.filter:
|
||||
return None
|
||||
|
||||
return item if match_str(item.filter, info) else None
|
||||
246
app/features/conditions/tests/test_conditions.py
Normal file
246
app/features/conditions/tests/test_conditions.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"""Tests for ConditionsRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from typing import Any
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from app.features.conditions.router import conditions_test
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.features.conditions.repository import ConditionsRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.tests.helpers import make_in_memory_db_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo():
|
||||
ConditionsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=make_in_memory_db_path("conditions-repository"))
|
||||
await store.get_connection()
|
||||
|
||||
# Create repository
|
||||
repository = ConditionsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
# Cleanup - close connections properly
|
||||
await store.close()
|
||||
|
||||
# Reset singletons
|
||||
ConditionsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
def _json_request(path: str, payload: object) -> web.Request:
|
||||
mock_request: Any = make_mocked_request("POST", path)
|
||||
|
||||
async def _json() -> object:
|
||||
return payload
|
||||
|
||||
mock_request.json = _json
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestAllowInternalUrlsScope:
|
||||
def setup_method(self) -> None:
|
||||
Config._reset_singleton()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_internal_url(self) -> None:
|
||||
config = Config.get_instance()
|
||||
config.allow_internal_urls = False
|
||||
encoder = Encoder()
|
||||
cache = SimpleNamespace(hash=lambda value: value, has=lambda _key: False, set=lambda **_kwargs: None)
|
||||
|
||||
request = _json_request(
|
||||
"/api/conditions/test/",
|
||||
{"url": "http://127.0.0.1/test", "condition": "title", "preset": config.default_preset},
|
||||
)
|
||||
|
||||
response = await conditions_test(request, encoder, cache, config)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
body = response.body
|
||||
assert isinstance(body, bytes)
|
||||
assert b"internal urls" in body.lower()
|
||||
|
||||
|
||||
class TestConditionsRepository:
|
||||
"""Test suite for ConditionsRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_condition(self, repo):
|
||||
"""Create condition with valid data."""
|
||||
data = {
|
||||
"name": "Test Condition",
|
||||
"filter": "duration > 60",
|
||||
"cli": "--format best",
|
||||
"enabled": True,
|
||||
"priority": 10,
|
||||
"description": "Test description",
|
||||
"extras": {"key": "value"},
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.id is not None, "Should generate ID for new condition"
|
||||
assert model.name == "Test Condition", "Should store name correctly"
|
||||
assert model.filter == "duration > 60", "Should store filter correctly"
|
||||
assert model.cli == "--format best", "Should store CLI correctly"
|
||||
assert model.enabled is True, "Should store enabled flag correctly"
|
||||
assert model.priority == 10, "Should store priority correctly"
|
||||
assert model.description == "Test description", "Should store description correctly"
|
||||
assert model.extras == {"key": "value"}, "Should store extras as dict"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_defaults(self, repo):
|
||||
"""Create condition with minimal data uses defaults."""
|
||||
data = {
|
||||
"name": "Minimal",
|
||||
"filter": "duration > 30",
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.cli == "", "Should default CLI to empty string"
|
||||
assert model.enabled is True, "Should default enabled to True"
|
||||
assert model.priority == 0, "Should default priority to 0"
|
||||
assert model.description == "", "Should default description to empty"
|
||||
assert model.extras == {}, "Should default extras to empty dict"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id(self, repo):
|
||||
"""Get condition by integer ID."""
|
||||
created = await repo.create({"name": "Get Test", "filter": "duration > 40"})
|
||||
|
||||
retrieved = await repo.get(created.id)
|
||||
|
||||
assert retrieved is not None, "Should retrieve created condition"
|
||||
assert retrieved.id == created.id, "Should retrieve correct condition by ID"
|
||||
assert retrieved.name == "Get Test", "Should match created condition name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_name(self, repo):
|
||||
"""Get condition by string name."""
|
||||
await repo.create({"name": "Named Test", "filter": "duration > 50"})
|
||||
|
||||
retrieved = await repo.get("Named Test")
|
||||
|
||||
assert retrieved is not None, "Should retrieve by name"
|
||||
assert retrieved.name == "Named Test", "Should match condition name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent(self, repo):
|
||||
"""Get nonexistent condition returns None."""
|
||||
result = await repo.get(99999)
|
||||
assert result is None, "Should return None for nonexistent ID"
|
||||
|
||||
result = await repo.get("nonexistent")
|
||||
assert result is None, "Should return None for nonexistent name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_condition(self, repo):
|
||||
"""Update existing condition."""
|
||||
created = await repo.create({"name": "Update Test", "filter": "duration > 60"})
|
||||
|
||||
updated = await repo.update(
|
||||
created.id,
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"priority": 5,
|
||||
"extras": {"updated": True},
|
||||
},
|
||||
)
|
||||
|
||||
assert updated.name == "Updated Name", "Should update name"
|
||||
assert updated.priority == 5, "Should update priority"
|
||||
assert updated.extras == {"updated": True}, "Should update extras"
|
||||
assert updated.filter == "duration > 60", "Should preserve unchanged filter"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_raises(self, repo):
|
||||
"""Update nonexistent condition raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.update(99999, {"name": "Should Fail"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_condition(self, repo):
|
||||
"""Delete existing condition."""
|
||||
created = await repo.create({"name": "Delete Test", "filter": "duration > 70"})
|
||||
|
||||
deleted = await repo.delete(created.id)
|
||||
|
||||
assert deleted.id == created.id, "Should return deleted condition"
|
||||
|
||||
result = await repo.get(created.id)
|
||||
assert result is None, "Deleted condition should not be retrievable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_raises(self, repo):
|
||||
"""Delete nonexistent condition raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.delete(99999)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo):
|
||||
"""List paginated returns correct subset."""
|
||||
for i in range(5):
|
||||
await repo.create({"name": f"Item {i}", "filter": "duration > 10", "priority": i})
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||
|
||||
assert len(items) == 2, "Should return 2 items per page"
|
||||
assert total == 5, "Should report total count of 5"
|
||||
assert page == 1, "Should be on page 1"
|
||||
assert total_pages == 3, "Should have 3 pages total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_ordering(self, repo):
|
||||
"""List orders by priority desc then name asc."""
|
||||
await repo.create({"name": "B", "filter": "test", "priority": 1})
|
||||
await repo.create({"name": "A", "filter": "test", "priority": 1})
|
||||
await repo.create({"name": "C", "filter": "test", "priority": 2})
|
||||
|
||||
items = await repo.all()
|
||||
|
||||
assert items[0].name == "C", "Highest priority should be first"
|
||||
assert items[1].name == "A", "Same priority sorted alphabetically"
|
||||
assert items[2].name == "B", "Same priority sorted alphabetically"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_name_excludes_id(self, repo):
|
||||
"""Get by name can exclude specific ID."""
|
||||
first = await repo.create({"name": "Duplicate", "filter": "test"})
|
||||
|
||||
result = await repo.get_by_name("Duplicate", exclude_id=first.id)
|
||||
assert result is None, "Should not find when excluding only match"
|
||||
|
||||
result = await repo.get_by_name("Duplicate", exclude_id=None)
|
||||
assert result is not None, "Should find without exclusion"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_all(self, repo):
|
||||
"""Replace all conditions atomically."""
|
||||
await repo.create({"name": "Old 1", "filter": "test"})
|
||||
await repo.create({"name": "Old 2", "filter": "test"})
|
||||
|
||||
new_items = [
|
||||
{"name": "New 1", "filter": "duration > 10"},
|
||||
{"name": "New 2", "filter": "duration > 20"},
|
||||
]
|
||||
|
||||
result = await repo.replace_all(new_items)
|
||||
|
||||
assert len(result) == 2, "Should create 2 new conditions"
|
||||
|
||||
all_items = await repo.all()
|
||||
assert len(all_items) == 2, "Should only have new conditions"
|
||||
assert all_items[0].name in ["New 1", "New 2"], "Should only have new items"
|
||||
12
app/features/core/deps.py
Normal file
12
app/features/core/deps.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_session() -> AsyncGenerator[AsyncSession]:
|
||||
async with SqliteStore.get_instance().sessionmaker()() as session:
|
||||
yield session
|
||||
71
app/features/core/migration.py
Normal file
71
app/features/core/migration.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.library.config import Config
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(abc.ABC):
|
||||
name: str = ""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self._migrated_dir: Path = Path(config.config_path) / "migrated"
|
||||
|
||||
async def run(self) -> bool:
|
||||
if not await self.should_run():
|
||||
return False
|
||||
|
||||
self._migrated_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
await self.migrate()
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Feature migration '%s' failed.",
|
||||
self.name,
|
||||
extra={"feature": self.name, "exception_type": type(exc).__name__},
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@abc.abstractmethod
|
||||
async def should_run(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
async def migrate(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _move_file(self, source: Path) -> Path:
|
||||
destination: Path = self._migrated_dir / source.name
|
||||
if destination.exists():
|
||||
timestamp = int(time.time())
|
||||
destination: Path = self._migrated_dir / f"{source.stem}_{timestamp}{source.suffix}"
|
||||
|
||||
source.rename(destination)
|
||||
return destination
|
||||
|
||||
def _unique_name(self, name: str, seen_names: dict[str, int]) -> str:
|
||||
base = name
|
||||
count = seen_names.get(base, 0)
|
||||
if count == 0:
|
||||
seen_names[base] = 1
|
||||
return base
|
||||
|
||||
suffix = count + 1
|
||||
new_name = f"{base} ({suffix})"
|
||||
while new_name in seen_names:
|
||||
suffix += 1
|
||||
new_name = f"{base} ({suffix})"
|
||||
|
||||
seen_names[base] = suffix
|
||||
seen_names[new_name] = 1
|
||||
return new_name
|
||||
41
app/features/core/models.py
Normal file
41
app/features/core/models.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime as SQLADateTime
|
||||
from sqlalchemy import Dialect, TypeDecorator
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""
|
||||
Return current UTC time as timezone-aware datetime.
|
||||
|
||||
This is the canonical way to get current UTC time for database fields.
|
||||
"""
|
||||
return datetime.now(tz=UTC)
|
||||
|
||||
|
||||
class UTCDateTime(TypeDecorator):
|
||||
impl = SQLADateTime
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None:
|
||||
"""Convert datetime to UTC before storing."""
|
||||
_ = dialect
|
||||
if value is not None:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value: datetime | None, dialect: Dialect) -> datetime | None:
|
||||
"""Ensure datetime is timezone-aware (UTC) when loading."""
|
||||
_ = dialect
|
||||
if value is not None and value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
44
app/features/core/schemas.py
Normal file
44
app/features/core/schemas.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
page: int
|
||||
per_page: int
|
||||
total: int
|
||||
total_pages: int
|
||||
has_next: bool
|
||||
has_prev: bool
|
||||
|
||||
|
||||
class CEFeature(str, Enum):
|
||||
PRESETS = "presets"
|
||||
DL_FIELDS = "dl_fields"
|
||||
CONDITIONS = "conditions"
|
||||
NOTIFICATIONS = "notifications"
|
||||
TASKS = "tasks"
|
||||
TASKS_DEFINITIONS = "tasks_definitions"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class CEAction(str, Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
REPLACE = "replace"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class ConfigEvent(BaseModel):
|
||||
feature: CEFeature
|
||||
action: CEAction
|
||||
data: dict[str, Any] | list[dict[str, Any]]
|
||||
extras: dict[str, Any] = Field(default_factory=dict)
|
||||
98
app/features/core/utils.py
Normal file
98
app/features/core/utils.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aiohttp.web import Request
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
def parse_int(value: Any, *, field: str, minimum: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except Exception as exc:
|
||||
msg = f"{field} must be an integer."
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
if minimum is not None and parsed < minimum:
|
||||
msg = f"{field} must be >= {minimum}."
|
||||
raise ValueError(msg)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def normalize_pagination(request: Request, page: int | None = None, per_page: int | None = None) -> tuple[int, int]:
|
||||
if page is None:
|
||||
page = int(request.query.get("page", 1))
|
||||
|
||||
if per_page is None:
|
||||
per_page = int(request.query.get("per_page", 0))
|
||||
|
||||
if 0 == per_page:
|
||||
from app.library.config import Config
|
||||
|
||||
items_per_page: int = int(Config.get_instance().default_pagination)
|
||||
per_page = items_per_page // 2 if items_per_page > 50 else items_per_page
|
||||
|
||||
if page < 1:
|
||||
msg = "page must be >= 1."
|
||||
raise ValueError(msg)
|
||||
|
||||
if per_page < 1 or per_page > 1000:
|
||||
msg = "per_page must be between 1 and 1000."
|
||||
raise ValueError(msg)
|
||||
|
||||
return int(page), int(per_page)
|
||||
|
||||
|
||||
def build_pagination(total: int, page: int, per_page: int, total_pages: int) -> dict:
|
||||
return {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
}
|
||||
|
||||
|
||||
def format_validation_errors(exc: ValidationError) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Format Pydantic ValidationError.
|
||||
|
||||
Args:
|
||||
exc: The ValidationError from Pydantic
|
||||
|
||||
Returns:
|
||||
List of dicts with loc, msg, and type
|
||||
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"loc": list(error.get("loc", [])),
|
||||
"msg": error.get("msg", "Validation error"),
|
||||
"type": error.get("type", "value_error"),
|
||||
}
|
||||
for error in exc.errors()
|
||||
]
|
||||
|
||||
|
||||
def gen_random(length: int = 16) -> str:
|
||||
import secrets
|
||||
import string
|
||||
|
||||
if length < 1:
|
||||
msg = "length must be >= 1"
|
||||
raise ValueError(msg)
|
||||
|
||||
middle_alphabet = string.ascii_letters + string.digits + "-_"
|
||||
edge_alphabet = string.ascii_letters + string.digits
|
||||
|
||||
if 1 == length:
|
||||
return secrets.choice(edge_alphabet)
|
||||
|
||||
return (
|
||||
secrets.choice(edge_alphabet)
|
||||
+ "".join(secrets.choice(middle_alphabet) for _ in range(length - 2))
|
||||
+ secrets.choice(edge_alphabet)
|
||||
)
|
||||
1
app/features/dl_fields/__init__.py
Normal file
1
app/features/dl_fields/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""DL Fields Feature"""
|
||||
5
app/features/dl_fields/deps.py
Normal file
5
app/features/dl_fields/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
|
||||
|
||||
def get_dl_fields_repo() -> DLFieldsRepository:
|
||||
return DLFieldsRepository.get_instance()
|
||||
121
app/features/dl_fields/migration.py
Normal file
121
app/features/dl_fields/migration.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.dl_fields.schemas import DLField
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "dl_fields"
|
||||
|
||||
def __init__(self, repo: DLFieldsRepository, config: Config | None = None):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: DLFieldsRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "dl_fields.json"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
return self._source_file.exists()
|
||||
|
||||
async def migrate(self) -> None:
|
||||
if await self._repo.count() > 0:
|
||||
LOG.warning("DL fields already exist in the database; skipping migration.")
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to read download fields migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if items is None:
|
||||
LOG.warning("No dl fields found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := self._normalize(item, index, seen_names)):
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to insert download field '%s'.",
|
||||
normalized["name"],
|
||||
extra={"field_name": normalized["name"], "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s dl field(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping dl field at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping dl field at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping dl field at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
description: str = item.get("description") if isinstance(item.get("description"), str) else ""
|
||||
field_value: str | None = item.get("field")
|
||||
if not field_value or not isinstance(field_value, str):
|
||||
LOG.warning("Skipping dl field '%s' due to missing field value.", name)
|
||||
return None
|
||||
|
||||
kind: str = item.get("kind") if isinstance(item.get("kind"), str) else "text"
|
||||
icon: str = item.get("icon") if isinstance(item.get("icon"), str) else ""
|
||||
value: str = item.get("value") if isinstance(item.get("value"), str) else ""
|
||||
extras = item.get("extras") if isinstance(item.get("extras"), dict) else {}
|
||||
extras = cast("dict[str, Any]", extras)
|
||||
|
||||
order_value: int = 0
|
||||
if isinstance(item.get("order"), int) and not isinstance(item.get("order"), bool):
|
||||
order_value = item.get("order", 0)
|
||||
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"field": field_value,
|
||||
"kind": kind,
|
||||
"icon": icon,
|
||||
"order": order_value,
|
||||
"value": value,
|
||||
"extras": extras,
|
||||
}
|
||||
|
||||
try:
|
||||
DLField.model_validate(payload)
|
||||
except Exception as exc:
|
||||
LOG.warning("Skipping dl field '%s' due to validation error: %s", name, exc)
|
||||
return None
|
||||
|
||||
return payload
|
||||
29
app/features/dl_fields/models.py
Normal file
29
app/features/dl_fields/models.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import JSON, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class DLFieldModel(Base):
|
||||
__tablename__: str = "dl_fields"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_dl_fields_name", "name"),
|
||||
Index("ix_dl_fields_order", "order"),
|
||||
Index("ix_dl_fields_kind", "kind"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
field: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(50), nullable=False, default="text")
|
||||
icon: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
extras: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
190
app/features/dl_fields/repository.py
Normal file
190
app/features/dl_fields/repository.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.dl_fields.migration import Migration
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model_from_payload(payload: dict[str, Any]) -> DLFieldModel:
|
||||
model = DLFieldModel()
|
||||
for key, value in payload.items():
|
||||
if not hasattr(model, key):
|
||||
msg = f"'{key}' is an invalid keyword argument for DLFieldModel"
|
||||
raise TypeError(msg)
|
||||
setattr(model, key, value)
|
||||
return model
|
||||
|
||||
|
||||
def _coerce_model(payload: DLFieldModel | dict[str, Any]) -> DLFieldModel:
|
||||
if isinstance(payload, DLFieldModel):
|
||||
return payload
|
||||
return _model_from_payload(payload)
|
||||
|
||||
|
||||
class DLFieldsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self).run()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> DLFieldsRepository:
|
||||
return DLFieldsRepository()
|
||||
|
||||
async def all(self) -> list[DLFieldModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(
|
||||
select(DLFieldModel).order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[DLFieldModel], int, int, int]:
|
||||
async with self.session() as session:
|
||||
total: int = await self.count()
|
||||
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
if page > total_pages and total > 0:
|
||||
page = total_pages
|
||||
|
||||
query: Select[tuple[DLFieldModel]] = (
|
||||
select(DLFieldModel)
|
||||
.order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(query)
|
||||
return list(result.scalars().all()), total, page, total_pages
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(DLFieldModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> DLFieldModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = DLFieldModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
|
||||
else:
|
||||
clause = DLFieldModel.name == identifier
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> DLFieldModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[DLFieldModel]] = select(DLFieldModel).where(DLFieldModel.name == name)
|
||||
if exclude_id is not None:
|
||||
query = query.where(DLFieldModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: DLFieldModel | dict[str, Any]) -> DLFieldModel:
|
||||
async with self.session() as session:
|
||||
model = _coerce_model(payload)
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"DL field with name '{model.name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def update(self, identifier: int | str, payload: dict[str, Any]) -> DLFieldModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = DLFieldModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
|
||||
else:
|
||||
clause = DLFieldModel.name == identifier
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
|
||||
model: DLFieldModel | None = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"DL field '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
|
||||
if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
|
||||
msg = f"DL field with name '{payload['name']}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
for key, value in payload.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def delete(self, identifier: int | str) -> DLFieldModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = DLFieldModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
|
||||
else:
|
||||
clause = DLFieldModel.name == identifier
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
|
||||
model = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"DL field '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
|
||||
async def replace_all(self, items: Iterable[dict[str, Any] | DLFieldModel]) -> list[DLFieldModel]:
|
||||
async with self.session() as session:
|
||||
try:
|
||||
await session.execute(delete(DLFieldModel))
|
||||
models: list[DLFieldModel] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
models.append(DLFieldModel(**cast("dict[str, Any]", item)))
|
||||
else:
|
||||
models.append(item)
|
||||
session.add_all(models)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
return models
|
||||
176
app/features/dl_fields/router.py
Normal file
176
app/features/dl_fields/router.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.dl_fields.schemas import DLField, DLFieldList, DLFieldPatch
|
||||
from app.features.dl_fields.service import DLFields
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model(model: Any) -> DLField:
|
||||
return DLField.model_validate(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/dl_fields/", "dl_fields")
|
||||
async def dl_fields_list(request: Request, encoder: Encoder) -> Response:
|
||||
repo = DLFields.get_instance()._repo
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||
return web.json_response(
|
||||
data=DLFieldList(
|
||||
items=[_model(model) for model in items],
|
||||
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||
),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/dl_fields/", "dl_fields_add")
|
||||
async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: DLField = DLField.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
saved = _serialize(await DLFields.get_instance().save(item=item.model_dump()))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.CREATE, data=saved))
|
||||
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("GET", r"api/dl_fields/{id:\d+}", "dl_fields_get")
|
||||
async def dl_fields_get(request: Request, encoder: Encoder) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await DLFields.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/dl_fields/{id:\d+}", "dl_fields_delete")
|
||||
async def dl_fields_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
deleted = _serialize(await DLFields.get_instance()._repo.delete(identifier))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.DELETE, data=deleted)
|
||||
)
|
||||
return web.json_response(
|
||||
data=deleted,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
except KeyError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
|
||||
@route("PATCH", r"api/dl_fields/{id:\d+}", "dl_fields_patch")
|
||||
async def dl_fields_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await DLFields.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = DLFieldPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"DL field with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
return web.json_response(
|
||||
data=updated,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", r"api/dl_fields/{id:\d+}", "dl_fields_update")
|
||||
async def dl_fields_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await DLFields.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = DLField.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"DL field with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
101
app/features/dl_fields/schemas.py
Normal file
101
app/features/dl_fields/schemas.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.features.core.utils import parse_int
|
||||
|
||||
|
||||
class FieldType(str, Enum):
|
||||
STRING = "string"
|
||||
TEXT = "text"
|
||||
BOOL = "bool"
|
||||
|
||||
|
||||
class DLField(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
field: str = Field(min_length=1)
|
||||
kind: FieldType = FieldType.TEXT
|
||||
icon: str = ""
|
||||
order: int = 0
|
||||
value: str = ""
|
||||
extras: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("field")
|
||||
@classmethod
|
||||
def _validate_field(cls, value: str) -> str:
|
||||
if not value.startswith("--"):
|
||||
msg: str = "Field must start with '--'."
|
||||
raise ValueError(msg)
|
||||
if " " in value:
|
||||
msg = "Field must not contain spaces."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@field_validator("order", mode="before")
|
||||
@classmethod
|
||||
def _normalize_order(cls, value: Any) -> int:
|
||||
return parse_int(value, field="Order", minimum=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize_extras(self) -> DLField:
|
||||
if not isinstance(self.extras, dict):
|
||||
msg: str = "Extras must be a dictionary."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class DLFieldPatch(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
field: str | None = None
|
||||
kind: FieldType | None = None
|
||||
icon: str | None = None
|
||||
order: int | None = None
|
||||
value: str | None = None
|
||||
extras: dict[str, Any] | None = None
|
||||
|
||||
@field_validator("field")
|
||||
@classmethod
|
||||
def _validate_field(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
if not value.startswith("--"):
|
||||
msg: str = "Field must start with '--'."
|
||||
raise ValueError(msg)
|
||||
if " " in value:
|
||||
msg = "Field must not contain spaces."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@field_validator("order", mode="before")
|
||||
@classmethod
|
||||
def _normalize_order(cls, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
return parse_int(value, field="Order", minimum=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize_extras(self) -> DLFieldPatch:
|
||||
if self.extras is None:
|
||||
return self
|
||||
if not isinstance(self.extras, dict):
|
||||
msg: str = "Extras must be a dictionary."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class DLFieldList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[DLField] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
62
app/features/dl_fields/service.py
Normal file
62
app/features/dl_fields/service.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.features.dl_fields.schemas import DLField
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aiohttp import web
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class DLFields(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
self._repo: DLFieldsRepository = DLFieldsRepository.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> DLFields:
|
||||
return DLFields()
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
async def handle_event(_, __):
|
||||
await self._repo.run_migrations()
|
||||
|
||||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "DLFieldsRepository.run_migrations")
|
||||
|
||||
async def get_all(self) -> list[DLFieldModel]:
|
||||
return await self._repo.all()
|
||||
|
||||
async def get_all_serialized(self) -> list[dict[str, Any]]:
|
||||
items = await self._repo.all()
|
||||
return [DLField.model_validate(item).model_dump() for item in items]
|
||||
|
||||
async def save(self, item: DLField | dict) -> DLFieldModel:
|
||||
try:
|
||||
if not isinstance(item, DLField):
|
||||
item = DLField.model_validate(item)
|
||||
except Exception as exc:
|
||||
msg: str = f"Failed to parse item. '{exc!s}'"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
try:
|
||||
repo = self._repo
|
||||
if item.id is None or 0 == item.id:
|
||||
model = await repo.create(item.model_dump(exclude_unset=True))
|
||||
else:
|
||||
model = await repo.update(item.id, item.model_dump(exclude_unset=True))
|
||||
except KeyError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
return model
|
||||
|
||||
async def get(self, identifier: int | str) -> DLFieldModel | None:
|
||||
return await self._repo.get(identifier)
|
||||
74
app/features/dl_fields/tests/test_dl_fields.py
Normal file
74
app/features/dl_fields/tests/test_dl_fields.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Tests for dl_fields feature service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.features.dl_fields.service import DLFields
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.tests.helpers import make_in_memory_db_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo():
|
||||
"""Provide a fresh repository instance with initialized database for each test."""
|
||||
DLFieldsRepository._reset_singleton()
|
||||
DLFields._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=make_in_memory_db_path("dl-fields-service"))
|
||||
await store.get_connection()
|
||||
|
||||
repository = DLFieldsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
await store.close()
|
||||
|
||||
DLFieldsRepository._reset_singleton()
|
||||
DLFields._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestDLFieldsService:
|
||||
"""Test suite for DLFields service methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_creates_field(self, repo):
|
||||
"""Save should create a new dl field when ID is missing."""
|
||||
service = DLFields.get_instance()
|
||||
payload = {
|
||||
"name": "quality",
|
||||
"description": "Video quality",
|
||||
"field": "--format",
|
||||
"kind": "string",
|
||||
"order": 1,
|
||||
"extras": {"options": ["best", "worst"]},
|
||||
}
|
||||
|
||||
model = await service.save(payload)
|
||||
|
||||
assert model.id is not None, "Should create new dl field"
|
||||
assert model.name == "quality", "Should store name correctly"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_serialized(self, repo):
|
||||
"""Get all serialized returns list of dictionaries."""
|
||||
service = DLFields.get_instance()
|
||||
await service.save(
|
||||
{
|
||||
"name": "audio_only",
|
||||
"description": "Audio",
|
||||
"field": "--extract-audio",
|
||||
"kind": "bool",
|
||||
"order": 2,
|
||||
}
|
||||
)
|
||||
|
||||
items = await service.get_all_serialized()
|
||||
|
||||
assert len(items) == 1, "Should return one dl field"
|
||||
assert items[0]["name"] == "audio_only", "Should serialize name"
|
||||
assert isinstance(items[0]["id"], int), "Should serialize integer ID"
|
||||
248
app/features/dl_fields/tests/test_dl_fields_repository.py
Normal file
248
app/features/dl_fields/tests/test_dl_fields_repository.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""Tests for DLFieldsRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.tests.helpers import make_in_memory_db_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo():
|
||||
"""Provide a fresh repository instance with initialized database for each test."""
|
||||
DLFieldsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=make_in_memory_db_path("dl-fields-repository"))
|
||||
await store.get_connection()
|
||||
|
||||
repository = DLFieldsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
await store.close()
|
||||
|
||||
DLFieldsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestDLFieldsRepository:
|
||||
"""Test suite for DLFieldsRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_field(self, repo):
|
||||
"""Create field with valid data."""
|
||||
data = {
|
||||
"name": "quality",
|
||||
"description": "Video quality setting",
|
||||
"field": "--format",
|
||||
"kind": "string",
|
||||
"icon": "fa-video",
|
||||
"order": 1,
|
||||
"value": "best",
|
||||
"extras": {"options": ["best", "worst"]},
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.id is not None, "Should generate ID for new field"
|
||||
assert model.name == "quality", "Should store name correctly"
|
||||
assert model.description == "Video quality setting", "Should store description correctly"
|
||||
assert model.field == "--format", "Should store field correctly"
|
||||
assert model.kind == "string", "Should store kind correctly"
|
||||
assert model.icon == "fa-video", "Should store icon correctly"
|
||||
assert model.order == 1, "Should store order correctly"
|
||||
assert model.value == "best", "Should store value correctly"
|
||||
assert model.extras == {"options": ["best", "worst"]}, "Should store extras as dict"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_defaults(self, repo):
|
||||
"""Create field with minimal data uses defaults."""
|
||||
data = {
|
||||
"name": "minimal",
|
||||
"description": "Minimal",
|
||||
"field": "--minimal",
|
||||
"kind": "text",
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.icon == "", "Should default icon to empty string"
|
||||
assert model.order == 0, "Should default order to 0"
|
||||
assert model.value == "", "Should default value to empty string"
|
||||
assert model.extras == {}, "Should default extras to empty dict"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id(self, repo):
|
||||
"""Get field by integer ID."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "get_test",
|
||||
"description": "Get test",
|
||||
"field": "--get-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get(created.id)
|
||||
|
||||
assert retrieved is not None, "Should retrieve created field"
|
||||
assert retrieved.id == created.id, "Should retrieve correct field by ID"
|
||||
assert retrieved.name == "get_test", "Should match created field name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_name(self, repo):
|
||||
"""Get field by string name."""
|
||||
await repo.create(
|
||||
{
|
||||
"name": "named_test",
|
||||
"description": "Named test",
|
||||
"field": "--named-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get("named_test")
|
||||
|
||||
assert retrieved is not None, "Should retrieve by name"
|
||||
assert retrieved.name == "named_test", "Should match field name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent(self, repo):
|
||||
"""Get nonexistent field returns None."""
|
||||
result = await repo.get(99999)
|
||||
assert result is None, "Should return None for nonexistent ID"
|
||||
|
||||
result = await repo.get("nonexistent")
|
||||
assert result is None, "Should return None for nonexistent name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_field(self, repo):
|
||||
"""Update existing field."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "update_test",
|
||||
"description": "Update test",
|
||||
"field": "--update-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
updated = await repo.update(
|
||||
created.id,
|
||||
{
|
||||
"name": "updated_name",
|
||||
"order": 5,
|
||||
"extras": {"updated": True},
|
||||
},
|
||||
)
|
||||
|
||||
assert updated.name == "updated_name", "Should update name"
|
||||
assert updated.order == 5, "Should update order"
|
||||
assert updated.extras == {"updated": True}, "Should update extras"
|
||||
assert updated.field == "--update-test", "Should preserve unchanged field"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_raises(self, repo):
|
||||
"""Update nonexistent field raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.update(99999, {"name": "should_fail"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_field(self, repo):
|
||||
"""Delete existing field."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "delete_test",
|
||||
"description": "Delete test",
|
||||
"field": "--delete-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
deleted = await repo.delete(created.id)
|
||||
|
||||
assert deleted.id == created.id, "Should return deleted field"
|
||||
|
||||
result = await repo.get(created.id)
|
||||
assert result is None, "Deleted field should not be retrievable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_raises(self, repo):
|
||||
"""Delete nonexistent field raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.delete(99999)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo):
|
||||
"""List paginated returns correct subset."""
|
||||
for i in range(5):
|
||||
await repo.create(
|
||||
{
|
||||
"name": f"item_{i}",
|
||||
"description": "desc",
|
||||
"field": f"--item-{i}",
|
||||
"kind": "text",
|
||||
"order": i,
|
||||
}
|
||||
)
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||
|
||||
assert len(items) == 2, "Should return 2 items per page"
|
||||
assert total == 5, "Should report total count of 5"
|
||||
assert page == 1, "Should be on page 1"
|
||||
assert total_pages == 3, "Should have 3 pages total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_ordering(self, repo):
|
||||
"""List orders by order asc then name asc."""
|
||||
await repo.create({"name": "b", "description": "b", "field": "--b", "kind": "text", "order": 1})
|
||||
await repo.create({"name": "a", "description": "a", "field": "--a", "kind": "text", "order": 1})
|
||||
await repo.create({"name": "c", "description": "c", "field": "--c", "kind": "text", "order": 0})
|
||||
|
||||
items = await repo.all()
|
||||
|
||||
assert items[0].name == "c", "Lowest order should be first"
|
||||
assert items[1].name == "a", "Same order sorted alphabetically"
|
||||
assert items[2].name == "b", "Same order sorted alphabetically"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_name_excludes_id(self, repo):
|
||||
"""Get by name can exclude specific ID."""
|
||||
first = await repo.create(
|
||||
{
|
||||
"name": "duplicate",
|
||||
"description": "duplicate",
|
||||
"field": "--duplicate",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
result = await repo.get_by_name("duplicate", exclude_id=first.id)
|
||||
assert result is None, "Should not find when excluding only match"
|
||||
|
||||
result = await repo.get_by_name("duplicate", exclude_id=None)
|
||||
assert result is not None, "Should find without exclusion"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_all(self, repo):
|
||||
"""Replace all fields atomically."""
|
||||
await repo.create({"name": "old_1", "description": "old", "field": "--old-1", "kind": "text"})
|
||||
await repo.create({"name": "old_2", "description": "old", "field": "--old-2", "kind": "text"})
|
||||
|
||||
new_items = [
|
||||
{"name": "new_1", "description": "new", "field": "--new-1", "kind": "text"},
|
||||
{"name": "new_2", "description": "new", "field": "--new-2", "kind": "text"},
|
||||
]
|
||||
|
||||
result = await repo.replace_all(new_items)
|
||||
|
||||
assert len(result) == 2, "Should create 2 new fields"
|
||||
|
||||
all_items = await repo.all()
|
||||
assert len(all_items) == 2, "Should only have new fields"
|
||||
assert {item.name for item in all_items} == {"new_1", "new_2"}, "Should only have new items"
|
||||
5
app/features/notifications/deps.py
Normal file
5
app/features/notifications/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.notifications.repository import NotificationsRepository
|
||||
|
||||
|
||||
def get_notifications_repo() -> NotificationsRepository:
|
||||
return NotificationsRepository.get_instance()
|
||||
187
app/features/notifications/migration.py
Normal file
187
app/features/notifications/migration.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.notifications.schemas import NotificationEvents
|
||||
from app.features.presets.service import Presets
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "notifications"
|
||||
|
||||
def __init__(self, repo: NotificationsRepository, config: Config | None = None):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: NotificationsRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "notifications.json"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
return self._source_file.exists()
|
||||
|
||||
async def migrate(self) -> None:
|
||||
if await self._repo.count() > 0:
|
||||
LOG.warning("Notification targets already exist in the database; skipping migration.")
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
try:
|
||||
items: list[dict] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to read notifications migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if items is None:
|
||||
LOG.warning("No notification targets found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
normalized = self._normalize(item, index, seen_names)
|
||||
if not normalized:
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to insert notification target '%s'.",
|
||||
normalized.get("name"),
|
||||
extra={"target_name": normalized.get("name"), "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s notification target(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping notification at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping notification at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping notification at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
request = item.get("request") if isinstance(item.get("request"), dict) else {}
|
||||
request = cast("dict[str, Any]", request)
|
||||
url: str | None = request.get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
LOG.warning("Skipping notification '%s' due to missing request url.", name)
|
||||
return None
|
||||
|
||||
raw_method = request.get("method")
|
||||
method = raw_method.upper() if isinstance(raw_method, str) else "POST"
|
||||
if method not in {"POST", "PUT"}:
|
||||
LOG.warning("Skipping notification '%s' due to invalid method '%s'.", name, method)
|
||||
return None
|
||||
|
||||
raw_type = request.get("type")
|
||||
req_type = raw_type.lower() if isinstance(raw_type, str) else "json"
|
||||
if req_type not in {"json", "form"}:
|
||||
LOG.warning("Skipping notification '%s' due to invalid type '%s'.", name, req_type)
|
||||
return None
|
||||
|
||||
data_key = request.get("data_key") if isinstance(request.get("data_key"), str) else "data"
|
||||
if not data_key:
|
||||
data_key = "data"
|
||||
|
||||
headers = self._normalize_headers(request.get("headers"))
|
||||
|
||||
enabled_value = item.get("enabled")
|
||||
enabled: bool = enabled_value if isinstance(enabled_value, bool) else True
|
||||
events = self._normalize_events(item.get("on"))
|
||||
if events is None:
|
||||
LOG.warning("Skipping notification '%s' due to invalid events.", name)
|
||||
return None
|
||||
|
||||
presets = self._normalize_presets(item.get("presets"))
|
||||
if presets is None:
|
||||
LOG.warning("Skipping notification '%s' due to invalid presets.", name)
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"on": events,
|
||||
"presets": presets,
|
||||
"enabled": enabled,
|
||||
"request_url": url,
|
||||
"request_method": method,
|
||||
"request_type": req_type,
|
||||
"request_data_key": data_key,
|
||||
"request_headers": headers,
|
||||
}
|
||||
|
||||
def _normalize_events(self, events: Any) -> list[str] | None:
|
||||
if events is None:
|
||||
return []
|
||||
if not isinstance(events, list):
|
||||
return []
|
||||
|
||||
allowed = set(NotificationEvents.get_events().values())
|
||||
valid = [event for event in events if event in allowed]
|
||||
invalid = [event for event in events if event not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
return None
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification events: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def _normalize_presets(self, presets: Any) -> list[str] | None:
|
||||
if presets is None:
|
||||
return []
|
||||
if not isinstance(presets, list):
|
||||
return []
|
||||
|
||||
allowed = {preset.name for preset in Presets.get_instance().get_all()}
|
||||
valid = [preset for preset in presets if preset in allowed]
|
||||
invalid = [preset for preset in presets if preset not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
return None
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification presets: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def _normalize_headers(self, headers: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(headers, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for header in headers:
|
||||
if not isinstance(header, dict):
|
||||
continue
|
||||
key = str(header.get("key", "")).strip()
|
||||
value = str(header.get("value", "")).strip()
|
||||
if key and value:
|
||||
normalized.append({"key": key, "value": value})
|
||||
return normalized
|
||||
39
app/features/notifications/models.py
Normal file
39
app/features/notifications/models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy import Enum as SQLAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
from app.features.notifications.schemas import NotificationRequestMethod, NotificationRequestType
|
||||
|
||||
|
||||
class NotificationModel(Base):
|
||||
__tablename__: str = "notifications"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_notifications_name", "name"),
|
||||
Index("ix_notifications_enabled", "enabled"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
on: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
presets: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
request_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
request_method: Mapped[NotificationRequestMethod] = mapped_column(
|
||||
SQLAEnum(NotificationRequestMethod, name="notification_request_method", native_enum=False),
|
||||
nullable=False,
|
||||
default=NotificationRequestMethod.POST,
|
||||
)
|
||||
request_type: Mapped[NotificationRequestType] = mapped_column(
|
||||
SQLAEnum(NotificationRequestType, name="notification_request_type", native_enum=False),
|
||||
nullable=False,
|
||||
default=NotificationRequestType.JSON,
|
||||
)
|
||||
request_data_key: Mapped[str] = mapped_column(String(255), nullable=False, default="data")
|
||||
request_headers: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
181
app/features/notifications/repository.py
Normal file
181
app/features/notifications/repository.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.notifications.migration import Migration
|
||||
from app.features.notifications.models import NotificationModel
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model_from_payload(payload: dict[str, Any]) -> NotificationModel:
|
||||
model = NotificationModel()
|
||||
for key, value in payload.items():
|
||||
if not hasattr(model, key):
|
||||
msg = f"'{key}' is an invalid keyword argument for NotificationModel"
|
||||
raise TypeError(msg)
|
||||
setattr(model, key, value)
|
||||
return model
|
||||
|
||||
|
||||
def _coerce_model(payload: NotificationModel | dict[str, Any]) -> NotificationModel:
|
||||
if isinstance(payload, NotificationModel):
|
||||
return payload
|
||||
return _model_from_payload(payload)
|
||||
|
||||
|
||||
class NotificationsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self, config=None).run()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> NotificationsRepository:
|
||||
return NotificationsRepository()
|
||||
|
||||
async def all(self) -> list[NotificationModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).order_by(NotificationModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[NotificationModel], int, int, int]:
|
||||
async with self.session() as session:
|
||||
total: int = await self.count()
|
||||
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
if page > total_pages and total > 0:
|
||||
page = total_pages
|
||||
|
||||
query: Select[tuple[NotificationModel]] = (
|
||||
select(NotificationModel)
|
||||
.order_by(NotificationModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(query)
|
||||
return list(result.scalars().all()), total, page, total_pages
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(NotificationModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> NotificationModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = NotificationModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(NotificationModel.id == int(identifier), NotificationModel.name == identifier)
|
||||
else:
|
||||
clause = NotificationModel.name == identifier
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).where(clause).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> NotificationModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[NotificationModel]] = select(NotificationModel).where(NotificationModel.name == name)
|
||||
if exclude_id is not None:
|
||||
query = query.where(NotificationModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: NotificationModel | dict[str, Any]) -> NotificationModel:
|
||||
async with self.session() as session:
|
||||
model = _coerce_model(payload)
|
||||
if model.id is not None:
|
||||
model.id = None # ty: ignore
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Notification target with name '{model.name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def update(self, identifier: int | str, payload: dict[str, Any]) -> NotificationModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = NotificationModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(NotificationModel.id == int(identifier), NotificationModel.name == identifier)
|
||||
else:
|
||||
clause = NotificationModel.name == identifier
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).where(clause).limit(1)
|
||||
)
|
||||
model: NotificationModel | None = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"Notification target '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
|
||||
if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
|
||||
msg = f"Notification target with name '{payload['name']}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
for key, value in payload.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def delete(self, identifier: int | str) -> NotificationModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = NotificationModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(NotificationModel.id == int(identifier), NotificationModel.name == identifier)
|
||||
else:
|
||||
clause = NotificationModel.name == identifier
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).where(clause).limit(1)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"Notification target '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
218
app/features/notifications/router.py
Normal file
218
app/features/notifications/router.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.notifications.schemas import Notification, NotificationEvents, NotificationList, NotificationPatch
|
||||
from app.features.notifications.service import Notifications
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model(model: Any) -> Notification:
|
||||
return Notifications.get_instance().model_to_schema(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/notifications/", "notifications_list")
|
||||
async def notifications_list(request: Request, encoder: Encoder) -> Response:
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await Notifications.get_instance().list_paginated(page, per_page)
|
||||
return web.json_response(
|
||||
data=NotificationList(
|
||||
items=[_model(model) for model in items],
|
||||
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||
),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/notifications/events/", "notifications_events")
|
||||
async def notifications_events(encoder: Encoder) -> Response:
|
||||
return web.json_response(
|
||||
data={"events": list(NotificationEvents.get_events().values())},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/notifications/test/", "notification_test")
|
||||
async def notification_test(encoder: Encoder, notify: EventBus) -> Response:
|
||||
notify.emit(Events.TEST, title="Test Notification", message="This is a test notification.")
|
||||
return web.json_response(data={}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/notifications/", "notification_add")
|
||||
async def notifications_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: Notification = Notification.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate notification.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
saved = _serialize(await Notifications.get_instance().create(item))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.CREATE, data=saved),
|
||||
)
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("GET", r"api/notifications/{id:\d+}", "notification_get")
|
||||
async def notifications_get(request: Request, encoder: Encoder) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Notifications.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "Notification not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/notifications/{id:\d+}", "notification_delete")
|
||||
async def notifications_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
deleted = _serialize(await Notifications.get_instance().delete(identifier))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.DELETE, data=deleted),
|
||||
)
|
||||
return web.json_response(
|
||||
data=deleted,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
except KeyError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
|
||||
@route("PATCH", r"api/notifications/{id:\d+}", "notification_patch")
|
||||
async def notifications_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Notifications.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "Notification not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = NotificationPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate notification.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
current = _model(model).model_dump()
|
||||
if validated.name is not None:
|
||||
current["name"] = validated.name
|
||||
if validated.on is not None:
|
||||
current["on"] = validated.on
|
||||
if validated.presets is not None:
|
||||
current["presets"] = validated.presets
|
||||
if validated.enabled is not None:
|
||||
current["enabled"] = validated.enabled
|
||||
|
||||
if validated.request is not None:
|
||||
request_payload = current.get("request", {})
|
||||
if validated.request.url is not None:
|
||||
request_payload["url"] = validated.request.url
|
||||
if validated.request.method is not None:
|
||||
request_payload["method"] = validated.request.method
|
||||
if validated.request.type is not None:
|
||||
request_payload["type"] = validated.request.type
|
||||
if validated.request.headers is not None:
|
||||
request_payload["headers"] = [header.model_dump() for header in validated.request.headers]
|
||||
if validated.request.data_key is not None:
|
||||
request_payload["data_key"] = validated.request.data_key
|
||||
current["request"] = request_payload
|
||||
|
||||
try:
|
||||
updated = _serialize(await Notifications.get_instance().update(model.id, current))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except KeyError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(
|
||||
data=updated,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", r"api/notifications/{id:\d+}", "notification_update")
|
||||
async def notifications_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Notifications.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "Notification not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = Notification.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate notification.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
updated = _serialize(await Notifications.get_instance().update(model.id, validated))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except KeyError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
146
app/features/notifications/schemas.py
Normal file
146
app/features/notifications/schemas.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.library.Events import Events
|
||||
|
||||
|
||||
class NotificationEvents:
|
||||
TEST: str = Events.TEST
|
||||
|
||||
ITEM_ADDED: str = Events.ITEM_ADDED
|
||||
ITEM_COMPLETED: str = Events.ITEM_COMPLETED
|
||||
ITEM_CANCELLED: str = Events.ITEM_CANCELLED
|
||||
ITEM_DELETED: str = Events.ITEM_DELETED
|
||||
ITEM_BULK_DELETED: str = Events.ITEM_BULK_DELETED
|
||||
ITEM_PAUSED: str = Events.ITEM_PAUSED
|
||||
ITEM_RESUMED: str = Events.ITEM_RESUMED
|
||||
ITEM_MOVED: str = Events.ITEM_MOVED
|
||||
|
||||
PAUSED: str = Events.PAUSED
|
||||
RESUMED: str = Events.RESUMED
|
||||
|
||||
LOG_INFO: str = Events.LOG_INFO
|
||||
LOG_SUCCESS: str = Events.LOG_SUCCESS
|
||||
LOG_WARNING: str = Events.LOG_WARNING
|
||||
LOG_ERROR: str = Events.LOG_ERROR
|
||||
|
||||
TASK_DISPATCHED: str = Events.TASK_DISPATCHED
|
||||
|
||||
@staticmethod
|
||||
def get_events() -> dict[str, str]:
|
||||
return {k: v for k, v in vars(NotificationEvents).items() if not k.startswith("__") and not callable(v)}
|
||||
|
||||
@staticmethod
|
||||
def events() -> list[str]:
|
||||
return [
|
||||
getattr(NotificationEvents, ev)
|
||||
for ev in dir(NotificationEvents)
|
||||
if not ev.startswith("_") and not callable(getattr(NotificationEvents, ev))
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def is_valid(event: str) -> bool:
|
||||
return event in NotificationEvents.get_events().values()
|
||||
|
||||
|
||||
class NotificationRequestType(str, Enum):
|
||||
JSON = "json"
|
||||
FORM = "form"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class NotificationRequestMethod(str, Enum):
|
||||
POST = "POST"
|
||||
PUT = "PUT"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class NotificationRequestHeader(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
key: str = Field(min_length=1)
|
||||
value: str = Field(min_length=1)
|
||||
|
||||
|
||||
class NotificationRequest(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
type: NotificationRequestType = NotificationRequestType.JSON
|
||||
method: NotificationRequestMethod = NotificationRequestMethod.POST
|
||||
url: str = Field(min_length=1)
|
||||
headers: list[NotificationRequestHeader] = Field(default_factory=list)
|
||||
data_key: str = Field(default="data", min_length=1)
|
||||
|
||||
@field_validator("method", mode="before")
|
||||
@classmethod
|
||||
def _normalize_method(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, NotificationRequestMethod):
|
||||
return value
|
||||
return str(value).upper()
|
||||
|
||||
@field_validator("type", mode="before")
|
||||
@classmethod
|
||||
def _normalize_type(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, NotificationRequestType):
|
||||
return value
|
||||
return str(value).lower()
|
||||
|
||||
|
||||
class NotificationRequestPatch(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
type: NotificationRequestType | None = None
|
||||
method: NotificationRequestMethod | None = None
|
||||
url: str | None = None
|
||||
headers: list[NotificationRequestHeader] | None = None
|
||||
data_key: str | None = None
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
on: list[str] = Field(default_factory=list)
|
||||
presets: list[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
request: NotificationRequest
|
||||
|
||||
@field_validator("on", "presets", mode="before")
|
||||
@classmethod
|
||||
def _normalize_list(cls, value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return [str(value).strip()]
|
||||
|
||||
|
||||
class NotificationPatch(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
name: str | None = None
|
||||
on: list[str] | None = None
|
||||
presets: list[str] | None = None
|
||||
enabled: bool | None = None
|
||||
request: NotificationRequestPatch | None = None
|
||||
|
||||
|
||||
class NotificationList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[Notification] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
363
app/features/notifications/service.py
Normal file
363
app/features/notifications/service.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.notifications.models import NotificationModel
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
from app.features.notifications.schemas import (
|
||||
Notification,
|
||||
NotificationEvents,
|
||||
NotificationRequest,
|
||||
NotificationRequestHeader,
|
||||
NotificationRequestType,
|
||||
)
|
||||
from app.features.presets.schemas import Preset
|
||||
from app.features.presets.service import Presets
|
||||
from app.library.BackgroundWorker import BackgroundWorker
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.httpx_client import async_client
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable
|
||||
|
||||
import httpx
|
||||
from aiohttp import web
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Notifications(metaclass=Singleton):
|
||||
def __init__(
|
||||
self,
|
||||
repo: NotificationsRepository | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
encoder: Encoder | None = None,
|
||||
config: Config | None = None,
|
||||
background_worker: BackgroundWorker | None = None,
|
||||
) -> None:
|
||||
self._repo: NotificationsRepository = repo or NotificationsRepository.get_instance()
|
||||
|
||||
config = config or Config.get_instance()
|
||||
|
||||
self._debug: bool = config.debug
|
||||
self._version: str = config.app_version
|
||||
self._client: httpx.AsyncClient = client or async_client()
|
||||
self._encoder: Encoder = encoder or Encoder()
|
||||
self._offload: BackgroundWorker = background_worker or BackgroundWorker.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> Notifications:
|
||||
return Notifications()
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
async def handle_event(_, __):
|
||||
await self._repo.run_migrations()
|
||||
|
||||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "NotificationsRepository.run_migrations")
|
||||
EventBus.get_instance().subscribe(NotificationEvents.events(), self.emit, f"{type(self).__name__}.emit")
|
||||
|
||||
async def all(self) -> list[NotificationModel]:
|
||||
return await self._repo.all()
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[NotificationModel], int, int, int]:
|
||||
return await self._repo.list_paginated(page, per_page)
|
||||
|
||||
async def get(self, identifier: int | str) -> NotificationModel | None:
|
||||
return await self._repo.get(identifier)
|
||||
|
||||
async def create(self, item: Notification | dict) -> NotificationModel:
|
||||
if not isinstance(item, Notification):
|
||||
item = Notification.model_validate(item)
|
||||
normalized = self._normalize(item)
|
||||
payload = self._payload_from_schema(normalized)
|
||||
return await self._repo.create(payload)
|
||||
|
||||
async def update(self, identifier: int | str, payload: Notification | dict) -> NotificationModel:
|
||||
if not isinstance(payload, Notification):
|
||||
payload = Notification.model_validate(payload)
|
||||
normalized = self._normalize(payload)
|
||||
update_payload = self._payload_from_schema(normalized)
|
||||
return await self._repo.update(identifier, update_payload)
|
||||
|
||||
async def delete(self, identifier: int | str) -> NotificationModel:
|
||||
return await self._repo.delete(identifier)
|
||||
|
||||
async def send(self, ev: Event, wait: bool = True) -> list[dict | Awaitable[dict]]:
|
||||
targets = await self._repo.all()
|
||||
if len(targets) < 1:
|
||||
return []
|
||||
|
||||
if not isinstance(ev.data, (ItemDTO, Item, dict)):
|
||||
LOG.debug("Received invalid item type '%s' with event '%s'.", type(ev.data), ev.event)
|
||||
return []
|
||||
|
||||
tasks: list[Awaitable[dict]] = []
|
||||
apprise_targets: list[Notification] = []
|
||||
|
||||
for target_model in targets:
|
||||
target = self.model_to_schema(target_model)
|
||||
if not target.enabled:
|
||||
continue
|
||||
|
||||
if len(target.on) > 0 and ev.event not in target.on and NotificationEvents.TEST != ev.event:
|
||||
continue
|
||||
|
||||
if NotificationEvents.TEST != ev.event and not self._check_preset(target, ev):
|
||||
continue
|
||||
|
||||
if not target.request.url.startswith("http"):
|
||||
apprise_targets.append(target)
|
||||
else:
|
||||
tasks.append(self._send(target, ev))
|
||||
|
||||
if len(apprise_targets) > 0:
|
||||
tasks.append(self._apprise(apprise_targets, ev))
|
||||
|
||||
if wait:
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
return cast("list[dict | Awaitable[dict]]", tasks)
|
||||
|
||||
def emit(self, e: Event, _, **__) -> None:
|
||||
if not NotificationEvents.is_valid(e.event):
|
||||
return
|
||||
|
||||
self._offload.submit(self.send, e)
|
||||
|
||||
def _normalize(self, item: Notification) -> Notification:
|
||||
if item.enabled is not None and not isinstance(item.enabled, bool):
|
||||
msg: str = "Enabled must be a boolean."
|
||||
raise ValueError(msg)
|
||||
|
||||
item.on = self._filter_events(item.on)
|
||||
item.presets = self._filter_presets(item.presets)
|
||||
item.request.headers = [
|
||||
NotificationRequestHeader(key=header.key, value=header.value)
|
||||
for header in item.request.headers
|
||||
if header.key and header.value
|
||||
]
|
||||
return item
|
||||
|
||||
def _filter_events(self, events: list[str]) -> list[str]:
|
||||
if not events:
|
||||
return []
|
||||
|
||||
allowed = set(NotificationEvents.get_events().values())
|
||||
valid = [event for event in events if event in allowed]
|
||||
invalid = [event for event in events if event not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
msg: str = f"Invalid notification target. Invalid events '{', '.join(invalid)}' found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification events: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def _filter_presets(self, presets: list[str]) -> list[str]:
|
||||
if not presets:
|
||||
return []
|
||||
|
||||
all_presets: list[Preset] = Presets.get_instance().get_all()
|
||||
allowed = {preset.name for preset in all_presets}
|
||||
valid = [preset for preset in presets if preset in allowed]
|
||||
invalid = [preset for preset in presets if preset not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
msg: str = f"Invalid notification target. Invalid presets '{', '.join(invalid)}' found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification presets: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def model_to_schema(self, model: NotificationModel) -> Notification:
|
||||
headers: list[NotificationRequestHeader] = []
|
||||
if isinstance(model.request_headers, list):
|
||||
for header in model.request_headers:
|
||||
if not isinstance(header, dict):
|
||||
continue
|
||||
key = str(header.get("key", "")).strip()
|
||||
value = str(header.get("value", "")).strip()
|
||||
if key and value:
|
||||
headers.append(NotificationRequestHeader(key=key, value=value))
|
||||
|
||||
return Notification(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
on=list(model.on or []),
|
||||
presets=list(model.presets or []),
|
||||
enabled=model.enabled,
|
||||
request=NotificationRequest.model_validate(
|
||||
{
|
||||
"type": model.request_type,
|
||||
"method": model.request_method,
|
||||
"url": model.request_url,
|
||||
"headers": headers,
|
||||
"data_key": model.request_data_key,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def _payload_from_schema(self, item: Notification) -> dict[str, Any]:
|
||||
return {
|
||||
"name": item.name,
|
||||
"on": list(item.on),
|
||||
"presets": list(item.presets),
|
||||
"enabled": item.enabled,
|
||||
"request_url": item.request.url,
|
||||
"request_method": str(item.request.method),
|
||||
"request_type": str(item.request.type),
|
||||
"request_data_key": item.request.data_key,
|
||||
"request_headers": [header.model_dump() for header in item.request.headers],
|
||||
}
|
||||
|
||||
def _check_preset(self, target: Notification, ev: Event) -> bool:
|
||||
if len(target.presets) < 1:
|
||||
return True
|
||||
|
||||
if not isinstance(ev.data, (Item, ItemDTO, dict)):
|
||||
return False
|
||||
|
||||
preset_name: str | None = None
|
||||
|
||||
if isinstance(ev.data, Item):
|
||||
preset_name = ev.data.preset
|
||||
|
||||
if isinstance(ev.data, ItemDTO):
|
||||
preset_name = ev.data.preset
|
||||
|
||||
if isinstance(ev.data, dict):
|
||||
preset_name = ev.data.get("preset", None)
|
||||
|
||||
if not preset_name:
|
||||
return False
|
||||
|
||||
return preset_name in target.presets
|
||||
|
||||
async def _apprise(self, targets: list[Notification], ev: Event) -> dict:
|
||||
if not targets:
|
||||
return {}
|
||||
|
||||
import apprise
|
||||
|
||||
try:
|
||||
notify = apprise.Apprise(debug=self._debug)
|
||||
apr_config = Path(Config.get_instance().apprise_config)
|
||||
if apr_config.exists():
|
||||
apprise_file = apprise.AppriseConfig()
|
||||
apprise_file.add(str(apr_config))
|
||||
notify.add(apprise_file)
|
||||
|
||||
for target in targets:
|
||||
notify.add(target.request.url)
|
||||
|
||||
status = await notify.async_notify(
|
||||
body=ev.message or self._encoder.encode(ev.serialize()),
|
||||
title=ev.title or f"YTPTube Event: {ev.event}",
|
||||
)
|
||||
|
||||
if not status:
|
||||
msg = "Apprise failed to send notification."
|
||||
self._raise_apprise_error(msg)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to send Apprise notification for event '%s'.",
|
||||
ev.event,
|
||||
extra={
|
||||
"event_id": ev.id,
|
||||
"event": ev.event,
|
||||
"target_count": len(targets),
|
||||
"targets": [t.name for t in targets],
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return {"error": str(exc), "event": ev.event, "id": ev.id, "targets": [t.name for t in targets]}
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _raise_apprise_error(msg: str) -> None:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
async def _send(self, target: Notification, ev: Event) -> dict:
|
||||
try:
|
||||
LOG.info("Sending notification event '%s: %s' to '%s'.", ev.event, ev.id, target.name)
|
||||
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": f"YTPTube/{self._version}",
|
||||
"X-Event-Id": ev.id,
|
||||
"X-Event": ev.event,
|
||||
"Content-Type": "application/json"
|
||||
if NotificationRequestType.JSON == target.request.type
|
||||
else "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
if len(target.request.headers) > 0:
|
||||
headers.update({h.key: h.value for h in target.request.headers if h.key and h.value})
|
||||
|
||||
payload_data: dict[str, Any] = ev.serialize()
|
||||
|
||||
if "data" != target.request.data_key:
|
||||
payload_data[target.request.data_key] = payload_data["data"]
|
||||
payload_data.pop("data", None)
|
||||
|
||||
if NotificationRequestType.FORM == target.request.type:
|
||||
payload_data[target.request.data_key] = self._encoder.encode(payload_data[target.request.data_key])
|
||||
data_payload: dict[str, Any] | None = payload_data
|
||||
content_payload: str | None = None
|
||||
else:
|
||||
data_payload = None
|
||||
content_payload = self._encoder.encode(payload_data)
|
||||
|
||||
response = await self._client.request(
|
||||
method=str(target.request.method).upper(),
|
||||
url=target.request.url,
|
||||
headers=headers,
|
||||
data=data_payload,
|
||||
content=content_payload,
|
||||
)
|
||||
|
||||
resp_data: dict[str, Any] = {
|
||||
"url": target.request.url,
|
||||
"status": response.status_code,
|
||||
"text": response.text,
|
||||
}
|
||||
|
||||
msg: str = (
|
||||
f"Notification target '{target.name}' Responded to event '{ev.event}: {ev.id}' "
|
||||
f"with status '{response.status_code}'."
|
||||
)
|
||||
if self._debug and resp_data.get("text"):
|
||||
msg += f" body '{resp_data.get('text', '??')}'."
|
||||
|
||||
LOG.info(msg)
|
||||
|
||||
return resp_data
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to send notification event '%s: %s' to '%s'.",
|
||||
ev.event,
|
||||
ev.id,
|
||||
target.name,
|
||||
extra={
|
||||
"event_id": ev.id,
|
||||
"event": ev.event,
|
||||
"target_name": target.name,
|
||||
"url": target.request.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return {"url": target.request.url, "status": 500, "text": str(ev)}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"""Tests for NotificationsRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.tests.helpers import make_in_memory_db_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo():
|
||||
"""Provide a fresh repository instance with initialized database for each test."""
|
||||
NotificationsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=make_in_memory_db_path("notifications-repository"))
|
||||
await store.get_connection()
|
||||
|
||||
repository = NotificationsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
await store.close()
|
||||
|
||||
NotificationsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestNotificationsRepository:
|
||||
"""Test suite for NotificationsRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_empty(self, repo):
|
||||
"""List returns empty when no notifications exist."""
|
||||
notifications = await repo.all()
|
||||
assert notifications == [], "Should return empty list when no notifications exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_notification(self, repo):
|
||||
"""Create notification with valid data."""
|
||||
payload = {
|
||||
"name": "Webhook",
|
||||
"on": ["item_completed"],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com/webhook",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [{"key": "Authorization", "value": "Bearer token"}],
|
||||
}
|
||||
|
||||
model = await repo.create(payload)
|
||||
|
||||
assert model.id is not None, "Should generate ID for new notification"
|
||||
assert model.name == "Webhook", "Should store name correctly"
|
||||
assert model.request_url == "https://example.com/webhook", "Should store request url"
|
||||
assert model.request_method == "POST", "Should store request method"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id(self, repo):
|
||||
"""Get notification by integer ID."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Get Test",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get(created.id)
|
||||
|
||||
assert retrieved is not None, "Should retrieve created notification"
|
||||
assert retrieved.id == created.id, "Should match created notification id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_notification(self, repo):
|
||||
"""Update existing notification."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Update Test",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
updated = await repo.update(created.id, {"name": "Updated Name", "enabled": False})
|
||||
|
||||
assert updated.name == "Updated Name", "Should update name"
|
||||
assert updated.enabled is False, "Should update enabled flag"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_notification(self, repo):
|
||||
"""Delete existing notification."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Delete Test",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
deleted = await repo.delete(created.id)
|
||||
|
||||
assert deleted.id == created.id, "Should return deleted notification"
|
||||
assert await repo.get(created.id) is None, "Deleted notification should not be retrievable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo):
|
||||
"""List paginated returns correct subset."""
|
||||
for i in range(4):
|
||||
await repo.create(
|
||||
{
|
||||
"name": f"Item {i}",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||
|
||||
assert len(items) == 2, "Should return 2 items per page"
|
||||
assert total == 4, "Should report total count of 4"
|
||||
assert page == 1, "Should be on page 1"
|
||||
assert total_pages == 2, "Should have 2 pages total"
|
||||
1
app/features/presets/__init__.py
Normal file
1
app/features/presets/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Presets feature module."""
|
||||
94
app/features/presets/defaults.py
Normal file
94
app/features/presets/defaults.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
DEFAULT_PRESET_UPDATED_AT: datetime = datetime(2026, 1, 25, tzinfo=UTC)
|
||||
|
||||
DEFAULT_PRESETS: list[dict[str, object]] = [
|
||||
{
|
||||
"name": "default",
|
||||
"default": True,
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s",
|
||||
"description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "mobile",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n-t mp4 --merge-output-format mp4 --add-chapters --remux-video mp4 \n--embed-metadata --embed-thumbnail \n--postprocessor-args "-movflags +faststart"',
|
||||
"default": True,
|
||||
"description": "This preset is designed for mobile devices. It will download the best quality video and audio in mp4 format, merge them, and add chapters, metadata, and thumbnail.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "1080p",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||
"default": True,
|
||||
"description": "Download the best quality video and audio in mp4 format for 1080p resolution.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "720p",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||
"default": True,
|
||||
"description": "Download the best quality video and audio in mp4 format for 720p resolution.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "audio_only",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
|
||||
"default": True,
|
||||
"description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "info_reader_plugin",
|
||||
"description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary',
|
||||
"folder": "",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(id)s].%(ext)s",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
|
||||
"cookies": "",
|
||||
"default": True,
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "nfo_maker_tv",
|
||||
"description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for episodes.",
|
||||
"folder": "",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
|
||||
"cookies": "",
|
||||
"default": True,
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "nfo_maker_movie",
|
||||
"description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for movies.",
|
||||
"folder": "",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move;mode=movie" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
|
||||
"cookies": "",
|
||||
"default": True,
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
]
|
||||
10
app/features/presets/deps.py
Normal file
10
app/features/presets/deps.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from app.features.presets.repository import PresetsRepository
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
|
||||
def get_presets_repo() -> PresetsRepository:
|
||||
return PresetsRepository.get_instance()
|
||||
|
||||
|
||||
def get_presets_service() -> Presets:
|
||||
return Presets.get_instance()
|
||||
121
app/features/presets/migration.py
Normal file
121
app/features/presets/migration.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.presets.schemas import Preset
|
||||
from app.features.presets.utils import preset_name
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "presets"
|
||||
|
||||
def __init__(self, repo: PresetsRepository, config: Config | None = None) -> None:
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: PresetsRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "presets.json"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
return self._source_file.exists()
|
||||
|
||||
async def migrate(self) -> None:
|
||||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to read presets migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if not items:
|
||||
LOG.warning("No presets found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {preset_name(preset.name): 1 for preset in await self._repo.all()}
|
||||
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := self._normalize(item, index, seen_names)):
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to insert preset '%s'.",
|
||||
normalized["name"],
|
||||
extra={"preset": normalized["name"], "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s preset(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping preset at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping preset at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
if not (name := preset_name(name)):
|
||||
LOG.warning("Skipping preset at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(name, seen_names)
|
||||
|
||||
description: str = item.get("description") if isinstance(item.get("description"), str) else ""
|
||||
folder: str = item.get("folder") if isinstance(item.get("folder"), str) else ""
|
||||
template: str = item.get("template") if isinstance(item.get("template"), str) else ""
|
||||
cookies: str = item.get("cookies") if isinstance(item.get("cookies"), str) else ""
|
||||
cli: str = item.get("cli") if isinstance(item.get("cli"), str) else ""
|
||||
priority = 0
|
||||
if isinstance(item.get("priority"), int) and not isinstance(item.get("priority"), bool):
|
||||
priority = int(item.get("priority"))
|
||||
|
||||
if not cli and isinstance(item.get("format"), str):
|
||||
cli = f"--format {item.get('format')}"
|
||||
|
||||
if cli:
|
||||
try:
|
||||
from app.features.ytdlp.utils import arg_converter
|
||||
|
||||
arg_converter(args=cli, level=True)
|
||||
except Exception as exc:
|
||||
LOG.warning("Skipping preset '%s' due to invalid CLI: %s", name, exc)
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"folder": folder,
|
||||
"template": template,
|
||||
"cookies": cookies,
|
||||
"cli": cli,
|
||||
"default": False,
|
||||
"priority": priority,
|
||||
}
|
||||
|
||||
try:
|
||||
Preset.model_validate(payload)
|
||||
except Exception as exc:
|
||||
LOG.warning("Skipping preset '%s' due to validation error: %s", name, exc)
|
||||
return None
|
||||
|
||||
return payload
|
||||
27
app/features/presets/models.py
Normal file
27
app/features/presets/models.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class PresetModel(Base):
|
||||
__tablename__: str = "presets"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_presets_name", "name"),
|
||||
Index("ix_presets_is_default", "is_default"),
|
||||
Index("ix_presets_priority", "priority"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
folder: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
template: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
cookies: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
default: Mapped[bool] = mapped_column("is_default", Boolean, nullable=False, default=False)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
334
app/features/presets/repository.py
Normal file
334
app/features/presets/repository.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.core.schemas import CEFeature, ConfigEvent
|
||||
from app.features.presets.migration import Migration
|
||||
from app.features.presets.models import PresetModel
|
||||
from app.features.presets.utils import preset_name, seed_defaults
|
||||
from app.library.config import Config
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from aiohttp import web
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _payload_data(payload: PresetModel | dict[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(payload, dict):
|
||||
data: dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str):
|
||||
msg = "Preset payload keys must be strings."
|
||||
raise TypeError(msg)
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
return {
|
||||
"name": payload.name,
|
||||
"description": payload.description,
|
||||
"folder": payload.folder,
|
||||
"template": payload.template,
|
||||
"cookies": payload.cookies,
|
||||
"cli": payload.cli,
|
||||
"default": payload.default,
|
||||
"priority": payload.priority,
|
||||
"created_at": payload.created_at,
|
||||
"updated_at": payload.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class PresetsRepository(metaclass=Singleton):
|
||||
SORT_FIELDS: dict[str, Any] = {
|
||||
"id": PresetModel.id,
|
||||
"name": PresetModel.name,
|
||||
"priority": PresetModel.priority,
|
||||
"default": PresetModel.default,
|
||||
"created_at": PresetModel.created_at,
|
||||
"updated_at": PresetModel.updated_at,
|
||||
}
|
||||
SORT_DIRECTIONS: tuple[str, str] = ("asc", "desc")
|
||||
DEFAULT_SORT_ORDER: tuple[tuple[str, str], ...] = (("priority", "desc"), ("name", "asc"))
|
||||
FIELD_DEFAULT_DIRECTIONS: dict[str, str] = {
|
||||
"id": "asc",
|
||||
"name": "asc",
|
||||
"priority": "desc",
|
||||
"default": "desc",
|
||||
"created_at": "desc",
|
||||
"updated_at": "desc",
|
||||
}
|
||||
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self).run()
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
async def handle_event(_, __):
|
||||
await seed_defaults(self)
|
||||
await self.run_migrations()
|
||||
await self._update_cache()
|
||||
await self._ensure_default_preset()
|
||||
|
||||
async def handler(e: Event, __):
|
||||
if isinstance(e.data, ConfigEvent) and CEFeature.PRESETS == e.data.feature:
|
||||
LOG.debug("Refreshing presets cache due to configuration update.")
|
||||
await self._update_cache()
|
||||
|
||||
Services.get_instance().add(PresetsRepository.__name__, self)
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.STARTED, handle_event, f"{PresetsRepository.__name__}.run_migrations"
|
||||
).subscribe(Events.CONFIG_UPDATE, handler, "Presets.refresh_cache")
|
||||
|
||||
async def _update_cache(self) -> None:
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
await Presets.get_instance().refresh_cache(await self.all())
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> PresetsRepository:
|
||||
return PresetsRepository()
|
||||
|
||||
async def _ensure_default_preset(self) -> None:
|
||||
config = Config.get_instance()
|
||||
if not (default_name := preset_name(config.default_preset)):
|
||||
config.default_preset = "default"
|
||||
default_name = config.default_preset
|
||||
|
||||
if await self.get_by_name(default_name) is None:
|
||||
LOG.error("Default preset '%s' not found, using 'default' preset.", default_name)
|
||||
config.default_preset = "default"
|
||||
|
||||
async def all(self) -> list[PresetModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[PresetModel]] = await session.execute(
|
||||
select(PresetModel).order_by(PresetModel.priority.desc(), PresetModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
def parse_sorting(cls, sort: str | None = None, order: str | None = None) -> tuple[tuple[str, str], ...]:
|
||||
sort_value = (sort or "").strip()
|
||||
order_value = (order or "").strip()
|
||||
|
||||
if not sort_value and not order_value:
|
||||
return cls.DEFAULT_SORT_ORDER
|
||||
|
||||
if order_value and not sort_value:
|
||||
msg = "sort is required when order is provided."
|
||||
raise ValueError(msg)
|
||||
|
||||
fields: list[str] = []
|
||||
for raw_field in sort_value.split(","):
|
||||
field = raw_field.strip().lower()
|
||||
if not field:
|
||||
continue
|
||||
if field not in cls.SORT_FIELDS:
|
||||
msg = f"sort must use supported fields: {', '.join(cls.SORT_FIELDS)}."
|
||||
raise ValueError(msg)
|
||||
if field not in fields:
|
||||
fields.append(field)
|
||||
|
||||
if not fields:
|
||||
msg = "sort must include at least one field."
|
||||
raise ValueError(msg)
|
||||
|
||||
directions: list[str] = []
|
||||
if order_value:
|
||||
for raw_direction in order_value.split(","):
|
||||
direction = raw_direction.strip().lower()
|
||||
if not direction:
|
||||
continue
|
||||
if direction not in cls.SORT_DIRECTIONS:
|
||||
msg = "order must be 'asc' or 'desc'."
|
||||
raise ValueError(msg)
|
||||
directions.append(direction)
|
||||
|
||||
if not directions:
|
||||
msg = "order must include at least one direction."
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(directions) not in {1, len(fields)}:
|
||||
msg = "order must provide one direction or match the number of sort fields."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not directions:
|
||||
return tuple((field, cls.FIELD_DEFAULT_DIRECTIONS.get(field, "asc")) for field in fields)
|
||||
|
||||
if len(directions) == 1:
|
||||
return tuple((field, directions[0]) for field in fields)
|
||||
|
||||
return tuple((field, directions[index]) for index, field in enumerate(fields))
|
||||
|
||||
@classmethod
|
||||
def _apply_sort_direction(cls, field: str, direction: str) -> Any:
|
||||
column = cls.SORT_FIELDS[field]
|
||||
return column.asc() if direction == "asc" else column.desc()
|
||||
|
||||
@classmethod
|
||||
def _build_order_by(cls, sort: str | None = None, order: str | None = None) -> list[Any]:
|
||||
sorting = cls.parse_sorting(sort, order)
|
||||
|
||||
order_by: list[Any] = [cls._apply_sort_direction(field, direction) for field, direction in sorting]
|
||||
|
||||
if all(field != "id" for field, _ in sorting):
|
||||
order_by.append(PresetModel.id.asc())
|
||||
|
||||
return order_by
|
||||
|
||||
async def list_paginated(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str | None = None,
|
||||
order: str | None = None,
|
||||
exclude_defaults: bool = False,
|
||||
) -> tuple[list[PresetModel], int, int, int]:
|
||||
order_by = self._build_order_by(sort, order)
|
||||
|
||||
async with self.session() as session:
|
||||
total: int = await self.count(exclude_defaults=exclude_defaults)
|
||||
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
if page > total_pages and total > 0:
|
||||
page = total_pages
|
||||
|
||||
query: Select[tuple[PresetModel]] = select(PresetModel)
|
||||
if exclude_defaults:
|
||||
query = query.where(PresetModel.default.is_(False))
|
||||
|
||||
query = query.order_by(*order_by).limit(per_page).offset((page - 1) * per_page)
|
||||
result: Result[tuple[PresetModel]] = await session.execute(query)
|
||||
return list(result.scalars().all()), total, page, total_pages
|
||||
|
||||
async def count(self, exclude_defaults: bool = False) -> int:
|
||||
async with self.session() as session:
|
||||
query = select(func.count()).select_from(PresetModel)
|
||||
if exclude_defaults:
|
||||
query = query.where(PresetModel.default.is_(False))
|
||||
|
||||
result: Result[tuple[int]] = await session.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> PresetModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = PresetModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(PresetModel.id == int(identifier), PresetModel.name == identifier)
|
||||
else:
|
||||
name = preset_name(identifier) if isinstance(identifier, str) else identifier
|
||||
if not name:
|
||||
return None
|
||||
clause = PresetModel.name == name
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> PresetModel | None:
|
||||
async with self.session() as session:
|
||||
if not (name := preset_name(name)):
|
||||
return None
|
||||
|
||||
query: Select[tuple[PresetModel]] = select(PresetModel).where(PresetModel.name == name)
|
||||
if None is not exclude_id:
|
||||
query = query.where(PresetModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: PresetModel | dict[str, Any]) -> PresetModel:
|
||||
async with self.session() as session:
|
||||
data = _payload_data(payload)
|
||||
|
||||
data.pop("id", None)
|
||||
model = PresetModel(**data)
|
||||
|
||||
model.name = preset_name(model.name)
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Preset with name '{model.name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def update(self, identifier: int | str, payload: dict[str, Any]) -> PresetModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = PresetModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(PresetModel.id == int(identifier), PresetModel.name == identifier)
|
||||
else:
|
||||
clause = PresetModel.name == identifier
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||
model: PresetModel | None = result.scalar_one_or_none()
|
||||
|
||||
if model is None:
|
||||
msg: str = f"Preset '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
payload.pop("created_at", None)
|
||||
payload.pop("updated_at", None)
|
||||
|
||||
for key, value in payload.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
normalized_name = preset_name(model.name)
|
||||
model.name = normalized_name
|
||||
if await self.get_by_name(name=normalized_name, exclude_id=model.id) is not None:
|
||||
msg = f"Preset with name '{normalized_name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def delete(self, identifier: int | str) -> PresetModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = PresetModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(PresetModel.id == int(identifier), PresetModel.name == identifier)
|
||||
else:
|
||||
clause = PresetModel.name == preset_name(identifier)
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||
model: PresetModel | None = result.scalar_one_or_none()
|
||||
if model is None:
|
||||
msg: str = f"Preset '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
200
app/features/presets/router.py
Normal file
200
app/features/presets/router.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
from app.features.presets.schemas import Preset, PresetList, PresetPatch
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
|
||||
|
||||
def _model(model: Any) -> Preset:
|
||||
return Preset.model_validate(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/presets/", "presets")
|
||||
async def presets_list(request: Request, encoder: Encoder, repo: PresetsRepository) -> Response:
|
||||
try:
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await repo.list_paginated(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort=request.query.get("sort"),
|
||||
order=request.query.get("order"),
|
||||
exclude_defaults=bool(request.query.get("exclude_defaults", False)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
return web.json_response(
|
||||
data=PresetList(
|
||||
items=[_model(model) for model in items],
|
||||
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||
),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", r"api/presets/{id:\d+}", "presets_get")
|
||||
async def presets_get(request: Request, encoder: Encoder, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/presets/", "presets_add")
|
||||
async def presets_add(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: Preset = Preset.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate preset.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
payload = item.model_dump(exclude_unset=True)
|
||||
payload.pop("id", None)
|
||||
payload["default"] = False
|
||||
|
||||
try:
|
||||
saved = _serialize(await repo.create(payload))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.CREATE, data=saved),
|
||||
)
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PATCH", r"api/presets/{id:\d+}", "presets_patch")
|
||||
async def presets_patch(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if model.default:
|
||||
return web.json_response(
|
||||
{"error": "Default presets cannot be modified."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = PresetPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate preset.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Preset with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
payload = validated.model_dump(exclude_unset=True)
|
||||
payload.pop("default", None)
|
||||
updated = _serialize(await repo.update(model.id, payload))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", r"api/presets/{id:\d+}", "presets_update")
|
||||
async def presets_update(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if model.default:
|
||||
return web.json_response(
|
||||
{"error": "Default presets cannot be modified."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = Preset.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate preset.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Preset with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
payload = validated.model_dump(exclude_unset=True)
|
||||
payload.pop("default", None)
|
||||
payload.pop("id", None)
|
||||
updated = _serialize(await repo.update(model.id, payload))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/presets/{id:\d+}", "presets_delete")
|
||||
async def presets_delete(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if model.default:
|
||||
return web.json_response({"error": "Default presets cannot be deleted."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
deleted = _serialize(await repo.delete(model.id))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.DELETE, data=deleted),
|
||||
)
|
||||
return web.json_response(data=deleted, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
97
app/features/presets/schemas.py
Normal file
97
app/features/presets/schemas.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.features.core.utils import parse_int
|
||||
from app.features.presets.utils import preset_name
|
||||
from app.library.config import Config
|
||||
from app.library.Utils import create_cookies_file
|
||||
|
||||
|
||||
class Preset(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
folder: str = ""
|
||||
template: str = ""
|
||||
cookies: str = ""
|
||||
cli: str = ""
|
||||
default: bool = False
|
||||
priority: int = 0
|
||||
|
||||
_cookies_file: Path | None = PrivateAttr(default=None)
|
||||
|
||||
@field_validator("name", mode="before")
|
||||
@classmethod
|
||||
def _normalize_name(cls, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
msg: str = "Name must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not (value := preset_name(value)):
|
||||
msg = "Name cannot be empty."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@field_validator("priority", mode="before")
|
||||
@classmethod
|
||||
def _normalize_priority(cls, value: Any) -> int:
|
||||
return parse_int(value, field="Priority", minimum=0)
|
||||
|
||||
@field_validator("cli", mode="before")
|
||||
@classmethod
|
||||
def _validate_cli(cls, value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
msg: str = "CLI must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from app.features.ytdlp.utils import arg_converter
|
||||
|
||||
arg_converter(args=value, level=True)
|
||||
except Exception as e:
|
||||
msg = f"Invalid command options for yt-dlp: {e!s}"
|
||||
raise ValueError(msg) from e
|
||||
return value
|
||||
|
||||
def get_cookies_file(self, config: Config | None = None) -> Path | None:
|
||||
if None is not self._cookies_file:
|
||||
return self._cookies_file
|
||||
|
||||
if not self.cookies or self.id is None:
|
||||
return None
|
||||
|
||||
config = config or Config.get_instance()
|
||||
self._cookies_file = create_cookies_file(self.cookies, Path(config.config_path) / "cookies" / f"{self.id}.txt")
|
||||
return self._cookies_file
|
||||
|
||||
|
||||
class PresetPatch(Preset):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
folder: str | None = None
|
||||
template: str | None = None
|
||||
cookies: str | None = None
|
||||
cli: str | None = None
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class PresetList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[Preset] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
44
app/features/presets/service.py
Normal file
44
app/features/presets/service.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.features.presets.models import PresetModel
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
from app.features.presets.schemas import Preset
|
||||
from app.features.presets.utils import preset_name
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
|
||||
class Presets(metaclass=Singleton):
|
||||
def __init__(self, repo: PresetsRepository | None = None) -> None:
|
||||
self._repo: PresetsRepository = repo or PresetsRepository.get_instance()
|
||||
self._cache: list[tuple[int, str, Preset]] = []
|
||||
Services.get_instance().add(type(self).__name__, self)
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> Presets:
|
||||
return Presets()
|
||||
|
||||
async def refresh_cache(self, items: list[PresetModel]) -> None:
|
||||
presets = [Preset.model_validate(item) for item in items]
|
||||
self._cache = [(preset.id if preset.id is not None else -1, preset.name, preset) for preset in presets]
|
||||
|
||||
def get_all(self) -> list[Preset]:
|
||||
return [preset for _, _, preset in self._cache]
|
||||
|
||||
def get(self, identifier: int | str) -> Preset | None:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, str) and not (identifier := preset_name(identifier)):
|
||||
return None
|
||||
|
||||
for id, name, preset in self._cache:
|
||||
if isinstance(identifier, int) and id == identifier:
|
||||
return preset
|
||||
if isinstance(identifier, str) and name == identifier:
|
||||
return preset
|
||||
|
||||
return None
|
||||
|
||||
def has(self, identifier: int | str) -> bool:
|
||||
return self.get(identifier) is not None
|
||||
179
app/features/presets/tests/test_presets_repository.py
Normal file
179
app/features/presets/tests/test_presets_repository.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request
|
||||
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
from app.features.presets.router import presets_list
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.tests.helpers import make_in_memory_db_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo():
|
||||
PresetsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=make_in_memory_db_path("presets-repository"))
|
||||
await store.get_connection()
|
||||
|
||||
repository = PresetsRepository.get_instance()
|
||||
yield repository
|
||||
|
||||
await store.close()
|
||||
|
||||
PresetsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestPresetsRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get(self, repo):
|
||||
preset = await repo.create({"name": "Custom", "cli": "--format best"})
|
||||
|
||||
assert preset.id is not None, "Should generate ID for new preset"
|
||||
assert preset.name == "custom", "Should normalize preset name"
|
||||
assert preset.cli == "--format best", "Should store preset cli"
|
||||
|
||||
fetched = await repo.get(preset.id)
|
||||
assert fetched is not None, "Should fetch preset by id"
|
||||
assert fetched.name == "custom", "Should return matching preset"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_normalizes_spaces(self, repo):
|
||||
preset = await repo.create({"name": "My Preset"})
|
||||
|
||||
assert preset.name == "my_preset", "Should normalize spaces to underscores"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orders_by_priority_then_name(self, repo):
|
||||
await repo.create({"name": "B", "priority": 1})
|
||||
await repo.create({"name": "A", "priority": 1})
|
||||
await repo.create({"name": "C", "priority": 2})
|
||||
|
||||
items = await repo.all()
|
||||
|
||||
assert items[0].name == "c", "Highest priority should be first"
|
||||
assert items[1].name == "a", "Same priority should sort by name"
|
||||
assert items[2].name == "b", "Same priority should sort by name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo):
|
||||
for i in range(5):
|
||||
await repo.create({"name": f"Item {i}", "priority": i})
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||
|
||||
assert len(items) == 2, "Should return 2 items per page"
|
||||
assert total == 5, "Should report total count"
|
||||
assert page == 1, "Should be on page 1"
|
||||
assert total_pages == 3, "Should have 3 pages total"
|
||||
assert [item.priority for item in items] == [4, 3], "Should keep default priority-desc order"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_sorts_by_name_desc(self, repo):
|
||||
await repo.create({"name": "Alpha", "priority": 1})
|
||||
await repo.create({"name": "Gamma", "priority": 3})
|
||||
await repo.create({"name": "Beta", "priority": 2})
|
||||
|
||||
items, _, _, _ = await repo.list_paginated(page=1, per_page=10, sort="name", order="desc")
|
||||
|
||||
assert [item.name for item in items] == ["gamma", "beta", "alpha"], "Should sort by requested field"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_excludes_defaults(self, repo):
|
||||
await repo.create({"name": "System Default", "default": True, "priority": 10})
|
||||
await repo.create({"name": "Custom Preset", "priority": 1})
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=10, exclude_defaults=True)
|
||||
|
||||
assert [item.name for item in items] == ["custom_preset"], "Should exclude default presets"
|
||||
assert total == 1, "Should count only custom presets"
|
||||
assert page == 1, "Should keep current page when filtered results exist"
|
||||
assert total_pages == 1, "Should compute pages from the filtered total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_multi_sort(self, repo):
|
||||
await repo.create({"name": "Charlie", "priority": 2})
|
||||
await repo.create({"name": "Alpha", "priority": 1})
|
||||
await repo.create({"name": "Bravo", "priority": 1})
|
||||
|
||||
items, _, _, _ = await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc")
|
||||
|
||||
assert [(item.priority, item.name) for item in items] == [
|
||||
(1, "bravo"),
|
||||
(1, "alpha"),
|
||||
(2, "charlie"),
|
||||
], "Should support multiple sort fields and directions"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_bad_sort(self, repo):
|
||||
with pytest.raises(ValueError, match="sort must use supported fields"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="cli", order="asc")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_bad_order(self, repo):
|
||||
with pytest.raises(ValueError, match="order must be 'asc' or 'desc'"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="name", order="sideways")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_mismatched_order(self, repo):
|
||||
with pytest.raises(ValueError, match="order must provide one direction or match the number of sort fields"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc,asc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPresetRoutes:
|
||||
async def test_list_route_sort(self, repo):
|
||||
await repo.create({"name": "Alpha", "priority": 1})
|
||||
await repo.create({"name": "Bravo", "priority": 1})
|
||||
await repo.create({"name": "Charlie", "priority": 2})
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {"page": "1", "per_page": "10", "sort": "priority,name", "order": "asc,desc"}
|
||||
|
||||
response = await presets_list(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should return 200 for valid sorting"
|
||||
assert [item["name"] for item in payload["items"]] == ["bravo", "alpha", "charlie"], "Should sort response"
|
||||
|
||||
async def test_list_route_bad_sort(self, repo):
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {"sort": "cli", "order": "asc"}
|
||||
|
||||
response = await presets_list(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort field"
|
||||
assert "sort" in payload["error"], "Should explain invalid sort field"
|
||||
|
||||
async def test_list_route_bad_order(self, repo):
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {"sort": "name", "order": "sideways"}
|
||||
|
||||
response = await presets_list(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort direction"
|
||||
assert "order" in payload["error"], "Should explain invalid sort direction"
|
||||
|
||||
async def test_list_route_exclude_defaults(self, repo):
|
||||
await repo.create({"name": "System Default", "default": True, "priority": 10})
|
||||
await repo.create({"name": "Custom Preset", "priority": 1})
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {"page": "1", "per_page": "10", "exclude_defaults": "true"}
|
||||
|
||||
response = await presets_list(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should return 200 for valid default exclusion"
|
||||
assert [item["name"] for item in payload["items"]] == ["custom_preset"], "Should exclude default presets"
|
||||
assert payload["pagination"]["total"] == 1, "Should report filtered total"
|
||||
60
app/features/presets/utils.py
Normal file
60
app/features/presets/utils.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
NAME_WHITESPACE_PATTERN = re.compile(r"\s+")
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
async def seed_defaults(repo) -> None:
|
||||
from .defaults import DEFAULT_PRESETS
|
||||
|
||||
for preset in DEFAULT_PRESETS:
|
||||
try:
|
||||
name = preset.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping default preset with invalid name: %s", preset)
|
||||
continue
|
||||
|
||||
if not (name := preset_name(name)):
|
||||
LOG.warning("Skipping default preset with empty normalized name: %s", name)
|
||||
continue
|
||||
|
||||
payload = {**preset}
|
||||
payload.pop("id", None)
|
||||
payload["default"] = True
|
||||
payload["name"] = name
|
||||
|
||||
updated_at_value: datetime | None = None
|
||||
updated_at_raw = payload.get("updated_at")
|
||||
if isinstance(updated_at_raw, str):
|
||||
updated_at_value = datetime.fromisoformat(updated_at_raw)
|
||||
elif isinstance(updated_at_raw, datetime):
|
||||
updated_at_value = updated_at_raw
|
||||
if isinstance(updated_at_value, datetime) and updated_at_value.tzinfo is None:
|
||||
updated_at_value = updated_at_value.replace(tzinfo=UTC)
|
||||
payload["updated_at"] = updated_at_value
|
||||
|
||||
existing = await repo.get_by_name(name)
|
||||
if None is existing:
|
||||
await repo.create(payload)
|
||||
continue
|
||||
|
||||
if existing.updated_at and updated_at_value and existing.updated_at >= updated_at_value:
|
||||
if False is existing.default:
|
||||
await repo.update(existing.id, {"default": True})
|
||||
continue
|
||||
|
||||
await repo.update(existing.id, payload)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to seed default preset '%s'.",
|
||||
preset.get("name"),
|
||||
extra={"preset": preset.get("name"), "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
|
||||
def preset_name(value: str) -> str:
|
||||
return NAME_WHITESPACE_PATTERN.sub("_", value.strip().lower())
|
||||
|
|
@ -5,21 +5,16 @@ Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
|
|||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
import os
|
||||
import subprocess # qa: ignore
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from app.features.streaming.types import FFProbeError
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FFProbeError(Exception):
|
||||
pass
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class FFStream:
|
||||
|
|
@ -42,21 +37,26 @@ class FFStream:
|
|||
|
||||
def __repr__(self):
|
||||
if "codec_long_name" not in self.__dict__:
|
||||
self.codec_long_name = self.__dict__.get("codec_name", "")
|
||||
self.__dict__["codec_long_name"] = self.__dict__.get("codec_name", "")
|
||||
|
||||
index = self.__dict__.get("index", "?")
|
||||
codec_type = self.__dict__.get("codec_type", "unknown")
|
||||
codec_long_name = self.__dict__.get("codec_long_name", "")
|
||||
|
||||
if self.is_video():
|
||||
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, {self.framerate}, ({self.width}x{self.height})>"
|
||||
return f"<Stream: #{index} [{codec_type}] {codec_long_name}, {self.__dict__.get('framerate')}, ({self.__dict__.get('width')}x{self.__dict__.get('height')})>"
|
||||
|
||||
if self.is_audio():
|
||||
return (
|
||||
f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, channels: {self.channels} ({self.channel_layout}), "
|
||||
"{sample_rate}Hz> "
|
||||
f"<Stream: #{index} [{codec_type}] {codec_long_name}, "
|
||||
f"channels: {self.__dict__.get('channels')} ({self.__dict__.get('channel_layout')}), "
|
||||
f"{self.__dict__.get('sample_rate')}Hz> "
|
||||
)
|
||||
|
||||
if self.is_subtitle() or self.is_attachment():
|
||||
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}>"
|
||||
return f"<Stream: #{index} [{codec_type}] {codec_long_name}>"
|
||||
|
||||
return f"<Stream: #{self.index} [{self.codec_type}]>"
|
||||
return f"<Stream: #{index} [{codec_type}]>"
|
||||
|
||||
def is_audio(self):
|
||||
"""
|
||||
|
|
@ -237,7 +237,7 @@ class FFProbeResult:
|
|||
|
||||
|
||||
@timed_lru_cache(ttl_seconds=300, max_size=128)
|
||||
async def ffprobe(file: Path|str) -> FFProbeResult:
|
||||
async def ffprobe(file: Path | str) -> FFProbeResult:
|
||||
"""
|
||||
Run ffprobe on a file and return the parsed data as a dictionary.
|
||||
|
||||
|
|
@ -255,14 +255,14 @@ async def ffprobe(file: Path|str) -> FFProbeResult:
|
|||
raise OSError(msg)
|
||||
|
||||
try:
|
||||
async with await anyio.open_file(os.devnull, "w") as tempf:
|
||||
await asyncio.create_subprocess_exec(
|
||||
"ffprobe",
|
||||
"-h",
|
||||
stdout=tempf,
|
||||
stderr=tempf,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe",
|
||||
"-h",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
await proc.wait()
|
||||
except FileNotFoundError as e:
|
||||
msg = "ffprobe not found."
|
||||
raise OSError(msg) from e
|
||||
|
|
@ -2,8 +2,8 @@ import math
|
|||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from .ffprobe import ffprobe
|
||||
from .Utils import StreamingError
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
from app.features.streaming.types import StreamingError
|
||||
|
||||
|
||||
class M3u8:
|
||||
|
|
@ -39,7 +39,7 @@ class M3u8:
|
|||
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
|
||||
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
||||
|
||||
segmentSize: float = f"{self.duration:.6f}"
|
||||
segmentSize: str = f"{self.duration:.6f}"
|
||||
splits: int = math.ceil(duration / self.duration)
|
||||
|
||||
segmentParams: dict = {}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from .ffprobe import ffprobe
|
||||
from .Utils import StreamingError, get_file_sidecar
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
from app.features.streaming.types import StreamingError
|
||||
from app.library.Utils import get_file_sidecar
|
||||
|
||||
|
||||
class Playlist:
|
||||
|
|
@ -13,7 +14,7 @@ class Playlist:
|
|||
"The path where files are downloaded."
|
||||
|
||||
async def make(self, file: Path) -> str:
|
||||
ref: str = Path(str(file.relative_to(self.download_path)).strip("/"))
|
||||
ref: Path = Path(str(file.relative_to(self.download_path)).strip("/"))
|
||||
|
||||
try:
|
||||
ff = await ffprobe(file)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# flake8: noqa: ARG002
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
|
|
@ -16,9 +16,13 @@ def detect_qsv_capabilities() -> dict[str, dict[str, bool]]:
|
|||
Returns a dict where keys are codec names (e.g. "h264", "hevc", "vp9") and
|
||||
values are dicts with keys "full" and "lp" booleans.
|
||||
"""
|
||||
vainfo = shutil.which("vainfo")
|
||||
if not vainfo:
|
||||
return {}
|
||||
|
||||
try:
|
||||
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
["vainfo"], # noqa: S607
|
||||
[vainfo],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
|
|
@ -85,11 +89,15 @@ def ffmpeg_encoders() -> set[str]:
|
|||
set[str]: A set of available ffmpeg encoder names.
|
||||
|
||||
"""
|
||||
from .config import SUPPORTED_CODECS
|
||||
from app.library.config import SUPPORTED_CODECS
|
||||
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
return set()
|
||||
|
||||
try:
|
||||
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], # noqa: S607
|
||||
[ffmpeg, "-hide_banner", "-loglevel", "error", "-encoders"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
|
|
@ -122,7 +130,7 @@ def select_encoder(configured: str) -> str:
|
|||
str: The selected concrete encoder name.
|
||||
|
||||
"""
|
||||
from .config import SUPPORTED_CODECS
|
||||
from app.library.config import SUPPORTED_CODECS
|
||||
|
||||
configured = (configured or "").strip()
|
||||
|
||||
|
|
@ -149,9 +157,11 @@ class _BaseBuilder:
|
|||
codec_name: str
|
||||
|
||||
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
_ = ctx
|
||||
return []
|
||||
|
||||
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
_ = ctx
|
||||
return [*args, "-codec:v", self.codec_name]
|
||||
|
||||
|
||||
|
|
@ -312,4 +322,4 @@ def encoder_fallback_chain(codec: str) -> tuple[str, ...]:
|
|||
"libx264": [],
|
||||
}
|
||||
|
||||
return chains.get(codec, chains["libx264"])
|
||||
return tuple(chains.get(codec, chains["libx264"]))
|
||||
|
|
@ -1,32 +1,32 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess # type: ignore
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from secrets import token_hex
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .config import SUPPORTED_CODECS, Config
|
||||
from .ffprobe import ffprobe
|
||||
from .SegmentEncoders import (
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
from app.features.streaming.library.segment_encoders import (
|
||||
detect_qsv_capabilities,
|
||||
encoder_fallback_chain,
|
||||
get_builder_for_codec,
|
||||
has_dri_devices,
|
||||
select_encoder,
|
||||
)
|
||||
from app.library.config import SUPPORTED_CODECS, Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
|
||||
from .ffprobe import FFProbeResult
|
||||
from .SegmentEncoders import EncoderBuilder
|
||||
from .segment_encoders import EncoderBuilder
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("player.segments")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Segments:
|
||||
|
|
@ -68,21 +68,21 @@ class Segments:
|
|||
self.attempted: set[str] = set()
|
||||
"The set of attempted codecs."
|
||||
|
||||
async def build_ffmpeg_args(self, file: Path, s_codec: str) -> list[str]:
|
||||
def _make_stream_input(self, file: Path) -> Path:
|
||||
while True:
|
||||
stream_input = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{token_hex(8)}")
|
||||
try:
|
||||
stream_input.symlink_to(file, target_is_directory=False)
|
||||
return stream_input
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
async def build_ffmpeg_args(self, file: Path, s_codec: str, *, stream_input: Path | None = None) -> list[str]:
|
||||
try:
|
||||
ff: FFProbeResult = await ffprobe(file)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
tmpFile: Path = Path(tempfile.gettempdir()).joinpath(
|
||||
f"ytptube_stream.{hashlib.sha256(str(file).encode()).hexdigest()}"
|
||||
)
|
||||
|
||||
if not tmpFile.exists():
|
||||
try:
|
||||
tmpFile.symlink_to(file, target_is_directory=False)
|
||||
except FileExistsError:
|
||||
pass
|
||||
input_path = stream_input or file
|
||||
|
||||
startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}"
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ class Segments:
|
|||
}
|
||||
|
||||
caps: dict[str, dict[str, bool]] = detect_qsv_capabilities()
|
||||
base_codec: str = s_codec.split("_")[0]
|
||||
base_codec: str = s_codec.split("_", maxsplit=1)[0]
|
||||
codec_caps: dict[str, bool] = caps.get(base_codec, {"full": False, "lp": False})
|
||||
ctx["qsv"] = codec_caps
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ class Segments:
|
|||
# hardware/global input options must come before -i
|
||||
*input_args,
|
||||
"-i",
|
||||
f"file:{tmpFile}",
|
||||
f"file:{input_path}",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
]
|
||||
|
|
@ -170,9 +170,15 @@ class Segments:
|
|||
|
||||
stderr_task: asyncio.Task[None] = asyncio.create_task(_drain_stderr())
|
||||
|
||||
LOG.debug(f"Streaming '{file}' segment '{self.index}'. ffmpeg: {' '.join(args)}")
|
||||
LOG.debug(
|
||||
"Streaming segment %s for '%s'.",
|
||||
self.index,
|
||||
file,
|
||||
extra={"file": str(file), "segment_index": self.index, "ffmpeg_args": args},
|
||||
)
|
||||
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
while True:
|
||||
chunk: bytes = await proc.stdout.read(1024 * 64)
|
||||
if not chunk:
|
||||
|
|
@ -191,7 +197,10 @@ class Segments:
|
|||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process did not terminate in time. Killing it.")
|
||||
LOG.warning(
|
||||
"Segment stream for '%s' did not stop ffmpeg in time after the client disconnected; killing it.",
|
||||
file,
|
||||
)
|
||||
proc.kill()
|
||||
raise
|
||||
except ConnectionResetError:
|
||||
|
|
@ -204,7 +213,10 @@ class Segments:
|
|||
try:
|
||||
rc: int = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process did not terminate in time after disconnect. Killing it.")
|
||||
LOG.warning(
|
||||
"Segment stream for '%s' did not stop ffmpeg in time after the client disconnected; killing it.",
|
||||
file,
|
||||
)
|
||||
proc.kill()
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
|
|
@ -215,7 +227,7 @@ class Segments:
|
|||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=30)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process wait timed out. Killing it.")
|
||||
LOG.warning("Segment stream for '%s' did not stop ffmpeg in time; killing the process.", file)
|
||||
proc.kill()
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
|
|
@ -229,33 +241,47 @@ class Segments:
|
|||
return (wrote_any, rc, client_disconnected_local, stderr_buf.decode("utf-8", errors="ignore").strip())
|
||||
|
||||
async def stream(self, file: Path, resp: web.StreamResponse) -> None:
|
||||
codec: str = Segments._cached_vcodec if Segments._cache_initialized else self.vcodec
|
||||
codec: str = self.vcodec
|
||||
if Segments._cache_initialized and Segments._cached_vcodec:
|
||||
codec = Segments._cached_vcodec
|
||||
if not codec or codec not in SUPPORTED_CODECS:
|
||||
codec: str = select_encoder(self.vcodec or "")
|
||||
|
||||
LOG.debug(f"Selected video codec '{codec}' for segment streaming.")
|
||||
LOG.debug("Selected video codec '%s' for segment streaming.", codec, extra={"codec": codec})
|
||||
|
||||
if Segments._cached_vcodec and Segments._cache_initialized:
|
||||
codecs: list[str] = [Segments._cached_vcodec]
|
||||
else:
|
||||
codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))]
|
||||
|
||||
for s_codec in codecs:
|
||||
if s_codec in self.attempted:
|
||||
continue
|
||||
stream_input = self._make_stream_input(file)
|
||||
try:
|
||||
for s_codec in codecs:
|
||||
if s_codec in self.attempted:
|
||||
continue
|
||||
|
||||
ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec)
|
||||
_, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args)
|
||||
ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec, stream_input=stream_input)
|
||||
_, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args)
|
||||
|
||||
if 0 == rc:
|
||||
Segments._cached_vcodec = s_codec
|
||||
Segments._cache_initialized = True
|
||||
return
|
||||
if 0 == rc:
|
||||
Segments._cached_vcodec = s_codec
|
||||
Segments._cache_initialized = True
|
||||
return
|
||||
|
||||
if client_disconnected:
|
||||
return
|
||||
if client_disconnected:
|
||||
return
|
||||
|
||||
if 0 != rc:
|
||||
err: str = stderr_text[:500] if stderr_text else "no error output"
|
||||
LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.")
|
||||
self.attempted.add(s_codec)
|
||||
if 0 != rc:
|
||||
err: str = stderr_text[:500] if stderr_text else "no error output"
|
||||
LOG.warning(
|
||||
"Retrying segment %s for '%s' because hardware encoder '%s' failed: %s.",
|
||||
self.index,
|
||||
file,
|
||||
s_codec,
|
||||
err,
|
||||
extra={"ffmpeg_args": ffmpeg_args, "returncode": rc, "stderr": err, "codec": s_codec},
|
||||
)
|
||||
self.attempted.add(s_codec)
|
||||
finally:
|
||||
if stream_input.is_symlink():
|
||||
stream_input.unlink()
|
||||
155
app/features/streaming/library/subtitle.py
Normal file
155
app/features/streaming/library/subtitle.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pysubs2
|
||||
from pysubs2.formats.substation import SubstationFormat
|
||||
from pysubs2.time import ms_to_times
|
||||
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import ALLOWED_SUBS_EXTENSIONS, get_file_sidecar
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
SOURCE_FORMATS: tuple[str, ...] = ("vtt", "srt", "ass")
|
||||
DELIVERY_FORMATS: dict[str, str] = {
|
||||
"vtt": "vtt",
|
||||
"srt": "vtt",
|
||||
"ass": "ass",
|
||||
}
|
||||
RENDERERS: dict[str, str] = {
|
||||
"vtt": "native",
|
||||
"srt": "native",
|
||||
"ass": "assjs",
|
||||
}
|
||||
MEDIA_TYPES: dict[str, str] = {
|
||||
"vtt": "text/vtt; charset=UTF-8",
|
||||
"ass": "text/x-ssa; charset=UTF-8",
|
||||
}
|
||||
TEXT_ENCODINGS: tuple[str, ...] = ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be", "cp1252")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubtitleTrack:
|
||||
file: Path
|
||||
lang: str
|
||||
name: str
|
||||
source_format: str
|
||||
delivery_format: str
|
||||
renderer: str
|
||||
|
||||
|
||||
def ms_to_timestamp(ms: int) -> str:
|
||||
ms = max(0, ms)
|
||||
h, m, s, ms = ms_to_times(ms)
|
||||
cs: int = ms // 10
|
||||
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
|
||||
|
||||
|
||||
_substation_format: Any = SubstationFormat
|
||||
_substation_format.ms_to_timestamp = ms_to_timestamp
|
||||
|
||||
|
||||
class Subtitle:
|
||||
@staticmethod
|
||||
def normalize_format(source_format: str) -> str | None:
|
||||
fmt = source_format.strip().lower().removeprefix(".")
|
||||
if fmt not in SOURCE_FORMATS:
|
||||
return None
|
||||
|
||||
return fmt
|
||||
|
||||
async def read_text(self, file: Path) -> str:
|
||||
async with await anyio.open_file(file, "rb") as f:
|
||||
subtitle_bytes = await f.read()
|
||||
|
||||
return self.decode_bytes(subtitle_bytes)
|
||||
|
||||
@staticmethod
|
||||
def decode_bytes(subtitle_bytes: bytes) -> str:
|
||||
for encoding in TEXT_ENCODINGS:
|
||||
try:
|
||||
return subtitle_bytes.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
return subtitle_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
def media_type(self, file: Path) -> str:
|
||||
fmt = self.normalize_format(file.suffix)
|
||||
if fmt is None:
|
||||
msg = f"File '{file}' subtitle type is not supported."
|
||||
raise Exception(msg)
|
||||
|
||||
return MEDIA_TYPES[DELIVERY_FORMATS[fmt]]
|
||||
|
||||
async def make(self, file: Path) -> str:
|
||||
fmt = self.normalize_format(file.suffix)
|
||||
if fmt is None or file.suffix not in ALLOWED_SUBS_EXTENSIONS:
|
||||
msg: str = f"File '{file}' subtitle type is not supported."
|
||||
raise Exception(msg)
|
||||
|
||||
if fmt == "vtt":
|
||||
return await self.read_text(file)
|
||||
|
||||
subs: pysubs2.SSAFile = pysubs2.load(path=str(file))
|
||||
|
||||
if len(subs.events) < 1:
|
||||
msg = f"No subtitle events were found in '{file}'."
|
||||
raise Exception(msg)
|
||||
|
||||
if len(subs.events) < 2:
|
||||
return subs.to_string("vtt")
|
||||
|
||||
try:
|
||||
if subs.events[0].end == subs.events[len(subs.events) - 1].end:
|
||||
subs.events.pop(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return subs.to_string("vtt")
|
||||
|
||||
async def make_delivery(self, file: Path) -> tuple[str, str]:
|
||||
fmt = self.normalize_format(file.suffix)
|
||||
if fmt is None or file.suffix not in ALLOWED_SUBS_EXTENSIONS:
|
||||
msg: str = f"File '{file}' subtitle type is not supported."
|
||||
raise Exception(msg)
|
||||
|
||||
if fmt == "ass":
|
||||
return await self.read_text(file), self.media_type(file)
|
||||
|
||||
return await self.make(file), self.media_type(file)
|
||||
|
||||
|
||||
def get_subtitle_tracks(file: Path) -> list[SubtitleTrack]:
|
||||
sidecars = get_file_sidecar(file).get("subtitle", [])
|
||||
indexed_tracks: list[tuple[int, SubtitleTrack]] = []
|
||||
|
||||
for index, item in enumerate(sidecars):
|
||||
track_file = item.get("file")
|
||||
if not isinstance(track_file, Path):
|
||||
continue
|
||||
|
||||
fmt = Subtitle.normalize_format(track_file.suffix)
|
||||
if fmt is None:
|
||||
continue
|
||||
|
||||
indexed_tracks.append(
|
||||
(
|
||||
index,
|
||||
SubtitleTrack(
|
||||
file=track_file,
|
||||
lang=str(item.get("lang") or "und"),
|
||||
name=str(item.get("name") or track_file.name),
|
||||
source_format=fmt,
|
||||
delivery_format=DELIVERY_FORMATS[fmt],
|
||||
renderer=RENDERERS[fmt],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
indexed_tracks.sort(key=lambda item: (SOURCE_FORMATS.index(item[1].source_format), item[0]))
|
||||
return [track for _, track in indexed_tracks]
|
||||
284
app/features/streaming/library/thumbnail.py
Normal file
284
app/features/streaming/library/thumbnail.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import FILES_TYPE, get_file_sidecar
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp")
|
||||
FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart")
|
||||
THUMBNAIL_SAMPLE_FRAMES = 200
|
||||
THUMBNAIL_DEFAULT_SEEK_SECONDS = 3.0
|
||||
THUMBNAIL_MIN_SEEK_SECONDS = 1.0
|
||||
THUMBNAIL_MAX_SEEK_SECONDS = 15.0
|
||||
THUMBNAIL_END_MARGIN_SECONDS = 0.1
|
||||
THUMBNAIL_MISS_TTL = 3600.0
|
||||
|
||||
_LOCK = asyncio.Lock()
|
||||
_IN_PROCESS: dict[str, asyncio.Task[Path | None]] = {}
|
||||
|
||||
|
||||
_SEM: asyncio.Semaphore | None = None
|
||||
_SEM_LIMIT: int | None = None
|
||||
|
||||
|
||||
def _get_semaphore() -> asyncio.Semaphore:
|
||||
global _SEM, _SEM_LIMIT # noqa: PLW0603
|
||||
|
||||
limit: int = max(1, int(Config.get_instance().thumb_concurrency))
|
||||
if _SEM is None or _SEM_LIMIT != limit:
|
||||
_SEM = asyncio.Semaphore(limit)
|
||||
_SEM_LIMIT = limit
|
||||
LOG.info("Configured thumbnail generation to run %s job(s) at a time.", limit, extra={"limit": limit})
|
||||
|
||||
return _SEM
|
||||
|
||||
|
||||
def _is_same_stem_image(media_file: Path, image_file: Path) -> bool:
|
||||
if image_file.parent != media_file.parent:
|
||||
return False
|
||||
|
||||
for image_type in FILES_TYPE:
|
||||
if "image" != image_type.get("type"):
|
||||
continue
|
||||
|
||||
rx = image_type.get("rx")
|
||||
if rx and rx.search(image_file.name):
|
||||
break
|
||||
else:
|
||||
return False
|
||||
|
||||
return image_file.stem == media_file.stem
|
||||
|
||||
|
||||
def pick_local_thumb(media_file: Path) -> Path | None:
|
||||
sidecar: dict[str, list[dict[str, str]]] = get_file_sidecar(media_file)
|
||||
images: list[dict[str, str]] = sidecar.get("image", [])
|
||||
local_images: list[Path] = [Path(item["file"]) for item in images if isinstance(item.get("file"), Path)]
|
||||
|
||||
for image_file in local_images:
|
||||
if image_file.suffix.lower() in IMAGE_TYPES and _is_same_stem_image(media_file, image_file):
|
||||
return image_file
|
||||
|
||||
by_name: dict[str, Path] = {
|
||||
image_file.stem.lower(): image_file for image_file in local_images if image_file.suffix.lower() in IMAGE_TYPES
|
||||
}
|
||||
for name in FOLDER_IMAGE_ORDER:
|
||||
if image_file := by_name.get(name):
|
||||
return image_file
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _seek_seconds(ff_info) -> float | None:
|
||||
duration: float = 0.0
|
||||
try:
|
||||
duration = float(ff_info.metadata.get("duration") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
if duration <= 0:
|
||||
return THUMBNAIL_DEFAULT_SEEK_SECONDS
|
||||
|
||||
if duration <= THUMBNAIL_END_MARGIN_SECONDS:
|
||||
return None
|
||||
|
||||
return min(
|
||||
max(duration * 0.10, THUMBNAIL_MIN_SEEK_SECONDS),
|
||||
THUMBNAIL_MAX_SEEK_SECONDS,
|
||||
duration - THUMBNAIL_END_MARGIN_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _build_ffmpeg_args(media_file: Path, output_file: Path, *, seek_seconds: float | None) -> list[str]:
|
||||
args: list[str] = [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
]
|
||||
|
||||
if seek_seconds is not None and seek_seconds > 0:
|
||||
args.extend(["-ss", f"{seek_seconds:.3f}"])
|
||||
|
||||
args.extend(
|
||||
[
|
||||
"-i",
|
||||
str(media_file),
|
||||
"-vf",
|
||||
f"thumbnail={THUMBNAIL_SAMPLE_FRAMES},scale=1280:-1:force_original_aspect_ratio=decrease",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-q:v",
|
||||
"3",
|
||||
"-an",
|
||||
str(output_file),
|
||||
]
|
||||
)
|
||||
return args
|
||||
|
||||
|
||||
async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
|
||||
ff_info = await ffprobe(media_file)
|
||||
if not ff_info.has_video():
|
||||
LOG.debug(
|
||||
"Skipping thumbnail generation for '%s' because no video stream exists.",
|
||||
media_file,
|
||||
extra={"media_file": str(media_file)},
|
||||
)
|
||||
return None
|
||||
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_file = output_file.with_suffix(".tmp.jpg")
|
||||
|
||||
if temp_file.exists():
|
||||
temp_file.unlink(missing_ok=True)
|
||||
|
||||
seek_seconds: float | None = _seek_seconds(ff_info)
|
||||
attempts: list[float | None] = [seek_seconds]
|
||||
if seek_seconds is not None and seek_seconds > 0:
|
||||
attempts.append(None)
|
||||
|
||||
sem = _get_semaphore()
|
||||
if sem.locked():
|
||||
limit = _SEM_LIMIT or 1
|
||||
LOG.debug(
|
||||
"Waiting for a thumbnail generation slot for '%s' (limit=%s).",
|
||||
media_file,
|
||||
limit,
|
||||
extra={"media_file": str(media_file), "limit": limit},
|
||||
)
|
||||
|
||||
async with sem:
|
||||
last_error: str = "ffmpeg produced an empty thumbnail file"
|
||||
for idx, attempt_seek in enumerate(attempts, start=1):
|
||||
temp_file.unlink(missing_ok=True)
|
||||
args: list[str] = _build_ffmpeg_args(media_file, temp_file, seek_seconds=attempt_seek)
|
||||
LOG.debug(
|
||||
"Generating thumbnail for '%s'. attempt=%s/%s seek=%s",
|
||||
media_file,
|
||||
idx,
|
||||
len(attempts),
|
||||
"none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
extra={
|
||||
"media_file": str(media_file),
|
||||
"attempt": idx,
|
||||
"attempt_count": len(attempts),
|
||||
"seek": "none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
msg = "ffmpeg not found."
|
||||
raise OSError(msg) from exc
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
if 0 == proc.returncode and temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
temp_file.replace(output_file)
|
||||
LOG.info(
|
||||
"Generated thumbnail '%s' for '%s'.",
|
||||
output_file,
|
||||
media_file,
|
||||
extra={"media_file": str(media_file), "output_file": str(output_file)},
|
||||
)
|
||||
return output_file
|
||||
|
||||
if 0 != proc.returncode:
|
||||
last_error: str = (
|
||||
f"ffmpeg thumbnail generation failed (rc={proc.returncode}).\n"
|
||||
f"stdout:\n{stdout.decode('utf-8', errors='replace')}\n"
|
||||
f"stderr:\n{stderr.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
else:
|
||||
last_error: str = "ffmpeg produced an empty thumbnail file"
|
||||
|
||||
LOG.debug(
|
||||
"Thumbnail generation attempt failed for '%s'. seek=%s",
|
||||
media_file,
|
||||
"none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
extra={
|
||||
"media_file": str(media_file),
|
||||
"seek": "none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
},
|
||||
)
|
||||
|
||||
temp_file.unlink(missing_ok=True)
|
||||
raise OSError(last_error)
|
||||
|
||||
|
||||
async def ensure_thumb(media_file: Path, cache_root: Path, item_id: str | None = None) -> Path | None:
|
||||
config: Config = Config.get_instance()
|
||||
cache: Cache = Cache.get_instance()
|
||||
if bool(config.thumb_generate) is not True:
|
||||
return None
|
||||
|
||||
sidecar: bool = bool(config.thumb_sidecar)
|
||||
if not sidecar and not item_id:
|
||||
msg = "item_id is required when thumbnail sidecar is disabled."
|
||||
raise ValueError(msg)
|
||||
|
||||
thumb_id: str = str(media_file.resolve()) if sidecar else str(item_id)
|
||||
cache_file: Path = media_file.with_name(f"{media_file.name}.jpg") if sidecar else cache_root / f"{item_id}.jpg"
|
||||
|
||||
miss_key: str = f"thumbnail-miss:{thumb_id}"
|
||||
|
||||
if cache_file.exists() and cache_file.stat().st_size > 0:
|
||||
cache.delete(miss_key)
|
||||
return cache_file
|
||||
|
||||
if cache.has(miss_key):
|
||||
return None
|
||||
|
||||
async with _LOCK:
|
||||
if cache_file.exists() and cache_file.stat().st_size > 0:
|
||||
cache.delete(miss_key)
|
||||
return cache_file
|
||||
|
||||
if cache.has(miss_key):
|
||||
return None
|
||||
|
||||
task = _IN_PROCESS.get(thumb_id)
|
||||
if task is not None and not task.done():
|
||||
LOG.debug("Waiting for thumbnail generation for '%s'.", media_file, extra={"media_file": str(media_file)})
|
||||
else:
|
||||
task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{item_id or media_file.stem}")
|
||||
_IN_PROCESS[thumb_id] = task
|
||||
LOG.debug(
|
||||
"Starting thumbnail generation for '%s' -> '%s'.",
|
||||
media_file,
|
||||
cache_file,
|
||||
extra={"media_file": str(media_file), "cache_file": str(cache_file)},
|
||||
)
|
||||
|
||||
try:
|
||||
result: Path | None = await task
|
||||
if result is None:
|
||||
cache.set(miss_key, value=True, ttl=THUMBNAIL_MISS_TTL)
|
||||
else:
|
||||
cache.delete(miss_key)
|
||||
return result
|
||||
except OSError:
|
||||
cache.set(miss_key, value=True, ttl=THUMBNAIL_MISS_TTL)
|
||||
raise
|
||||
finally:
|
||||
async with _LOCK:
|
||||
current = _IN_PROCESS.get(thumb_id)
|
||||
if current is task and task.done():
|
||||
_IN_PROCESS.pop(thumb_id, None)
|
||||
420
app/features/streaming/router.py
Normal file
420
app/features/streaming/router.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from aiohttp.web_response import StreamResponse
|
||||
|
||||
from app.features.streaming.library.m3u8 import M3u8
|
||||
from app.features.streaming.library.playlist import Playlist
|
||||
from app.features.streaming.library.segments import Segments
|
||||
from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks
|
||||
from app.features.streaming.types import StreamingError
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
from app.library.Utils import get_file
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
|
||||
async def playlist_create(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the playlist.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str | None = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
base_path: str = config.base_path.rstrip("/")
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=web.HTTPFound.status_code,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["playlist_create"].url_for(
|
||||
file=str(realFile).replace(config.download_path, "").strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
return web.Response(
|
||||
text=await Playlist(download_path=Path(config.download_path), url=f"{base_path}/").make(file=realFile),
|
||||
headers={
|
||||
"Content-Type": "application/x-mpegURL",
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Max-Age": "300",
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
except StreamingError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8_create")
|
||||
async def m3u8_create(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the m3u8 file.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str | None = request.match_info.get("file")
|
||||
mode: str | None = request.match_info.get("mode")
|
||||
|
||||
if mode not in ["video", "subtitle"]:
|
||||
return web.json_response(
|
||||
data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration: float | None = None
|
||||
duration_arg = request.query.get("duration", None)
|
||||
|
||||
if "subtitle" in mode:
|
||||
if not duration_arg:
|
||||
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration = float(duration_arg)
|
||||
|
||||
base_path: str = config.base_path.rstrip("/")
|
||||
|
||||
try:
|
||||
cls = M3u8(download_path=Path(config.download_path), url=f"{base_path}/")
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["m3u8_create"].url_for(
|
||||
mode=mode, file=str(realFile).replace(config.download_path, "").strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
if "subtitle" in mode:
|
||||
if duration is None:
|
||||
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
||||
text = await cls.make_subtitle(file=realFile, duration=duration)
|
||||
else:
|
||||
text = await cls.make_stream(file=realFile)
|
||||
except StreamingError as e:
|
||||
LOG.exception(
|
||||
"Failed to create %s streaming playlist for '%s': %s.",
|
||||
mode,
|
||||
file,
|
||||
e,
|
||||
extra={"route": "streaming.playlist", "file_path": file, "mode": mode, "exception_type": type(e).__name__},
|
||||
)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.Response(
|
||||
text=text,
|
||||
headers={
|
||||
"Content-Type": "application/x-mpegURL",
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Max-Age": "300",
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
|
||||
async def segments_stream(request: Request, config: Config, app: web.Application) -> StreamResponse:
|
||||
"""
|
||||
Get the segments.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str | None = request.match_info.get("file")
|
||||
segment: str | None = request.match_info.get("segment")
|
||||
sd: str | None = request.query.get("sd")
|
||||
vc: int = int(request.query.get("vc", 0))
|
||||
ac: int = int(request.query.get("ac", 0))
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not segment:
|
||||
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["segments_stream"].url_for(
|
||||
segment=segment,
|
||||
file=str(realFile).replace(config.download_path, "").strip("/"),
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
mtime = realFile.stat().st_mtime
|
||||
|
||||
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||
|
||||
resp = web.StreamResponse(
|
||||
status=web.HTTPOk.status_code,
|
||||
headers={
|
||||
"Content-Type": "video/mpegts",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Pragma": "public",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Last-Modified": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||
),
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
await Segments(
|
||||
download_path=config.download_path,
|
||||
index=int(segment),
|
||||
duration=float(f"{float(sd or M3u8.duration):.6f}"),
|
||||
vconvert=vc == 1,
|
||||
aconvert=ac == 1,
|
||||
).stream(realFile, resp)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles_get")
|
||||
async def subtitles_get(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the subtitles.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str | None = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["subtitles_get"].url_for(file=str(realFile).replace(config.download_path, "").strip("/"))
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
mtime = realFile.stat().st_mtime
|
||||
|
||||
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||
|
||||
return web.Response(
|
||||
body=await Subtitle().make(file=realFile),
|
||||
headers={
|
||||
"Content-Type": "text/vtt; charset=UTF-8",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Pragma": "public",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Last-Modified": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||
),
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||
),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/player/subtitles/manifest/{file:.*}", "subtitles_manifest_get")
|
||||
async def subtitles_manifest_get(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get subtitle track metadata for a media file.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str | None = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["subtitles_manifest_get"].url_for(
|
||||
file=str(realFile).replace(config.download_path, "").strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
tracks = [
|
||||
{
|
||||
"lang": track.lang,
|
||||
"name": track.name,
|
||||
"source_format": track.source_format,
|
||||
"delivery_format": track.delivery_format,
|
||||
"renderer": track.renderer,
|
||||
"url": str(
|
||||
app.router["subtitles_track_get"].url_for(
|
||||
source_format=track.source_format,
|
||||
file=str(track.file).replace(config.download_path, "").strip("/"),
|
||||
)
|
||||
),
|
||||
}
|
||||
for track in get_subtitle_tracks(realFile)
|
||||
]
|
||||
|
||||
return web.json_response(data={"subtitles": tracks}, status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/player/subtitles/{source_format}/{file:.*}", "subtitles_track_get")
|
||||
async def subtitles_track_get(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get a subtitle file using its preferred delivery format.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str | None = request.match_info.get("file")
|
||||
source_format: str | None = request.match_info.get("source_format")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
fmt = Subtitle.normalize_format(source_format or "")
|
||||
if fmt is None:
|
||||
return web.json_response(
|
||||
data={"error": "Only vtt, srt, and ass subtitle formats are supported."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["subtitles_track_get"].url_for(
|
||||
source_format=fmt,
|
||||
file=str(realFile).replace(config.download_path, "").strip("/"),
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
if Subtitle.normalize_format(realFile.suffix) != fmt:
|
||||
return web.json_response(
|
||||
data={"error": f"Subtitle file '{file}' does not match requested source format '{fmt}'."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
mtime = realFile.stat().st_mtime
|
||||
|
||||
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||
|
||||
body, content_type = await Subtitle().make_delivery(file=realFile)
|
||||
return web.Response(
|
||||
body=body,
|
||||
headers={
|
||||
"Content-Type": content_type,
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Pragma": "public",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Last-Modified": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||
),
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||
),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
|
@ -1,28 +1,30 @@
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tests.helpers import make_test_temp_dir
|
||||
|
||||
|
||||
class TestFFProbe:
|
||||
"""Test the ffprobe module functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test files."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.temp_dir = str(make_test_temp_dir("ffprobe"))
|
||||
self.test_file = Path(self.temp_dir) / "test_video.mp4"
|
||||
self.test_file.touch()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test files."""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_with_existing_file(self):
|
||||
"""Test ffprobe with an existing file."""
|
||||
from app.library.ffprobe import FFProbeResult, ffprobe
|
||||
from app.features.streaming.library.ffprobe import FFProbeResult, ffprobe
|
||||
|
||||
# Mock subprocess to avoid actual ffprobe execution
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
|
|
@ -42,7 +44,7 @@ class TestFFProbe:
|
|||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_with_nonexistent_file(self):
|
||||
"""Test ffprobe with a non-existent file."""
|
||||
from app.library.ffprobe import ffprobe
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
|
||||
nonexistent_file = Path(self.temp_dir) / "does_not_exist.mp4"
|
||||
|
||||
|
|
@ -52,10 +54,11 @@ class TestFFProbe:
|
|||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_caching_behavior(self):
|
||||
"""Test that ffprobe results are cached with enhanced async timed_lru_cache."""
|
||||
from app.library.ffprobe import ffprobe
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
|
||||
# Test that the function has been decorated with caching
|
||||
assert hasattr(ffprobe, "cache_clear"), "ffprobe should have cache_clear method from timed_lru_cache"
|
||||
assert hasattr(ffprobe, "cache_clear"), (
|
||||
"Test that the function has been decorated with caching - ffprobe should have cache_clear method from timed_lru_cache"
|
||||
)
|
||||
assert hasattr(ffprobe, "cache_info"), "ffprobe should have cache_info method from timed_lru_cache"
|
||||
|
||||
# Clear cache to start fresh
|
||||
|
|
@ -64,6 +67,7 @@ class TestFFProbe:
|
|||
# Mock subprocess to avoid actual ffprobe execution
|
||||
call_count = 0
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
|
||||
def create_mock_process(*_args, **_kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
|
@ -80,26 +84,25 @@ class TestFFProbe:
|
|||
|
||||
# First call should execute the function
|
||||
result1 = await ffprobe(str(self.test_file))
|
||||
assert result1 is not None
|
||||
assert isinstance(result1.metadata, dict)
|
||||
first_call_count = call_count
|
||||
|
||||
# Second call with same argument should use cached result
|
||||
result2 = await ffprobe(str(self.test_file))
|
||||
assert result2 is not None
|
||||
assert isinstance(result2.metadata, dict)
|
||||
|
||||
# The subprocess should not be called again for the actual ffprobe execution
|
||||
# (it may be called for the -h check, but the main execution should be cached)
|
||||
assert call_count == first_call_count, "Second call should use cached result"
|
||||
assert call_count == first_call_count, (
|
||||
"The subprocess should not be called again for the actual ffprobe execution (it may be called for the -h check, but the main execution should be cached) - Second call should use cached result"
|
||||
)
|
||||
|
||||
# Results should be equivalent (same data, may not be same object due to async nature)
|
||||
assert result1.metadata == result2.metadata
|
||||
assert result1.metadata == result2.metadata, (
|
||||
"Results should be equivalent (same data, may not be same object due to async nature)"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_with_path_object(self):
|
||||
"""Test ffprobe with Path object input."""
|
||||
from app.library.ffprobe import ffprobe
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
|
||||
# Mock subprocess to avoid actual ffprobe execution
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
|
|
@ -114,36 +117,26 @@ class TestFFProbe:
|
|||
|
||||
# Test with Path object
|
||||
result = await ffprobe(self.test_file)
|
||||
assert result is not None
|
||||
assert result.metadata == {"duration": "10.0"}
|
||||
|
||||
def test_ffprobe_result_properties(self):
|
||||
"""Test FFProbeResult object properties."""
|
||||
from app.library.ffprobe import FFProbeResult, FFStream
|
||||
from app.features.streaming.library.ffprobe import FFStream, FFProbeResult
|
||||
|
||||
result = FFProbeResult()
|
||||
|
||||
# Test empty result
|
||||
assert result.video == []
|
||||
assert result.video == [], "Test empty result"
|
||||
assert result.audio == []
|
||||
assert result.subtitle == []
|
||||
assert result.attachment == []
|
||||
assert result.metadata == {}
|
||||
|
||||
# Test adding streams
|
||||
video_stream = FFStream({
|
||||
"index": 0,
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
})
|
||||
video_stream = FFStream(
|
||||
{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080}
|
||||
)
|
||||
|
||||
audio_stream = FFStream({
|
||||
"index": 1,
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"channels": 2
|
||||
})
|
||||
audio_stream = FFStream({"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2})
|
||||
|
||||
result.video.append(video_stream)
|
||||
result.audio.append(audio_stream)
|
||||
|
|
@ -155,16 +148,12 @@ class TestFFProbe:
|
|||
|
||||
def test_stream_object_methods(self):
|
||||
"""Test Stream object methods."""
|
||||
from app.library.ffprobe import FFStream
|
||||
from app.features.streaming.library.ffprobe import FFStream
|
||||
|
||||
# Test video stream
|
||||
video_stream = FFStream({
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"duration": "10.5"
|
||||
})
|
||||
video_stream = FFStream(
|
||||
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080, "duration": "10.5"}
|
||||
)
|
||||
|
||||
assert video_stream.is_video()
|
||||
assert not video_stream.is_audio()
|
||||
|
|
@ -172,12 +161,7 @@ class TestFFProbe:
|
|||
assert not video_stream.is_attachment()
|
||||
|
||||
# Test audio stream
|
||||
audio_stream = FFStream({
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"channels": 2,
|
||||
"sample_rate": "44100"
|
||||
})
|
||||
audio_stream = FFStream({"codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "44100"})
|
||||
|
||||
assert not audio_stream.is_video()
|
||||
assert audio_stream.is_audio()
|
||||
|
|
@ -185,10 +169,7 @@ class TestFFProbe:
|
|||
assert not audio_stream.is_attachment()
|
||||
|
||||
# Test subtitle stream
|
||||
subtitle_stream = FFStream({
|
||||
"codec_type": "subtitle",
|
||||
"codec_name": "subrip"
|
||||
})
|
||||
subtitle_stream = FFStream({"codec_type": "subtitle", "codec_name": "subrip"})
|
||||
|
||||
assert not subtitle_stream.is_video()
|
||||
assert not subtitle_stream.is_audio()
|
||||
|
|
@ -196,10 +177,7 @@ class TestFFProbe:
|
|||
assert not subtitle_stream.is_attachment()
|
||||
|
||||
# Test attachment stream
|
||||
attachment_stream = FFStream({
|
||||
"codec_type": "attachment",
|
||||
"codec_name": "ttf"
|
||||
})
|
||||
attachment_stream = FFStream({"codec_type": "attachment", "codec_name": "ttf"})
|
||||
|
||||
assert not attachment_stream.is_video()
|
||||
assert not attachment_stream.is_audio()
|
||||
|
|
@ -4,8 +4,8 @@ from urllib.parse import quote
|
|||
|
||||
import pytest
|
||||
|
||||
from app.library.M3u8 import M3u8
|
||||
from app.library.Utils import StreamingError
|
||||
from app.features.streaming.library.m3u8 import M3u8
|
||||
from app.features.streaming.types import StreamingError
|
||||
|
||||
|
||||
class _Stream:
|
||||
|
|
@ -38,7 +38,7 @@ async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.M
|
|||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
|
||||
|
||||
m3 = M3u8(download_path=base, url="http://host/")
|
||||
out = await m3.make_stream(media)
|
||||
|
|
@ -64,7 +64,7 @@ async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.M
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_make_stream_transcode_flags(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
media = base / "v.mp4"
|
||||
|
|
@ -79,7 +79,7 @@ async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeyp
|
|||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
|
||||
|
||||
m3 = M3u8(download_path=base, url="https://s/")
|
||||
out = await m3.make_stream(media)
|
||||
|
|
@ -107,7 +107,7 @@ async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeyp
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_make_stream_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
media = base / "v.mp4"
|
||||
|
|
@ -116,7 +116,7 @@ async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch:
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={})
|
||||
|
||||
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
|
||||
|
||||
m3 = M3u8(download_path=base, url="http://s/")
|
||||
with pytest.raises(StreamingError, match="Unable to get"):
|
||||
|
|
@ -4,8 +4,8 @@ from urllib.parse import quote
|
|||
|
||||
import pytest
|
||||
|
||||
from app.library.Playlist import Playlist
|
||||
from app.library.Utils import StreamingError
|
||||
from app.features.streaming.library.playlist import Playlist
|
||||
from app.features.streaming.types import StreamingError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -21,13 +21,14 @@ async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.Mo
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={"duration": "60"})
|
||||
|
||||
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe)
|
||||
# No sidecar subtitles
|
||||
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []})
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: {"subtitle": []})
|
||||
|
||||
# Patch module-level quote to be robust for Path objects
|
||||
from urllib.parse import quote as _std_quote
|
||||
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
||||
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.quote", lambda v: _std_quote(str(v)))
|
||||
|
||||
pl = Playlist(download_path=base, url="http://localhost/")
|
||||
out = await pl.make(media)
|
||||
|
|
@ -53,7 +54,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={"duration": "12.5"})
|
||||
|
||||
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe)
|
||||
|
||||
# Build two subtitle sidecars with names and langs; ensure quoting/relative name used
|
||||
sub1 = media.with_suffix(".en.srt")
|
||||
|
|
@ -64,10 +65,11 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
|
|||
{"lang": "fr", "file": sub2, "name": "French"},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar)
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: sidecar)
|
||||
|
||||
from urllib.parse import quote as _std_quote
|
||||
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
||||
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.quote", lambda v: _std_quote(str(v)))
|
||||
|
||||
pl = Playlist(download_path=base, url="https://server/")
|
||||
out = await pl.make(media)
|
||||
|
|
@ -97,7 +99,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_make_playlist_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "downloads"
|
||||
base.mkdir()
|
||||
media = base / "file.mp4"
|
||||
|
|
@ -107,8 +109,8 @@ async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={})
|
||||
|
||||
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []})
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: {"subtitle": []})
|
||||
|
||||
pl = Playlist(download_path=base, url="http://localhost/")
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.Segments import Segments
|
||||
from app.features.streaming.library.segments import Segments
|
||||
from app.tests.helpers import get_test_system_temp_root
|
||||
|
||||
|
||||
class DummyFF:
|
||||
|
|
@ -22,6 +22,10 @@ class DummyFF:
|
|||
return self._a
|
||||
|
||||
|
||||
def _ffmpeg_input_path(args: list[str]) -> Path:
|
||||
return Path(args[args.index("-i") + 1].removeprefix("file:"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Create a dummy media file
|
||||
|
|
@ -32,7 +36,7 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=2, duration=5.5, vconvert=False, aconvert=False)
|
||||
|
||||
|
|
@ -53,8 +57,7 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py
|
|||
assert captured_args, "ffmpeg was not invoked"
|
||||
args = captured_args[0]
|
||||
|
||||
# Compute expected symlink path used by Segments
|
||||
tmpFile = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{hashlib.sha256(str(media).encode()).hexdigest()}")
|
||||
tmp_file = _ffmpeg_input_path(args)
|
||||
|
||||
# Start time is duration * index with 6 decimals for non-zero index
|
||||
assert "-ss" in args
|
||||
|
|
@ -64,7 +67,9 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py
|
|||
assert args[args.index("-t") + 1] == f"{5.5:.6f}"
|
||||
# Input uses file:<symlink>
|
||||
assert "-i" in args
|
||||
assert args[args.index("-i") + 1] == f"file:{tmpFile}"
|
||||
assert tmp_file.parent == get_test_system_temp_root()
|
||||
assert tmp_file.name.startswith("ytptube_stream.")
|
||||
assert not tmp_file.exists()
|
||||
# Includes video and audio mapping and codecs
|
||||
assert "-map" in args
|
||||
assert "0:v:0" in args
|
||||
|
|
@ -84,7 +89,7 @@ async def test_build_ffmpeg_args_audio_only(tmp_path: Path, monkeypatch: pytest.
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=False, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=9.0, vconvert=False, aconvert=False)
|
||||
|
||||
|
|
@ -144,18 +149,18 @@ class _FakeProc:
|
|||
self.killed = True
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, fail_with: Exception | None = None) -> None:
|
||||
class _FakeResp(web.StreamResponse):
|
||||
def __init__(self, fail_with: BaseException | None = None) -> None:
|
||||
self.data: bytearray = bytearray()
|
||||
self.eof = False
|
||||
self._exc = fail_with
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
async def write(self, data: bytes | bytearray | memoryview, *_args: Any, **_kwargs: Any) -> None:
|
||||
if self._exc:
|
||||
raise self._exc
|
||||
self.data.extend(data)
|
||||
|
||||
async def write_eof(self) -> None:
|
||||
async def write_eof(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
self.eof = True
|
||||
|
||||
|
||||
|
|
@ -168,22 +173,23 @@ class _FakeProcFail(_FakeProc):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_build_ffmpeg_args_no_dri(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
media = tmp_path / "file.mp4"
|
||||
media.write_bytes(b"data")
|
||||
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
# Simulate no /dev/dri present but GPU encoders otherwise available
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: False)
|
||||
monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"}
|
||||
"app.features.streaming.library.segment_encoders.ffmpeg_encoders",
|
||||
lambda: {"h264_nvenc", "h264_qsv", "h264_amf"},
|
||||
)
|
||||
|
||||
# reset encoder cache to ensure clean selection in this test
|
||||
from app.library.Segments import Segments as _Seg
|
||||
from app.features.streaming.library.segments import Segments as _Seg
|
||||
|
||||
_Seg._cached_vcodec = None
|
||||
_Seg._cache_initialized = False
|
||||
|
|
@ -214,16 +220,16 @@ async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, m
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_gpu_failure_falls_back_to_software(
|
||||
async def test_stream_gpu_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
# Allow GPU usage and advertise an NVENC encoder so first pick is GPU
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc"})
|
||||
monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.features.streaming.library.segment_encoders.ffmpeg_encoders", lambda: {"h264_nvenc"})
|
||||
|
||||
# First process fails (no data, rc=1), second succeeds and outputs bytes
|
||||
proc_fail = _FakeProcFail(err=b"nvenc failure: encoder not available")
|
||||
|
|
@ -241,7 +247,7 @@ async def test_stream_gpu_failure_falls_back_to_software(
|
|||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
# reset encoder cache to ensure we try GPU first
|
||||
from app.library.Segments import Segments as _Seg
|
||||
from app.features.streaming.library.segments import Segments as _Seg
|
||||
|
||||
_Seg._cached_vcodec = None
|
||||
_Seg._cache_initialized = False
|
||||
|
|
@ -250,15 +256,14 @@ async def test_stream_gpu_failure_falls_back_to_software(
|
|||
# Encourage GPU preference
|
||||
seg.vcodec = "" # empty -> try GPUs first
|
||||
resp = _FakeResp()
|
||||
with caplog.at_level(logging.WARNING, logger="player.segments"):
|
||||
with caplog.at_level(logging.WARNING, logger="ytptube"):
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
|
||||
# Ensure fallback path streamed data
|
||||
assert len(resp.data) > 0
|
||||
# Ensure we logged the reason for GPU failure (message text may vary)
|
||||
assert any(
|
||||
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message)
|
||||
for r in caplog.records
|
||||
("hardware encoder" in r.message.lower()) or ("transcoding has failed" in r.message) for r in caplog.records
|
||||
)
|
||||
assert any("nvenc failure" in r.message for r in caplog.records)
|
||||
# Verify second invocation switched codec to a safe fallback (software)
|
||||
|
|
@ -269,18 +274,16 @@ async def test_stream_gpu_failure_falls_back_to_software(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_gpu_fallback_switches_codec(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
# Only QSV advertised so initial build sets QSV
|
||||
# Patch both where it's defined AND where it's imported/used
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.library.Segments.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_qsv"})
|
||||
monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.features.streaming.library.segment_encoders.ffmpeg_encoders", lambda: {"h264_qsv"})
|
||||
|
||||
# Fail first, succeed second
|
||||
proc_fail = _FakeProcFail(err=b"qsv failure")
|
||||
|
|
@ -295,7 +298,7 @@ async def test_stream_gpu_fallback_switches_codec(
|
|||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
# reset cache
|
||||
from app.library.Segments import Segments as _Seg
|
||||
from app.features.streaming.library.segments import Segments as _Seg
|
||||
|
||||
_Seg._cached_vcodec = None
|
||||
_Seg._cache_initialized = False
|
||||
|
|
@ -340,24 +343,32 @@ async def test_stream_gpu_fallback_switches_codec(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
media = tmp_path / "file.mp4"
|
||||
media.write_bytes(b"data")
|
||||
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
|
||||
# Process that yields two chunks and then EOF
|
||||
proc = _FakeProc([b"abc", b"def", b""])
|
||||
|
||||
async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any):
|
||||
captured_args: list[list[str]] = []
|
||||
|
||||
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
|
||||
captured_args.append(list(args[1:]))
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||
resp = _FakeResp()
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
await seg.stream(media, resp)
|
||||
|
||||
assert bytes(resp.data) == b"abcdef"
|
||||
assert not _ffmpeg_input_path(captured_args[0]).exists()
|
||||
assert media.exists()
|
||||
# EOF behavior may differ; don't require True
|
||||
|
||||
|
||||
|
|
@ -366,11 +377,14 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
|
||||
proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail
|
||||
|
||||
async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any):
|
||||
captured_args: list[list[str]] = []
|
||||
|
||||
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
|
||||
captured_args.append(list(args[1:]))
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
|
@ -382,6 +396,7 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa
|
|||
|
||||
# Should not write EOF due to client disconnect
|
||||
assert resp.eof is False
|
||||
assert not _ffmpeg_input_path(captured_args[0]).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -389,7 +404,7 @@ async def test_stream_cancelled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
|
|||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
|
||||
|
||||
proc = _FakeProc([b"abc"]) # only one chunk
|
||||
|
||||
153
app/features/streaming/tests/test_subtitle.py
Normal file
153
app/features/streaming/tests/test_subtitle.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks, ms_to_timestamp
|
||||
|
||||
|
||||
class TestMsToTimestamp:
|
||||
def test_ms_to_timestamp_basic(self) -> None:
|
||||
assert ms_to_timestamp(0) == "0:00:00.00"
|
||||
assert ms_to_timestamp(9) == "0:00:00.00"
|
||||
assert ms_to_timestamp(10) == "0:00:00.01"
|
||||
assert ms_to_timestamp(12345) == "0:00:12.34"
|
||||
assert ms_to_timestamp(3600000 + 120000 + 3000) == "1:02:03.00", "1 hour, 2 minutes, 3 seconds"
|
||||
assert ms_to_timestamp(12 * 3600000 + 34 * 60000 + 56 * 1000 + 780) == "12:34:56.78", (
|
||||
"Over 10 hours (SubStation limit is < 10h, our override must exceed)"
|
||||
)
|
||||
assert ms_to_timestamp(37 * 3600000 + 12 * 60000 + 34 * 1000 + 560) == "37:12:34.56", (
|
||||
"Well over 36 hours to ensure no clamping at 9:59:59.99"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_unsupported_extension(tmp_path: Path) -> None:
|
||||
file = tmp_path / "sub.txt"
|
||||
file.write_text("not a subtitle")
|
||||
|
||||
subtitle = Subtitle()
|
||||
with pytest.raises(Exception, match="subtitle type is not supported"):
|
||||
await subtitle.make(file)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_vtt_reads_file(tmp_path: Path) -> None:
|
||||
vtt = tmp_path / "file.vtt"
|
||||
content = "WEBVTT\n\n00:00:00.00 --> 00:00:01.00\nHello"
|
||||
vtt.write_text(content)
|
||||
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(vtt)
|
||||
assert out == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_delivery_ass(tmp_path: Path) -> None:
|
||||
ass = tmp_path / "file.ass"
|
||||
content = "[Script Info]\nTitle: Demo\n"
|
||||
ass.write_text(content, encoding="utf-8")
|
||||
|
||||
subtitle = Subtitle()
|
||||
out, media_type = await subtitle.make_delivery(ass)
|
||||
assert out == content
|
||||
assert media_type == "text/x-ssa; charset=UTF-8"
|
||||
|
||||
|
||||
def test_tracks_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
media = tmp_path / "video.mkv"
|
||||
media.write_text("x", encoding="utf-8")
|
||||
ass_file = tmp_path / "video.ass"
|
||||
ass_file.write_text("ass", encoding="utf-8")
|
||||
vtt_file = tmp_path / "video.vtt"
|
||||
vtt_file.write_text("WEBVTT\n\n", encoding="utf-8")
|
||||
srt_file = tmp_path / "video.en.srt"
|
||||
srt_file.write_text("1\n00:00:00,000 --> 00:00:01,000\nHello\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.features.streaming.library.subtitle.get_file_sidecar",
|
||||
lambda _file: {
|
||||
"subtitle": [
|
||||
{"file": ass_file, "lang": "en", "name": "ASS"},
|
||||
{"file": srt_file, "lang": "en", "name": "SRT"},
|
||||
{"file": vtt_file, "lang": "en", "name": "VTT"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
tracks = get_subtitle_tracks(media)
|
||||
|
||||
assert [track.source_format for track in tracks] == ["vtt", "srt", "ass"]
|
||||
assert [track.delivery_format for track in tracks] == ["vtt", "vtt", "ass"]
|
||||
assert [track.renderer for track in tracks] == ["native", "native", "assjs"]
|
||||
|
||||
|
||||
class _DummySubs:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
self.snapshot = None
|
||||
|
||||
def to_string(self, fmt: str) -> str: # noqa: ARG002
|
||||
# Record end times to verify pop behavior
|
||||
self.snapshot = [e.end for e in self.events]
|
||||
return "OUT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_no_events_raises(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
with patch("app.features.streaming.library.subtitle.pysubs2.load") as mock_load:
|
||||
mock_load.return_value = _DummySubs(events=[])
|
||||
subtitle = Subtitle()
|
||||
with pytest.raises(Exception, match="No subtitle events were found"):
|
||||
await subtitle.make(srt)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_single_vtt(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.ass"
|
||||
srt.write_text("dummy")
|
||||
|
||||
single = SimpleNamespace(end=1000)
|
||||
d = _DummySubs(events=[single])
|
||||
|
||||
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(srt)
|
||||
assert out == "OUT"
|
||||
assert d.snapshot == [1000], "Snapshot should contain the single event"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_two_events_same_end(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
e1 = SimpleNamespace(end=5000)
|
||||
e2 = SimpleNamespace(end=5000)
|
||||
d = _DummySubs(events=[e1, e2])
|
||||
|
||||
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(srt)
|
||||
assert out == "OUT"
|
||||
assert d.snapshot == [5000], "Since ends are equal, first should be popped => only last remains"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_two_events_keep_both(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
e1 = SimpleNamespace(end=5000)
|
||||
e2 = SimpleNamespace(end=6000)
|
||||
d = _DummySubs(events=[e1, e2])
|
||||
|
||||
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(srt)
|
||||
assert out == "OUT"
|
||||
assert d.snapshot == [5000, 6000], "Both remain since ends differ"
|
||||
132
app/features/streaming/tests/test_subtitle_routes.py
Normal file
132
app/features/streaming/tests/test_subtitle_routes.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from app.features.streaming import router
|
||||
from app.library.config import Config
|
||||
|
||||
|
||||
class _DummyRoute:
|
||||
def __init__(self, pattern: str) -> None:
|
||||
self.pattern = pattern
|
||||
|
||||
def url_for(self, **params: str) -> str:
|
||||
path = self.pattern
|
||||
for key, value in params.items():
|
||||
path = path.replace(f"{{{key}:.*}}", value)
|
||||
path = path.replace(f"{{{key}}}", value)
|
||||
return path
|
||||
|
||||
|
||||
class _DummyRouter(dict[str, _DummyRoute]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
{
|
||||
"subtitles_manifest_get": _DummyRoute("/api/player/subtitles/manifest/{file:.*}"),
|
||||
"subtitles_track_get": _DummyRoute("/api/player/subtitles/{source_format}/{file:.*}"),
|
||||
"subtitles_get": _DummyRoute("/api/player/subtitle/{file:.*}.vtt"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_request(path: str, *, match_info: dict[str, str]) -> web.Request:
|
||||
return make_mocked_request("GET", path, app=SimpleNamespace(router=_DummyRouter()), match_info=match_info)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtitles_manifest_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = Config.get_instance()
|
||||
config.download_path = str(tmp_path)
|
||||
|
||||
media = tmp_path / "video.mp4"
|
||||
media.write_text("x", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.features.streaming.router.get_file",
|
||||
lambda **_kwargs: (media, web.HTTPOk.status_code),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.features.streaming.router.get_subtitle_tracks",
|
||||
lambda _file: [
|
||||
SimpleNamespace(
|
||||
lang="en",
|
||||
name="English VTT",
|
||||
source_format="vtt",
|
||||
delivery_format="vtt",
|
||||
renderer="native",
|
||||
file=tmp_path / "video.vtt",
|
||||
),
|
||||
SimpleNamespace(
|
||||
lang="en",
|
||||
name="English ASS",
|
||||
source_format="ass",
|
||||
delivery_format="ass",
|
||||
renderer="assjs",
|
||||
file=tmp_path / "video.ass",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
req = _make_request(
|
||||
"/api/player/subtitles/manifest/video.mp4",
|
||||
match_info={"file": "video.mp4"},
|
||||
)
|
||||
response = await router.subtitles_manifest_get(req, config, req.app)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code
|
||||
body = response.text
|
||||
assert '"source_format": "vtt"' in body
|
||||
assert '"renderer": "native"' in body
|
||||
assert '"source_format": "ass"' in body
|
||||
assert '"renderer": "assjs"' in body
|
||||
assert body.index('"source_format": "vtt"') < body.index('"source_format": "ass"')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtitles_track_get_ass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = Config.get_instance()
|
||||
config.download_path = str(tmp_path)
|
||||
|
||||
subtitle = tmp_path / "video.ass"
|
||||
subtitle.write_text("[Script Info]\nTitle: Demo\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.features.streaming.router.get_file",
|
||||
lambda **_kwargs: (subtitle, web.HTTPOk.status_code),
|
||||
)
|
||||
|
||||
req = _make_request(
|
||||
"/api/player/subtitles/ass/video.ass",
|
||||
match_info={"source_format": "ass", "file": "video.ass"},
|
||||
)
|
||||
response = await router.subtitles_track_get(req, config, req.app)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code
|
||||
assert response.text == "[Script Info]\nTitle: Demo\n"
|
||||
assert response.headers["Content-Type"] == "text/x-ssa; charset=UTF-8"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtitles_track_bad_format(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = Config.get_instance()
|
||||
config.download_path = str(tmp_path)
|
||||
|
||||
subtitle = tmp_path / "video.ass"
|
||||
subtitle.write_text("[Script Info]\nTitle: Demo\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.features.streaming.router.get_file",
|
||||
lambda **_kwargs: (subtitle, web.HTTPOk.status_code),
|
||||
)
|
||||
|
||||
req = _make_request(
|
||||
"/api/player/subtitles/vtt/video.ass",
|
||||
match_info={"source_format": "vtt", "file": "video.ass"},
|
||||
)
|
||||
response = await router.subtitles_track_get(req, config, req.app)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
assert b"does not match requested source format" in response.body
|
||||
358
app/features/streaming/tests/test_thumbnail.py
Normal file
358
app/features/streaming/tests/test_thumbnail.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tests.helpers import temporary_test_dir
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_same_stem() -> None:
|
||||
from app.features.streaming.library.thumbnail import pick_local_thumb
|
||||
|
||||
with temporary_test_dir("thumb-local") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
poster = temp_dir / "poster.jpg"
|
||||
thumb = temp_dir / "video.jpg"
|
||||
|
||||
media.write_text("video")
|
||||
poster.write_text("poster")
|
||||
thumb.write_text("thumb")
|
||||
|
||||
assert pick_local_thumb(media) == thumb
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_singleflight(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
thumbnail._IN_PROCESS.clear()
|
||||
thumbnail.Cache.get_instance().clear()
|
||||
|
||||
with temporary_test_dir("thumb-singleflight") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
cache_root = temp_dir / "cache"
|
||||
media.write_text("video")
|
||||
item_id = "item-1"
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
async def fake_run_ffmpeg(_file: Path, output_file: Path) -> Path:
|
||||
calls["count"] += 1
|
||||
await asyncio.sleep(0.01)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_file.write_text("image")
|
||||
return output_file
|
||||
|
||||
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
|
||||
|
||||
first, second = await asyncio.gather(
|
||||
thumbnail.ensure_thumb(media, cache_root, item_id=item_id),
|
||||
thumbnail.ensure_thumb(media, cache_root, item_id=item_id),
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert first is not None
|
||||
assert calls["count"] == 1
|
||||
assert thumbnail._IN_PROCESS == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
thumbnail._IN_PROCESS.clear()
|
||||
thumbnail.Cache.get_instance().clear()
|
||||
|
||||
with temporary_test_dir("thumb-cache") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
cache_root = temp_dir / "cache"
|
||||
media.write_text("video")
|
||||
item_id = "item-1"
|
||||
|
||||
cache_file = cache_root / f"{item_id}.jpg"
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text("image")
|
||||
|
||||
async def fake_run_ffmpeg(_file: Path, _output_file: Path) -> Path:
|
||||
raise AssertionError("ffmpeg should not run when cache exists")
|
||||
|
||||
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
|
||||
|
||||
result = await thumbnail.ensure_thumb(media, cache_root, item_id=item_id)
|
||||
|
||||
assert result == cache_file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
thumbnail._IN_PROCESS.clear()
|
||||
thumbnail.Cache.get_instance().clear()
|
||||
|
||||
with temporary_test_dir("thumb-disabled") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
media.write_text("video")
|
||||
|
||||
monkeypatch.setattr(
|
||||
thumbnail.Config,
|
||||
"get_instance",
|
||||
staticmethod(lambda: type("Cfg", (), {"thumb_generate": False, "thumb_sidecar": False})()),
|
||||
)
|
||||
|
||||
result = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id="item-1")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
thumbnail._IN_PROCESS.clear()
|
||||
thumbnail._SEM = None
|
||||
thumbnail._SEM_LIMIT = None
|
||||
thumbnail.Cache.get_instance().clear()
|
||||
|
||||
with temporary_test_dir("thumb-sidecar") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
media.write_text("video")
|
||||
|
||||
sidecar = media.with_name(f"{media.name}.jpg")
|
||||
|
||||
monkeypatch.setattr(
|
||||
thumbnail.Config,
|
||||
"get_instance",
|
||||
staticmethod(
|
||||
lambda: type(
|
||||
"Cfg",
|
||||
(),
|
||||
{"thumb_generate": True, "thumb_sidecar": True, "thumb_concurrency": 1},
|
||||
)()
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_run_ffmpeg(_file: Path, out_file: Path) -> Path:
|
||||
out_file.write_text("image")
|
||||
return out_file
|
||||
|
||||
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
|
||||
|
||||
result = await thumbnail.ensure_thumb(media, temp_dir / "cache")
|
||||
|
||||
assert result == sidecar
|
||||
assert sidecar.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_miss_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
thumbnail._IN_PROCESS.clear()
|
||||
thumbnail.Cache.get_instance().clear()
|
||||
|
||||
with temporary_test_dir("thumb-miss") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
media.write_text("video")
|
||||
item_id = "item-1"
|
||||
|
||||
monkeypatch.setattr(
|
||||
thumbnail.Config,
|
||||
"get_instance",
|
||||
staticmethod(
|
||||
lambda: type(
|
||||
"Cfg",
|
||||
(),
|
||||
{"thumb_generate": True, "thumb_sidecar": False, "thumb_concurrency": 1},
|
||||
)()
|
||||
),
|
||||
)
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
async def fake_run_ffmpeg(_file: Path, _out_file: Path) -> Path | None:
|
||||
calls["count"] += 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
|
||||
|
||||
first = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id=item_id)
|
||||
second = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id=item_id)
|
||||
|
||||
assert first is None
|
||||
assert second is None
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_sem_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
thumbnail._SEM = None
|
||||
thumbnail._SEM_LIMIT = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
thumbnail.Config,
|
||||
"get_instance",
|
||||
staticmethod(
|
||||
lambda: type("Cfg", (), {"thumb_concurrency": 3, "thumb_generate": True, "thumb_sidecar": False})()
|
||||
),
|
||||
)
|
||||
|
||||
sem = thumbnail._get_semaphore()
|
||||
|
||||
assert sem._value == 3
|
||||
assert thumbnail._SEM_LIMIT == 3
|
||||
|
||||
|
||||
def test_seek_bounds() -> None:
|
||||
from app.features.streaming.library.ffprobe import FFProbeResult
|
||||
from app.features.streaming.library.thumbnail import _seek_seconds
|
||||
|
||||
ff_info = FFProbeResult()
|
||||
ff_info.metadata = {"duration": "120.0"}
|
||||
assert _seek_seconds(ff_info) == 12.0
|
||||
|
||||
ff_info.metadata = {"duration": "2.0"}
|
||||
assert _seek_seconds(ff_info) == 1.0
|
||||
|
||||
ff_info.metadata = {"duration": "0.05"}
|
||||
assert _seek_seconds(ff_info) is None
|
||||
|
||||
ff_info.metadata = {}
|
||||
assert _seek_seconds(ff_info) == 3.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_name_uses_item_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
thumbnail._IN_PROCESS.clear()
|
||||
thumbnail.Cache.get_instance().clear()
|
||||
|
||||
with temporary_test_dir("thumb-id-name") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
cache_root = temp_dir / "cache"
|
||||
media.write_text("video")
|
||||
|
||||
async def fake_run_ffmpeg(_file: Path, output_file: Path) -> Path:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_file.write_text("image")
|
||||
return output_file
|
||||
|
||||
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
|
||||
|
||||
result = await thumbnail.ensure_thumb(media, cache_root, item_id="item-1")
|
||||
|
||||
assert result == cache_root / "item-1.jpg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library.ffprobe import FFProbeResult
|
||||
from app.features.streaming.library import thumbnail
|
||||
|
||||
with temporary_test_dir("thumb-retry") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
output = temp_dir / ".jpg"
|
||||
media.write_text("video")
|
||||
|
||||
ff_info: Any = FFProbeResult()
|
||||
ff_info.metadata = {"duration": "120.0"}
|
||||
ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()]
|
||||
|
||||
monkeypatch.setattr(thumbnail, "ffprobe", AsyncMock(return_value=ff_info))
|
||||
|
||||
calls: list[list[str]] = []
|
||||
|
||||
class DummyProc:
|
||||
def __init__(self, attempt: int, out_path: Path) -> None:
|
||||
self.returncode = 1 if attempt == 1 else 0
|
||||
self._out_path = out_path
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
if self.returncode == 0:
|
||||
self._out_path.write_text("image")
|
||||
return b"", b""
|
||||
return b"", b"seek failed"
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
del kwargs
|
||||
calls.append([str(arg) for arg in args])
|
||||
return DummyProc(len(calls), Path(str(args[-1])))
|
||||
|
||||
monkeypatch.setattr(thumbnail.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
result = await thumbnail._run_ffmpeg(media, output)
|
||||
|
||||
assert result == output
|
||||
assert len(calls) == 2
|
||||
assert "-ss" in calls[0]
|
||||
assert calls[0][calls[0].index("-ss") + 1] == "12.000"
|
||||
assert "-ss" not in calls[1]
|
||||
assert calls[0][calls[0].index("-vf") + 1] == "thumbnail=200,scale=1280:-1:force_original_aspect_ratio=decrease"
|
||||
assert calls[0][-1].endswith(".tmp.jpg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_limit_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.features.streaming.library import thumbnail
|
||||
from app.features.streaming.library.ffprobe import FFProbeResult
|
||||
|
||||
thumbnail._SEM = None
|
||||
thumbnail._SEM_LIMIT = None
|
||||
thumbnail._IN_PROCESS.clear()
|
||||
monkeypatch.setattr(
|
||||
thumbnail.Config,
|
||||
"get_instance",
|
||||
staticmethod(
|
||||
lambda: type("Cfg", (), {"thumb_concurrency": 1, "thumb_generate": True, "thumb_sidecar": False})()
|
||||
),
|
||||
)
|
||||
|
||||
with temporary_test_dir("thumb-limit") as temp_dir:
|
||||
media1 = temp_dir / "video1.mp4"
|
||||
media2 = temp_dir / "video2.mp4"
|
||||
out1 = temp_dir / "out1.jpg"
|
||||
out2 = temp_dir / "out2.jpg"
|
||||
media1.write_text("video")
|
||||
media2.write_text("video")
|
||||
|
||||
ff_info: Any = FFProbeResult()
|
||||
ff_info.metadata = {"duration": "60.0"}
|
||||
ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()]
|
||||
monkeypatch.setattr(thumbnail, "ffprobe", AsyncMock(return_value=ff_info))
|
||||
|
||||
active = {"count": 0, "max": 0}
|
||||
|
||||
class DummyProc:
|
||||
def __init__(self, out_path: Path) -> None:
|
||||
self.returncode = 0
|
||||
self._out_path = out_path
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
active["count"] += 1
|
||||
active["max"] = max(active["max"], active["count"])
|
||||
await asyncio.sleep(0.01)
|
||||
self._out_path.write_text("image")
|
||||
active["count"] -= 1
|
||||
return b"", b""
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
del kwargs
|
||||
return DummyProc(Path(str(args[-1])))
|
||||
|
||||
monkeypatch.setattr(thumbnail.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
first, second = await asyncio.gather(
|
||||
thumbnail._run_ffmpeg(media1, out1),
|
||||
thumbnail._run_ffmpeg(media2, out2),
|
||||
)
|
||||
|
||||
assert first == out1
|
||||
assert second == out2
|
||||
assert active["max"] == 1
|
||||
6
app/features/streaming/types.py
Normal file
6
app/features/streaming/types.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class StreamingError(Exception):
|
||||
"""Raised when an error occurs during streaming."""
|
||||
|
||||
|
||||
class FFProbeError(Exception):
|
||||
"""Raised when an error occurs during FFProbe processing."""
|
||||
1
app/features/tasks/__init__.py
Normal file
1
app/features/tasks/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tasks Feature."""
|
||||
1
app/features/tasks/definitions/__init__.py
Normal file
1
app/features/tasks/definitions/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tasks Definitions Feature."""
|
||||
5
app/features/tasks/definitions/deps.py
Normal file
5
app/features/tasks/definitions/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
|
||||
|
||||
def get_task_definitions_repo() -> TaskDefinitionsRepository:
|
||||
return TaskDefinitionsRepository.get_instance()
|
||||
1
app/features/tasks/definitions/handlers/__init__.py
Normal file
1
app/features/tasks/definitions/handlers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Handlers package for task definitions."""
|
||||
71
app/features/tasks/definitions/handlers/_base_handler.py
Normal file
71
app/features/tasks/definitions/handlers/_base_handler.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult
|
||||
from app.library.config import Config
|
||||
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
|
||||
class BaseHandler:
|
||||
@staticmethod
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
_ = task
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
async def inspect(cls, task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
return await cls.extract(task=task, config=config)
|
||||
|
||||
@staticmethod
|
||||
def parse(url: str) -> Any | None:
|
||||
_ = url
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def tests() -> list[tuple[str, bool]]:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def request(
|
||||
url: str, headers: dict | None = None, ytdlp_opts: dict | None = None, **kwargs
|
||||
) -> "httpx.Response":
|
||||
"""
|
||||
Make an HTTP request.
|
||||
|
||||
Args:
|
||||
url (str): The URL to request.
|
||||
headers (dict | None): Additional headers to include in the request.
|
||||
ytdlp_opts (dict | None): yt-dlp options that may affect the request.
|
||||
**kwargs: Additional arguments to pass to httpx request.
|
||||
|
||||
Returns:
|
||||
httpx.Response: The HTTP response.
|
||||
|
||||
"""
|
||||
headers = {} if not isinstance(headers, dict) else headers
|
||||
ytdlp_opts = {} if not isinstance(ytdlp_opts, dict) else ytdlp_opts
|
||||
|
||||
use_curl = resolve_curl_transport()
|
||||
request_headers = build_request_headers(
|
||||
base_headers=headers,
|
||||
user_agent=Globals.get_random_agent(),
|
||||
use_curl=use_curl,
|
||||
)
|
||||
|
||||
proxy = ytdlp_opts.get("proxy", None)
|
||||
client = get_async_client(proxy=proxy, use_curl=use_curl)
|
||||
method = kwargs.pop("method", "GET").upper()
|
||||
timeout = ytdlp_opts.get("timeout", ytdlp_opts.get("socket_timeout", 120))
|
||||
return await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=request_headers,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
925
app/features/tasks/definitions/handlers/generic.py
Normal file
925
app/features/tasks/definitions/handlers/generic.py
Normal file
|
|
@ -0,0 +1,925 @@
|
|||
"""Generic task handler driven by JSON definitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
import jmespath
|
||||
from parsel import Selector
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.tasks.definitions.schemas import (
|
||||
ExtractionRule,
|
||||
TaskDefinition,
|
||||
)
|
||||
from app.features.ytdlp.extractor import fetch_info
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from parsel.selector import SelectorList
|
||||
|
||||
LOG = get_logger()
|
||||
CACHE: Cache = Cache()
|
||||
|
||||
|
||||
class GenericTaskHandler(BaseHandler):
|
||||
"""Handler that scrapes arbitrary web pages based on JSON task definitions."""
|
||||
|
||||
_definitions: list[TaskDefinition] = []
|
||||
"""Cached loaded task definitions."""
|
||||
|
||||
_sources_mtime: dict[Path, float] = {}
|
||||
"""Modification times of source files to detect changes."""
|
||||
|
||||
@classmethod
|
||||
async def refresh_definitions(cls, force: bool = False) -> list[TaskDefinition]:
|
||||
"""
|
||||
Refresh the cached task definitions if source files have changed.
|
||||
|
||||
Args:
|
||||
force (bool): If True, force reload even if no changes detected.
|
||||
|
||||
"""
|
||||
if cls._definitions and not force:
|
||||
return cls._definitions
|
||||
|
||||
try:
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
from app.features.tasks.definitions.utils import model_to_schema
|
||||
|
||||
repo = TaskDefinitionsRepository.get_instance()
|
||||
models = await repo.all()
|
||||
|
||||
cls._definitions = [model_to_schema(model) for model in models]
|
||||
return cls._definitions
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to load generic task definitions.",
|
||||
extra={"error": str(exc), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
async def _find_definition(cls, url: str) -> TaskDefinition | None:
|
||||
"""
|
||||
Find a task definition that matches the given URL.
|
||||
|
||||
Args:
|
||||
url (str): The URL to match.
|
||||
|
||||
Returns:
|
||||
(TaskDefinition|None): A matching TaskDefinition if found, None otherwise.
|
||||
|
||||
"""
|
||||
await cls.refresh_definitions()
|
||||
|
||||
for definition in cls._definitions:
|
||||
if not definition.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
for matcher in definition.match_url:
|
||||
pattern_str: str | None = None
|
||||
|
||||
if matcher.startswith("/") and matcher.endswith("/") and len(matcher) > 2:
|
||||
pattern_str = matcher[1:-1]
|
||||
else:
|
||||
pattern_str = fnmatch.translate(matcher)
|
||||
|
||||
if pattern_str and re.match(pattern_str, url):
|
||||
return definition
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to match a generic task definition.",
|
||||
extra={
|
||||
"definition": definition.name,
|
||||
"url": url,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
"""
|
||||
Determine if this handler can process the given task.
|
||||
|
||||
Args:
|
||||
task (Task): The task to check.
|
||||
|
||||
Returns:
|
||||
(bool): True if the handler can process the task, False otherwise.
|
||||
|
||||
"""
|
||||
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
|
||||
if definition:
|
||||
LOG.debug(
|
||||
"Task '%s' matched a generic task definition.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url, "definition": definition.name},
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
|
||||
if not definition:
|
||||
return TaskFailure(message="No generic task definition matched the provided URL.")
|
||||
|
||||
ytdlp_opts: dict[str, Any] = task.get_ytdlp_opts().get_all()
|
||||
target_url: str = definition.definition.request.url or task.url
|
||||
|
||||
LOG.debug(
|
||||
"Fetching content for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_name": task.name,
|
||||
"definition": definition.name,
|
||||
"url": target_url,
|
||||
"engine": definition.definition.engine.type,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
body_text, json_data = await GenericTaskHandler._fetch_content(
|
||||
url=target_url, definition=definition, ytdlp_opts=ytdlp_opts
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to fetch content for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_name": task.name,
|
||||
"definition": definition.name,
|
||||
"url": target_url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
|
||||
|
||||
if "json" == definition.definition.response.type and json_data is None:
|
||||
return TaskFailure(message="Expected JSON response but decoding failed.")
|
||||
|
||||
if "json" != definition.definition.response.type and not body_text:
|
||||
return TaskFailure(message="Received empty response body.")
|
||||
|
||||
raw_items: list[dict[str, str]] = GenericTaskHandler._parse_items(
|
||||
definition=definition, html=body_text or "", base_url=target_url, json_data=json_data
|
||||
)
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
||||
def _generic_id(url):
|
||||
import os
|
||||
from urllib import parse
|
||||
|
||||
return parse.unquote(os.path.splitext(url.rstrip("/").split("/")[-1])[0])
|
||||
|
||||
for entry in raw_items:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
if not (url := entry.get("link") or entry.get("url")):
|
||||
continue
|
||||
|
||||
id_dict: dict[str, str | None] = get_archive_id(url=url)
|
||||
archive_id: str | None = id_dict.get("archive_id")
|
||||
if not archive_id:
|
||||
cache_key: str = hashlib.sha256(f"{task.name}-{url}".encode()).hexdigest()
|
||||
if CACHE.has(cache_key):
|
||||
archive_id = CACHE.get(cache_key)
|
||||
if not archive_id:
|
||||
continue
|
||||
else:
|
||||
LOG.warning(
|
||||
"Task '%s' could not generate a static archive ID. Fetching it with yt-dlp.",
|
||||
task.name,
|
||||
extra={"definition": definition.name, "task_name": task.name, "url": url},
|
||||
)
|
||||
|
||||
(info, _) = await fetch_info(
|
||||
config=task.get_ytdlp_opts().get_all(),
|
||||
url=url,
|
||||
no_archive=True,
|
||||
no_log=True,
|
||||
budget_sleep=True,
|
||||
)
|
||||
|
||||
if not info:
|
||||
LOG.error(
|
||||
"Task '%s' failed to extract info to generate an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"definition": definition.name, "task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
||||
if not info.get("id") or not info.get("extractor_key"):
|
||||
LOG.error(
|
||||
"Task '%s' returned incomplete info while generating an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"definition": definition.name, "task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
||||
archive_id = f"{str(info.get('extractor_key', '')).lower()} {info.get('id')}"
|
||||
CACHE.set(cache_key, archive_id)
|
||||
|
||||
metadata: dict[str, str] = {
|
||||
k: v for k, v in entry.items() if k not in {"link", "url", "title", "published", "archive_id"}
|
||||
}
|
||||
|
||||
task_items.append(
|
||||
TaskItem(
|
||||
url=url,
|
||||
title=entry.get("title"),
|
||||
archive_id=archive_id,
|
||||
metadata={"published": entry.get("published"), **metadata},
|
||||
)
|
||||
)
|
||||
|
||||
return TaskResult(
|
||||
items=task_items,
|
||||
metadata={
|
||||
"definition": definition.name,
|
||||
"response_format": definition.definition.response.type,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_content(
|
||||
url: str,
|
||||
definition: TaskDefinition,
|
||||
ytdlp_opts: dict[str, Any],
|
||||
) -> tuple[str | None, Any | None]:
|
||||
"""
|
||||
Fetch the content of the given URL using the specified engine.
|
||||
|
||||
Args:
|
||||
url (str): The URL to fetch.
|
||||
definition (TaskDefinitionRuntimeSchema): The task definition specifying the engine and request details.
|
||||
ytdlp_opts (dict[str, Any]): yt-dlp options that may influence fetching
|
||||
|
||||
Returns:
|
||||
(str|None): The fetched HTML content if successful, None otherwise.
|
||||
|
||||
"""
|
||||
if "selenium" == definition.definition.engine.type:
|
||||
return await GenericTaskHandler._fetch_with_selenium(url=url, definition=definition)
|
||||
|
||||
return await GenericTaskHandler._fetch_with_httpx(url=url, definition=definition, ytdlp_opts=ytdlp_opts)
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_with_httpx(
|
||||
url: str,
|
||||
definition: TaskDefinition,
|
||||
ytdlp_opts: dict[str, Any],
|
||||
) -> tuple[str | None, Any | None]:
|
||||
"""
|
||||
Fetch the content using httpx.
|
||||
|
||||
Args:
|
||||
url (str): The URL to fetch.
|
||||
definition (TaskDefinitionRuntimeSchema): The task definition specifying the request details.
|
||||
ytdlp_opts (dict[str, Any]): yt-dlp options that may influence fetching
|
||||
|
||||
Returns:
|
||||
(str|None): The fetched HTML content if successful, None otherwise.
|
||||
|
||||
"""
|
||||
headers: dict[str, str] = {**definition.definition.request.headers}
|
||||
use_curl = resolve_curl_transport()
|
||||
request_headers = build_request_headers(
|
||||
base_headers=headers,
|
||||
user_agent=Globals.get_random_agent(),
|
||||
use_curl=use_curl,
|
||||
)
|
||||
|
||||
timeout_value: float | Any = definition.definition.request.timeout or ytdlp_opts.get("socket_timeout", 120)
|
||||
|
||||
client = get_async_client(proxy=ytdlp_opts.get("proxy"), use_curl=use_curl)
|
||||
response: httpx.Response = await client.request(
|
||||
method=definition.definition.request.method.upper(),
|
||||
url=url,
|
||||
params=definition.definition.request.params or None,
|
||||
data=definition.definition.request.data,
|
||||
json=definition.definition.request.json_data,
|
||||
timeout=timeout_value,
|
||||
headers=request_headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
if "json" == definition.definition.response.type:
|
||||
try:
|
||||
json_data: dict[str, Any] = response.json()
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Task definition '%s' returned invalid JSON for '%s'.",
|
||||
definition.name,
|
||||
url,
|
||||
extra={
|
||||
"definition": definition.name,
|
||||
"url": url,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return response.text, None
|
||||
|
||||
return response.text, json_data
|
||||
|
||||
return response.text, None
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_with_selenium(
|
||||
url: str,
|
||||
definition: TaskDefinition,
|
||||
) -> tuple[str | None, Any | None]:
|
||||
"""
|
||||
Fetch the content using a Selenium WebDriver.
|
||||
|
||||
Args:
|
||||
url (str): The URL to fetch.
|
||||
definition (TaskDefinitionRuntimeSchema): The task definition specifying the engine options.
|
||||
|
||||
Returns:
|
||||
(str|None): The fetched HTML content if successful, None otherwise.
|
||||
|
||||
"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as ChromeOptions
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
except ImportError as exc:
|
||||
LOG.exception(
|
||||
"Task definition '%s' requested Selenium, but Selenium is not installed.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name, "error": str(exc), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return (None, None)
|
||||
|
||||
options_map: dict[str, Any] = definition.definition.engine.options
|
||||
command_executor_value = options_map.get("url")
|
||||
command_executor: str = (
|
||||
str(command_executor_value)
|
||||
if isinstance(command_executor_value, str) and command_executor_value
|
||||
else "http://localhost:4444/wd/hub"
|
||||
)
|
||||
browser: str = str(options_map.get("browser", "chrome")).lower()
|
||||
|
||||
if "chrome" != browser:
|
||||
LOG.error(
|
||||
"Task definition '%s' requested unsupported Selenium browser '%s'.",
|
||||
definition.name,
|
||||
browser,
|
||||
extra={"definition": definition.name, "browser": browser},
|
||||
)
|
||||
return (None, None)
|
||||
|
||||
arguments: list[str] | str = options_map.get("arguments", ["--headless", "--disable-gpu"])
|
||||
if isinstance(arguments, str):
|
||||
arguments = [arguments]
|
||||
|
||||
wait_for: Mapping | None = (
|
||||
options_map.get("wait_for") if isinstance(options_map.get("wait_for"), Mapping) else None
|
||||
)
|
||||
wait_timeout = float(options_map.get("wait_timeout") or 15)
|
||||
page_load_timeout = float(options_map.get("page_load_timeout") or 60)
|
||||
|
||||
def load_page() -> str | None:
|
||||
chrome_options = ChromeOptions()
|
||||
for arg in arguments:
|
||||
chrome_options.add_argument(str(arg))
|
||||
|
||||
driver = webdriver.Remote(command_executor=command_executor, options=chrome_options)
|
||||
try:
|
||||
driver.set_page_load_timeout(page_load_timeout)
|
||||
driver.get(url)
|
||||
|
||||
if wait_for:
|
||||
wait_type: str = str(wait_for.get("type", "css")).lower()
|
||||
expression: str | None = wait_for.get("expression") or wait_for.get("value")
|
||||
if isinstance(expression, str) and expression:
|
||||
locator = None
|
||||
locator = (By.XPATH, expression) if "xpath" == wait_type else (By.CSS_SELECTOR, expression)
|
||||
WebDriverWait(driver, wait_timeout).until(EC.presence_of_element_located(locator))
|
||||
|
||||
return driver.page_source
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
html: str | None = await asyncio.to_thread(load_page)
|
||||
return html, None
|
||||
|
||||
@staticmethod
|
||||
def _parse_items(
|
||||
definition: TaskDefinition,
|
||||
html: str,
|
||||
base_url: str,
|
||||
json_data: Any | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Parse the HTML content and extract items based on the definition.
|
||||
|
||||
Args:
|
||||
definition (TaskDefinitionRuntimeSchema): The task definition specifying the parsers.
|
||||
html (str): The HTML content to parse.
|
||||
base_url (str): The base URL to resolve relative links.
|
||||
json_data (Any|None): The JSON data to parse if applicable.
|
||||
|
||||
Returns:
|
||||
(list[dict[str, str]]): A list of extracted items as dictionaries.
|
||||
|
||||
"""
|
||||
if "json" == definition.definition.response.type:
|
||||
return GenericTaskHandler._parse_json_items(definition, json_data, base_url)
|
||||
|
||||
selector = Selector(text=html)
|
||||
|
||||
if definition.definition.parse.get("items"):
|
||||
return GenericTaskHandler._parse_with_container(
|
||||
definition=definition,
|
||||
selector=selector,
|
||||
html=html,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
extracted: dict[str, list[str]] = {}
|
||||
|
||||
for _field, rule_data in definition.definition.parse.field_items():
|
||||
if not isinstance(rule_data, dict):
|
||||
continue
|
||||
rule = ExtractionRule.model_validate(rule_data)
|
||||
values: list[str] = GenericTaskHandler._execute_rule(field=_field, selector=selector, html=html, rule=rule)
|
||||
extracted[_field] = values
|
||||
|
||||
link_values: list[str] = extracted.get("link", [])
|
||||
if not link_values:
|
||||
LOG.debug(
|
||||
"Definition '%s' produced no link values.", definition.name, extra={"definition": definition.name}
|
||||
)
|
||||
return []
|
||||
|
||||
total_items: int = len(link_values)
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
for index in range(total_items):
|
||||
entry: dict[str, str] = {}
|
||||
link_value: str = link_values[index]
|
||||
if not link_value:
|
||||
continue
|
||||
|
||||
entry["link"] = urljoin(base_url, link_value)
|
||||
|
||||
for _field, values in extracted.items():
|
||||
if "link" == _field:
|
||||
continue
|
||||
|
||||
value: str | None = values[index] if index < len(values) else None
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
entry[_field] = value
|
||||
|
||||
items.append(entry)
|
||||
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_items(
|
||||
definition: TaskDefinition,
|
||||
json_data: Any | None,
|
||||
base_url: str,
|
||||
) -> list[dict[str, str]]:
|
||||
if json_data is None:
|
||||
LOG.debug(
|
||||
"Definition '%s' expects JSON but no data was parsed.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name},
|
||||
)
|
||||
return []
|
||||
|
||||
if definition.definition.parse.get("items"):
|
||||
return GenericTaskHandler._parse_json_with_container(definition, json_data, base_url)
|
||||
|
||||
items: list[dict[str, str]] = []
|
||||
entry: dict[str, str] = {}
|
||||
|
||||
for _field, rule_data in definition.definition.parse.field_items():
|
||||
if not isinstance(rule_data, dict):
|
||||
continue
|
||||
rule = ExtractionRule.model_validate(rule_data)
|
||||
values: list[str] = GenericTaskHandler._execute_json_rule(_field, json_data, rule)
|
||||
if values:
|
||||
if "link" == _field:
|
||||
entry["link"] = urljoin(base_url, values[0])
|
||||
else:
|
||||
entry[_field] = values[0]
|
||||
|
||||
if "link" in entry:
|
||||
items.append(entry)
|
||||
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _parse_with_container(
|
||||
definition: TaskDefinition,
|
||||
selector: Selector,
|
||||
html: str,
|
||||
base_url: str,
|
||||
) -> list[dict[str, str]]:
|
||||
container: dict[str, Any] | None = definition.definition.parse.get("items")
|
||||
if not container:
|
||||
return []
|
||||
|
||||
container_type = container.get("type", "css")
|
||||
container_selector = container.get("selector") or container.get("expression") or ""
|
||||
if not container_selector:
|
||||
LOG.error(
|
||||
"Task definition '%s' is missing an item container selector.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name},
|
||||
)
|
||||
return []
|
||||
container_fields = container.get("fields", {})
|
||||
|
||||
selection: SelectorList[Selector] = (
|
||||
selector.css(container_selector) if "css" == container_type else selector.xpath(container_selector)
|
||||
)
|
||||
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
for node in selection:
|
||||
node_html: Any | str = node.get() or html
|
||||
entry: dict[str, str] = {}
|
||||
|
||||
for _field, rule_data in container_fields.items():
|
||||
rule = ExtractionRule.model_validate(rule_data)
|
||||
values: list[str] = GenericTaskHandler._execute_rule(
|
||||
field=_field,
|
||||
selector=node,
|
||||
html=node_html,
|
||||
rule=rule,
|
||||
)
|
||||
|
||||
value: str | None = values[0] if values else None
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
if "link" == _field:
|
||||
entry["link"] = urljoin(base_url, value)
|
||||
else:
|
||||
entry[_field] = value
|
||||
|
||||
if "link" not in entry:
|
||||
continue
|
||||
|
||||
items.append(entry)
|
||||
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_with_container(
|
||||
definition: TaskDefinition,
|
||||
json_data: Any,
|
||||
base_url: str,
|
||||
) -> list[dict[str, str]]:
|
||||
container: dict[str, Any] | None = definition.definition.parse.get("items")
|
||||
if not container:
|
||||
return []
|
||||
|
||||
container_type = container.get("type", "css")
|
||||
container_selector = container.get("selector") or container.get("expression", "")
|
||||
container_fields = container.get("fields", {})
|
||||
|
||||
if "jsonpath" != container_type:
|
||||
LOG.error(
|
||||
"JSON response requires container selector type 'jsonpath'. Definition '%s'.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name, "container_type": container_type},
|
||||
)
|
||||
return []
|
||||
|
||||
nodes: Any = GenericTaskHandler._json_search(json_data, container_selector)
|
||||
if nodes is None:
|
||||
return []
|
||||
|
||||
if not isinstance(nodes, list):
|
||||
nodes = [nodes]
|
||||
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
for node in nodes:
|
||||
entry: dict[str, str] = {}
|
||||
|
||||
for _field, rule_data in container_fields.items():
|
||||
rule = ExtractionRule.model_validate(rule_data)
|
||||
values: list[str] = GenericTaskHandler._execute_json_rule(_field, node, rule)
|
||||
if not values:
|
||||
continue
|
||||
|
||||
if "link" == _field:
|
||||
entry["link"] = urljoin(base_url, values[0])
|
||||
else:
|
||||
entry[_field] = values[0]
|
||||
|
||||
if "link" not in entry:
|
||||
continue
|
||||
|
||||
items.append(entry)
|
||||
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _execute_json_rule(field: str, data: Any, rule: ExtractionRule) -> list[str]:
|
||||
values: list[str] = []
|
||||
|
||||
if "jsonpath" == rule.type:
|
||||
result: Any = GenericTaskHandler._json_search(data, rule.expression)
|
||||
candidates: list | list[Any] = result if isinstance(result, list) else [result]
|
||||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
continue
|
||||
|
||||
text: str = GenericTaskHandler._coerce_to_string(candidate)
|
||||
processed: str | None = GenericTaskHandler._apply_post_filter(text, rule)
|
||||
if processed is not None:
|
||||
values.append(processed)
|
||||
|
||||
return values
|
||||
|
||||
if "regex" == rule.type:
|
||||
target: str = GenericTaskHandler._coerce_to_string(data)
|
||||
try:
|
||||
pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
|
||||
except re.error as exc:
|
||||
LOG.exception(
|
||||
"Invalid regex expression '%s'.",
|
||||
rule.expression,
|
||||
extra={
|
||||
"field": field,
|
||||
"expression": rule.expression,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return values
|
||||
|
||||
for match in pattern.finditer(target):
|
||||
raw: str | None = GenericTaskHandler._regex_value(match=match, attribute=rule.attribute)
|
||||
processed = GenericTaskHandler._apply_post_filter(raw, rule)
|
||||
if processed is not None:
|
||||
values.append(processed)
|
||||
|
||||
return values
|
||||
|
||||
LOG.error(
|
||||
"Unsupported extraction type '%s' for JSON data in field '%s'.",
|
||||
rule.type,
|
||||
field,
|
||||
extra={"field": field, "rule_type": rule.type},
|
||||
)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _json_search(data: Any, expression: str) -> Any:
|
||||
try:
|
||||
return jmespath.search(expression, data)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"JSONPath search failed for expression '%s'.",
|
||||
expression,
|
||||
extra={"expression": expression, "error": str(exc), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _coerce_to_string(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return "" if value is None else str(value)
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _execute_rule(field: str, selector: Selector, html: str, rule: ExtractionRule) -> list[str]:
|
||||
"""
|
||||
Execute a single extraction rule and return the list of extracted values.
|
||||
|
||||
Args:
|
||||
field (str): The name of the field being extracted.
|
||||
selector (Selector): The parsel Selector for the HTML content.
|
||||
html (str): The raw HTML content.
|
||||
rule (ExtractionRuleSchema): The extraction rule to execute.
|
||||
|
||||
Returns:
|
||||
(list[str]): A list of extracted values.
|
||||
|
||||
"""
|
||||
values: list[str] = []
|
||||
|
||||
if "regex" == rule.type:
|
||||
try:
|
||||
pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
|
||||
except re.error as exc:
|
||||
LOG.exception(
|
||||
"Invalid regex expression '%s'.",
|
||||
rule.expression,
|
||||
extra={
|
||||
"field": field,
|
||||
"expression": rule.expression,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return values
|
||||
|
||||
for match in pattern.finditer(html):
|
||||
raw: str | None = GenericTaskHandler._regex_value(match=match, attribute=rule.attribute)
|
||||
processed: str | None = GenericTaskHandler._apply_post_filter(raw, rule)
|
||||
if processed is not None:
|
||||
values.append(processed)
|
||||
|
||||
return values
|
||||
|
||||
if "jsonpath" == rule.type:
|
||||
LOG.error("Field '%s' uses 'jsonpath' on a non-JSON response.", field, extra={"field": field})
|
||||
return values
|
||||
|
||||
selection: SelectorList[Selector] = (
|
||||
selector.css(rule.expression) if "css" == rule.type else selector.xpath(rule.expression)
|
||||
)
|
||||
|
||||
for sel in selection:
|
||||
raw = GenericTaskHandler._selector_value(field, sel, rule.attribute)
|
||||
processed = GenericTaskHandler._apply_post_filter(raw, rule)
|
||||
if processed is not None:
|
||||
values.append(processed)
|
||||
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _regex_value(match: re.Match[str], attribute: str | None) -> str | None:
|
||||
"""
|
||||
Extract a value from a regex match based on the attribute.
|
||||
|
||||
Args:
|
||||
match (re.Match[str]): The regex match object.
|
||||
attribute (str|None): Optional group name or index to extract.
|
||||
|
||||
Returns:
|
||||
(str|None): The extracted value if found, None otherwise.
|
||||
|
||||
"""
|
||||
if attribute:
|
||||
try:
|
||||
return match.group(attribute)
|
||||
except (IndexError, KeyError):
|
||||
LOG.debug(
|
||||
"Regex group '%s' not found in pattern '%s'.",
|
||||
attribute,
|
||||
match.re.pattern,
|
||||
extra={"attribute": attribute, "pattern": match.re.pattern},
|
||||
)
|
||||
return None
|
||||
|
||||
if match.groupdict():
|
||||
return next((value for value in match.groupdict().values() if value), None)
|
||||
|
||||
if match.groups():
|
||||
return match.group(1)
|
||||
|
||||
return match.group(0)
|
||||
|
||||
@staticmethod
|
||||
def _selector_value(field: str, sel: Selector, attribute: str | None) -> str | None:
|
||||
"""
|
||||
Extract a value from a parsel Selector based on the attribute.
|
||||
|
||||
Args:
|
||||
field (str): The name of the field being extracted.
|
||||
sel (Selector): The parsel Selector object.
|
||||
attribute (str|None): Optional attribute to extract (e.g. 'href', 'src', 'text', etc.).
|
||||
|
||||
Returns:
|
||||
(str|None): The extracted value if found, None otherwise.
|
||||
|
||||
"""
|
||||
attr: str | None = attribute.lower() if isinstance(attribute, str) else None
|
||||
|
||||
if attr in {"text", "inner_text"}:
|
||||
return sel.xpath("normalize-space()").get()
|
||||
|
||||
if attr in {"html", "outer_html"}:
|
||||
value: Any = sel.get()
|
||||
return value if value is not None else None
|
||||
|
||||
if attr and attr not in {"html", "outer_html", "text", "inner_text"}:
|
||||
try:
|
||||
attributes: dict[str, str] | None = sel.attrib
|
||||
except AttributeError:
|
||||
attributes = None
|
||||
|
||||
if attributes and attr in attributes:
|
||||
return attributes.get(attr)
|
||||
|
||||
attr_value: str | None = sel.xpath(f"@{attr}").get()
|
||||
if attr_value is not None:
|
||||
return attr_value
|
||||
|
||||
if attr is None and "link" == field.lower():
|
||||
href = None
|
||||
try:
|
||||
attributes: dict[str, str] | None = sel.attrib
|
||||
except AttributeError:
|
||||
attributes = None
|
||||
|
||||
if attributes and "href" in attributes:
|
||||
href: str | None = attributes.get("href")
|
||||
if not href:
|
||||
href: str | None = sel.xpath("@href").get()
|
||||
if href:
|
||||
return href
|
||||
|
||||
if attr is None:
|
||||
text_value: str | None = sel.xpath("normalize-space()").get()
|
||||
if text_value:
|
||||
return text_value
|
||||
|
||||
value = sel.get()
|
||||
return value if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _apply_post_filter(value: str | None, rule: ExtractionRule) -> str | None:
|
||||
"""
|
||||
Apply the post-filter to the extracted value if defined.
|
||||
|
||||
Args:
|
||||
value (str|None): The extracted value to filter.
|
||||
rule (ExtractionRuleSchema): The extraction rule containing the post-filter.
|
||||
|
||||
Returns:
|
||||
(str|None): The filtered value if applicable, None otherwise.
|
||||
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
cleaned: str = value.strip()
|
||||
if rule.post_filter:
|
||||
# Apply post-filter inline (removed helper method)
|
||||
try:
|
||||
pattern = re.compile(rule.post_filter.filter)
|
||||
match = pattern.search(cleaned)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
if rule.post_filter.value:
|
||||
try:
|
||||
return match.group(rule.post_filter.value)
|
||||
except (IndexError, KeyError):
|
||||
return None
|
||||
|
||||
if match.groupdict():
|
||||
# Prefer first named group when available
|
||||
for group_value in match.groupdict().values():
|
||||
if group_value is not None:
|
||||
return group_value
|
||||
|
||||
if match.groups():
|
||||
return match.group(1)
|
||||
|
||||
return match.group(0)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
return cleaned or None
|
||||
|
|
@ -1,19 +1,23 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.extractor import fetch_info
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.cache import Cache
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import extract_info, get_archive_id
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
CACHE: Cache = Cache()
|
||||
|
||||
|
||||
|
|
@ -24,13 +28,17 @@ class RssGenericHandler(BaseHandler):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable RSS feed.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return RssGenericHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _get(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
parsed: dict[str, str],
|
||||
) -> tuple[str, list[dict[str, str]], int]:
|
||||
|
|
@ -49,7 +57,11 @@ class RssGenericHandler(BaseHandler):
|
|||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
feed_url: str = parsed["url"]
|
||||
LOG.debug(f"'{task.name}': Fetching RSS/Atom feed from {feed_url}")
|
||||
LOG.debug(
|
||||
"Fetching RSS/Atom feed for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
|
||||
response = await RssGenericHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
|
@ -70,7 +82,12 @@ class RssGenericHandler(BaseHandler):
|
|||
# Try to parse as Atom feed first
|
||||
entries = root.findall("atom:entry", ns)
|
||||
if entries:
|
||||
LOG.debug(f"'{task.name}': Detected Atom feed format with {len(entries)} entries")
|
||||
LOG.debug(
|
||||
"'%s': Detected Atom feed format with %s entries",
|
||||
task.name,
|
||||
len(entries),
|
||||
extra={"task_name": task.name, "feed_url": feed_url, "entry_count": len(entries)},
|
||||
)
|
||||
for entry in entries:
|
||||
link_elem: Element | None = entry.find("atom:link[@rel='alternate']", ns)
|
||||
if link_elem is None:
|
||||
|
|
@ -81,7 +98,11 @@ class RssGenericHandler(BaseHandler):
|
|||
url = link_elem.get("href", "")
|
||||
|
||||
if not url:
|
||||
LOG.warning(f"'{task.name}': Atom entry missing URL. Skipping.")
|
||||
LOG.warning(
|
||||
"'%s': Atom entry missing URL. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
title_elem: Element | None = entry.find("atom:title", ns)
|
||||
|
|
@ -95,7 +116,12 @@ class RssGenericHandler(BaseHandler):
|
|||
else:
|
||||
# Try to parse as RSS feed
|
||||
rss_items = root.findall(".//item")
|
||||
LOG.debug(f"'{task.name}': Detected RSS feed format with {len(rss_items)} items")
|
||||
LOG.debug(
|
||||
"'%s': Detected RSS feed format with %s items",
|
||||
task.name,
|
||||
len(rss_items),
|
||||
extra={"task_name": task.name, "feed_url": feed_url, "entry_count": len(rss_items)},
|
||||
)
|
||||
|
||||
for item in rss_items:
|
||||
# Try different link element names (link, url, media:content)
|
||||
|
|
@ -116,7 +142,11 @@ class RssGenericHandler(BaseHandler):
|
|||
url = enclosure_elem.get("url", "")
|
||||
|
||||
if not url:
|
||||
LOG.warning(f"'{task.name}': RSS item missing URL. Skipping.")
|
||||
LOG.warning(
|
||||
"'%s': RSS item missing URL. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
title_elem = item.find("title")
|
||||
|
|
@ -131,12 +161,14 @@ class RssGenericHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
"""
|
||||
Extract items from an RSS/Atom feed.
|
||||
|
||||
Args:
|
||||
task (Task): The task containing the feed URL.
|
||||
config (Config | None): Optional handler configuration.
|
||||
|
||||
Returns:
|
||||
TaskResult | TaskFailure: Extraction result with parsed items or failure information.
|
||||
|
|
@ -150,8 +182,19 @@ class RssGenericHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, real_count = await RssGenericHandler._get(task, params, parsed)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch RSS/Atom feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -171,31 +214,41 @@ class RssGenericHandler(BaseHandler):
|
|||
if CACHE.has(cache_key):
|
||||
archive_id = CACHE.get(cache_key)
|
||||
if not archive_id:
|
||||
LOG.debug(f"'{task.name}': Cached failure for URL '{url}'. Skipping.")
|
||||
LOG.debug(
|
||||
"Task '%s' has a cached archive ID lookup failure. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
continue
|
||||
else:
|
||||
LOG.warning(
|
||||
f"'{task.name}': Unable to generate static archive ID for '{url}' in feed. "
|
||||
"Doing real request to fetch yt-dlp archive ID."
|
||||
"Task '%s' could not generate a static archive ID. Fetching it with yt-dlp.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
|
||||
info = extract_info(
|
||||
(info, _) = await fetch_info(
|
||||
config=params,
|
||||
url=url,
|
||||
no_archive=True,
|
||||
no_log=True,
|
||||
budget_sleep=True,
|
||||
)
|
||||
|
||||
if not info:
|
||||
LOG.error(
|
||||
f"'{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping."
|
||||
"Task '%s' failed to extract info to generate an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
||||
if not info.get("id") or not info.get("extractor_key"):
|
||||
LOG.error(
|
||||
f"'{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping."
|
||||
"Task '%s' returned incomplete info while generating an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
|
@ -1,18 +1,21 @@
|
|||
import logging
|
||||
import re
|
||||
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TverHandler(BaseHandler):
|
||||
SERIES_API = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
|
||||
SESSION_API = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
|
||||
HEADERS = {
|
||||
SERIES_API: str = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
|
||||
SESSION_API: str = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
|
||||
HEADERS: dict[str, str] = {
|
||||
"x-tver-platform-type": "web",
|
||||
"Origin": "https://tver.jp",
|
||||
"Referer": "https://tver.jp/",
|
||||
|
|
@ -21,8 +24,12 @@ class TverHandler(BaseHandler):
|
|||
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?P<id>sr[a-z0-9_]+)$")
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable Tver series URL.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return TverHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -53,12 +60,16 @@ class TverHandler(BaseHandler):
|
|||
|
||||
return {"platform_uid": platform_uid, "platform_token": platform_token}
|
||||
except Exception as exc:
|
||||
LOG.warning(f"Failed to create tver session: {exc}")
|
||||
LOG.warning(
|
||||
"Failed to create Tver session.",
|
||||
extra={"error": str(exc), "exception_type": type(exc).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _collect_feed(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
series_id: str,
|
||||
) -> tuple[str, list[dict[str, str]], bool]:
|
||||
|
|
@ -81,7 +92,11 @@ class TverHandler(BaseHandler):
|
|||
|
||||
feed_url = TverHandler.SERIES_API.format(id=series_id)
|
||||
|
||||
LOG.debug(f"Fetching '{task.name}' episodes from tver series {series_id}.")
|
||||
LOG.debug(
|
||||
"Fetching Tver episodes for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url},
|
||||
)
|
||||
|
||||
response = await TverHandler.request(
|
||||
url=feed_url,
|
||||
|
|
@ -103,7 +118,11 @@ class TverHandler(BaseHandler):
|
|||
try:
|
||||
contents = data.get("result", {}).get("contents", [])
|
||||
if not contents:
|
||||
LOG.warning(f"No contents found in tver series response for '{task.name}'.")
|
||||
LOG.warning(
|
||||
"No contents found in Tver series response for '%s'.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url},
|
||||
)
|
||||
return feed_url, items, has_items
|
||||
|
||||
season_block = contents[0] if contents else {}
|
||||
|
|
@ -114,9 +133,13 @@ class TverHandler(BaseHandler):
|
|||
continue
|
||||
|
||||
content = episode_data.get("content", {})
|
||||
episode_id = content.pop("id")
|
||||
episode_id = content.pop("id", None)
|
||||
if not episode_id:
|
||||
LOG.warning(f"Episode missing ID in '{task.name}' feed. Skipping.")
|
||||
LOG.warning(
|
||||
"Episode missing ID in '%s' feed. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
url = f"https://tver.jp/episodes/{episode_id}"
|
||||
|
|
@ -127,7 +150,9 @@ class TverHandler(BaseHandler):
|
|||
archive_id = id_dict.get("archive_id")
|
||||
if not archive_id:
|
||||
LOG.warning(
|
||||
f"Could not compute archive ID for episode '{episode_id}' in '{task.name}' feed. Skipping."
|
||||
"Task '%s' could not compute an archive ID for an episode. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "episode_id": episode_id, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
@ -137,12 +162,24 @@ class TverHandler(BaseHandler):
|
|||
)
|
||||
|
||||
except Exception as exc:
|
||||
LOG.warning(f"Error parsing tver episodes for '{task.name}': {exc}")
|
||||
LOG.warning(
|
||||
"Failed to parse Tver episodes for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"series_id": series_id,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
series_id: str | None = TverHandler.parse(task.url)
|
||||
if not series_id:
|
||||
return TaskFailure(message="Unrecognized Tver series URL.")
|
||||
|
|
@ -151,8 +188,20 @@ class TverHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, has_items = await TverHandler._collect_feed(task, params, series_id)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch Tver feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"series_id": series_id,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -161,10 +210,11 @@ class TverHandler(BaseHandler):
|
|||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
task_items.append(
|
||||
TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=entry.get("metadata", {}))
|
||||
)
|
||||
archive_id: str | None = entry.get("archive_id")
|
||||
metadata = entry.get("metadata", {})
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata))
|
||||
|
||||
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})
|
||||
|
||||
|
|
@ -1,32 +1,39 @@
|
|||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TwitchHandler(BaseHandler):
|
||||
FEED = "https://twitchrss.appspot.com/vodonly/{handle}"
|
||||
FEED: str = "https://twitchrss.appspot.com/vodonly/{handle}"
|
||||
|
||||
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?twitch\.tv\/(?P<id>[a-z0-9_]{3,25})(?:\/.*)?$")
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable Twitch URL.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return TwitchHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _collect_feed(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
handle_name: str,
|
||||
) -> tuple[str, list[dict[str, str]], bool]:
|
||||
|
|
@ -34,7 +41,7 @@ class TwitchHandler(BaseHandler):
|
|||
|
||||
feed_url: str = TwitchHandler.FEED.format(handle=handle_name)
|
||||
|
||||
LOG.debug(f"Fetching '{task.name}' feed.")
|
||||
LOG.debug("Fetching '%s' feed.", task.name, extra={"task_name": task.name, "feed_url": feed_url})
|
||||
response = await TwitchHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
||||
|
|
@ -46,14 +53,22 @@ class TwitchHandler(BaseHandler):
|
|||
link_elem: Element[str] | None = entry.find("link")
|
||||
url: str = link_elem.text.strip() if link_elem is not None and link_elem.text else ""
|
||||
if not url:
|
||||
LOG.warning(f"Entry in '{task.name}' feed is missing URL. Skipping entry.")
|
||||
LOG.warning(
|
||||
"Entry in '%s' feed is missing URL. Skipping entry.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
match: re.Match[str] | None = re.search(
|
||||
r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url
|
||||
)
|
||||
if not match:
|
||||
LOG.warning(f"URL in '{task.name}' feed does not look like a VOD link: {url}")
|
||||
LOG.warning(
|
||||
"Task '%s' produced a feed entry that does not look like a Twitch VOD link. Skipping entry.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
vid: str = match.group("id")
|
||||
|
|
@ -66,7 +81,11 @@ class TwitchHandler(BaseHandler):
|
|||
id_dict = get_archive_id(url)
|
||||
archive_id: str | None = id_dict.get("archive_id")
|
||||
if not archive_id:
|
||||
LOG.warning(f"Could not compute archive ID for video '{vid}' in '{task.name}' feed. Skipping entry.")
|
||||
LOG.warning(
|
||||
"Task '%s' could not compute an archive ID for a Twitch video. Skipping entry.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "video_id": vid, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
items.append({"id": vid, "url": url, "title": title, "archive_id": archive_id})
|
||||
|
|
@ -74,7 +93,8 @@ class TwitchHandler(BaseHandler):
|
|||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
handle_name: str | None = TwitchHandler.parse(task.url)
|
||||
if not handle_name:
|
||||
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
||||
|
|
@ -83,8 +103,19 @@ class TwitchHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, has_items = await TwitchHandler._collect_feed(task, params, handle_name)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch Twitch feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -93,10 +124,10 @@ class TwitchHandler(BaseHandler):
|
|||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
archive_id: str | None = entry.get("archive_id")
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id))
|
||||
|
||||
return TaskResult( items=task_items, metadata={ "feed_url": feed_url, "has_entries": has_items } )
|
||||
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})
|
||||
|
||||
@staticmethod
|
||||
def parse(url: str) -> str | None:
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class YoutubeHandler(BaseHandler):
|
||||
FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
|
||||
FEED: str = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
|
||||
|
||||
CHANNEL_REGEX: re.Pattern[str] = re.compile(
|
||||
r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$"
|
||||
|
|
@ -26,12 +29,16 @@ class YoutubeHandler(BaseHandler):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable YouTube URL.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return YoutubeHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _get(task: Task, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
|
||||
async def _get(task: HandleTask, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
|
||||
"""
|
||||
Fetch the feed and return raw entries.
|
||||
|
||||
|
|
@ -47,7 +54,7 @@ class YoutubeHandler(BaseHandler):
|
|||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
|
||||
LOG.debug(f"'{task.name}': Fetching feed.")
|
||||
LOG.debug("'%s': Fetching feed.", task.name, extra={"task_name": task.name, "feed_url": feed_url})
|
||||
|
||||
response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
|
@ -65,7 +72,11 @@ class YoutubeHandler(BaseHandler):
|
|||
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
|
||||
vid: str = vid_elem.text if vid_elem is not None and vid_elem.text else ""
|
||||
if not vid:
|
||||
LOG.warning(f"'{task.name}': Entry in the feed is missing a video ID. Skipping.")
|
||||
LOG.warning(
|
||||
"'%s': Entry in the feed is missing a video ID. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
url: str = f"https://www.youtube.com/watch?v={vid}"
|
||||
|
|
@ -73,7 +84,11 @@ class YoutubeHandler(BaseHandler):
|
|||
id_dict: dict[str, str | None] = get_archive_id(url)
|
||||
archive_id: str | None = id_dict.get("archive_id")
|
||||
if not archive_id:
|
||||
LOG.warning(f"'{task.name}': Could not compute archive ID for video '{vid}' in feed. Skipping.")
|
||||
LOG.warning(
|
||||
"Task '%s' could not compute an archive ID for a YouTube video. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "video_id": vid, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
title_elem: Element[str] | None = entry.find("atom:title", ns)
|
||||
|
|
@ -89,7 +104,8 @@ class YoutubeHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||
if not parsed:
|
||||
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
|
||||
|
|
@ -98,8 +114,19 @@ class YoutubeHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, real_count = await YoutubeHandler._get(task, params, parsed)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch YouTube feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -108,7 +135,7 @@ class YoutubeHandler(BaseHandler):
|
|||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
archive_id: str | None = entry.get("archive_id")
|
||||
metadata: dict[str, Any] = {"published": entry.get("published")}
|
||||
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata))
|
||||
138
app/features/tasks/definitions/migration.py
Normal file
138
app/features/tasks/definitions/migration.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "task_definitions"
|
||||
|
||||
def __init__(self, repo: TaskDefinitionsRepository, config: Config | None = None) -> None:
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: TaskDefinitionsRepository = repo
|
||||
self._source_dir: Path = Path(self._config.config_path) / "tasks"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
if not self._source_dir.exists():
|
||||
return False
|
||||
|
||||
return any(self._source_dir.glob("*.json"))
|
||||
|
||||
async def migrate(self) -> None:
|
||||
if await self._repo.count() > 0:
|
||||
LOG.warning("Task definitions already exist in the database; skipping migration.")
|
||||
await self._archive_sources()
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
|
||||
for path in sorted(self._source_dir.glob("*.json")):
|
||||
normalized = await self._normalize(path, seen_names)
|
||||
if not normalized:
|
||||
await self._move_file(path)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to insert task definition '%s'.",
|
||||
normalized.get("name"),
|
||||
extra={"definition": normalized.get("name"), "exception_type": type(exc).__name__},
|
||||
)
|
||||
finally:
|
||||
await self._move_file(path)
|
||||
|
||||
LOG.info("Migrated %s task definition(s) from %s.", inserted, self._source_dir)
|
||||
|
||||
async def _archive_sources(self) -> None:
|
||||
for path in self._source_dir.glob("*.json"):
|
||||
await self._move_file(path)
|
||||
|
||||
async def _normalize(self, path: Path, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to read task definition '%s'.",
|
||||
path,
|
||||
extra={"path": str(path), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to parse JSON for task definition '%s'.",
|
||||
path,
|
||||
extra={"path": str(path), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
LOG.error("Task definition in '%s' must be a JSON object.", path)
|
||||
return None
|
||||
|
||||
if "match" in payload and "match_url" not in payload:
|
||||
payload["match_url"] = payload.pop("match")
|
||||
|
||||
# Normalize match_url from old object format to new string format
|
||||
if "match_url" in payload and isinstance(payload["match_url"], list):
|
||||
normalized_match: list[str] = []
|
||||
for item in payload["match_url"]:
|
||||
if isinstance(item, str):
|
||||
normalized_match.append(item)
|
||||
elif isinstance(item, dict):
|
||||
if "regex" in item and isinstance(item["regex"], str):
|
||||
# Convert {regex: "pattern"} to /pattern/
|
||||
normalized_match.append(f"/{item['regex']}/")
|
||||
elif "glob" in item and isinstance(item["glob"], str):
|
||||
# Convert {glob: "pattern"} to pattern
|
||||
normalized_match.append(item["glob"])
|
||||
payload["match_url"] = normalized_match
|
||||
|
||||
# Rename request.json to request.json_data
|
||||
if "request" in payload and isinstance(payload["request"], dict) and "json" in payload["request"]:
|
||||
payload["request"]["json_data"] = payload["request"].pop("json")
|
||||
|
||||
if "definition" not in payload:
|
||||
definition_fields = {}
|
||||
for field in ["parse", "engine", "request", "response"]:
|
||||
if field in payload:
|
||||
definition_fields[field] = payload.pop(field)
|
||||
|
||||
if definition_fields:
|
||||
payload["definition"] = definition_fields
|
||||
# Also handle nested definition.request.json
|
||||
elif isinstance(payload["definition"], dict):
|
||||
if (
|
||||
"request" in payload["definition"]
|
||||
and isinstance(payload["definition"]["request"], dict)
|
||||
and "json" in payload["definition"]["request"]
|
||||
):
|
||||
payload["definition"]["request"]["json_data"] = payload["definition"]["request"].pop("json")
|
||||
|
||||
name_value = payload.get("name")
|
||||
if not isinstance(name_value, str) or not name_value.strip():
|
||||
LOG.error("Task definition in '%s' missing a valid name.", path)
|
||||
return None
|
||||
|
||||
name = self._unique_name(name_value.strip(), seen_names)
|
||||
payload["name"] = name
|
||||
|
||||
# Repository will handle validation and field extraction
|
||||
return payload
|
||||
27
app/features/tasks/definitions/models.py
Normal file
27
app/features/tasks/definitions/models.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class TaskDefinitionModel(Base):
|
||||
__tablename__: str = "task_definitions"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_task_definitions_name", "name"),
|
||||
Index("ix_task_definitions_priority", "priority"),
|
||||
Index("ix_task_definitions_match_url", "match_url"),
|
||||
Index("ix_task_definitions_enabled", "enabled"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
match_url: Mapped[list] = mapped_column(JSON, nullable=False)
|
||||
definition: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
187
app/features/tasks/definitions/repository.py
Normal file
187
app/features/tasks/definitions/repository.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.core.schemas import CEFeature, ConfigEvent
|
||||
from app.features.tasks.definitions.migration import Migration
|
||||
from app.features.tasks.definitions.models import TaskDefinitionModel
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TaskDefinitionsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self).run()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> TaskDefinitionsRepository:
|
||||
return TaskDefinitionsRepository()
|
||||
|
||||
def attach(self, _: Any) -> None:
|
||||
async def handle_event(_, __):
|
||||
await self.run_migrations()
|
||||
|
||||
async def handler(e: Event, __):
|
||||
from app.features.tasks.definitions.handlers.generic import GenericTaskHandler
|
||||
|
||||
if isinstance(e.data, ConfigEvent) and CEFeature.TASKS_DEFINITIONS == e.data.feature:
|
||||
LOG.debug("Refreshing task definitions due to configuration update.")
|
||||
await GenericTaskHandler.refresh_definitions(force=True)
|
||||
|
||||
Services.get_instance().add(TaskDefinitionsRepository.__name__, self)
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.STARTED, handle_event, f"{TaskDefinitionsRepository.__name__}.run_migrations"
|
||||
).subscribe(Events.CONFIG_UPDATE, handler, "GenericTaskHandler.refresh_definitions")
|
||||
|
||||
async def all(self) -> list[TaskDefinitionModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).order_by(TaskDefinitionModel.priority.asc(), TaskDefinitionModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[TaskDefinitionModel], int, int, int]:
|
||||
async with self.session() as session:
|
||||
total: int = await self.count()
|
||||
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
if page > total_pages and total > 0:
|
||||
page = total_pages
|
||||
|
||||
query: Select[tuple[TaskDefinitionModel]] = (
|
||||
select(TaskDefinitionModel)
|
||||
.order_by(TaskDefinitionModel.priority.asc(), TaskDefinitionModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(query)
|
||||
return list(result.scalars().all()), total, page, total_pages
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(TaskDefinitionModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> TaskDefinitionModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskDefinitionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskDefinitionModel.id == int(identifier), TaskDefinitionModel.name == identifier)
|
||||
else:
|
||||
clause = TaskDefinitionModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).where(clause).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> TaskDefinitionModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[TaskDefinitionModel]] = select(TaskDefinitionModel).where(
|
||||
TaskDefinitionModel.name == name
|
||||
)
|
||||
if exclude_id is not None:
|
||||
query = query.where(TaskDefinitionModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: dict[str, Any]) -> TaskDefinitionModel:
|
||||
async with self.session() as session:
|
||||
model: TaskDefinitionModel = TaskDefinitionModel(**payload)
|
||||
if model.id is not None:
|
||||
model.id = None # ty: ignore
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Task definition with name '{model.name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def update(self, identifier: int | str, payload: dict[str, Any]) -> TaskDefinitionModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskDefinitionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskDefinitionModel.id == int(identifier), TaskDefinitionModel.name == identifier)
|
||||
else:
|
||||
clause = TaskDefinitionModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).where(clause).limit(1)
|
||||
)
|
||||
model: TaskDefinitionModel | None = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"Task definition '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
payload.pop("created_at", None)
|
||||
payload.pop("updated_at", None)
|
||||
|
||||
if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
|
||||
msg = f"Task definition with name '{payload['name']}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
for key, value in payload.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def delete(self, identifier: int | str) -> TaskDefinitionModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskDefinitionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskDefinitionModel.id == int(identifier), TaskDefinitionModel.name == identifier)
|
||||
else:
|
||||
clause = TaskDefinitionModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).where(clause).limit(1)
|
||||
)
|
||||
|
||||
if not (model := result.scalar_one_or_none()):
|
||||
msg: str = f"Task definition '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
233
app/features/tasks/definitions/results.py
Normal file
233
app/features/tasks/definitions/results.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.features.tasks.schemas import Task as TaskSchema
|
||||
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
|
||||
|
||||
from .utils import split_inspect_metadata
|
||||
|
||||
|
||||
class HandleTask(TaskSchema):
|
||||
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||
"""
|
||||
Get the yt-dlp options for the task.
|
||||
|
||||
Returns:
|
||||
YTDLPOpts: The yt-dlp options.
|
||||
|
||||
"""
|
||||
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
|
||||
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if self.preset:
|
||||
params = params.preset(name=self.preset)
|
||||
|
||||
if self.cli:
|
||||
params = params.add_cli(self.cli, from_user=True)
|
||||
|
||||
if self.template:
|
||||
params = params.add({"outtmpl": {"default": self.template}}, from_user=False)
|
||||
|
||||
return params
|
||||
|
||||
async def mark(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Mark the task's items as downloaded in the archive file.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: A tuple indicating success and a message.
|
||||
|
||||
"""
|
||||
from app.features.ytdlp.utils import archive_add
|
||||
|
||||
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
|
||||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
archive_file = ret.get("file")
|
||||
items = ret.get("items", set())
|
||||
if not isinstance(archive_file, Path) or not isinstance(items, set):
|
||||
return (False, "Failed to get archive information.")
|
||||
archive_items = [item for item in items if isinstance(item, str)]
|
||||
|
||||
if len(archive_items) < 1 or not archive_add(archive_file, archive_items):
|
||||
return (True, "No new items to mark as downloaded.")
|
||||
|
||||
return (True, f"Task '{self.name}' items marked as downloaded.")
|
||||
|
||||
async def unmark(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Unmark the task's items from the archive file.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: A tuple indicating success and a message.
|
||||
|
||||
"""
|
||||
from app.features.ytdlp.utils import archive_delete
|
||||
|
||||
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
|
||||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
archive_file = ret.get("file")
|
||||
items = ret.get("items", set())
|
||||
if not isinstance(archive_file, Path) or not isinstance(items, set):
|
||||
return (False, "Failed to get archive information.")
|
||||
archive_items = [item for item in items if isinstance(item, str)]
|
||||
|
||||
if len(archive_items) < 1 or not archive_delete(archive_file, archive_items):
|
||||
return (True, "No items to remove from archive file.")
|
||||
|
||||
return (True, f"Removed '{self.name}' items from archive file.")
|
||||
|
||||
async def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, bool, str]:
|
||||
"""
|
||||
Fetch metadata for the task's URL.
|
||||
|
||||
Args:
|
||||
full (bool): Whether to fetch full metadata including all entries for playlists.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, Any]|None, bool, str]: A tuple containing the metadata (or None on failure), a boolean
|
||||
indicating if the operation was successful, and a message.
|
||||
|
||||
"""
|
||||
from app.features.ytdlp.extractor import fetch_info
|
||||
|
||||
if not self.url:
|
||||
return ({}, False, "No URL found in task parameters.")
|
||||
|
||||
params = self.get_ytdlp_opts()
|
||||
if not full:
|
||||
params.add_cli("-I0", from_user=False)
|
||||
|
||||
params_dict = params.get_all()
|
||||
|
||||
(ie_info, _) = await fetch_info(
|
||||
params_dict,
|
||||
self.url,
|
||||
no_archive=True,
|
||||
follow_redirect=False,
|
||||
sanitize_info=True,
|
||||
budget_sleep=True,
|
||||
)
|
||||
|
||||
if not ie_info or not isinstance(ie_info, dict):
|
||||
return ({}, False, "Failed to extract information from URL.")
|
||||
|
||||
return (ie_info, True, "")
|
||||
|
||||
async def _mark_logic(self) -> tuple[bool, str] | dict[str, Any]:
|
||||
"""
|
||||
Internal logic for marking/un-marking items.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str] | dict[str, Any]: Either an error tuple or a dict with 'file' and 'items' keys.
|
||||
|
||||
"""
|
||||
from app.features.ytdlp.extractor import fetch_info
|
||||
|
||||
if not self.url:
|
||||
return (False, "No URL found in task parameters.")
|
||||
|
||||
params: dict = self.get_ytdlp_opts().get_all()
|
||||
if not (archive_file := params.get("download_archive")):
|
||||
return (False, "No archive file found.")
|
||||
|
||||
archive_file: Path = Path(archive_file)
|
||||
|
||||
(ie_info, _) = await fetch_info(params, self.url, no_archive=True, follow_redirect=True, budget_sleep=True)
|
||||
if not ie_info or not isinstance(ie_info, dict):
|
||||
return (False, "Failed to extract information from URL.")
|
||||
|
||||
if "playlist" != ie_info.get("_type"):
|
||||
return (False, "Expected a playlist type from extract_info.")
|
||||
|
||||
items: set[str] = set()
|
||||
|
||||
def _process(item: dict):
|
||||
for entry in item.get("entries", []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
if "playlist" == entry.get("_type"):
|
||||
_process(entry)
|
||||
continue
|
||||
|
||||
if entry.get("_type") not in ("video", "url"):
|
||||
continue
|
||||
|
||||
if not entry.get("id") or not entry.get("ie_key"):
|
||||
continue
|
||||
|
||||
archive_id: str = f"{entry.get('ie_key', '').lower()} {entry.get('id')}"
|
||||
|
||||
items.add(archive_id)
|
||||
|
||||
_process(ie_info)
|
||||
|
||||
return {"file": archive_file, "items": items}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskItem:
|
||||
"""Represents a single item in a task result."""
|
||||
|
||||
url: str
|
||||
"The URL of the item."
|
||||
title: str | None = None
|
||||
"The title of the item."
|
||||
archive_id: str | None = None
|
||||
"The archive ID of the item."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the item."
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskResult:
|
||||
"""Represents a successful task handler execution result."""
|
||||
|
||||
items: list[TaskItem] = field(default_factory=list)
|
||||
"The list of items."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the result."
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
primary, extra = split_inspect_metadata(self.metadata)
|
||||
payload: dict[str, Any] = {**primary, "items": [asdict(item) for item in self.items]}
|
||||
|
||||
if extra:
|
||||
payload["metadata"] = extra
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskFailure:
|
||||
"""Represents a failed task handler execution result."""
|
||||
|
||||
message: str
|
||||
"A human-readable message describing the failure."
|
||||
error: str | None = None
|
||||
"An optional error code or string."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the failure."
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
primary, extra = split_inspect_metadata(self.metadata)
|
||||
payload: dict[str, Any] = dict(primary)
|
||||
|
||||
if self.error:
|
||||
payload["error"] = self.error
|
||||
|
||||
if self.message and (not self.error or self.message != self.error):
|
||||
payload["message"] = self.message
|
||||
|
||||
if extra:
|
||||
payload["metadata"] = extra
|
||||
|
||||
return payload
|
||||
255
app/features/tasks/definitions/router.py
Normal file
255
app/features/tasks/definitions/router.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository as Repo
|
||||
from app.features.tasks.definitions.schemas import (
|
||||
TaskDefinition,
|
||||
TaskDefinitionList,
|
||||
TaskDefinitionPatch,
|
||||
)
|
||||
from app.features.tasks.definitions.utils import model_to_schema, schema_to_payload
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
@route("GET", "api/tasks/definitions/", "task_definitions")
|
||||
async def task_definitions_list(request: Request, encoder: Encoder, repo: Repo) -> Response:
|
||||
page, per_page = normalize_pagination(request)
|
||||
models, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||
|
||||
include: str | None = request.query.get("include")
|
||||
summary: bool = "definition" != include
|
||||
|
||||
return web.json_response(
|
||||
data=TaskDefinitionList(
|
||||
items=[model_to_schema(model, summary=summary) for model in models],
|
||||
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||
),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", r"api/tasks/definitions/{id:\d+}", "task_definitions_get")
|
||||
async def task_definitions_get(request: Request, encoder: Encoder, repo: Repo) -> Response:
|
||||
identifier: str = request.match_info.get("id", "").strip()
|
||||
if not identifier:
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response(
|
||||
data={"error": f"Task definition '{identifier}' not found."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
definition = model_to_schema(model)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/tasks/definitions/", "task_definitions_create")
|
||||
async def task_definitions_create(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
try:
|
||||
payload: Any = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
data={"error": "Invalid JSON in request body."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition_input = TaskDefinition.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task definition.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
repo_payload = schema_to_payload(definition_input)
|
||||
model = await repo.create(repo_payload)
|
||||
definition = model_to_schema(model)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.CREATE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPCreated.status_code, dumps=encoder.encode)
|
||||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to create task definition '%s'.",
|
||||
getattr(definition_input, "name", None),
|
||||
extra={"definition": getattr(definition_input, "name", None), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to create task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", r"api/tasks/definitions/{id:\d+}", "task_definitions_update")
|
||||
async def task_definitions_update(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
if not (identifier := request.match_info.get("id", "").strip()):
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
payload: dict | None = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
data={"error": "Invalid JSON in request body."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition_input: TaskDefinition = TaskDefinition.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task definition.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition: TaskDefinition = model_to_schema(await repo.update(identifier, schema_to_payload(definition_input)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.UPDATE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except KeyError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to update task definition '%s'.",
|
||||
definition_input.name,
|
||||
extra={
|
||||
"definition_id": identifier,
|
||||
"definition": definition_input.name,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to update task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("PATCH", r"api/tasks/definitions/{id:\d+}", "task_definitions_patch")
|
||||
async def task_definitions_patch(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
if not (identifier := request.match_info.get("id", "").strip()):
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not await repo.get(identifier):
|
||||
return web.json_response(
|
||||
data={"error": f"Task definition '{identifier}' not found."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
payload: dict | None = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
data={"error": "Invalid JSON in request body."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
patch_input: TaskDefinitionPatch = TaskDefinitionPatch.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task definition patch.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition: TaskDefinition = model_to_schema(
|
||||
await repo.update(identifier, patch_input.model_dump(exclude_unset=True))
|
||||
)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.UPDATE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except KeyError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to patch task definition '%s'.",
|
||||
identifier,
|
||||
extra={"definition_id": identifier, "exception_type": type(exc).__name__},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to patch task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("DELETE", r"api/tasks/definitions/{id:\d+}", "task_definitions_delete")
|
||||
async def task_definitions_delete(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
if not (identifier := request.match_info.get("id", "").strip()):
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition: TaskDefinition = model_to_schema(await repo.delete(identifier))
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.DELETE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except KeyError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to delete task definition '%s'.",
|
||||
identifier,
|
||||
extra={"definition_id": identifier, "exception_type": type(exc).__name__},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to delete task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
222
app/features/tasks/definitions/schemas.py
Normal file
222
app/features/tasks/definitions/schemas.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime # noqa: TC003
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.features.core.utils import parse_int
|
||||
|
||||
|
||||
class PostFilter(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
filter: str = Field(min_length=1)
|
||||
value: str | None = None
|
||||
|
||||
@field_validator("filter")
|
||||
@classmethod
|
||||
def _validate_filter(cls, value: str) -> str:
|
||||
try:
|
||||
re.compile(value)
|
||||
except re.error as exc:
|
||||
msg: str = f"Invalid post_filter regex pattern: {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
return value
|
||||
|
||||
|
||||
class ExtractionRule(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["css", "xpath", "regex", "jsonpath"]
|
||||
expression: str = Field(min_length=1)
|
||||
attribute: str | None = None
|
||||
post_filter: PostFilter | None = None
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Support bracket notation access."""
|
||||
if not hasattr(self, key):
|
||||
raise KeyError(key)
|
||||
return getattr(self, key)
|
||||
|
||||
|
||||
class ParseItems(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["css", "xpath", "jsonpath"] = "css"
|
||||
selector: str | None = Field(None, min_length=1)
|
||||
expression: str | None = Field(None, min_length=1)
|
||||
fields: dict[str, ExtractionRule]
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a field value by key, supporting dict-like access."""
|
||||
return getattr(self, key, default)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Support bracket notation access."""
|
||||
if not hasattr(self, key):
|
||||
raise KeyError(key)
|
||||
return getattr(self, key)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_items(self) -> ParseItems:
|
||||
if not self.selector and not self.expression:
|
||||
msg = "Either 'selector' or 'expression' must be provided."
|
||||
raise ValueError(msg)
|
||||
if not self.selector:
|
||||
self.selector = self.expression
|
||||
if "link" not in self.fields:
|
||||
msg = "Container 'fields' must include a 'link' field."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class Parse(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, extra="allow")
|
||||
items: ParseItems | None = None
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a field value by key, supporting dict-like access."""
|
||||
return getattr(self, key, default)
|
||||
|
||||
def field_items(self) -> list[tuple[str, Any]]:
|
||||
"""Return field items like a dict, excluding private fields and 'items'."""
|
||||
data: dict[str, Any] = self.model_dump()
|
||||
return [(k, v) for k, v in data.items() if k not in ("items",)]
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Support bracket notation access."""
|
||||
if not hasattr(self, key):
|
||||
raise KeyError(key)
|
||||
return getattr(self, key)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
"""Support 'in' operator."""
|
||||
return hasattr(self, key) and not key.startswith("_")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _validate_parse(cls, value: Any) -> Any:
|
||||
"""Validate that we have either items or direct parsers with link."""
|
||||
if not isinstance(value, dict):
|
||||
msg: str = "Parse must be a dict"
|
||||
raise ValueError(msg)
|
||||
|
||||
has_items: bool = "items" in value and value["items"] is not None
|
||||
direct_parsers: dict[str, Any] = {
|
||||
k: v for k, v in value.items() if k not in ("items",) and not k.startswith("_")
|
||||
}
|
||||
has_direct_parsers: bool = len(direct_parsers) > 0
|
||||
has_link_parser: bool = "link" in direct_parsers
|
||||
|
||||
if not has_items and not has_direct_parsers:
|
||||
msg: str = "Field 'parse' must contain either 'items' or direct parsers."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not has_items and not has_link_parser:
|
||||
msg: str = "Missing required 'link' parser definition."
|
||||
raise ValueError(msg)
|
||||
|
||||
for field_name, field_value in direct_parsers.items():
|
||||
if not isinstance(field_value, dict):
|
||||
msg: str = f"Parse field '{field_name}' must be an object."
|
||||
raise ValueError(msg)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class EngineConfig(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["httpx", "selenium"] = "httpx"
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RequestConfig(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, protected_namespaces=())
|
||||
method: str = "GET"
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
data: dict[str, Any] | None = None
|
||||
json_data: dict[str, Any] | None = None
|
||||
timeout: float | None = None
|
||||
url: str | None = None
|
||||
|
||||
|
||||
class ResponseConfig(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["html", "json"] = "html"
|
||||
|
||||
|
||||
class Definition(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
parse: Parse
|
||||
engine: EngineConfig = Field(default_factory=EngineConfig)
|
||||
request: RequestConfig = Field(default_factory=RequestConfig)
|
||||
response: ResponseConfig = Field(default_factory=ResponseConfig)
|
||||
|
||||
|
||||
class TaskDefinitionSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
priority: int = Field(default=0, ge=0)
|
||||
match_url: list[str] = Field(min_length=1)
|
||||
enabled: bool = Field(default=True)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskDefinition(TaskDefinitionSummary):
|
||||
definition: Definition
|
||||
|
||||
@field_validator("priority", mode="before")
|
||||
@classmethod
|
||||
def _normalize_priority(cls, value: Any) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
return parse_int(value, field="Priority", minimum=0)
|
||||
|
||||
@field_validator("match_url", mode="before")
|
||||
@classmethod
|
||||
def _validate_match_url(cls, value: Any) -> list[str]:
|
||||
"""Validate that match_url is a list of strings and validate regex patterns."""
|
||||
if not isinstance(value, list):
|
||||
msg = "match_url must be a list"
|
||||
raise ValueError(msg)
|
||||
|
||||
validated: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
msg: str = f"match_url items must be strings, got {type(item).__name__}"
|
||||
raise ValueError(msg)
|
||||
|
||||
item: str = item.strip()
|
||||
if not item:
|
||||
msg = "match_url items cannot be empty"
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.startswith("/") and item.endswith("/") and len(item) > 2:
|
||||
pattern = item[1:-1]
|
||||
try:
|
||||
re.compile(pattern)
|
||||
except re.error as exc:
|
||||
msg = f"Invalid regex pattern '{pattern}': {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
validated.append(item)
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
class TaskDefinitionPatch(TaskDefinition):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
name: str | None = None
|
||||
priority: int | None = None
|
||||
match_url: list[str] | None = None
|
||||
definition: Definition | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class TaskDefinitionList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
items: list[TaskDefinitionSummary | TaskDefinition] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
629
app/features/tasks/definitions/service.py
Normal file
629
app/features/tasks/definitions/service.py
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
import pkgutil
|
||||
import random
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.tasks.definitions.handlers._base_handler import BaseHandler
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.features.ytdlp.utils import archive_read
|
||||
from app.library.downloads.queue_manager import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
from app.library.config import Config
|
||||
from app.library.Scheduler import Scheduler
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TaskHandle:
|
||||
def __init__(self, scheduler: Scheduler, tasks: TasksRepository, config: Config) -> None:
|
||||
self._handlers: list[type[BaseHandler]] = []
|
||||
"The available handlers."
|
||||
self._repo: TasksRepository = tasks
|
||||
"The tasks manager."
|
||||
self._scheduler: Scheduler = scheduler
|
||||
"The scheduler."
|
||||
self._config: Config = config
|
||||
"The configuration."
|
||||
self._task_name: str = f"{TaskHandle.__name__}._dispatcher"
|
||||
"The task name for the scheduler."
|
||||
self._queued: dict[str, set[str]] = {}
|
||||
"Queued archive IDs per handler."
|
||||
self._failure_count: dict[str, dict[str, int]] = {}
|
||||
"Failure counts per handler and archive ID."
|
||||
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.ITEM_ERROR,
|
||||
self._handle_item_error,
|
||||
f"{TaskHandle.__name__}.item_error",
|
||||
)
|
||||
|
||||
def load(self) -> None:
|
||||
self._handlers: list[type[BaseHandler]] = self._discover()
|
||||
|
||||
timer: str = self._config.tasks_handler_timer
|
||||
try:
|
||||
from cronsim import CronSim
|
||||
|
||||
CronSim(timer, datetime.now(UTC))
|
||||
except Exception as e:
|
||||
timer = "15 */1 * * *"
|
||||
LOG.error(
|
||||
"Invalid task handler timer '%s'; using default '%s'.",
|
||||
self._config.tasks_handler_timer,
|
||||
timer,
|
||||
extra={
|
||||
"timer": self._config.tasks_handler_timer,
|
||||
"default_timer": timer,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
self._scheduler.add(
|
||||
timer=timer,
|
||||
func=lambda: asyncio.create_task(self._dispatcher(), name="task-handler-dispatcher"),
|
||||
id=f"{TaskHandle.__name__}._dispatcher",
|
||||
)
|
||||
|
||||
async def _dispatcher(self):
|
||||
s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []}
|
||||
|
||||
handler_groups: dict[str, list[tuple[HandleTask, type[BaseHandler]]]] = {}
|
||||
dispatches: list[tuple[HandleTask, type[BaseHandler], asyncio.Task[TaskResult | TaskFailure | None]]] = []
|
||||
|
||||
tasks: list[TaskModel] = await self._repo.all()
|
||||
|
||||
for task_model in tasks:
|
||||
task: HandleTask = HandleTask.model_validate(task_model)
|
||||
|
||||
if not task.enabled or not task.handler_enabled:
|
||||
s["d"].append(task.name)
|
||||
continue
|
||||
|
||||
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
||||
LOG.debug(
|
||||
"Task '%s' does not have an archive file configured.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name},
|
||||
)
|
||||
s["f"].append(task.name)
|
||||
continue
|
||||
|
||||
try:
|
||||
handler: type[BaseHandler] | None = await self._find_handler(task)
|
||||
if handler is None:
|
||||
s["u"].append(task.name)
|
||||
continue
|
||||
|
||||
handler_name: str = handler.__name__
|
||||
if handler_name not in handler_groups:
|
||||
handler_groups[handler_name] = []
|
||||
handler_groups[handler_name].append((task, handler))
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"Failed to find handler for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "exception_type": type(e).__name__},
|
||||
)
|
||||
s["f"].append(task.name)
|
||||
|
||||
for tasks_with_handlers in handler_groups.values():
|
||||
for idx, (task, handler) in enumerate(tasks_with_handlers):
|
||||
try:
|
||||
t: asyncio.Task[TaskResult | TaskFailure | None] = asyncio.create_task(
|
||||
coro=self._dispatch(
|
||||
task,
|
||||
handler,
|
||||
delay=0.0 if 0 == idx else random.uniform(1.0, self._config.task_handler_random_delay),
|
||||
),
|
||||
name=f"taskHandler-{task.id}",
|
||||
)
|
||||
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
|
||||
dispatches.append((task, handler, t))
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"Failed to schedule handler '%s' for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
s["f"].append(task.name)
|
||||
|
||||
if dispatches:
|
||||
results = await asyncio.gather(*(t for _, _, t in dispatches), return_exceptions=True)
|
||||
|
||||
for (task, handler, _), result in zip(dispatches, results, strict=True):
|
||||
if isinstance(result, TaskResult):
|
||||
s["h"].append(task.name)
|
||||
continue
|
||||
|
||||
if isinstance(result, TaskFailure):
|
||||
s["f"].append(task.name)
|
||||
continue
|
||||
|
||||
if result is None:
|
||||
LOG.error(
|
||||
"Handler '%s' returned no result for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "handler_name": handler.__name__},
|
||||
)
|
||||
|
||||
s["f"].append(task.name)
|
||||
|
||||
if len(tasks) > 0:
|
||||
LOG.info(
|
||||
"Task dispatch finished: %s handled, %s unhandled, %s disabled, %s failed.",
|
||||
len(s["h"]),
|
||||
len(s["u"]),
|
||||
len(s["d"]),
|
||||
len(s["f"]),
|
||||
extra={
|
||||
"handled_count": len(s["h"]),
|
||||
"unhandled_count": len(s["u"]),
|
||||
"disabled_count": len(s["d"]),
|
||||
"failed_count": len(s["f"]),
|
||||
},
|
||||
)
|
||||
|
||||
async def _dispatch(
|
||||
self, task: HandleTask, handler: type[BaseHandler], delay: float
|
||||
) -> TaskResult | TaskFailure | None:
|
||||
"""
|
||||
Dispatch a task after a random delay to avoid rate limiting.
|
||||
|
||||
Args:
|
||||
task: The task to dispatch.
|
||||
handler: The handler to use.
|
||||
delay: The delay in seconds before dispatching.
|
||||
|
||||
Returns:
|
||||
The dispatch result.
|
||||
|
||||
"""
|
||||
if delay > 0:
|
||||
LOG.debug(
|
||||
"Delaying dispatch of task '%s' by %.1f seconds.",
|
||||
task.name,
|
||||
delay,
|
||||
extra={"task_id": task.id, "task_name": task.name, "delay_s": round(delay, 1)},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
return await self.dispatch(task, handler=handler)
|
||||
|
||||
def _handle_exception(self, fut: asyncio.Task, task: HandleTask) -> None:
|
||||
if fut.cancelled():
|
||||
return
|
||||
|
||||
if exc := fut.exception():
|
||||
LOG.exception(
|
||||
"Task handler raised after dispatch.",
|
||||
extra={"task_id": task.id, "task_name": task.name, "exception_type": type(exc).__name__},
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
|
||||
async def _find_handler(self, task: HandleTask) -> type[BaseHandler] | None:
|
||||
for cls in self._handlers:
|
||||
try:
|
||||
if await Services.get_instance().handle_async(handler=cls.can_handle, task=task):
|
||||
return cls
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"Handler '%s' capability check failed for task '%s'.",
|
||||
cls.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": cls.__name__,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
task: HandleTask,
|
||||
handler: type[BaseHandler] | None = None,
|
||||
**kwargs,
|
||||
) -> TaskResult | TaskFailure | None:
|
||||
_ = kwargs
|
||||
"""
|
||||
Dispatch a task to the appropriate handler.
|
||||
|
||||
Args:
|
||||
task: The task to dispatch.
|
||||
handler: Optional specific handler to use instead of finding one.
|
||||
**kwargs: Additional context to pass to the handler.
|
||||
|
||||
Returns:
|
||||
The extraction outcome, or None if no handler matched.
|
||||
|
||||
"""
|
||||
if not handler:
|
||||
handler = await self._find_handler(task)
|
||||
if handler is None:
|
||||
return None
|
||||
|
||||
services: Services = Services.get_instance()
|
||||
|
||||
try:
|
||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler.extract, task=task, config=self._config
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
LOG.exception(
|
||||
"Task handler '%s' does not implement extraction for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Handler does not support extraction.")
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Handler '%s' extraction failed for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
msg: str = extraction.message
|
||||
if extraction.error and extraction.error != extraction.message:
|
||||
msg = f"{msg} {extraction.error}"
|
||||
|
||||
LOG.error(
|
||||
"Handler '%s' failed to extract items for task '%s' because %s.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
msg,
|
||||
extra={"task_id": task.id, "task_name": task.name, "handler_name": handler.__name__, "error": msg},
|
||||
)
|
||||
return extraction
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
"Handler '%s' returned unexpected result type '%s' for task '%s'.",
|
||||
handler.__name__,
|
||||
type(extraction).__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"result_type": type(extraction).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(
|
||||
message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__}
|
||||
)
|
||||
|
||||
raw_items: list[TaskItem] = extraction.items or []
|
||||
metadata: dict[str, Any] = extraction.metadata or {}
|
||||
|
||||
handler_name: str = handler.__name__
|
||||
queued: set[str] = self._queued.setdefault(handler_name, set())
|
||||
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
|
||||
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
archive_file: str | None = params.get("download_archive")
|
||||
|
||||
download_queue: DownloadQueue = services.get("queue") or DownloadQueue.get_instance()
|
||||
notify: EventBus = services.get("notify") or EventBus.get_instance()
|
||||
|
||||
archive_ids: list[str] = [
|
||||
item.archive_id for item in raw_items if isinstance(item, TaskItem) and item.archive_id
|
||||
]
|
||||
downloaded: list[str] = archive_read(archive_file, archive_ids) if archive_file else []
|
||||
|
||||
filtered: list[TaskItem] = []
|
||||
|
||||
for item in raw_items:
|
||||
if not isinstance(item, TaskItem):
|
||||
LOG.warning(
|
||||
"Handler '%s' returned unexpected item type '%s' for task '%s'.",
|
||||
handler.__name__,
|
||||
type(item).__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"item_type": type(item).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
url: str = item.url
|
||||
if not url:
|
||||
continue
|
||||
|
||||
archive_id: str | None = item.archive_id
|
||||
if not archive_id:
|
||||
LOG.warning(
|
||||
"Handler '%s' skipped '%s' for task '%s' because it has no archive ID.",
|
||||
handler.__name__,
|
||||
url,
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "handler_name": handler.__name__, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
if archive_id in queued:
|
||||
continue
|
||||
|
||||
queued.add(archive_id)
|
||||
|
||||
if archive_file and archive_id in downloaded:
|
||||
continue
|
||||
|
||||
if await download_queue.queue.exists(url=url):
|
||||
continue
|
||||
|
||||
try:
|
||||
done = await download_queue.done.get(url=url)
|
||||
if "error" != done.info.status:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if archive_id not in failures:
|
||||
failures[archive_id] = 0
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
if not filtered:
|
||||
if raw_items:
|
||||
LOG.debug(
|
||||
"Handler '%s' found %s item(s) for task '%s', but none were queued.",
|
||||
handler.__name__,
|
||||
len(raw_items),
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"raw_count": len(raw_items),
|
||||
},
|
||||
)
|
||||
return TaskResult(items=[], metadata=metadata)
|
||||
|
||||
LOG.info(
|
||||
"Handler '%s' found %s new item(s) for task '%s'.",
|
||||
handler.__name__,
|
||||
len(filtered),
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"item_count": len(filtered),
|
||||
"raw_count": len(raw_items),
|
||||
},
|
||||
)
|
||||
|
||||
base_item = Item.format(
|
||||
{
|
||||
"url": task.url,
|
||||
"preset": task.preset or self._config.default_preset,
|
||||
"folder": task.folder or "",
|
||||
"template": task.template or "",
|
||||
"cli": task.cli or "",
|
||||
"auto_start": task.auto_start,
|
||||
"extras": {"source_name": task.name, "source_id": task.id, "source_handler": handler.__name__},
|
||||
}
|
||||
)
|
||||
|
||||
for item in filtered:
|
||||
metadata_entry: dict[str, Any] = item.metadata if isinstance(item.metadata, dict) else {}
|
||||
extras: dict[str, Any] = base_item.extras.copy()
|
||||
if metadata_entry:
|
||||
extras["metadata"] = metadata_entry
|
||||
|
||||
notify.emit(
|
||||
Events.ADD_URL,
|
||||
data=base_item.new_with(url=item.url, extras=extras).serialize(),
|
||||
)
|
||||
|
||||
return TaskResult(items=filtered, metadata=metadata)
|
||||
|
||||
async def inspect(
|
||||
self,
|
||||
url: str,
|
||||
preset: str | None = None,
|
||||
handler_name: str | None = None,
|
||||
static_only: bool = False,
|
||||
) -> TaskResult | TaskFailure:
|
||||
"""
|
||||
Inspect a URL to find a matching handler and optionally extract items.
|
||||
|
||||
Args:
|
||||
url: The URL to inspect.
|
||||
preset: Optional preset name to use.
|
||||
handler_name: Optional specific handler name to use.
|
||||
static_only: If True, only check if a handler matches without extraction.
|
||||
|
||||
Returns:
|
||||
TaskResult or TaskFailure with inspection results.
|
||||
|
||||
"""
|
||||
if not self._handlers:
|
||||
self._handlers = self._discover()
|
||||
|
||||
task = HandleTask(
|
||||
id=None,
|
||||
name="Inspector",
|
||||
url=url,
|
||||
preset=preset or self._config.default_preset,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
services = Services.get_instance()
|
||||
|
||||
handler_cls: type | None
|
||||
if handler_name:
|
||||
handler_cls = next((cls for cls in self._handlers if cls.__name__.lower() == handler_name.lower()), None)
|
||||
if handler_cls is None:
|
||||
message: str = f"Handler '{handler_name}' not found."
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": handler_name},
|
||||
)
|
||||
|
||||
try:
|
||||
matched = await services.handle_async(handler=handler_cls.can_handle, task=task)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
LOG.exception(
|
||||
"Handler '%s' inspection capability check failed for '%s'.",
|
||||
handler_cls.__name__,
|
||||
url,
|
||||
extra={"handler_name": handler_cls.__name__, "url": url, "exception_type": type(exc).__name__},
|
||||
)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||
)
|
||||
|
||||
if not matched:
|
||||
return TaskFailure(
|
||||
message="Handler cannot process the supplied URL.",
|
||||
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||
)
|
||||
else:
|
||||
handler_cls = await self._find_handler(task)
|
||||
if handler_cls is None:
|
||||
message = "No handler matched the supplied URL."
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": None},
|
||||
)
|
||||
|
||||
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
|
||||
|
||||
if static_only:
|
||||
return TaskResult(items=[], metadata=base_metadata)
|
||||
|
||||
try:
|
||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler_cls.extract, task=task, config=self._config
|
||||
)
|
||||
except NotImplementedError:
|
||||
return TaskFailure(
|
||||
message="Handler does not support manual inspection.",
|
||||
metadata={**base_metadata, "supported": False},
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Handler '%s' manual inspection failed for '%s'.",
|
||||
handler_cls.__name__,
|
||||
url,
|
||||
extra={"handler_name": handler_cls.__name__, "url": url, "exception_type": type(exc).__name__},
|
||||
)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={**base_metadata, "supported": True},
|
||||
)
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
combined_failure_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||
if extraction.metadata:
|
||||
combined_failure_metadata.update(extraction.metadata)
|
||||
|
||||
return TaskFailure(
|
||||
message=extraction.message,
|
||||
error=extraction.error or extraction.message,
|
||||
metadata=combined_failure_metadata,
|
||||
)
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
"Handler '%s' returned unexpected result type '%s' while inspecting '%s'.",
|
||||
handler_cls.__name__,
|
||||
type(extraction).__name__,
|
||||
url,
|
||||
extra={"handler_name": handler_cls.__name__, "url": url, "result_type": type(extraction).__name__},
|
||||
)
|
||||
extraction = TaskResult()
|
||||
|
||||
combined_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||
if extraction.metadata:
|
||||
combined_metadata.update(extraction.metadata)
|
||||
|
||||
return TaskResult(items=list(extraction.items), metadata=combined_metadata)
|
||||
|
||||
def _discover(self) -> list[type[BaseHandler]]:
|
||||
"""Discover all available task handlers."""
|
||||
import app.features.tasks.definitions.handlers as handlers_pkg
|
||||
|
||||
handlers: list[type[BaseHandler]] = []
|
||||
|
||||
for _, module_name, _ in pkgutil.iter_modules(handlers_pkg.__path__):
|
||||
if module_name.startswith("_"):
|
||||
continue
|
||||
|
||||
module = importlib.import_module(f"{handlers_pkg.__name__}.{module_name}")
|
||||
for _, cls in inspect.getmembers(module, inspect.isclass):
|
||||
if cls.__module__ != module.__name__:
|
||||
continue
|
||||
|
||||
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "extract", None)):
|
||||
handlers.append(cls)
|
||||
|
||||
return handlers
|
||||
|
||||
async def _handle_item_error(self, event, _name, **_kwargs):
|
||||
"""Handle item error events to clean up queued items and track failures."""
|
||||
item: ItemDTO | None = getattr(event, "data", None)
|
||||
if not isinstance(item, ItemDTO):
|
||||
return
|
||||
|
||||
extras: dict[Any, Any] = getattr(item, "extras", {}) or {}
|
||||
handler_name: Any | None = extras.get("source_handler")
|
||||
if not handler_name:
|
||||
return
|
||||
|
||||
archive_id: str | None = item.archive_id
|
||||
if not archive_id:
|
||||
return
|
||||
|
||||
queued: set[str] | None = self._queued.get(handler_name)
|
||||
if queued:
|
||||
queued.discard(archive_id)
|
||||
|
||||
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
|
||||
failures[archive_id] = failures.get(archive_id, 0) + 1
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.tasks.definitions.handlers.generic import GenericTaskHandler
|
||||
from app.features.tasks.definitions.results import TaskFailure, TaskResult
|
||||
from app.features.tasks.definitions.schemas import (
|
||||
Definition,
|
||||
EngineConfig,
|
||||
Parse,
|
||||
RequestConfig,
|
||||
ResponseConfig,
|
||||
TaskDefinition,
|
||||
)
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_generic_handler(monkeypatch):
|
||||
monkeypatch.setattr(GenericTaskHandler, "_definitions", [])
|
||||
monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {})
|
||||
|
||||
|
||||
def test_build_def_payload():
|
||||
definition = TaskDefinition(
|
||||
id=1,
|
||||
name="example",
|
||||
priority=0,
|
||||
match_url=["https://example.com/articles/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)"},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
assert definition.name == "example"
|
||||
assert definition.match_url == ["https://example.com/articles/*"]
|
||||
assert definition.definition.parse["link"]["expression"] == ".article a.link::attr(href)"
|
||||
|
||||
|
||||
def test_build_def_container():
|
||||
definition = TaskDefinition(
|
||||
id=2,
|
||||
name="container",
|
||||
priority=0,
|
||||
match_url=["https://example.com/cards"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"selector": ".cards .card",
|
||||
"fields": {
|
||||
"link": {"type": "css", "expression": ".card-header a", "attribute": "href"},
|
||||
"title": {"type": "css", "expression": ".card-header a", "attribute": "text"},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
assert definition.definition.parse["items"]["selector"] == ".cards .card"
|
||||
assert definition.definition.parse["items"]["fields"]["link"]["attribute"] == "href"
|
||||
|
||||
|
||||
def test_build_def_json():
|
||||
definition = TaskDefinition(
|
||||
id=3,
|
||||
name="json-def",
|
||||
priority=0,
|
||||
match_url=["https://example.com/api"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
assert definition.definition.response.type == "json"
|
||||
assert definition.definition.parse["items"]["type"] == "jsonpath"
|
||||
assert definition.definition.parse["items"]["fields"]["link"]["type"] == "jsonpath"
|
||||
|
||||
|
||||
def test_parse_items_basic():
|
||||
definition = TaskDefinition(
|
||||
id=4,
|
||||
name="example",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)", "attribute": None},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
"id": {"type": "css", "expression": ".article", "attribute": "data-id"},
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
html = """
|
||||
<div class="article" data-id="101">
|
||||
<a class="link" href="/article-101">First</a>
|
||||
<span class="title">First Title</span>
|
||||
</div>
|
||||
<div class="article" data-id="102">
|
||||
<a class="link" href="https://example.com/article-102">Second</a>
|
||||
<span class="title">Second Title</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/")
|
||||
|
||||
assert len(items) == 2
|
||||
assert items[0] == {
|
||||
"link": "https://example.com/article-101",
|
||||
"title": "First Title",
|
||||
"id": "101",
|
||||
}
|
||||
assert items[1]["link"] == "https://example.com/article-102"
|
||||
|
||||
|
||||
def test_parse_items_cards():
|
||||
definition = TaskDefinition(
|
||||
id=5,
|
||||
name="nested",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "css",
|
||||
"selector": ".columns .card",
|
||||
"fields": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "href",
|
||||
},
|
||||
"title": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "text",
|
||||
},
|
||||
"poet": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:first-child a",
|
||||
"attribute": "text",
|
||||
},
|
||||
"category": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:nth-child(2) a",
|
||||
"attribute": "text",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
html = """
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
|
||||
<a href="/poems/view/111" title="First Poem">First Poem</a>
|
||||
</p>
|
||||
</div>
|
||||
<footer class="card-footer has-text-centered">
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> By <a href="/poet/alpha">Poet Alpha</a></span>
|
||||
</p>
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> In <a href="/category/one">Category One</a></span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
|
||||
<a href="/poems/view/222" title="Second Poem">Second Poem</a>
|
||||
</p>
|
||||
</div>
|
||||
<footer class="card-footer has-text-centered">
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> By <a href="/poet/beta">Poet Beta</a></span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com")
|
||||
|
||||
assert len(items) == 2
|
||||
assert items[0] == {
|
||||
"link": "https://example.com/poems/view/111",
|
||||
"title": "First Poem",
|
||||
"poet": "Poet Alpha",
|
||||
"category": "Category One",
|
||||
}
|
||||
assert items[1] == {
|
||||
"link": "https://example.com/poems/view/222",
|
||||
"title": "Second Poem",
|
||||
"poet": "Poet Beta",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_items_json():
|
||||
definition = TaskDefinition(
|
||||
id=6,
|
||||
name="json",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "entries",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
"id": {"type": "jsonpath", "expression": "id"},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
payload = {
|
||||
"entries": [
|
||||
{"url": "/video/1", "title": "First", "id": 1},
|
||||
{"url": "https://example.com/video/2", "title": "Second", "id": 2},
|
||||
{"title": "Missing Link", "id": 3},
|
||||
]
|
||||
}
|
||||
|
||||
items = GenericTaskHandler._parse_items(
|
||||
definition=definition,
|
||||
html="",
|
||||
base_url="https://example.com",
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
assert items == [
|
||||
{"link": "https://example.com/video/1", "title": "First", "id": "1"},
|
||||
{"link": "https://example.com/video/2", "title": "Second", "id": "2"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_task_handler_inspect(monkeypatch):
|
||||
definition = TaskDefinition(
|
||||
id=7,
|
||||
name="json-inspect",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_find_definition(cls, url): # noqa: ARG001
|
||||
return definition
|
||||
|
||||
monkeypatch.setattr(
|
||||
GenericTaskHandler,
|
||||
"_find_definition",
|
||||
classmethod(fake_find_definition),
|
||||
)
|
||||
|
||||
async def fake_fetch_content(url, definition, ytdlp_opts): # noqa: ARG001
|
||||
return "", {"items": [{"url": "/video/1", "title": "First"}]}
|
||||
|
||||
monkeypatch.setattr(GenericTaskHandler, "_fetch_content", staticmethod(fake_fetch_content))
|
||||
|
||||
# Mock fetch_info to return valid info with required fields for archive ID generation
|
||||
async def fake_fetch_info(config, url, **kwargs): # noqa: ARG001
|
||||
return ({"id": "test_video_1", "extractor_key": "Example"}, [])
|
||||
|
||||
with patch("app.features.tasks.definitions.handlers.generic.fetch_info", side_effect=fake_fetch_info):
|
||||
task = HandleTask(id=1, name="Inspect", url="https://example.com/api")
|
||||
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
|
||||
|
||||
assert isinstance(result, TaskResult)
|
||||
assert len(result.items) == 1
|
||||
item = result.items[0]
|
||||
assert item.url == "https://example.com/video/1"
|
||||
assert item.title == "First"
|
||||
|
||||
|
||||
def test_parse_items_json_list():
|
||||
definition = TaskDefinition(
|
||||
id=8,
|
||||
name="json-list",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "[]",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
payload = [
|
||||
{"url": "/video/1", "title": "First"},
|
||||
{"url": "/video/2", "title": "Second"},
|
||||
]
|
||||
|
||||
items = GenericTaskHandler._parse_items(
|
||||
definition=definition,
|
||||
html="",
|
||||
base_url="https://example.com",
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
assert items == [
|
||||
{"link": "https://example.com/video/1", "title": "First"},
|
||||
{"link": "https://example.com/video/2", "title": "Second"},
|
||||
]
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.task_handlers.rss import RssGenericHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -20,6 +23,10 @@ class DummyOpts:
|
|||
return self._data
|
||||
|
||||
|
||||
def _opts(tmp_path: Path) -> DummyOpts:
|
||||
return DummyOpts({"download_archive": str(tmp_path / "archive.txt")})
|
||||
|
||||
|
||||
class TestRssHandlerParsing:
|
||||
"""Test URL parsing for RSS/Atom feeds using the tests() method."""
|
||||
|
||||
|
|
@ -46,7 +53,7 @@ class TestRssHandlerExtraction:
|
|||
"""Test RSS feed extraction and parsing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rss_atom_feed_extraction(self, monkeypatch):
|
||||
async def test_rss_atom_feed_extraction(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test extraction from Atom feed."""
|
||||
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
|
|
@ -68,10 +75,10 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_rss",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test Atom Feed",
|
||||
url="https://example.com/feed.atom",
|
||||
preset="default",
|
||||
|
|
@ -88,7 +95,7 @@ class TestRssHandlerExtraction:
|
|||
assert result.metadata["entry_count"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rss_feed_extraction(self, monkeypatch):
|
||||
async def test_rss_feed_extraction(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test extraction from RSS feed."""
|
||||
rss_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
|
|
@ -112,10 +119,10 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(rss_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_rss",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test RSS Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -132,7 +139,7 @@ class TestRssHandlerExtraction:
|
|||
assert result.metadata["entry_count"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_handle(self, monkeypatch):
|
||||
async def test_can_handle(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test can_handle method."""
|
||||
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
|
|
@ -146,32 +153,32 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_rss",
|
||||
name="Test Feed",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test rss Feed",
|
||||
url="https://example.com/feed.atom",
|
||||
preset="default",
|
||||
)
|
||||
|
||||
assert RssGenericHandler.can_handle(task) is True
|
||||
assert await RssGenericHandler.can_handle(task) is True
|
||||
|
||||
non_feed_task = Task(
|
||||
id="test_youtube",
|
||||
non_feed_task = HandleTask(
|
||||
id=1,
|
||||
name="YouTube Video",
|
||||
url="https://www.youtube.com/watch?v=abc123",
|
||||
preset="default",
|
||||
)
|
||||
|
||||
assert RssGenericHandler.can_handle(non_feed_task) is False
|
||||
assert await RssGenericHandler.can_handle(non_feed_task) is False
|
||||
|
||||
|
||||
class TestRssHandlerEdgeCases:
|
||||
"""Test edge cases in RSS handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_feed(self, monkeypatch):
|
||||
async def test_empty_feed(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test handling of empty feed."""
|
||||
empty_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
|
|
@ -185,10 +192,10 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(empty_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_empty",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Empty Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -201,19 +208,19 @@ class TestRssHandlerEdgeCases:
|
|||
assert result.metadata["entry_count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_feed_url(self, monkeypatch):
|
||||
async def test_invalid_feed_url(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test handling of invalid feed URL."""
|
||||
from app.library.Tasks import TaskFailure
|
||||
from app.features.tasks.definitions.results import TaskFailure
|
||||
|
||||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
msg = "Network error"
|
||||
raise Exception(msg)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_invalid",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Invalid Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -224,7 +231,7 @@ class TestRssHandlerEdgeCases:
|
|||
assert isinstance(result, TaskFailure)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_urls_in_feed(self, monkeypatch):
|
||||
async def test_missing_urls_in_feed(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test handling of entries missing URLs."""
|
||||
feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
|
|
@ -244,10 +251,10 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_missing",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Feed with Missing URLs",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
226
app/features/tasks/definitions/tests/test_task_definitions.py
Normal file
226
app/features/tasks/definitions/tests/test_task_definitions.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request
|
||||
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
from app.features.tasks.definitions.router import (
|
||||
task_definitions_create,
|
||||
task_definitions_delete,
|
||||
task_definitions_get,
|
||||
task_definitions_list,
|
||||
task_definitions_patch,
|
||||
task_definitions_update,
|
||||
)
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.main import EventBus
|
||||
from app.tests.helpers import make_in_memory_db_path
|
||||
|
||||
|
||||
def _sample_definition(name: str = "example", *, priority: int = 0) -> dict:
|
||||
"""Returns a properly structured task definition payload for the repository."""
|
||||
return {
|
||||
"name": name,
|
||||
"match_url": ["https://example.com/*"],
|
||||
"priority": priority,
|
||||
"definition": {
|
||||
"parse": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": "a",
|
||||
"attribute": "href",
|
||||
}
|
||||
},
|
||||
"engine": {"type": "httpx"},
|
||||
"request": {},
|
||||
"response": {"type": "html"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo() -> AsyncGenerator[TaskDefinitionsRepository, None]:
|
||||
TaskDefinitionsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=make_in_memory_db_path("task-definitions-repository"))
|
||||
await store.get_connection()
|
||||
|
||||
repository = TaskDefinitionsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
await store.close()
|
||||
|
||||
TaskDefinitionsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestTaskDefinitionsRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_list(self, repo: TaskDefinitionsRepository) -> None:
|
||||
await repo.create(_sample_definition("Alpha", priority=2))
|
||||
await repo.create(_sample_definition("Beta", priority=1))
|
||||
|
||||
items = await repo.all()
|
||||
assert len(items) == 2, "Should return two task definitions"
|
||||
assert [item.name for item in items] == ["Beta", "Alpha"], "Should sort by priority then name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_name_raises(self, repo: TaskDefinitionsRepository) -> None:
|
||||
payload = _sample_definition("Dup")
|
||||
await repo.create(payload)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await repo.create(payload)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await repo.create(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_missing_raises(self, repo: TaskDefinitionsRepository) -> None:
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.update(999, {"name": "Missing"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo: TaskDefinitionsRepository) -> None:
|
||||
for idx in range(5):
|
||||
await repo.create(_sample_definition(f"Item {idx}", priority=idx))
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||
assert len(items) == 2, "Should return two items per page"
|
||||
assert total == 5, "Should report total count of 5"
|
||||
assert page == 1, "Should return requested page"
|
||||
assert total_pages == 3, "Should compute total pages"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskDefinitionRoutes:
|
||||
async def test_list_definitions(self, repo: TaskDefinitionsRepository) -> None:
|
||||
await repo.create(_sample_definition("Sample"))
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {}
|
||||
|
||||
response = await task_definitions_list(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should return 200 for list"
|
||||
assert payload["items"][0]["name"] == "Sample", "Should include created definition"
|
||||
|
||||
async def test_get_definition_not_found(self, repo: TaskDefinitionsRepository) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": "999"}
|
||||
|
||||
response = await task_definitions_get(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPNotFound.status_code, "Should return 404 for missing definition"
|
||||
assert "error" in payload, "Should include error payload"
|
||||
|
||||
async def test_create_definition_success(self, repo: TaskDefinitionsRepository) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=_sample_definition("New", priority=3))
|
||||
|
||||
response = await task_definitions_create(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPCreated.status_code, "Should create task definition"
|
||||
assert body["name"] == "New", "Should return created name"
|
||||
assert body["priority"] == 3, "Should return created priority"
|
||||
|
||||
async def test_update_definition_success(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("Original", priority=0))
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
request.json = AsyncMock(return_value=_sample_definition("Updated", priority=4))
|
||||
|
||||
response = await task_definitions_update(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should update task definition"
|
||||
assert body["name"] == "Updated", "Should return updated name"
|
||||
assert body["priority"] == 4, "Should return updated priority"
|
||||
|
||||
async def test_delete_definition_success(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("Delete"))
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
|
||||
response = await task_definitions_delete(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
assert response.status == web.HTTPOk.status_code, "Should delete task definition"
|
||||
|
||||
async def test_patch_definition_enabled(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("PatchTest", priority=5))
|
||||
assert created.enabled is True, "Should be enabled by default"
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
request.json = AsyncMock(return_value={"enabled": False})
|
||||
|
||||
response = await task_definitions_patch(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should patch task definition"
|
||||
assert body["name"] == "PatchTest", "Should keep original name"
|
||||
assert body["priority"] == 5, "Should keep original priority"
|
||||
assert body["enabled"] is False, "Should update enabled status"
|
||||
|
||||
async def test_patch_definition_priority(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("PatchPriority", priority=1))
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
request.json = AsyncMock(return_value={"priority": 10})
|
||||
|
||||
response = await task_definitions_patch(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should patch task definition"
|
||||
assert body["priority"] == 10, "Should update priority"
|
||||
assert body["enabled"] is True, "Should keep original enabled status"
|
||||
|
||||
async def test_patch_definition_not_found(self, repo: TaskDefinitionsRepository) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": "999"}
|
||||
request.json = AsyncMock(return_value={"enabled": False})
|
||||
|
||||
response = await task_definitions_patch(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPNotFound.status_code, "Should return 404 for missing definition"
|
||||
assert "error" in payload, "Should include error payload"
|
||||
|
||||
async def test_create_with_regex_pattern(self, repo: TaskDefinitionsRepository) -> None:
|
||||
payload = _sample_definition("RegexTest", priority=0)
|
||||
payload["match_url"] = ["/https://example\\.com/post/[0-9]+/"]
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=payload)
|
||||
|
||||
response = await task_definitions_create(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPCreated.status_code, "Should create task definition with regex pattern"
|
||||
assert body["match_url"][0] == "/https://example\\.com/post/[0-9]+/", "Should preserve regex pattern format"
|
||||
|
||||
async def test_create_with_invalid_regex_pattern(self, repo: TaskDefinitionsRepository) -> None:
|
||||
payload = _sample_definition("BadRegex", priority=0)
|
||||
payload["match_url"] = ["/[invalid(/"]
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=payload)
|
||||
|
||||
response = await task_definitions_create(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
payload_response = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code, "Should reject invalid regex pattern"
|
||||
assert "error" in payload_response, "Should include error payload"
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.task_handlers.tver import TverHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.tver import TverHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -24,7 +27,7 @@ class DummyOpts:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tver_handler_extract(monkeypatch):
|
||||
async def test_tver_handler_extract(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test tver handler extraction of episodes from series."""
|
||||
session_response = {
|
||||
"result": {
|
||||
|
|
@ -117,10 +120,12 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
msg = f"Unexpected URL: {url}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
|
||||
archive_path = tmp_path / "archive.txt"
|
||||
|
||||
task = Task(id="test_tver", name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": str(archive_path)}))
|
||||
|
||||
task = HandleTask(id=1, name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
|
||||
result = await TverHandler.extract(task)
|
||||
|
||||
|
|
@ -130,7 +135,10 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
assert "木村拓哉がタクシー 運転手!目黒蓮が木村と二人旅" == result.items[0].title
|
||||
|
||||
assert result.items[1].url == "https://tver.jp/episodes/epejwb9mvx"
|
||||
assert "木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!" == result.items[1].title
|
||||
assert (
|
||||
"木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!"
|
||||
== result.items[1].title
|
||||
)
|
||||
|
||||
assert result.metadata.get("has_entries") is True
|
||||
assert "callSeriesEpisodes" in result.metadata.get("feed_url", "")
|
||||
|
|
@ -138,7 +146,7 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(("url", "should_match"), TverHandler.tests())
|
||||
def test_tver_handler_parse(url: str, should_match: bool):
|
||||
def test_parse(url: str, should_match: bool) -> None:
|
||||
"""Test tver URL parsing."""
|
||||
result = TverHandler.parse(url)
|
||||
if should_match:
|
||||
|
|
@ -148,9 +156,10 @@ def test_tver_handler_parse(url: str, should_match: bool):
|
|||
assert result is None
|
||||
|
||||
|
||||
def test_tver_handler_can_handle():
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_handle() -> None:
|
||||
"""Test tver handler can_handle method."""
|
||||
task_valid = Task(id="test1", name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
task_invalid = Task(id="test2", name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||
assert TverHandler.can_handle(task_valid) is True
|
||||
assert TverHandler.can_handle(task_invalid) is False
|
||||
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||
assert await TverHandler.can_handle(task_valid) is True
|
||||
assert await TverHandler.can_handle(task_invalid) is False
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.task_handlers.twitch import TwitchHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -21,7 +24,7 @@ class DummyOpts:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twitch_handler_inspect(monkeypatch):
|
||||
async def test_twitch_handler_inspect(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
feed = """
|
||||
<rss><channel>
|
||||
<item>
|
||||
|
|
@ -38,11 +41,13 @@ async def test_twitch_handler_inspect(monkeypatch):
|
|||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
archive_path = tmp_path / "archive.txt"
|
||||
|
||||
task = Task(
|
||||
id="inspect",
|
||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": str(archive_path)})) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Inspect",
|
||||
url="https://www.twitch.tv/testchannel",
|
||||
preset="default",
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.task_handlers.youtube import YoutubeHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -21,7 +24,7 @@ class DummyOpts:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_youtube_handler_inspect(monkeypatch):
|
||||
async def test_youtube_handler_inspect(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
feed = """
|
||||
<feed xmlns='http://www.w3.org/2005/Atom' xmlns:yt='http://www.youtube.com/xml/schemas/2015'>
|
||||
<entry>
|
||||
|
|
@ -40,11 +43,13 @@ async def test_youtube_handler_inspect(monkeypatch):
|
|||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
archive_path = tmp_path / "archive.txt"
|
||||
|
||||
task = Task(
|
||||
id="inspect",
|
||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": str(archive_path)})) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Inspect",
|
||||
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||
preset="default",
|
||||
77
app/features/tasks/definitions/utils.py
Normal file
77
app/features/tasks/definitions/utils.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from typing import Any, Literal, overload
|
||||
|
||||
from app.features.tasks.definitions.models import TaskDefinitionModel
|
||||
from app.features.tasks.definitions.schemas import Definition, TaskDefinition, TaskDefinitionSummary
|
||||
|
||||
|
||||
@overload
|
||||
def model_to_schema(model: TaskDefinitionModel, summary: Literal[False] = False) -> TaskDefinition: ...
|
||||
|
||||
|
||||
@overload
|
||||
def model_to_schema(model: TaskDefinitionModel, summary: Literal[True]) -> TaskDefinitionSummary: ...
|
||||
|
||||
|
||||
def model_to_schema(model: TaskDefinitionModel, summary: bool = False) -> TaskDefinition | TaskDefinitionSummary:
|
||||
"""
|
||||
Convert a TaskDefinitionModel to a TaskDefinition or TaskDefinitionSummary schema.
|
||||
|
||||
Args:
|
||||
model (TaskDefinitionModel): The model instance to convert.
|
||||
summary (bool): Whether to return a summary schema.
|
||||
|
||||
Returns:
|
||||
TaskDefinition | TaskDefinitionSummary: The corresponding schema instance.
|
||||
|
||||
"""
|
||||
dct = {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"priority": model.priority,
|
||||
"match_url": model.match_url,
|
||||
"enabled": model.enabled,
|
||||
"created_at": model.created_at,
|
||||
"updated_at": model.updated_at,
|
||||
}
|
||||
return TaskDefinitionSummary(**dct) if summary else TaskDefinition(**dct, definition=Definition(**model.definition))
|
||||
|
||||
|
||||
def schema_to_payload(item: TaskDefinition) -> dict[str, Any]:
|
||||
"""
|
||||
Convert a TaskDefinition schema to a dictionary payload for database operations.
|
||||
|
||||
Args:
|
||||
item (TaskDefinition): The schema instance to convert.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The corresponding dictionary payload.
|
||||
|
||||
"""
|
||||
return {
|
||||
"name": item.name,
|
||||
"priority": item.priority,
|
||||
"match_url": item.match_url,
|
||||
"enabled": item.enabled,
|
||||
"definition": item.definition.model_dump(exclude_unset=True, exclude_none=True),
|
||||
}
|
||||
|
||||
|
||||
def split_inspect_metadata(metadata: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Split commonly consumed metadata keys from the rest.
|
||||
|
||||
Args:
|
||||
metadata (dict[str, Any]|None): The metadata to split.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, Any], dict[str, Any]]: The primary and extra metadata.
|
||||
|
||||
"""
|
||||
metadata = dict(metadata or {})
|
||||
primary: dict[str, Any] = {}
|
||||
|
||||
for key in ("matched", "handler", "supported"):
|
||||
if key in metadata:
|
||||
primary[key] = metadata.pop(key)
|
||||
|
||||
return primary, metadata
|
||||
14
app/features/tasks/deps.py
Normal file
14
app/features/tasks/deps.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
from app.features.tasks.service import Tasks
|
||||
|
||||
|
||||
def get_tasks_repo() -> TasksRepository:
|
||||
"""Get tasks repository instance."""
|
||||
return TasksRepository.get_instance()
|
||||
|
||||
|
||||
def get_tasks_service() -> Tasks:
|
||||
"""Get tasks service instance."""
|
||||
return Tasks.get_instance()
|
||||
124
app/features/tasks/migration.py
Normal file
124
app/features/tasks/migration.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.tasks.schemas import Task
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "tasks"
|
||||
|
||||
def __init__(self, repo: TasksRepository, config: Config | None = None):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: TasksRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "tasks.json"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
return self._source_file.exists()
|
||||
|
||||
async def migrate(self) -> None:
|
||||
if await self._repo.count() > 0:
|
||||
LOG.warning("Tasks already exist in the database; skipping migration.")
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to read tasks migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if items is None:
|
||||
LOG.warning("No tasks found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := self._normalize(item, index, seen_names)):
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception(
|
||||
"Failed to insert task '%s'.",
|
||||
normalized["name"],
|
||||
extra={"task_name": normalized["name"], "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s task(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping task at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping task at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping task at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
|
||||
url: str | None = item.get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
LOG.warning("Skipping task '%s' at index %s due to missing URL.", name, index)
|
||||
return None
|
||||
|
||||
url = url.strip()
|
||||
if not url:
|
||||
LOG.warning("Skipping task '%s' at index %s due to empty URL.", name, index)
|
||||
return None
|
||||
|
||||
folder: str = item.get("folder") if isinstance(item.get("folder"), str) else ""
|
||||
preset: str = item.get("preset") if isinstance(item.get("preset"), str) else ""
|
||||
timer: str = item.get("timer") if isinstance(item.get("timer"), str) else ""
|
||||
template: str = item.get("template") if isinstance(item.get("template"), str) else ""
|
||||
cli: str = item.get("cli") if isinstance(item.get("cli"), str) else ""
|
||||
auto_start: bool = item.get("auto_start") if isinstance(item.get("auto_start"), bool) else True
|
||||
handler_enabled: bool = item.get("handler_enabled") if isinstance(item.get("handler_enabled"), bool) else True
|
||||
enabled: bool = item.get("enabled") if isinstance(item.get("enabled"), bool) else True
|
||||
|
||||
try:
|
||||
validated = Task(
|
||||
name=name,
|
||||
url=url,
|
||||
folder=folder,
|
||||
preset=preset,
|
||||
timer=timer,
|
||||
template=template,
|
||||
cli=cli,
|
||||
auto_start=auto_start,
|
||||
handler_enabled=handler_enabled,
|
||||
enabled=enabled,
|
||||
)
|
||||
return validated.model_dump()
|
||||
except Exception as e:
|
||||
LOG.warning("Skipping task '%s' at index %s due to validation error: %s", name, index, e)
|
||||
return None
|
||||
31
app/features/tasks/models.py
Normal file
31
app/features/tasks/models.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class TaskModel(Base):
|
||||
__tablename__: str = "tasks"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_tasks_name", "name"),
|
||||
Index("ix_tasks_enabled", "enabled"),
|
||||
Index("ix_tasks_timer", "timer"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
folder: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
preset: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
timer: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
template: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
auto_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
handler_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue