Compare commits

...

840 commits

Author SHA1 Message Date
Abdulmohsen
c68646e266
Merge pull request #621 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: setting simple_mode cookie was crashing http layer
2026-06-19 19:59:35 +03:00
arabcoders
3c571429e7 fix: seeting simple_mode cookie was crashing http layer 2026-06-19 19:43:44 +03:00
Abdulmohsen
0ba52fa38e
Merge pull request #620 from arabcoders/dev
Refactor: re-design simple mode
2026-06-19 18:50:15 +03:00
arabcoders
19f0b7819e refactor: improve logs view 2026-06-19 18:39:35 +03:00
arabcoders
3381ca3805 refactor: re-design simple mode toggle. 2026-06-19 17:41:04 +03:00
arabcoders
72f2373c42 refactor: update the history item in UI automaticly whenever a item_update received via sockets 2026-06-17 22:44:37 +03:00
Abdulmohsen
44f5f2eee0
Merge pull request #618 from arabcoders/dev
chore: use ty as type checker.
2026-06-14 19:35:40 +03:00
arabcoders
13424d1bcf ci: bump node version to 22 for eslint compatibility 2026-06-14 18:41:03 +03:00
arabcoders
3631947234 chore: use ty as type checker. 2026-06-14 18:35:03 +03:00
Abdulmohsen
aedeaf9a0a
Merge pull request #617 from arabcoders/dev
Feat: paginate queue
2026-06-14 18:29:27 +03:00
arabcoders
18f54a28c6 feat: paginate queue 2026-06-12 20:18:58 +03:00
arabcoders
db8d8663eb refactor: update some logging messages. 2026-06-11 23:54:15 +03:00
Abdulmohsen
a614a48108
Merge pull request #615 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: breaking change between yt-dlp 2026.03.17 -> 2026.06.09
2026-06-11 18:03:12 +03:00
arabcoders
4f5ac8d8a0 fix: breaking change between yt-dlp 2026.03.17 -> 2026.06.09 due to CVE related to exec 2026-06-11 17:51:30 +03:00
arabcoders
c7fbe6b484 refactor: add item_progress event for queue status update 2026-06-11 08:18:08 +03:00
arabcoders
8c54ed6c0a refactor: remove folders from configuration endpoint 2026-06-10 21:46:41 +03:00
Abdulmohsen
9ada8b5a91
Merge pull request #613 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: video progress bar on iOS would fail
2026-06-10 16:53:16 +03:00
arabcoders
a346ed69d9 chore: external packages update 2026-06-10 16:26:00 +03:00
arabcoders
3e663bd483 fix: video progress bar on iOS would fail 2026-06-09 18:54:13 +03:00
arabcoders
4b7230843e docs: update screenshots 2026-06-09 18:35:44 +03:00
arabcoders
196d2d4e32 feat: add share button 2026-06-09 18:30:39 +03:00
Abdulmohsen
a194b9bf28
Merge pull request #610 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Refactor: minor improvements to the WebUI
2026-06-08 10:08:19 +03:00
arabcoders
d6318fca0f refactor: fix loading older logs 2026-06-08 09:56:21 +03:00
arabcoders
4881fabb15 feat: add YTP_DISABLE_EXEC to strip dangerous yt-dlp options 2026-06-08 09:29:31 +03:00
arabcoders
7a3c41b6bc refactor: update other uselect to use uselectmenu 2026-06-08 09:00:56 +03:00
arabcoders
473e45c55b refactor: use list with filtering for preset menus 2026-06-08 08:55:08 +03:00
arabcoders
4319be36f6 fix: disable file logging during tests 2026-06-08 08:44:25 +03:00
arabcoders
2efc09d587 refactor: improve log details view 2026-06-08 08:38:12 +03:00
arabcoders
acca33a7dc rerfactor: add page transition toggle 2026-06-08 07:53:05 +03:00
arabcoders
e61df14ec0 refactor: improve how cards look 2026-06-08 07:11:31 +03:00
arabcoders
0482df2fec chore: update external packages 2026-06-04 09:31:43 +03:00
arabcoders
50dd2d336c docs: screenshots updates 2026-06-01 07:44:41 +03:00
Abdulmohsen
6df1250a43
Merge pull request #609 from arabcoders/dev
feat: make log level runtime configurable
2026-05-31 21:07:00 +03:00
arabcoders
c70b3a46bd chore: update dependancies 2026-05-31 16:15:31 +03:00
arabcoders
10c18ff5b9 refactor: minor UI update for diagnostics page 2026-05-31 16:08:45 +03:00
arabcoders
9259119227 docs: document security recommendations 2026-05-29 17:39:29 +03:00
arabcoders
ab56a7d8c9 fix: startup crash if access long was turned off 2026-05-28 22:21:56 +03:00
arabcoders
fa8ba1af34 feat: make log level runtime managable. 2026-05-28 21:26:23 +03:00
arabcoders
1267dbaf6b refactor: use single logger namespace 2026-05-28 19:07:11 +03:00
arabcoders
39ed3d0361 refactor: add more context to the actual log message. 2026-05-28 17:56:28 +03:00
arabcoders
cf90db3b4c refactor: update log mesages to take advantage of JSONL 2026-05-27 23:53:42 +03:00
Abdulmohsen
ef8642ace7
Merge pull request #608 from arabcoders/dev
feat: add system diagnostics endpoint.
2026-05-26 19:49:47 +03:00
arabcoders
434d3e981d feat: add system diagnostics endpoint. 2026-05-26 19:22:36 +03:00
arabcoders
376e439115 docs: add info about youtube rss feeds 2026-05-26 16:15:29 +03:00
arabcoders
64fdcac0f7 refactor: implement re-extraction logic and process-safe handling for non-picklable objects. 2026-05-25 20:06:34 +03:00
Abdulmohsen
8e14258c7a
Merge pull request #607 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Fix: recent change made fetch_info unpicklable
2026-05-24 22:07:41 +03:00
arabcoders
2e9684de5d refactor: standardize JSONL structure. 2026-05-24 21:19:25 +03:00
arabcoders
ca7227ffc5 refactor: adjust width and shrink behavior of filter input 2026-05-18 22:20:31 +03:00
arabcoders
3b3c8120b8 fix: recent change made fetch_info unpicklable 2026-05-18 22:12:13 +03:00
arabcoders
88db357b7f refactor: removed thumbnail proxying 2026-05-18 12:28:06 +03:00
arabcoders
854f5f4b42 refactor: use jsonl for loggng for better log view experience 2026-05-18 09:44:29 +03:00
arabcoders
d18145f1c5 refactor: make clear message more clear 2026-05-18 07:44:37 +03:00
arabcoders
3bd2a7bee7 refactor: minor ui cleanup 2026-05-17 20:21:58 +03:00
Abdulmohsen
f65cdddbfe
Merge pull request #604 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Refactor: use item guid as thumbnail name for easier cleanup
2026-05-15 19:40:35 +03:00
arabcoders
2146e51368 fix: flakey test 2026-05-15 19:00:06 +03:00
arabcoders
98dd77f004 refactor: rename button 2026-05-15 18:47:43 +03:00
arabcoders
ee8218764f fix: suppress task related archive logs 2026-05-15 18:45:56 +03:00
arabcoders
73b8d867a0 feat: add resume for videos 2026-05-15 18:24:52 +03:00
arabcoders
734ef1c53c refactor: use item guid as thumbnail name for easier cleanup 2026-05-15 16:50:13 +03:00
Abdulmohsen
cee3bcf48d
Merge pull request #603 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
feat: thumbnail generation
2026-05-14 23:34:00 +03:00
arabcoders
d9dbbb18ea feat: thumbnail generation 2026-05-14 23:22:27 +03:00
Abdulmohsen
e7772b361e
Merge pull request #602 from arabcoders/dev
Refactor: do not log httpx related as exceptions but errors.
2026-05-13 11:37:28 +03:00
arabcoders
1d52ea61da refactor: do not log httpx related as exceptions but errors. 2026-05-13 08:39:22 +03:00
arabcoders
34e99951fc fix: tasks handler summary should reflect correct status. 2026-05-13 06:58:43 +03:00
arabcoders
56dbf6fc2d refactor: update test functions to use tmp_path for archive file handling 2026-05-08 21:56:41 +03:00
arabcoders
a0b50ac351 fix: use yt-dlp meta extractors methods instead of local rolled ones 2026-05-08 21:39:12 +03:00
arabcoders
12eb4de83d fix: update extractor options and improve media polling logic 2026-05-08 20:54:41 +03:00
Abdulmohsen
d3a3b5d95f
Merge pull request #601 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
feat: add generic browser extractor
2026-05-08 01:15:59 +03:00
arabcoders
928ddd5b9a feat: add generic browser extractor 2026-05-08 01:08:08 +03:00
Abdulmohsen
522e6a75ef
Merge pull request #600 from arabcoders/dev
fix: Handle TimeoutError in fetch_info to prevent double wait
2026-05-07 21:54:47 +03:00
arabcoders
9b3cc8b310 fix: respect sleep-requests arg in specific code paths. 2026-05-07 21:35:40 +03:00
arabcoders
ac3c666d32 fix: handle TimeoutError in fetch_info function to prevent double wait 2026-05-07 19:09:31 +03:00
arabcoders
691e37d279 fix: move dns lookup to thread 2026-05-07 18:34:50 +03:00
arabcoders
5951837ab9 fix: automatic fallback to hls.js 2026-05-07 18:01:05 +03:00
arabcoders
71f9a7bda4 fix: show add button on same row 2026-05-06 23:07:49 +03:00
Abdulmohsen
6cf2eb053c
Merge pull request #599 from arabcoders/dev
refactor: improve video player rendering
2026-05-05 23:42:24 +03:00
arabcoders
8f436a7afb refactor: improve video player rendering 2026-05-05 23:01:50 +03:00
Abdulmohsen
b35ac1831a
Merge pull request #598 from arabcoders/dev
feat: extra placeholders for output template
2026-05-05 10:53:05 +03:00
arabcoders
f2d7e59420 refactor: more eye candy for video player. 2026-05-05 10:10:33 +03:00
arabcoders
b438947b0d fix: tasks should require timer when there are no handler support. 2026-05-05 10:10:11 +03:00
arabcoders
7ac7039592 chore: tests clean up 2026-05-04 22:23:19 +03:00
arabcoders
2ab0db93dc fix: new video player subtitle files were missing folder arg 2026-05-04 21:53:13 +03:00
arabcoders
cca97d9a57 feat: extra placeholders for output template 2026-05-04 21:42:52 +03:00
arabcoders
285e661667 feat: add rename to history items. 2026-05-04 20:29:34 +03:00
arabcoders
80316df952 feat: custom video player with direct ssa rendering 2026-05-04 09:21:43 +03:00
arabcoders
11bd4204aa refactor: update terminal api 2026-04-29 22:25:39 +03:00
Abdulmohsen
cf4386e1a5
Merge pull request #594 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
feat: add support for ignoring conditions in downloads and UI
2026-04-28 01:21:27 +03:00
arabcoders
084f5082ad feat: add support for ignoring conditions in downloads and UI 2026-04-27 20:58:00 +03:00
arabcoders
fc6bc88e61 refactor: use one tmp directory for tests 2026-04-27 00:31:14 +03:00
Abdulmohsen
cb1c355e58
Merge pull request #593 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
refactor: re-add the automatic history update after download
2026-04-26 20:26:51 +03:00
arabcoders
f33dc84c57 refactor: minor ui update 2026-04-26 20:18:38 +03:00
arabcoders
50253c650a refactor: re-add the automatic history update after download 2026-04-25 20:24:45 +03:00
Abdulmohsen
4ee7537733
Merge pull request #592 from arabcoders/dev
fix: Slowness in deleting big number of items
2026-04-25 01:42:01 +03:00
arabcoders
73410f2acd fix: edge swipe on iOS safari 2026-04-24 23:27:53 +03:00
arabcoders
30a0c30d94 refactor: fix slowness in deleting big number of items
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 23:18:14 +03:00
arabcoders
7d79e0e8b0 refactor: minor UI fixes for logs 2026-04-24 20:48:17 +03:00
arabcoders
31786ad8d9 fix: sidebar should follow same overall opcity 2026-04-24 20:22:30 +03:00
arabcoders
3c1567930f feat: add system limits API endpoint and UI for download capacity overview 2026-04-22 22:02:40 +03:00
arabcoders
29777ff928 refactor: remove ui_update_title and standardize page title 2026-04-22 20:55:29 +03:00
Abdulmohsen
fdc08989ae
Merge pull request #591 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Fix pagination UI and refactor presets layout
2026-04-22 20:01:40 +03:00
arabcoders
c2cb5527f5 fix: broken tests in CI 2026-04-22 19:50:56 +03:00
arabcoders
35f38d7cee refactor: update presets UI to follow same rules as other pages.
Co-authored-by: Copilot <copilot@github.com>
2026-04-22 19:38:35 +03:00
arabcoders
c9534bdf11 refactor: remove useless wrappers 2026-04-22 19:04:11 +03:00
arabcoders
510bc2df2b fix: tasks was missing pagination UI elements 2026-04-22 18:07:21 +03:00
Abdulmohsen
ed77ea508c
Merge pull request #590 from arabcoders/dev
Refactor: Seperate history items from queue
2026-04-21 23:24:42 +03:00
arabcoders
716fce8941 fix: newDownload was causing infinite loop when switching to mulit url mode 2026-04-21 22:57:25 +03:00
arabcoders
03cabe6c70 refactor: wait on db flush before returning in API history clear response 2026-04-21 22:56:46 +03:00
arabcoders
6eaa15b37a refactor: standardize pagination position 2026-04-21 22:55:52 +03:00
arabcoders
e7dfee66b1 refactor: seperate history from queue 2026-04-21 21:48:38 +03:00
arabcoders
210712cee2 refactor: drop pinia stores 2026-04-21 20:51:47 +03:00
arabcoders
fc5cf84dd2 refactor(ui): more consistency changes. 2026-04-19 21:22:29 +03:00
arabcoders
e644ea6357 refactor: rename sidebarOpen to showSidebar 2026-04-18 22:17:10 +03:00
arabcoders
fe7c18e6c6 feat: add swipe functionality for sidebar in mobile 2026-04-18 22:12:32 +03:00
arabcoders
00ce7efb32 feat: add validation for internal URLs in conditions_test 2026-04-18 20:39:20 +03:00
arabcoders
3f6149aaf7 refactor: use plain text for multi-line messages ni confirm dialog 2026-04-18 20:18:41 +03:00
arabcoders
9d1bde7355 fix: restore tri-option for theme color 2026-04-18 20:00:32 +03:00
arabcoders
00404fc6d4 refactor: made _static.py dynamic 2026-04-18 19:26:44 +03:00
arabcoders
70648c2253 Refactor: standardize the look and feel of the pages after recent nuxt ui migration 2026-04-15 23:53:15 +03:00
arabcoders
bb246cc6f5 wip 2026-04-15 21:52:52 +03:00
arabcoders
e7fd60010b fix: opacity interaction with the new form guard 2026-04-15 18:14:36 +03:00
arabcoders
9337e81f62 refactor: remove useless tests 2026-04-15 18:13:04 +03:00
arabcoders
182a2e97e6 feat: add form guards to prevent accidental data loss 2026-04-15 00:37:12 +03:00
arabcoders
360d2f3df8 refactor: minor fixes to overall design. 2026-04-14 23:43:48 +03:00
Abdulmohsen
8729f030c7
Merge pull request #589 from arabcoders/dev
feat(console): make terminal window more durable
2026-04-14 17:32:17 +03:00
arabcoders
dbff4c6798 feat(console): make terminal window more durable and support reloading and stopping commands. 2026-04-14 02:19:35 +03:00
arabcoders
c70d5340f1 feat: implement patch to make Windows Popen support stopping live stream 2026-04-11 23:31:48 +03:00
arabcoders
1786db46cc fix: native build could fail due to cache restore 2026-04-11 19:48:48 +03:00
Abdulmohsen
0eabc2197b
Merge pull request #588 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: better partial live download handling
2026-04-11 19:27:44 +03:00
arabcoders
890aae8a72 chore: external packages update 2026-04-11 19:17:55 +03:00
arabcoders
f9119f5a33 fix: better partial live download handling 2026-04-11 19:13:00 +03:00
arabcoders
ae20b518f0 fix: version tagging in native build 2026-04-10 20:22:25 +03:00
Abdulmohsen
28ec496430
Merge pull request #587 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: improve the native build
2026-04-10 20:11:51 +03:00
arabcoders
4620d1aaed fix: improve the native build 2026-04-10 19:58:36 +03:00
Abdulmohsen
2139e20332
Merge pull request #586 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
minor bug fixes and overall ui improvements
2026-04-10 18:33:29 +03:00
arabcoders
1fa60c3909 fix: playlist keys pre-processing. Closes #585 2026-04-10 18:26:54 +03:00
arabcoders
c3132858e7 feat: add download_skipped flag to track intentionally skipped downloads 2026-04-10 18:07:05 +03:00
arabcoders
acf1de5c69 fix: adjust step value for background opacity slider 2026-04-09 22:02:16 +03:00
arabcoders
7992e1a1c3 refactor: minor logs view changes 2026-04-09 21:17:49 +03:00
arabcoders
684f3740a8 docs: remove redundant sentence about future UI/UX refactoring 2026-04-09 20:35:00 +03:00
arabcoders
f77cb7cff8 docs: update README 2026-04-09 20:33:48 +03:00
arabcoders
c552c63274 refactor: swap icon for real favicon and better status indicator 2026-04-09 01:37:52 +03:00
Abdulmohsen
1087596486
Merge pull request #584 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: video player responsiveness
2026-04-09 00:42:56 +03:00
arabcoders
6553d0d957 fix: use custom-first in presets 2026-04-08 01:10:43 +03:00
arabcoders
846e89f58e fix: video player responsiveness 2026-04-05 22:59:50 +03:00
Abdulmohsen
dbf1f59efb
Merge pull request #583 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Improve MiniFilter tokenization
2026-03-31 19:15:34 +03:00
arabcoders
ca03bf716b refactor: use static width for table cells. 2026-03-31 18:34:59 +03:00
arabcoders
90ebf44ad4 fix: some type issues and dependcies update 2026-03-29 22:20:40 +03:00
arabcoders
7d8df4f087 feat: improve MiniFilter tokenization 2026-03-29 22:15:51 +03:00
Abdulmohsen
235438af51
Merge pull request #581 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
refactor: minor fixes for better mobile view.
2026-03-27 00:20:49 +03:00
arabcoders
cdca27d4b9 refactor: minor fixes for better mobile view. 2026-03-26 19:12:40 +03:00
Abdulmohsen
4bea8a315a
Merge pull request #579 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Feat: Rebase the UI on top of nuxt/ui
2026-03-25 23:48:31 +03:00
arabcoders
218832a493 chore: update package versions 2026-03-25 23:38:40 +03:00
arabcoders
4e60f4bc8a chore: update screenshots 2026-03-25 23:28:27 +03:00
arabcoders
d41f9a6f41 feat: add dark theme preference 2026-03-25 23:21:47 +03:00
arabcoders
295c74b985 Refactor: rebase the UI on top of nuxt/ui 2026-03-25 22:59:53 +03:00
arabcoders
980f39d52c feat: add sorting and ordering functionality to paginated presets list 2026-03-22 19:21:31 +03:00
arabcoders
670744890c chore: update dependencies 2026-03-22 19:20:57 +03:00
Abdulmohsen
932f51c583
Merge pull request #576 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
chore: update external packages.
2026-03-18 21:46:27 +03:00
arabcoders
58fc5573dc chore: updating external packages. 2026-03-18 21:13:22 +03:00
arabcoders
e7b48d237a chore: update external packages. 2026-03-16 23:51:11 +03:00
Abdulmohsen
2f435a3a64
Merge pull request #574 from arabcoders/dev
chore: update external packages
2026-03-09 21:19:22 +03:00
arabcoders
ee2bddcb81 chore: update external packages 2026-03-08 18:48:55 +03:00
Abdulmohsen
b462048679
Merge pull request #573 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
chore: update dependancies
2026-03-03 22:39:05 +03:00
arabcoders
0808a055af chore: update dependancies 2026-03-03 22:12:57 +03:00
arabcoders
9a86f1358e feat: implement generic browser extractor with Playwright and Selenium support 2026-03-03 22:12:28 +03:00
Abdulmohsen
ffbd82096c
Merge pull request #572 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
chore: update external packages
2026-02-22 22:38:43 +03:00
arabcoders
dd03673e52 chore: update external packages 2026-02-22 21:09:51 +03:00
Abdulmohsen
705973219c
Merge pull request #570 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
refactor: move frontend to bun
2026-02-20 23:38:49 +03:00
arabcoders
764b837312 refactor: replace notify with useNotification for error/success handling in composables 2026-02-20 23:27:54 +03:00
arabcoders
633fc7d4d2 fix: add jsdom as dev dependancy 2026-02-20 23:14:23 +03:00
arabcoders
850a1b2eca fix: refactor document focus handling in test files - tests were failing in gitea CI 2026-02-20 22:59:01 +03:00
arabcoders
befc7df583 refactor: switch from pnpm to bun 2026-02-20 22:46:30 +03:00
arabcoders
f2e9dc97f5 fix: update docker setup to include temporary and download directories 2026-02-19 20:03:00 +03:00
arabcoders
09de683300 fix: update iOS shortcut links 2026-02-18 22:49:27 +03:00
Abdulmohsen
d9c9a85939
Merge pull request #569 from arabcoders/dev
feat: improve task creation to support multiple URLs.
2026-02-17 21:02:09 +03:00
arabcoders
f166c418f8 feat: improve task creation to support multiple URLs. 2026-02-17 20:03:27 +03:00
arabcoders
5e40e2f610 chore: update external packages 2026-02-17 18:50:36 +03:00
Abdulmohsen
0c7ebb66ed
Merge pull request #567 from arabcoders/dev
fix: forcibly register the NFOMaker PP
2026-02-14 20:32:56 +03:00
arabcoders
84077349c7 feat: improve status tracking in downloads 2026-02-14 20:05:08 +03:00
arabcoders
a0fef50248 fix: some times adding a link fails with UnboundLocalError 2026-02-14 18:49:52 +03:00
Abdulmohsen
c36295d76f
Merge pull request #566 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: improve exception handling in status tracker
2026-02-13 22:07:05 +03:00
arabcoders
90e07ce71b feat: improve the file browser with pagination. 2026-02-13 21:46:01 +03:00
arabcoders
19d5d60de1 feat: add NFO generation endpoint and UI integration for downloads 2026-02-13 20:38:33 +03:00
arabcoders
a3fc7f6abb fix: remove unnecessary terminator call and improve exception handling in status tracker 2026-02-13 18:16:18 +03:00
Abdulmohsen
ee0942b37a
Merge pull request #562 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: update import path for fetch_info in HandleTask class. Closes #561
2026-02-11 18:58:15 +03:00
arabcoders
ecf566df84 fix: update pytest arguments for testing configuration in vscode 2026-02-11 18:54:17 +03:00
arabcoders
cebc72875b fix: update import path for fetch_info in HandleTask class. Closes #561 2026-02-11 18:44:32 +03:00
Abdulmohsen
1cc6172862
Merge pull request #560 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
2026-02-08 11:11:05 +03:00
arabcoders
5844290d74 fix: sometimes downloads fails due to pickling issue 2026-02-08 03:45:56 +03:00
Abdulmohsen
0473c86fd6
Merge pull request #557 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix: use yt-dlp[default] for upgrading logic
2026-02-05 18:08:35 +03:00
arabcoders
c67c9912cc fix: show alert instead of notification for missing video file. 2026-02-05 16:18:32 +03:00
arabcoders
435882e16b chore: packages update and some formatting 2026-02-05 01:28:06 +03:00
arabcoders
14622ae317 chore: update packages 2026-02-01 15:53:30 +03:00
arabcoders
abf4c5cffd fix: remove useless log statement. 2026-01-30 21:13:48 +03:00
arabcoders
f7c74c0a63 fix: upgrader use yt-dlp[default] as package name 2026-01-30 16:55:58 +03:00
arabcoders
dfcbd2d23d chore: update external packages 2026-01-30 16:31:20 +03:00
arabcoders
b5c3bfddc8 fix: only add batch extras when there are more than one item being processed. 2026-01-29 16:51:43 +03:00
arabcoders
ed6cdf1f92 refactor: minor UX fixes. 2026-01-29 16:48:18 +03:00
Abdulmohsen
d17aa6c29e
Merge pull request #553 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Fix: sidecar loading for history items
2026-01-28 18:07:57 +03:00
arabcoders
651aa14882 refactor: reload config on websocket connection. 2026-01-28 17:49:26 +03:00
arabcoders
d8ccbe00fa fix: sidecar loading for history items 2026-01-28 17:22:32 +03:00
arabcoders
dd0633c2aa refactor: improve error messaging during failure to load config 2026-01-28 17:09:08 +03:00
arabcoders
f88422e92b Refactor: move log related inside the logs module 2026-01-28 16:22:44 +03:00
arabcoders
b1a05b1d79 refactor: move archiver to be under yt_dlp submodule 2026-01-27 20:04:54 +03:00
arabcoders
2c5edc64f4 refactor: isolate yt_dlp related code into submodule 2026-01-27 19:36:13 +03:00
arabcoders
3383d5d5dc refactor: move streaming logic to a feature submodule 2026-01-27 17:42:12 +03:00
Abdulmohsen
8659f74a3b
Merge pull request #552 from arabcoders/dev
Fix: offload info extraction to ProcessPoolExecutor
2026-01-27 11:53:15 +03:00
arabcoders
240a920b6c fix; proper fix for dispatching many websocket events 2026-01-27 11:46:00 +03:00
arabcoders
dae141a6c2 revert: changes to playlist processor 2026-01-27 10:41:17 +03:00
arabcoders
da9f082978 fix: unintended return type change. 2026-01-27 10:17:22 +03:00
arabcoders
fe0a1c5b70 fix: offload info extraction to ProcessPoolExecutor 2026-01-26 23:32:06 +03:00
arabcoders
98f25027bb refactor: use shared http clients 2026-01-26 17:42:24 +03:00
arabcoders
41bca681e5 refactor: limit item add 2026-01-26 16:36:14 +03:00
Abdulmohsen
412ad9565d
Merge pull request #549 from arabcoders/dev
Refactor: migrate from file-based config to db model
2026-01-25 21:42:20 +03:00
arabcoders
fe6fe42a65 refactor: switch api/history to be fully async. 2026-01-25 21:34:26 +03:00
arabcoders
7d6dc7ae76 refactor: move presets to db model 2026-01-25 19:17:54 +03:00
arabcoders
fd4e755938 refactor: migrate tasks to db model 2026-01-24 23:09:20 +03:00
arabcoders
00c3aefff1 Feat: add yt-dlp version checker 2026-01-24 20:04:30 +03:00
arabcoders
042379035a refactor: migrate task definitions to db model 2026-01-23 23:38:59 +03:00
arabcoders
e551ad7848 refactor: improve connection status and history loading logic 2026-01-22 23:00:45 +03:00
arabcoders
66c349d41f fix: set the configuration dict 2026-01-22 20:04:09 +03:00
arabcoders
c3eb7091af refactor: make the UI work without websocket 2026-01-22 19:36:20 +03:00
arabcoders
501455029c refactor: remove socket.io dependency 2026-01-21 22:07:12 +03:00
arabcoders
ce7b59a8be refactor: update API documentation 2026-01-21 18:54:56 +03:00
arabcoders
48feca72d3 fix: dont reject non-unique names during migration. 2026-01-21 18:39:47 +03:00
arabcoders
5b7d9ddbde refactor: migrate notifications to the new db model 2026-01-21 18:29:16 +03:00
arabcoders
ea1fc59e88 refactor: standardize config_update event for UI 2026-01-20 16:27:04 +03:00
arabcoders
8a10627d6a refactor: migrated dl_fields to db model. 2026-01-19 18:31:16 +03:00
arabcoders
c33827c6f7 fix: minor typing fix 2026-01-19 17:15:10 +03:00
arabcoders
57d582e265 Fix: remove debug statement 2026-01-18 23:13:40 +03:00
arabcoders
6da58254bc refactor: update validation logic to require cli or extras in Condition model 2026-01-18 22:57:39 +03:00
arabcoders
a09343938d refactor: conditions feature to db model 2026-01-18 22:17:07 +03:00
arabcoders
f98acbaa42 refactor: clean up sqlalchemy connection maker 2026-01-18 19:18:00 +03:00
arabcoders
a85ca93d5a refactor: use sqlalchemy as base for sqlite 2026-01-17 23:32:39 +03:00
arabcoders
d663cfd77a chore: update external packages. 2026-01-16 20:50:38 +03:00
arabcoders
795943196e Refactor: replace direct extract_info calls to async fetch_info 2026-01-16 20:45:10 +03:00
arabcoders
ea572ec4c5 feat: implement cache cleanup 2026-01-16 20:42:51 +03:00
arabcoders
d87995c4b0 fix: popover on mobile going over edge 2026-01-16 19:20:27 +03:00
Abdulmohsen
be6b68efdd
Merge pull request #542 from arabcoders/dev 2026-01-14 23:16:23 +03:00
arabcoders
45e27003c1 fix: do not show action buttons while loading tasks 2026-01-14 20:05:06 +03:00
arabcoders
072a45efba refactor: missed loading check. 2026-01-14 20:02:48 +03:00
arabcoders
ad54d80077 Refactor: better final filename status tracking 2026-01-14 19:50:35 +03:00
arabcoders
e0a47e2fe3 Refactor: facelift the WebUI 2026-01-14 18:56:07 +03:00
arabcoders
48e653b70a refactor: limit concurrent extract info calls to prevent loop blocking 2026-01-14 16:11:21 +03:00
arabcoders
55a298e0ff Refactor: download module into testable chunks 2026-01-13 20:05:01 +03:00
arabcoders
d37cc6120a refactor: move popover to separate button 2026-01-12 17:14:29 +03:00
Abdulmohsen
3b49678272
Merge pull request #541 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Fix: preset export was exporting cookies due to inverted check.
2026-01-11 21:36:42 +03:00
arabcoders
044bad41e1 Fix: preset export was exporting cookies due to inverted check. 2026-01-11 21:26:03 +03:00
arabcoders
2b02cd1a12 fix: premiere video with --live-from-start 2026-01-10 17:29:15 +03:00
arabcoders
b4de0d9eb4 Fix: remove hardcoded test version 2026-01-09 22:26:02 +03:00
arabcoders
81a4ef0f36 Feat: add version checker and refactor the page footer. 2026-01-09 21:11:39 +03:00
arabcoders
b912186502 chore: update tests assertion messages 2026-01-09 18:57:51 +03:00
arabcoders
345337a1a2 refactor: support more yt-dlp options for metadata save_path. ref #539 2026-01-09 18:57:25 +03:00
Abdulmohsen
2435144cb1
Merge pull request #540 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
feat: support using output template for metadata generation.
2026-01-08 20:05:47 +03:00
arabcoders
c8e2fc5dc9 fix: put status downloading to prevent live stream from showing preparing endlessly 2026-01-08 19:08:39 +03:00
arabcoders
cf2ee306d2 feat: support using output template for metadata generation. Closes #539 2026-01-08 17:03:22 +03:00
Abdulmohsen
1189d31f5c
Merge pull request #538 from arabcoders/dev
Feat: add flag to ignore already archived items from showing in download history.
2026-01-07 18:03:29 +03:00
arabcoders
4a3f0316ea fix: update folder reference in socket store and adjust history count logic in state store 2026-01-07 17:57:53 +03:00
arabcoders
cf8704a2aa feat: add flag to ignore already archived items from being added to download history. 2026-01-07 17:42:08 +03:00
arabcoders
ff9fd6a061 refactor: change how global buttons are rendered 2026-01-07 17:16:47 +03:00
arabcoders
ffc0b45bc9 Fix: use b instead of strong for tag items. 2026-01-06 18:14:24 +03:00
Abdulmohsen
163be08803
Merge pull request #537 from arabcoders/dev
Refactor: more UX consistency changes
2026-01-06 18:02:40 +03:00
arabcoders
700847f4ad Refactor: more UX consistency changes 2026-01-06 17:51:55 +03:00
Abdulmohsen
0a70c02b3e
Merge pull request #536 from arabcoders/dev
Refactor: add priority to presets
2026-01-05 19:02:10 +03:00
arabcoders
f74229fdc1 Fix: access to uninitialized variable 2026-01-05 18:53:01 +03:00
arabcoders
3c7157542d Refactor: add priority to presets 2026-01-05 18:19:55 +03:00
arabcoders
459dee77db Refactor: try to keep extra information available in all stages of the request process 2026-01-05 17:37:52 +03:00
arabcoders
d7fc66a63a Refactor: remove underline for file_size in history. 2026-01-04 23:42:20 +03:00
Abdulmohsen
d5ae882df0
Merge pull request #535 from arabcoders/dev
Refactor: add enabled field to notifications.
2026-01-04 23:37:50 +03:00
arabcoders
6f717de345 Refactor: make the UI buttons more consistent 2026-01-04 23:26:09 +03:00
arabcoders
c272fa903d Feat: Add popover component with the ability to disable it 2026-01-04 23:25:30 +03:00
arabcoders
a6b68ed3e4 Refactor: add enabled field to notifications. 2026-01-04 19:31:41 +03:00
Abdulmohsen
9eb78ecdc0
Merge pull request #534 from arabcoders/dev
Refactor: add priority and description fields to conditions
2026-01-04 19:18:10 +03:00
arabcoders
750322dae4 Refactor: remove socket connection checks in various UI components. 2026-01-04 18:54:22 +03:00
arabcoders
b03d4ab42e Refactor: add priority and description fields to conditions 2026-01-04 18:23:09 +03:00
Abdulmohsen
a924ddb718
Merge pull request #533 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Refactor: add enabled field to conditions
2026-01-04 17:30:15 +03:00
arabcoders
0f7e4effd8 Refactor: add enabled field to conditions. 2026-01-04 16:47:32 +03:00
arabcoders
1c5b4f344d Refactor: re-design the settings page. 2026-01-03 17:39:22 +03:00
arabcoders
8e91eb4ac7 Refactor: streamline contributing guidelines 2026-01-02 21:04:43 +03:00
Abdulmohsen
67f3a24580
Merge pull request #531 from arabcoders/dev
Refactor: integrate cf solver for both yt-dlp and httpx
2026-01-02 20:34:48 +03:00
arabcoders
a21fbbc2cc Fix: Removed outdated info 2026-01-02 20:25:33 +03:00
arabcoders
3c9362d763 Refactor: update README 2026-01-02 20:22:15 +03:00
arabcoders
837de278b2 Refactor: change ignore download message 2026-01-02 19:55:18 +03:00
arabcoders
e1297f3799 Fix: sometimes the socket does not reconnect on mobile 2026-01-02 19:25:31 +03:00
arabcoders
5dec543169 Refactor: integrate cf solver for both yt-dlp and httpx 2026-01-02 18:44:52 +03:00
Abdulmohsen
b9421f442a
Merge pull request #530 from arabcoders/dev
Refactor: reduce cf_solver priority  to allow impersonate to work
2026-01-01 22:37:44 +03:00
arabcoders
f32bb266bd Refactor: make it possible to enable/disable simple mode via query param 2026-01-01 22:32:48 +03:00
arabcoders
770d7c71b6 Refactor: reduce cf_solver priority to allow impersonate to work 2026-01-01 21:52:47 +03:00
Abdulmohsen
3b55c4d677
Merge pull request #529 from arabcoders/dev
Feat: CF Solver handler
2026-01-01 21:03:22 +03:00
arabcoders
47be76fd73 Refactor: add caching for FlareSolverr solutions. 2026-01-01 20:55:57 +03:00
arabcoders
6c1421b3b4 Feat: Added Cloudflare solver handler 2026-01-01 20:27:07 +03:00
arabcoders
baacf83974 Refactor: run extract_info in separate thread 2026-01-01 20:19:31 +03:00
Abdulmohsen
b8934d40eb
Merge pull request #528 from arabcoders/dev
Refactor: improve filtering capabilities in downloads page
2025-12-31 21:37:28 +03:00
arabcoders
7b51c6f245 Refactor: improve filtering capabilities in downloads page with detailed usage examples. 2025-12-31 21:27:59 +03:00
Abdulmohsen
2f6dcbb4d4
Merge pull request #527 from arabcoders/dev
Refactor: playlists sometimes return different ID what we got
2025-12-30 23:28:41 +03:00
arabcoders
c3f230de73 Refactor: playlists sometimes return different ID what we got 2025-12-30 22:55:15 +03:00
Abdulmohsen
18673f0a55
Merge pull request #526 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Refactor: stagger tasks creation for playlist processing
2025-12-30 20:50:44 +03:00
arabcoders
f32e323309 Refactor: improve linting and formatting 2025-12-30 20:42:50 +03:00
arabcoders
9595a69b50 Refactor: stagger tasks creation for playlist processing 2025-12-30 17:05:59 +03:00
Abdulmohsen
714b6d95ba
Merge pull request #525 from arabcoders/dev 2025-12-29 23:07:07 +03:00
arabcoders
f2eebbcd3d Refactor: expand ytdlp tests 2025-12-29 17:59:38 +03:00
arabcoders
0dccfad5c8 Refactor: improve UX to prevent accidental space as download path 2025-12-29 17:44:47 +03:00
Abdulmohsen
c9ba2b0f4c
Merge pull request #524 from arabcoders/dev
Fix: prevent sending only space as download path
2025-12-28 23:20:34 +03:00
arabcoders
450b140561 Fix: prevent sending only space as download path 2025-12-28 21:56:36 +03:00
Abdulmohsen
3d14a50f07
Merge pull request #523 from arabcoders/dev
Feat: Introduce random delays to task handlers
2025-12-28 17:52:38 +03:00
arabcoders
0c0fe9f5e6 Feat: Introduce random delays to task handlers to stagger requests to sites. 2025-12-28 16:35:42 +03:00
Abdulmohsen
b5276feba0
Merge pull request #522 from arabcoders/dev
Fix: Dont Crash on download_path being set to root path
2025-12-28 00:15:30 +03:00
arabcoders
ac4ce99b01 Fix: fixes for windows native 2025-12-27 23:58:36 +03:00
arabcoders
633f192c8f Fix: Dont crash on download_path being set to root 2025-12-27 22:52:51 +03:00
Abdulmohsen
54dedb6696
Merge pull request #521 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Refactor: switch native builds to onedir
2025-12-27 22:06:00 +03:00
arabcoders
4773e73d88 refactor: dont raise GracefulExit on app_shutdown 2025-12-27 21:58:32 +03:00
arabcoders
4ad218e97d Fix: returning response from finally block 2025-12-27 21:52:41 +03:00
arabcoders
b6b16666f6 make the release notes retain the previous text 2025-12-27 21:46:21 +03:00
arabcoders
561f7d0387 Refactor: remove direct as_posix calls 2025-12-27 21:43:08 +03:00
arabcoders
3268cd4791 Fix: fix history items sort in simple view 2025-12-27 21:42:13 +03:00
arabcoders
54f0f89dc8 Refactor: switch native builds to onedir 2025-12-27 20:46:05 +03:00
Abdulmohsen
20e6c129ba
Merge pull request #517 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Some extractors dont work with --flat-playlist
2025-12-26 20:08:38 +03:00
arabcoders
9ba68dcfaf Fix: some extractors dont support --flat-playlist, so expand support to cover them 2025-12-26 20:02:09 +03:00
arabcoders
2ae5f7e3c8 Fix: double video in archive message 2025-12-26 19:58:32 +03:00
arabcoders
01eecbffad Chore: update external packages 2025-12-26 19:39:40 +03:00
Abdulmohsen
47ca5461f1
Merge pull request #515 from arabcoders/dev
Refactor: swap sqlite to aiosqlite and add queued db updates
2025-12-19 22:11:36 +03:00
arabcoders
5a5f16c9f8 Refactor: make classes more modular 2025-12-19 20:12:01 +03:00
arabcoders
71cdcc220f Chore: update external packages 2025-12-19 19:48:22 +03:00
arabcoders
d5727ca387 Fix: add fallback to placeholder if loading thumbnail fails 2025-12-18 18:58:11 +03:00
arabcoders
b36d9fd641 Refactor: migrated whatleft of sync code to async 2025-12-18 18:21:34 +03:00
arabcoders
699e21f54d revert: revert changes to DownloadQueue to load finished history 2025-12-18 17:19:23 +03:00
arabcoders
50f50bed9f Refactor: do not load history items in DownloadQueue 2025-12-18 17:08:18 +03:00
arabcoders
7a85bcdb5c Refactor: swap sqlite to aiosqlite and add queued db updates 2025-12-18 16:31:15 +03:00
Abdulmohsen
93ce7a730b
Merge pull request #514 from arabcoders/dev
Fix: performance regression while loading items.
2025-12-16 15:33:06 +03:00
arabcoders
8a62f053a5 Fix: sorting order for more items in simple view. 2025-12-15 17:23:00 +03:00
arabcoders
e4ffa5d77a Fix: Fix performance regression while loading items. 2025-12-15 17:22:41 +03:00
Abdulmohsen
2214bc66de
Merge pull request #512 from arabcoders/dev
Feat: set_preset key for condition to switch preset based on yt-dlp data
2025-12-08 20:47:37 +03:00
arabcoders
ddc71cc1bd Feat: Add new PATCH api for updating the task and improved the no timer messaging 2025-12-08 19:43:06 +03:00
arabcoders
b33cb0a845 Refactor: change how we build frontend inside docker. 2025-12-08 17:44:05 +03:00
arabcoders
7e09bf7e18 Fix: remove dev packages during final build 2025-12-08 17:29:30 +03:00
arabcoders
fa7292ac35 Feat: set_preset key for condition to switch preset based on yt-dlp data 2025-12-08 17:19:11 +03:00
arabcoders
eaa433e264 Fix: incorrect usage of function name 2025-12-08 16:47:43 +03:00
arabcoders
2b0d8424ea Chore: update external packages 2025-12-08 16:47:08 +03:00
Abdulmohsen
3ece619834
Merge pull request #507 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Drop download_archive for generic extractors
2025-12-04 20:21:28 +03:00
arabcoders
5237a33d7a update external packages 2025-12-04 20:06:26 +03:00
arabcoders
c9de6b9f2f Drop download_archive for generic extractors when using default presets. Fixes #506 2025-12-04 20:04:58 +03:00
Abdulmohsen
af84a03ad1
Merge pull request #504 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
QoL: Support newlines as seperator for URLs.
2025-11-29 19:13:25 +03:00
arabcoders
a616a77773 Chore: remove $ from commands examples 2025-11-28 17:56:01 +03:00
arabcoders
1f0a5975e5 Feat: Add commands history to console 2025-11-28 17:53:53 +03:00
arabcoders
d99875eb57 QoL: Support newlines as seperator for URLs. 2025-11-28 17:15:46 +03:00
Abdulmohsen
7e6b6f0544
Merge pull request #502 from arabcoders/dev
Feat: Add task handler for tver.jp
2025-11-25 21:10:05 +03:00
arabcoders
13ac81b56e Feat: Add task handler for tver.jp 2025-11-25 16:52:03 +03:00
Abdulmohsen
91f527887c
Merge pull request #501 from arabcoders/dev
Refactor: Improve display of notifications to indicate severity
2025-11-22 21:07:43 +03:00
arabcoders
b3ee857ceb Fix: dont reject downloads if they provide a info.url only. 2025-11-22 15:30:41 +03:00
arabcoders
4d22e345e5 Refactor: Improve display of notifications to indicate severity. This allow user to disable toasts and just rely on color coding 2025-11-20 16:49:50 +03:00
Abdulmohsen
69dc19ad4f
Merge pull request #500 from arabcoders/dev
Refactor: move static serving to our internal implementation
2025-11-19 21:24:17 +03:00
arabcoders
c74c6ed60e Refactor: hide Downloads title in index while on mobile 2025-11-19 18:54:18 +03:00
arabcoders
7569ff252a Refactor: drop aiohttp serve_static in favor of our own to have more control 2025-11-19 18:46:55 +03:00
arabcoders
ab56b875f3 Minor tasks page update 2025-11-19 16:32:52 +03:00
arabcoders
77d48bb8c4 Doc: update FAQ.md to include explainer for how prevent live premiere works. 2025-11-19 16:17:15 +03:00
Abdulmohsen
9272e0b173
Merge pull request #498 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Fix: regrassion in tasks due change in how we handle event loop
2025-11-19 02:57:16 +03:00
arabcoders
32c5d4ed09 Fix: regrassion in tasks due change in how we handle event loop 2025-11-19 02:52:32 +03:00
Abdulmohsen
c6d89dde9c
Merge pull request #497 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
2025-11-19 01:42:59 +03:00
arabcoders
1c2b621324 Fix: tasks were shown as disabled even if they are actually enabled. 2025-11-18 23:29:39 +03:00
Abdulmohsen
89a92028e0
Merge pull request #496 from arabcoders/dev
Fix: Not being able to download subtitles.
2025-11-18 22:56:14 +03:00
arabcoders
bb59e25580 Fix: possible fix for not being able to download subtitles. 2025-11-18 21:31:08 +03:00
Abdulmohsen
70d015a2a5
Merge pull request #495 from arabcoders/dev
Feat: Make it possible to enable/disable task
2025-11-18 19:39:33 +03:00
arabcoders
f8de9b5b7a Chore: followup to update API.md for new endpoint. 2025-11-18 19:36:21 +03:00
arabcoders
a311611024 Fix: add permissions to new ci 2025-11-18 19:29:26 +03:00
arabcoders
793b67c135 Feat: Make it possible to enable/disable task. Ref #493 2025-11-18 19:27:19 +03:00
Abdulmohsen
d13ac83425
Merge pull request #492 from arabcoders/dev
Refactor: change how clear in/completed buttons behave
2025-11-16 20:07:50 +03:00
arabcoders
6d2deedc21 Refactor: change how clear in/completed buttons behave due to changes in how we load history now. 2025-11-16 19:17:33 +03:00
arabcoders
46fb943243 Fix: change icons and show no items in history. 2025-11-16 00:46:02 +03:00
arabcoders
2274af0a2b Fix: build #2 2025-11-15 23:21:45 +03:00
arabcoders
abe5a5f571 Fix: build 2025-11-15 23:12:34 +03:00
arabcoders
486888aea8 Refactor: make it possible to disable ARM builds in private repos 2025-11-15 22:53:04 +03:00
Abdulmohsen
83a416ec4c
Merge pull request #491 from arabcoders/dev
Refactor: Use tab design for index page
2025-11-15 22:12:25 +03:00
arabcoders
9ea8b639f0 Refactor: Add --no-continue as toggle option 2025-11-15 22:04:45 +03:00
arabcoders
58ccab3365 add support for embedding google drive 2025-11-15 21:51:28 +03:00
arabcoders
81a2998303 Refactor: Use tab design for index page 2025-11-15 20:26:26 +03:00
Abdulmohsen
477dc41bb6
Merge pull request #489 from arabcoders/dev
Feat: File browser controls improvements
2025-11-14 20:17:14 +03:00
arabcoders
4a69b0b6a1 Feat: Moving files in file browser should trigger item update if exists and update sidecars as well. 2025-11-14 20:05:58 +03:00
arabcoders
1c7444e232 Feat: Renaming files in file browser should trigger item update if exists and update sidecars as well. 2025-11-14 19:26:59 +03:00
arabcoders
6d9c7d70d9 Feat: update datastore.get_items to support operations 2025-11-14 17:49:01 +03:00
Abdulmohsen
59fe171a9d
Merge pull request #488 from arabcoders/dev
Add app manifest to allow the app to be installed
2025-11-13 23:36:14 +03:00
arabcoders
98fbf00a63 Feat: add app manifest to allow the app to be installed 2025-11-13 23:13:01 +03:00
arabcoders
8e6f5fc293 Feat: allow selecting browser as target for toasts 2025-11-13 22:21:16 +03:00
arabcoders
d835c027d5 Chore: rename Notification interface type to notification 2025-11-13 21:42:46 +03:00
arabcoders
c77d232c72 Feat: add link to upload cookies 2025-11-13 21:32:19 +03:00
arabcoders
7edc21ee2d Refactor: removed debug statement. 2025-11-13 19:58:42 +03:00
arabcoders
739d8e702f Feat: Paginate history items to improve performance 2025-11-13 19:28:10 +03:00
arabcoders
1107567f86 Feat: add paginated history endpoint 2025-11-13 17:15:05 +03:00
Abdulmohsen
46a3abebde
Merge pull request #487 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
revert: Revert changes to video player, it was causing issue with sid…
2025-11-13 00:21:35 +03:00
arabcoders
1144ac0e12 revert: Revert changes to video player, it was causing issue with side scrolling due to video width and height. let it auto scale 2025-11-13 00:08:36 +03:00
Abdulmohsen
b94d62accd
Merge pull request #486 from arabcoders/dev
Fix: add yt-dlp-ejs
2025-11-12 23:55:47 +03:00
arabcoders
c82e840c4b Fix: updated default yt-dlp package to include all default dependancies 2025-11-12 23:40:17 +03:00
arabcoders
d4b481f2a8 add yt-dlp-ejs 2025-11-12 23:38:18 +03:00
arabcoders
919322ea22 include procps in container. 2025-11-12 22:49:47 +03:00
Abdulmohsen
d4c14c7991
Merge pull request #485 from arabcoders/dev
Refactor: refactor postprocessor into correct directory
2025-11-12 22:06:20 +03:00
arabcoders
3da5e54774 Refactor: move nfo maker into correct namespace and move metadataPP patcher into utils 2025-11-12 21:56:29 +03:00
Abdulmohsen
c1b9034ef8
Merge pull request #483 from arabcoders/dev
Feat: add drop file support for cookies field.
2025-11-12 18:59:49 +03:00
arabcoders
b0c9db2d7d Refactor: update video player to account for video height and width 2025-11-12 18:48:33 +03:00
arabcoders
70ff5e79c5 refactor: improve messaging about websocket errors 2025-11-12 18:08:43 +03:00
arabcoders
ad3a3ddb72 Feat: add drop file support for cookies field. 2025-11-12 17:48:43 +03:00
arabcoders
0972b23b0d Chore: update external packages 2025-11-12 17:48:04 +03:00
Abdulmohsen
8ff6582316
Merge pull request #482 from arabcoders/dev
Fix: handle errors when fetching remote thumbnails
2025-11-11 23:45:47 +03:00
arabcoders
4e86729206 FEAT: add auto clear env variable 2025-11-11 22:08:32 +03:00
arabcoders
28207bcd41 Refactor: swap the order to poll and than upgrade to WS 2025-11-11 17:25:51 +03:00
arabcoders
fa9f1fcefc Fix: reduce the initial connection data 2025-11-11 17:24:07 +03:00
arabcoders
e6296de3fe Fix: handle errors when fetching remote thumbnail and return appropriate status code 2025-11-10 22:45:15 +03:00
arabcoders
3754722126 Doc: update docs update tmpfs 2025-11-10 21:47:24 +03:00
arabcoders
e1eb17ad7a [CHORE] update dependancies and remove lagecy code 2025-11-10 19:02:55 +03:00
Abdulmohsen
d1a20b3972
Merge pull request #481 from arabcoders/dev
Strip out comment lines when creating yt-dlp command
2025-11-10 18:29:25 +03:00
arabcoders
3dd5357f24 [FIX] sometimes responses dont contain a _path reference 2025-11-10 16:47:35 +03:00
arabcoders
ddd7f77551 [FIX] Show loading screen while waiting for modal text content. 2025-11-10 16:43:13 +03:00
arabcoders
1316cbf7c1 [FIX] strip out comment lines when creating yt-dlp command 2025-11-10 16:42:34 +03:00
Abdulmohsen
f0b5e20af8
Merge pull request #480 from arabcoders/dev
[FIX] implement --max-downloads arg. Ref #479
2025-11-10 00:35:48 +03:00
arabcoders
7c9233e82e [FIX] implement --max-downloads arg. Ref #479 2025-11-09 23:36:45 +03:00
Abdulmohsen
8273847d46
Merge pull request #478 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
[FIX] Chromium browser does not render subtitles after HLS attach
2025-11-08 23:19:41 +03:00
arabcoders
8c8b932d6b [FIX] Chromium browser does not render subtitles after HLS attach 2025-11-08 23:11:14 +03:00
Abdulmohsen
f4e5dfb2f6
Merge pull request #477 from arabcoders/dev
Minor fixes
2025-11-08 21:40:21 +03:00
arabcoders
1ced5b3314 [FIX] fix regression in video player 2025-11-08 21:33:22 +03:00
arabcoders
5a393bfd9f [FIX] MetadataParserPP. Ref #475 2025-11-08 20:42:07 +03:00
arabcoders
357ca323e1 [FIX] update some web related design choices and improve messaging 2025-11-08 02:08:24 +03:00
arabcoders
058bf567d1 [FEAT] add option to see the compiled yt-dlp options 2025-11-08 02:07:37 +03:00
arabcoders
b939aad1db [CHORES] update external packages 2025-11-08 02:06:51 +03:00
Abdulmohsen
03e8166afe
Merge pull request #476 from arabcoders/dev
hide keyboard shortcuts on mobile
2025-11-07 00:26:30 +03:00
arabcoders
dda90ce17a hide keyboard shortcuts on mobile 2025-11-03 16:21:27 +03:00
Abdulmohsen
1db5e26843
Merge pull request #473 from arabcoders/dev
[FEAT] Add keyboard shortcuts to built-in video player
2025-11-03 01:46:07 +03:00
arabcoders
c65e0f23b0 Fix qsv for low power mode. 2025-11-03 01:35:18 +03:00
arabcoders
be3d57ff95 minor fixes to video player 2025-11-02 19:50:22 +03:00
arabcoders
4b996a5675 [FEAT] Add keyboard shortcuts to built-in video player 2025-11-02 19:07:14 +03:00
Abdulmohsen
ee9e2a1d0b
Merge pull request #470 from arabcoders/dev
[FEAT] Add yt-dlp cli wrapper
2025-11-01 18:12:31 +03:00
arabcoders
44fd225ab1 Add yt-dlp wrapper to keep it in sync with user installed yt-dlp. Ref #469 2025-11-01 17:22:16 +03:00
arabcoders
f140b598f9 updated external packages 2025-11-01 17:21:48 +03:00
Abdulmohsen
71153d6fb4
Merge pull request #468 from arabcoders/dev
[FEAT] Support running commands from newDownload into console.
2025-10-27 20:04:13 +03:00
arabcoders
2b9b36eacf [FEAT] ADd new yt-dlp merger class. 2025-10-27 19:57:05 +03:00
arabcoders
b888e67877 [FEAT] When console enabled show button to run commands from newDownload into console. 2025-10-27 19:55:52 +03:00
arabcoders
444dc72e8f Add new api endpoint to generate yt-dlp commands 2025-10-27 19:55:19 +03:00
arabcoders
b5683a3e77 Migrate inline cookie creation to standalone function 2025-10-27 19:54:49 +03:00
arabcoders
24493885aa minor fixes and style update 2025-10-26 21:42:48 +03:00
Abdulmohsen
a81c73a469
Merge pull request #467 from arabcoders/dev
minor fixes to file listing, and unify how we present tips
2025-10-26 16:15:44 +03:00
arabcoders
211089bead minor fixes to file listing, and unifiy how we present tips 2025-10-25 20:05:24 +03:00
Abdulmohsen
70c42252a6
Merge pull request #466 from arabcoders/dev
[FEAT] add grid display style for files
2025-10-24 20:06:28 +03:00
arabcoders
75911d01dc Add more checks before fallback for hls stream 2025-10-24 19:26:48 +03:00
arabcoders
94dd37a123 Reverted some changes to the navbar 2025-10-24 18:56:56 +03:00
arabcoders
32452e0596 [FEAT] add grid display style for files 2025-10-24 18:56:10 +03:00
Abdulmohsen
477c7e7c98
Merge pull request #464 from arabcoders/dev
[FIX] Cancelling live streams. Closes #463
2025-10-24 00:47:11 +03:00
arabcoders
026962d1a7 [FIX] Cancelling live streams. Closes #463 2025-10-23 19:58:12 +03:00
Abdulmohsen
b95b6eb6be
Merge pull request #460 from arabcoders/dev
Some checks failed
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
v1.0.2
2025-10-22 19:02:44 +03:00
arabcoders
9d36ab1351 Added generic rss feed parser for tasks. 2025-10-22 18:52:57 +03:00
arabcoders
9ec427ff31 updated external packages. 2025-10-22 18:52:57 +03:00
arabcoders
81999084e1 Fix issue with some instagram:story extractor reporting incorrect type. 2025-10-22 17:40:53 +03:00
arabcoders
5208d0ab1f fix issue sending sometimes events to apprise backends. 2025-10-22 17:39:28 +03:00
arabcoders
13b13d1f7b [FIX] Force generic handler to generate real archive_id for generic urls. 2025-10-21 23:58:01 +03:00
arabcoders
7512fde77b Added the socket API to API.md 2025-10-20 19:46:54 +03:00
arabcoders
716bbbc6fb add table-container for list view in index 2025-10-20 19:29:02 +03:00
arabcoders
dd34853373 add filter for changelog 2025-10-20 19:28:34 +03:00
Abdulmohsen
238deddb9d
Merge pull request #455 from arabcoders/dev
UI/UX improvements
2025-10-19 22:22:52 +03:00
Abdulmohsen
4fafe0cbe7
Merge pull request #449 from newhinton/feature/noid/navbar
Update Navbar and Settings-Styling
2025-10-19 19:54:06 +03:00
arabcoders
09c8984a9d fix merge conflicts 2025-10-19 19:44:12 +03:00
arabcoders
3a092988a7 fix minor issues with settings display page 2025-10-19 19:13:02 +03:00
felix
56fa3c96c3 re-add aspect ratio 2025-10-19 17:18:27 +02:00
felix
acdc429776 move buttons into their own rows 2025-10-19 17:18:27 +02:00
felix
1ed7f6d3dd Put settings into card 2025-10-19 17:18:27 +02:00
arabcoders
17738fbf10 updated external packages 2025-10-19 17:18:27 +02:00
arabcoders
86a5329a58 update build-pr to run on dev PRs as well 2025-10-19 17:18:27 +02:00
arabcoders
478f78ece6 [FEAT] create channel metadata and thumbnails. Closes #446 2025-10-19 17:18:27 +02:00
arabcoders
f196587df6 feat: add quick contributing guide. 2025-10-19 17:18:27 +02:00
arabcoders
d93ddb91b4 feat: make it possible to load different UI 2025-10-19 17:18:27 +02:00
arabcoders
5d60d27e07 more minor design updates. Ref #441 2025-10-19 17:18:27 +02:00
arabcoders
048139ae2a minor design update. Ref #441 2025-10-19 17:18:27 +02:00
arabcoders
145dd056c5 Fix #443 2025-10-19 17:18:27 +02:00
arabcoders
06698cb54e Fix possible issue with apprise notification. 2025-10-19 17:51:58 +03:00
felix
ea19772d06 Add route 2025-10-19 12:05:38 +02:00
felix
4fbafecc3b Add sections to settings-ui 2025-10-19 12:04:10 +02:00
felix
5881025931 Make notification icons fit better 2025-10-19 11:54:59 +02:00
felix
2dcdb6ec21 move simple view settings back to modal 2025-10-19 11:39:20 +02:00
arabcoders
ed9ce05ac0 [FEAT] Added checkbox in task add page to directly archive all items in a task. 2025-10-17 17:21:21 +03:00
Abdulmohsen
618ce4d387
Merge pull request #451 from arabcoders/dev
[FEAT] add custom download fields to simple mode.
2025-10-16 17:23:02 +03:00
arabcoders
32bdbf5402 update README 2025-10-16 17:21:21 +03:00
arabcoders
9ea860a967 [FEAT] allow user to choose thumbnail aspect ratio. 2025-10-16 17:00:33 +03:00
arabcoders
5a35854f45 [FEAT] add custom download fields to simple mode. Closes #437 2025-10-16 16:41:59 +03:00
Abdulmohsen
5bf97047af
Merge pull request #450 from arabcoders/dev
create new branch for testing ground
2025-10-16 15:58:57 +03:00
arabcoders
c35cca39a9 create new branch for testing ground 2025-10-16 15:55:40 +03:00
felix
433bad7e5f fix button styling
rename definition site to web scraper
2025-10-15 19:03:43 +02:00
felix
7dcd67e69e remove unused functions 2025-10-15 18:45:45 +02:00
felix
65084345f9 make simple mode reversible again 2025-10-15 18:39:39 +02:00
felix
e5c15053d9 Update Navbar and Settings-Styling 2025-10-15 18:32:57 +02:00
Abdulmohsen
2a13c47bb6
Merge pull request #444 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
v1.0.1
2025-10-15 19:22:47 +03:00
arabcoders
568d92c33e updated external packages 2025-10-15 19:16:17 +03:00
Abdulmohsen
7a4634bfd6
Merge pull request #448 from arabcoders/channel_metadata
[FEAT] create channel metadata and thumbnails.
2025-10-15 19:04:45 +03:00
arabcoders
17cb32b689 update build-pr to run on dev PRs as well 2025-10-15 18:58:55 +03:00
arabcoders
e085e680cc [FEAT] create channel metadata and thumbnails. Closes #446 2025-10-15 18:55:50 +03:00
arabcoders
319f7d2f7a feat: add quick contributing guide. 2025-10-15 01:10:25 +03:00
arabcoders
a374c19978 feat: make it possible to load different UI 2025-10-15 01:09:54 +03:00
arabcoders
d5a0a3aafe more minor design updates. Ref #441 2025-10-14 18:32:09 +03:00
arabcoders
8e0f8045ab minor design update. Ref #441 2025-10-14 18:16:52 +03:00
arabcoders
74dfcbaa58 Fix #443 2025-10-14 17:58:45 +03:00
Abdulmohsen
f91811ad50
Merge pull request #440 from arabcoders/dev
Update bookmarklet URL and add note on mixed content
2025-10-13 19:24:28 +03:00
arabcoders
9801258b1c Update bookmarklet URL and add note on mixed content 2025-10-13 18:54:39 +03:00
Abdulmohsen
f51b11c421
Merge pull request #438 from arabcoders/dev
Clarify standalone builds limitations
2025-10-12 21:17:55 +03:00
arabcoders
dd23bab4e9 Clarify standalone builds limitations 2025-10-12 21:15:13 +03:00
Abdulmohsen
fa4da50403
Merge pull request #436 from arabcoders/dev
Some checks failed
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
Fixes issue with simple view and background toggle
2025-10-11 21:21:48 +03:00
arabcoders
b51bc94eb3 minor fixes 2025-10-11 20:55:56 +03:00
arabcoders
d51ca37ace added the ability to reconnect 2025-10-05 18:37:37 +03:00
arabcoders
74103f3e56 fix 2025-10-04 21:54:24 +03:00
arabcoders
3072a5e66b Add locales to final image. 2025-10-04 21:51:24 +03:00
arabcoders
743566efb1 Fixes issue with simple view and background toggle due to sharing same ID. ref #431 2025-10-03 00:08:36 +03:00
Abdulmohsen
851124a047
Merge pull request #434 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Prepare for v1.0 release.
2025-10-02 19:25:25 +03:00
arabcoders
ffeaefa93a fix PR build test 2025-10-02 19:18:46 +03:00
arabcoders
0ef0f6a90a Add Gitea support instructions to FAQ. 2025-10-02 18:57:05 +03:00
arabcoders
214c39bce7 Fix artifact upload/download on selfhosted gitea 2025-10-02 18:47:55 +03:00
arabcoders
d36598a316 another attempt at fixing CI native builds 2025-10-02 18:43:55 +03:00
arabcoders
8c0b4461dc fix native build. 2025-10-02 18:32:56 +03:00
arabcoders
72f27f6112 fix 2025-10-02 18:29:27 +03:00
arabcoders
0850e705ce support selfhosted container registry 2025-10-02 18:28:19 +03:00
arabcoders
6c925f1450 complete API documentation in prep for v1.0 release 2025-10-02 17:58:24 +03:00
arabcoders
e8c194e389 Added YTP_SIMPLE_MODE to FAQ 2025-10-02 17:43:15 +03:00
arabcoders
b63d3f7d5b README updates 2025-10-02 17:39:01 +03:00
arabcoders
aa8c9e7b34 fixes 2025-10-02 17:20:31 +03:00
arabcoders
d1060c43e2 Improve autocomplete for console 2025-10-02 17:11:51 +03:00
arabcoders
a1c65572d5 Fix dropdown on mobile 2025-10-02 16:21:41 +03:00
Abdulmohsen
1103b93f00
Merge pull request #431 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Add experimental simple view mode
2025-09-27 21:05:58 +03:00
arabcoders
fb22218016 update uv action 2025-09-27 19:41:06 +03:00
arabcoders
881232d5aa minor improvements to simple view 2025-09-27 15:49:39 +03:00
arabcoders
e82e7b1bf5 fixing some issue with simple view 2025-09-26 01:28:11 +03:00
arabcoders
42bcaa32db break long video description 2025-09-25 17:50:18 +03:00
arabcoders
1fd7477a9c improve the simple view flow and change settings render to work on both 2025-09-25 17:32:28 +03:00
arabcoders
4d16618cfb merge in the simple mode for testing 2025-09-25 00:47:43 +03:00
Abdulmohsen
8a852bcd24
Merge pull request #427 from arabcoders/dev
Bundles deno runtime
2025-09-24 22:39:50 +03:00
arabcoders
4d493433af fix nfo maker 2025-09-24 19:23:57 +03:00
arabcoders
d09761bd1c Ensure a valid channel/playlist id present when validating url in youtube task handler. 2025-09-23 22:00:26 +03:00
arabcoders
03cd3312cc Bundles deno runtime. Closes #426 2025-09-23 21:34:31 +03:00
Abdulmohsen
b105749121
Merge pull request #424 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Added Web UI for task definitions & inspect handler
2025-09-23 19:49:09 +03:00
arabcoders
1ba6c04cf3 remove some logging from DownloadQueue 2025-09-23 19:38:31 +03:00
arabcoders
e0c299ee21 update task_definitions template 2025-09-23 19:25:53 +03:00
arabcoders
520ba86ac9 Allow internal requests. Closes #425 2025-09-23 19:13:07 +03:00
arabcoders
021c0908e1 Added new NFO Maker preset 2025-09-23 19:13:07 +03:00
arabcoders
3e4079915c when we are extracting non supported site, use compatible generic archive_id 2025-09-23 19:13:07 +03:00
arabcoders
29d30b5f54 Added inspect task handler UI Elements 2025-09-23 02:16:20 +03:00
arabcoders
09c0267404 Added Web UI for task definitions 2025-09-23 01:41:22 +03:00
Abdulmohsen
dd3059d01e
Merge pull request #423 from arabcoders/dev
Use local downloaded thumbnail if available
2025-09-22 00:38:51 +03:00
arabcoders
7b645ad2ad add /api/tasks/inspect to API.md 2025-09-22 00:31:43 +03:00
arabcoders
1dc37205ba update external packages 2025-09-22 00:15:15 +03:00
arabcoders
fe740d8a26 added minAge for npm packages 2025-09-22 00:09:47 +03:00
arabcoders
7dc5e55eb8 Refactored HandleTask to support a generic extractor and made it more testable 2025-09-21 23:44:11 +03:00
arabcoders
4986aea300 fix an issue with objection mutation in timed_lru_cache cache 2025-09-16 22:41:53 +03:00
arabcoders
387a071a59 update ui tests 2025-09-16 19:28:52 +03:00
arabcoders
ba2bfd6c9b Use local downloaded thumbnail if avaliable instead of referencing external one 2025-09-16 17:45:23 +03:00
arabcoders
27893bcbf9 Add desc & sidecar to item data 2025-09-16 17:00:32 +03:00
arabcoders
816fd55397 cleanup FAQ and README 2025-09-16 16:15:07 +03:00
arabcoders
314752a644 Disallow setting unsupported vcodec. 2025-09-16 16:02:38 +03:00
arabcoders
215a023da8 Cache calls to frequently called functions 2025-09-15 19:34:03 +03:00
Abdulmohsen
5c862eb613
Merge pull request #422 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
[FEAT] Add per extractor limits
2025-09-14 22:23:42 +03:00
arabcoders
d48faed4f7 Minor design update 2025-09-14 22:15:43 +03:00
arabcoders
2b669cc8c1 Update FAQ & README 2025-09-14 22:03:50 +03:00
arabcoders
7ad2bd6358 [FEAT] Add per extractor limits. Closes #421 2025-09-14 21:58:54 +03:00
arabcoders
a96be4c0fe Add docs to footer 2025-09-14 19:43:52 +03:00
Abdulmohsen
871f9e803e
Merge pull request #420 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Support using GPU encoding for playback
2025-09-14 01:46:49 +03:00
arabcoders
82cd085ed5 exclude intel packages on arm builds 2025-09-14 01:38:39 +03:00
arabcoders
1ce7c82d9c improve logic for codec selection 2025-09-14 01:30:34 +03:00
arabcoders
980b43f835 update external packages. 2025-09-13 20:02:41 +03:00
arabcoders
38e1828ec3 support using gpu codecs for player 2025-09-13 19:49:48 +03:00
arabcoders
c91f199939 extends tests 2025-09-13 18:35:41 +03:00
arabcoders
0b86df87c0 add faq about remote storage. 2025-09-13 16:15:09 +03:00
Abdulmohsen
0de61566fb
Merge pull request #418 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
fix version tagging
2025-09-12 23:41:14 +03:00
arabcoders
9380d92af8 fix version tagging 2025-09-12 23:36:48 +03:00
Abdulmohsen
2323739c49
Merge pull request #417 from arabcoders/dev
Remove deprecated features and flags
2025-09-12 22:59:26 +03:00
arabcoders
393cc6729c More tests 2025-09-12 22:43:20 +03:00
arabcoders
f490c78179 removed deprecated features and flags 2025-09-12 20:38:09 +03:00
arabcoders
669f70d9b8 fixes 2025-09-12 19:34:12 +03:00
arabcoders
5d1eed876b updated tests 2025-09-12 17:09:02 +03:00
arabcoders
63f6ca0cf4 fixed last calls to emit that were awaiting 2025-09-12 11:45:22 +03:00
Abdulmohsen
049b9a4e38
Merge pull request #416 from arabcoders/dev
Mostly CI improvements to include tests and linting
2025-09-12 03:02:03 +03:00
arabcoders
2eba728dd2 Refactor event handlers to be asynchronous across multiple modules 2025-09-12 02:55:11 +03:00
arabcoders
12768c0c82 update perms 2025-09-11 23:27:51 +03:00
arabcoders
75c3ef0984 vue-tsc to dev 2025-09-11 22:59:02 +03:00
arabcoders
3b10148d4d add eslint as dev dependency 2025-09-11 22:57:27 +03:00
arabcoders
d21642c1e3 another attempt at fixing ci 2025-09-11 22:55:41 +03:00
arabcoders
c3d360cbb9 added ruff as dev dependency 2025-09-11 22:32:36 +03:00
arabcoders
ed60573787 use uv tool run for ruff 2025-09-11 21:55:46 +03:00
arabcoders
7a50cb34d8 fix linter issues 2025-09-11 21:49:39 +03:00
arabcoders
669256fa69 fixes 2025-09-11 16:18:09 +03:00
arabcoders
ff28899afa fix @nuxt/eslint import 2025-09-11 02:13:53 +03:00
arabcoders
fae1d7c577 improved match_str by implementing extended subset. 2025-09-11 02:09:48 +03:00
Abdulmohsen
c968ce3ed2
Merge pull request #415 from arabcoders/dev
updated external packages
2025-09-09 00:26:27 +03:00
arabcoders
a39a009395 updated external packages 2025-09-09 00:13:43 +03:00
Abdulmohsen
125a1a81a0
Merge pull request #414 from arabcoders/dev
Make it possible to ignore item download via conditions
2025-09-08 19:20:05 +03:00
arabcoders
ae1b6dd470 fixes 2025-09-08 18:07:24 +03:00
arabcoders
f417041f53 made condition.cli optional 2025-09-08 17:48:04 +03:00
arabcoders
2ae1f5b6ad update external packages. 2025-09-08 17:28:55 +03:00
arabcoders
d0a11cbcd6 make it possible to ignore item download via conditions 2025-09-08 17:25:40 +03:00
arabcoders
0d6532df5d update conditions to support extras options. 2025-09-08 17:25:40 +03:00
arabcoders
fca82b3cc9 show rss feed logs 2025-09-08 17:25:40 +03:00
Abdulmohsen
801fb29cba
Merge pull request #412 from arabcoders/create-pull-request/patch 2025-09-06 03:23:21 +03:00
ArabCoders
c27ce91a13 Update yt-dlp to 2025.9.5 2025-09-06 00:22:13 +00:00
arabcoders
adb72f4584 better base_path support. 2025-09-05 12:54:41 +03:00
Abdulmohsen
71ba8ea7f7
Merge pull request #411 from arabcoders/dev
renamed screenshot
2025-09-04 17:30:20 +03:00
arabcoders
b01c0049ae renamed screenshot 2025-09-04 17:29:40 +03:00
Abdulmohsen
bb37f7b6ba
Merge pull request #410 from arabcoders/dev
Minor FAQ/README update
2025-09-04 17:28:57 +03:00
arabcoders
91d4bf7296 minor update to FAQ/README 2025-09-04 17:17:26 +03:00
arabcoders
de338e4492 add build sha to .version file to be able to cleanup user python packages on master/dev builds. 2025-09-04 17:03:38 +03:00
Abdulmohsen
2c7ba8e184
Merge pull request #409 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
FEAT: new TwitchHandler
2025-09-04 00:02:29 +03:00
arabcoders
bdb6d0e36a FEAT: Update default presets 2025-09-03 23:49:19 +03:00
arabcoders
881b7373eb Lock nuxt to 4.0.3, there is regression in 4.1.0 2025-09-03 23:48:33 +03:00
arabcoders
9d5d6595fc FEAT: new TwitchHandler 2025-09-03 22:45:33 +03:00
arabcoders
2799d3c0d7 fixed issue with cards vs grid naming 2025-09-03 22:45:07 +03:00
arabcoders
ff9e173e18 FEAT: Enhance URL handling in Item class to support YouTube video IDs 2025-09-03 21:04:28 +03:00
arabcoders
551daf6b70 removed sentry as we really don't use it for anything. 2025-09-03 21:04:12 +03:00
Abdulmohsen
ab8545392e
Merge pull request #408 from arabcoders/dev
Expose archiver as an API endpoint.
2025-09-03 19:37:59 +03:00
arabcoders
53949b177a FEAT: Enable file browser feature by default and remove related environment variable 2025-09-03 19:34:55 +03:00
arabcoders
aab690a822 reflect preset options in tasks page. 2025-09-03 19:25:08 +03:00
arabcoders
063f62bebb Expose archiver as an API endpoint. 2025-09-03 19:01:41 +03:00
arabcoders
0abaf98b53 FEAT: Enhance DownloadQueue to handle archived items 2025-09-03 18:36:08 +03:00
arabcoders
86952200b5 FEAT: Enhance item retrieval in DataStore and DownloadQueue with flexible attribute search 2025-09-03 18:35:40 +03:00
arabcoders
88451f8044 Reflect preset settings in new download form. 2025-09-03 17:00:07 +03:00
Abdulmohsen
c528e2b557
Merge pull request #405 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
FEAT: Make it possible to limit notify target to specific presets.
2025-09-02 22:59:22 +03:00
arabcoders
691f0d5bfc FEAT: Make it possible to limit notify target to specific presets. 2025-09-02 22:36:31 +03:00
Abdulmohsen
a5d91e58fa
Merge pull request #404 from arabcoders/dev
Cleanup user python packages dir after each version change.
2025-09-02 19:54:34 +03:00
arabcoders
a528bf33ec Updated the autocomplete support to be more smarter 2025-09-02 19:41:27 +03:00
arabcoders
6cf4f6b988 Cleanup user python packages dir after each version change. 2025-09-02 18:58:32 +03:00
arabcoders
7faa3109a0 updated external packages. 2025-09-02 05:03:42 +03:00
arabcoders
fa15576608 Refactor logging setup and enhance user site directory management in PackageInstaller and Upgrader 2025-09-02 04:59:07 +03:00
Abdulmohsen
984d47e946
Merge pull request #402 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
Enhance file browser API
2025-09-01 19:56:16 +03:00
arabcoders
c9af30edda Refactor arg_converter usage to remove logging of removed options in Item and YTDLPOpts classes 2025-09-01 19:35:35 +03:00
arabcoders
28e3062bef Enhance file browser API and dialog components 2025-09-01 19:35:25 +03:00
arabcoders
2695bb40fc Migrated useConfirm to internally uses useDialog for eventual migration 2025-09-01 17:33:21 +03:00
arabcoders
cf382f0963 add short ttl on archiver read 2025-09-01 17:14:44 +03:00
arabcoders
bf1c79577f wip 2025-09-01 01:01:50 +03:00
arabcoders
e0227c1772 wip 2025-08-31 22:05:11 +03:00
Abdulmohsen
2a1cd0a632
Merge pull request #400 from arabcoders/dev
allow updating github release notes
2025-08-31 02:14:55 +03:00
arabcoders
974efc700d allow updating github release notes 2025-08-31 02:12:44 +03:00
Abdulmohsen
7185bae58d
Merge pull request #399 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
test new build system
2025-08-31 01:52:57 +03:00
arabcoders
acc6d54db2 pip is so slow using arm64 during build 2025-08-31 01:48:16 +03:00
arabcoders
d84096c1c8 lets see if uv able to get pre-built to speed up builds 2025-08-31 01:31:08 +03:00
arabcoders
e7614b8ebf add DEBIAN_FRONTEND 2025-08-31 01:21:45 +03:00
arabcoders
1a2d5bc74e rebase our docker containers on top of debian. 2025-08-31 01:17:17 +03:00
arabcoders
4690d3a307 rebase on top of debian instead of alpine 2025-08-31 00:27:15 +03:00
arabcoders
ae6c86d5d8 Add permissions section to build PR workflow 2025-08-30 23:25:16 +03:00
arabcoders
fbaf8e5404 updates 2025-08-30 23:08:09 +03:00
arabcoders
ec43b0290c test new build system 2025-08-30 22:57:34 +03:00
Abdulmohsen
c5e3899c26
Merge pull request #398 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
Re-implemented Archive feature
2025-08-30 20:57:55 +03:00
arabcoders
1c9a893865 Reimplemented Archive feature 2025-08-30 20:45:17 +03:00
Abdulmohsen
aaab5a72e8
Merge pull request #397 from arabcoders/dev
Major refactor
2025-08-30 02:29:02 +03:00
arabcoders
2c797c6824 updated external packages 2025-08-30 01:58:24 +03:00
arabcoders
ed4bb1e440 finalizing removed features 2025-08-30 01:50:42 +03:00
arabcoders
db57d6de11 Refactor code for improved type hinting; add unarchive functionality to tasks API and UI. 2025-08-30 01:02:13 +03:00
arabcoders
da8358bad6 Further updates on how we handle archive status 2025-08-29 22:11:14 +03:00
arabcoders
e6aa56a008 refactor DeprecatedNotice component for improved state management 2025-08-29 19:29:34 +03:00
arabcoders
e2b4a8caa3 moved socket event pause/resume to api/system/ 2025-08-29 19:00:26 +03:00
arabcoders
20cfc00640 migrated /api/archive to /api/history/{id}/archive 2025-08-29 18:41:13 +03:00
arabcoders
fd999211ef add deprecated notice. 2025-08-29 18:41:13 +03:00
arabcoders
deed79b8d2 major refactor of archive_file handling 2025-08-29 18:41:13 +03:00
arabcoders
29b7748b8a add is_archived to both local info and yt-dlp info 2025-08-29 18:41:13 +03:00
arabcoders
f8e3a2f6e6 update API.md 2025-08-29 18:41:13 +03:00
Abdulmohsen
9d4984b7a1
Merge pull request #396 from arabcoders/create-pull-request/patch 2025-08-28 03:25:56 +03:00
ArabCoders
9b08a17ce2 Update yt-dlp to 2025.8.27 2025-08-28 00:23:57 +00:00
arabcoders
283ed74f71 handle redirection for thumbnails. 2025-08-27 19:52:35 +03:00
arabcoders
19b78c9564 correctly mark status as cancelled. 2025-08-27 19:52:15 +03:00
arabcoders
309d4ca013 Update default placeholder for save_in dynamically based on selected preset. 2025-08-27 17:21:55 +03:00
arabcoders
bfe4ea6354 add cancelled as accepted status 2025-08-27 17:14:49 +03:00
arabcoders
71bd40a2e8 added new endpoint to get urls archive ids 2025-08-27 16:28:19 +03:00
Abdulmohsen
3de3471bd5
Merge pull request #395 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
Implement a workaround for slowness in status update
2025-08-27 01:02:17 +03:00
arabcoders
21c7b0f208 Fixes #384 2025-08-27 00:53:08 +03:00
arabcoders
1adc837d12 attempt 2 2025-08-26 01:23:41 +03:00
arabcoders
23f599d5d0 make it possible to use different app.spec from workflow 2025-08-26 01:19:12 +03:00
Abdulmohsen
8b9077d476
Merge pull request #393 from arabcoders/dev
Fix windows multiprocessing threads
2025-08-26 01:07:37 +03:00
arabcoders
d538d94216 Fix windows multiprocessing threads 2025-08-26 00:59:14 +03:00
Abdulmohsen
963fa5870c
Merge pull request #392 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
restore pycryptodome
2025-08-26 00:31:42 +03:00
arabcoders
daa41c1c66 restore pycryptodome 2025-08-26 00:31:01 +03:00
Abdulmohsen
b48daf11c8
Merge pull request #391 from arabcoders/dev
more fixes.
2025-08-26 00:22:20 +03:00
arabcoders
703b5a6052 more fixes. 2025-08-26 00:21:24 +03:00
Abdulmohsen
c3d8aa8348
Merge pull request #390 from arabcoders/dev
followup fix
2025-08-25 23:41:50 +03:00
arabcoders
3e755af621 followup fix 2025-08-25 23:41:13 +03:00
Abdulmohsen
1dd464d6cc
Merge pull request #389 from arabcoders/dev
Fix native builds.
2025-08-25 23:39:08 +03:00
arabcoders
5f2b0661af Fix native builds. 2025-08-25 23:38:34 +03:00
Abdulmohsen
c91ca38035
Merge pull request #388 from arabcoders/dev
Some checks failed
Build Native wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build Native wrappers / build (arm64, windows-latest) (push) Has been cancelled
Remove webview in favor of using built-in browser.
2025-08-25 22:43:05 +03:00
arabcoders
70e06f0122 remove webview in favor of using built-in browser. 2025-08-25 22:24:04 +03:00
arabcoders
fe38837b9e Add dialog composable 2025-08-25 22:21:32 +03:00
Abdulmohsen
46725e6f8e
Merge pull request #387 from arabcoders/dev
Added support for pausing adding items from playlist.
2025-08-25 19:20:36 +03:00
arabcoders
3adc4eb062 Sometimes yt-dlp doesn't provide playlist_count, so we need to have fallback. Fixes #384 2025-08-25 18:48:36 +03:00
arabcoders
30830a94e8 fix info extracting expired to use epoch as it is the ended instead of timestamp. 2025-08-25 17:50:47 +03:00
arabcoders
19b9f8dc6c Added support for pausing adding items from playlist. 2025-08-25 17:34:40 +03:00
Abdulmohsen
43e0128c3b
Merge pull request #383 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
upgrade curl_cffi to 0.13.x
2025-08-24 20:06:20 +03:00
arabcoders
32bebcaf46 add pnpm configuration for built dependencies 2025-08-24 17:14:06 +03:00
arabcoders
1624fc0ee4 update README 2025-08-24 17:00:08 +03:00
arabcoders
10ae84abec update README 2025-08-24 16:41:45 +03:00
arabcoders
ad87274929 upgrade curl_cffi to 0.13.x 2025-08-24 16:28:49 +03:00
Abdulmohsen
8473618726
Merge pull request #382 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Added better fallback to hls.
2025-08-23 21:44:50 +03:00
arabcoders
e9190b6dab added better fallback to hls. 2025-08-23 21:41:37 +03:00
arabcoders
d9ec7733a1 consider file size when caching ffprobe results. 2025-08-23 20:21:16 +03:00
arabcoders
c4b8121c3d external packages update 2025-08-23 13:41:51 +03:00
Abdulmohsen
7789c8a1d3
Merge pull request #381 from arabcoders/create-pull-request/patch 2025-08-23 12:29:19 +03:00
ArabCoders
dcc2359f49 Update yt-dlp to 2025.8.22 2025-08-23 00:23:41 +00:00
Abdulmohsen
d2ce9940e8
Merge pull request #380 from arabcoders/dev
Refactor: update basic mode watch logic to check config loading state
2025-08-22 18:22:59 +03:00
arabcoders
abf9c7a9d8 feat: add download_info_expires config to manage download info validity 2025-08-22 18:10:22 +03:00
arabcoders
f21fee96a3 refactor: update basic mode watch logic to check config loading state 2025-08-22 17:47:59 +03:00
arabcoders
2adc529a5b reset dropdown position when writing 2025-08-22 17:21:39 +03:00
arabcoders
cde6d956bb fix bug in textarea cli options auto triggering complete on enter even if nothing is written 2025-08-21 23:26:24 +03:00
Abdulmohsen
4c9c9eab84
Merge pull request #379 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Added autocomplete for cli options
2025-08-20 19:37:34 +03:00
arabcoders
3ebd66fd60 add support for --no-continue 2025-08-20 19:33:20 +03:00
arabcoders
a0533a0861 Added autocomplete for cli options 2025-08-20 19:18:39 +03:00
arabcoders
f31ea55ca0 Fixed order by and sorting 2025-08-19 23:06:46 +03:00
Abdulmohsen
b4505d2fcd
Merge pull request #378 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Added built-in reference for yt-dlp options.
2025-08-19 22:33:42 +03:00
arabcoders
89d4c026c5 Added built-in reference for yt-dlp options. 2025-08-19 22:24:51 +03:00
arabcoders
444e1458be minor ui updates/ 2025-08-19 19:27:45 +03:00
Abdulmohsen
f6d1618c91
Merge pull request #377 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
bump playlist_items_concurrency to 4 by default
2025-08-16 20:29:11 +03:00
arabcoders
53b7474069 bump playlist_items_concurrency to 4 by default 2025-08-16 20:06:33 +03:00
Abdulmohsen
650ba810a8
Merge pull request #376 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
fix breaking change to playlist handling.
2025-08-15 04:47:20 +03:00
arabcoders
8da9d51646 fix breaking change to playlist handling. 2025-08-15 04:44:41 +03:00
Abdulmohsen
286d8ce818
Merge pull request #374 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Make playlist_index works
2025-08-14 12:30:41 +03:00
arabcoders
a05568f44c make playlist_index works 2025-08-14 11:46:58 +03:00
arabcoders
a81657b1ef Fixed bug in get info endpoint not considering preset. 2025-08-14 11:16:21 +03:00
arabcoders
764e59b0f3 use random user agent for requesting thumbnails and background 2025-08-13 19:35:42 +03:00
arabcoders
2dfaa6b161 update external packages 2025-08-13 19:35:26 +03:00
Abdulmohsen
2cdfa2503b
Merge pull request #373 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Update yt-dlp to 2025.8.11
2025-08-12 03:45:45 +03:00
Abdulmohsen
22f7bc62ff
Merge pull request #372 from arabcoders/create-pull-request/patch 2025-08-12 03:29:38 +03:00
ArabCoders
4b00435fdd Update yt-dlp to 2025.8.11 2025-08-12 00:25:21 +00:00
arabcoders
f4fa2ea4fa as bilibili as target for embedable content 2025-08-11 16:52:14 +03:00
Abdulmohsen
6aab6fd062
Merge pull request #370 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Make it possible to pin specific yt-dlp version
2025-08-09 18:39:35 +03:00
arabcoders
7551aa1fd4 minor history fix 2025-08-09 18:32:51 +03:00
arabcoders
ec72c76b9c Added docs on how to enable bgutil ytdlp pot provider 2025-08-09 18:10:58 +03:00
arabcoders
21accb012d Add ffprobe to local data when available 2025-08-09 17:40:48 +03:00
arabcoders
96149f5e2d Make it possible to pin specific yt-dlp version 2025-08-09 17:27:00 +03:00
Abdulmohsen
3447ac8032
Merge pull request #368 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Disable opacity whenever an overlay is showing
2025-08-08 22:37:33 +03:00
arabcoders
d4e837a805 Disable opacity whenever an overlay is showing 2025-08-08 22:11:03 +03:00
Abdulmohsen
3f11262afb
Merge pull request #366 from arabcoders/dev
Custom Fields Feature
2025-08-08 00:02:45 +03:00
arabcoders
568c244cd8 merge force download into the new custom fields handling. 2025-08-07 23:47:39 +03:00
arabcoders
00fc97c570 working version of custom fields. 2025-08-07 23:03:34 +03:00
arabcoders
45401639ad Update download form to use the custom input 2025-08-07 22:08:59 +03:00
arabcoders
355adfade4 more changes to support custom fields in download form 2025-08-07 21:01:24 +03:00
arabcoders
0ea1d80243 Added initial code to support adding custom fields to NewDownload form. 2025-08-07 18:12:24 +03:00
arabcoders
c261cb6e4f Add FAQ 2025-08-06 20:00:29 +03:00
Abdulmohsen
ffd99e7c09
Merge pull request #365 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Fixed a bug in DownloadQueue
2025-08-05 18:15:45 +03:00
arabcoders
a30843510e Fixed a bug in DownloadQueue 2025-08-05 18:12:00 +03:00
Abdulmohsen
4c54f80b87
Merge pull request #364 from arabcoders/dev
Implement a way to mark items from task as already downloaded.
2025-08-05 17:59:17 +03:00
arabcoders
7b0cb87c32 make it clear we are doing something with playlist items. 2025-08-05 17:56:43 +03:00
arabcoders
1589e73063 Implement a way to mark items from task as already downloaded. 2025-08-05 17:34:07 +03:00
arabcoders
59508b5332 Add new version notification for WebUI 2025-08-03 16:11:26 +03:00
Abdulmohsen
c310ae9abc
Merge pull request #361 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Add force download switch
2025-08-02 16:45:06 +03:00
arabcoders
2d82737a3a Add configurable download_path depth env to control auto complete depth 2025-08-02 16:32:56 +03:00
arabcoders
48fb35329a Added force download switch 2025-08-02 16:32:21 +03:00
arabcoders
2bfa45e64d vscode luncher updates 2025-08-02 15:56:22 +03:00
Abdulmohsen
4c5ebf2f39
Merge pull request #359 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
fix windows build
2025-08-01 18:32:02 +03:00
arabcoders
7e4fd51e8c fix windows build 2025-08-01 18:28:17 +03:00
Abdulmohsen
cc3057f432
Merge pull request #358 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Update the pyinstaller build to include version number.
2025-08-01 18:11:58 +03:00
arabcoders
bac1a51a4a Do not register signals in non posix OS 2025-08-01 18:07:28 +03:00
arabcoders
92a6101bbe Add exception catch for sub download task 2025-08-01 17:37:46 +03:00
arabcoders
c6535f0ce1 Update the pyinstaller build to include version number. 2025-08-01 17:31:18 +03:00
Abdulmohsen
517d4e7528
Merge pull request #357 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Fix threading issue on windows exe
2025-08-01 17:13:12 +03:00
arabcoders
da6bd95733 Fix threading issue on windows exe 2025-08-01 17:03:26 +03:00
Abdulmohsen
a939654715
Merge pull request #356 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Respect auto_start when re-queuing item
2025-07-31 23:56:36 +03:00
arabcoders
3918fa0b8b hard reset YTDLPOpts. 2025-07-31 23:50:57 +03:00
arabcoders
1fbe80c915 allow custom PPs 2025-07-31 23:50:33 +03:00
arabcoders
918b11fc77 do not consider auto_start False items as stale 2025-07-31 23:50:19 +03:00
arabcoders
432f277329 respect auto_start when re-queuing item 2025-07-31 23:49:52 +03:00
arabcoders
15ab126a51 external packages updates 2025-07-31 23:49:30 +03:00
Abdulmohsen
9b5c1d9ae7
Merge pull request #355 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Fix automatic premiere re-queue.
2025-07-27 17:08:28 +03:00
arabcoders
36b590cda3 Fix automatic premiere re-queue. 2025-07-27 17:04:14 +03:00
Abdulmohsen
33fbf4bda8
Merge pull request #354 from arabcoders/dev
Added toggle for task handler & convert_url
2025-07-27 16:57:27 +03:00
arabcoders
781b767879 Add support for converting youtube handle channels urls to channel_id. Closes #353 2025-07-27 16:44:36 +03:00
arabcoders
7b9144b200 added cli opts support for info extraction endpoint 2025-07-27 16:07:38 +03:00
arabcoders
964a24cdd5 completed the ts migration 2025-07-26 21:14:53 +03:00
arabcoders
c94a4e5c75 migrated file browser to ts 2025-07-26 20:39:06 +03:00
arabcoders
5942d12f2e migrated presets.vue to ts 2025-07-26 20:08:49 +03:00
arabcoders
4b0522e72e Added list view for notifications. 2025-07-26 19:57:44 +03:00
arabcoders
da53055be8 use info color for exporting icon 2025-07-25 20:48:12 +03:00
arabcoders
6c7542e8a3 Fixed incorrect form control for handler enable 2025-07-25 20:30:21 +03:00
arabcoders
e44077b99f typo 2025-07-25 20:24:48 +03:00
arabcoders
584d4e107e Added toggle for task handler. 2025-07-25 20:17:51 +03:00
arabcoders
dcaeb7acc8 migrated more code to ts 2025-07-25 19:41:17 +03:00
arabcoders
4e1a1e65ed Add loading screen 2025-07-25 18:38:04 +03:00
arabcoders
438254c594 Added task to kill debugpy, sometimes vscode leave it behind causing problems during development 2025-07-25 17:37:52 +03:00
Abdulmohsen
b4e5990b83
Merge pull request #352 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Fix playlist_autonumber. Closes #351
2025-07-24 23:50:17 +03:00
arabcoders
c60c14d674 Fix playlist_autonumber. Closes #351 2025-07-24 23:47:12 +03:00
Abdulmohsen
fd75d3cb8f
Merge pull request #350 from arabcoders/dev
Fixed in browser match_str to closely match yt-dlp match_str
2025-07-24 19:39:01 +03:00
arabcoders
4a017500c7 Fixed in browser match_str to closely match yt-dlp match_str 2025-07-24 19:31:19 +03:00
arabcoders
6f059c78b8 nuxt4 migration 2025-07-24 18:51:12 +03:00
Abdulmohsen
6ba83ac00b
Merge pull request #349 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Fix upgrading and installing pip packages
2025-07-24 17:26:08 +03:00
arabcoders
ca4e3dfd79 Fix upgrading and installing pip packages 2025-07-24 17:22:05 +03:00
arabcoders
78e09e03cf update external dependencies 2025-07-24 17:21:47 +03:00
arabcoders
3f338dba20 Added event for item in archive 2025-07-24 15:44:32 +03:00
arabcoders
bdbc2f5b7a migrate more code to ts 2025-07-23 19:34:08 +03:00
Abdulmohsen
35aa158f40
Merge pull request #347 from arabcoders/dev 2025-07-23 03:32:31 +03:00
Abdulmohsen
b0f08f1531
Merge pull request #348 from arabcoders/create-pull-request/patch 2025-07-23 03:28:29 +03:00
ArabCoders
d0706dd1af Update yt-dlp to 2025.7.21 2025-07-23 00:27:07 +00:00
arabcoders
a58921dba5 Showing Premiering right now instead on not live 2025-07-21 16:07:23 +03:00
arabcoders
e07751c41f Minor messaging changes 2025-07-20 17:51:05 +03:00
Abdulmohsen
b7cb89f656
Merge pull request #346 from arabcoders/dev
Remove duplicate notifications, and improve yt-dlp opts handling
2025-07-20 17:09:31 +03:00
arabcoders
ac2056f038 Fix text overflow, Closes #345 2025-07-20 17:02:01 +03:00
arabcoders
472b8cb468 Fixes #344 2025-07-20 16:53:56 +03:00
arabcoders
94193b808f removed useless log statement. 2025-07-19 20:49:50 +03:00
Abdulmohsen
10182e1373
Merge pull request #343 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Completed the events system upgrade and expanded the notifications events with support for apprise.
2025-07-19 19:54:15 +03:00
arabcoders
a7e8d242c9 Completed the events system upgrade and expanded the notifications events with support for apprise. 2025-07-19 19:45:07 +03:00
Abdulmohsen
e398321901
Merge pull request #342 from arabcoders/dev
Cleaning up the event dispatcher system.
2025-07-18 22:06:53 +03:00
arabcoders
0f7f22bdbf removed event type ERROR and migrated it's uses to LOG_ERROR 2025-07-18 21:59:43 +03:00
arabcoders
138a497703 only offload to bg worker when needed. 2025-07-18 21:14:42 +03:00
arabcoders
09c5d7794d Cleaning up the event dispatcher system. 2025-07-18 21:06:09 +03:00
arabcoders
84f623f2e4 Convert socketStore and stateStore to ts 2025-07-18 19:49:36 +03:00
arabcoders
1580388b96 renamed types folder 2025-07-18 18:56:38 +03:00
arabcoders
caf60333ea minor improvements to the bg worker 2025-07-18 17:24:19 +03:00
arabcoders
49ac2720ac auto focus the confirm button in confirm dialog. 2025-07-18 17:01:32 +03:00
Abdulmohsen
f9b70fec5f
Merge pull request #341 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Allow the UI to bypass some checks for apprise URLs
2025-07-18 00:40:37 +03:00
arabcoders
395d01fe4a Remove task ID from completion notification message 2025-07-18 00:32:30 +03:00
arabcoders
95c294556c Allow the UI to bypass some checks for apprise URLs 2025-07-18 00:24:57 +03:00
Abdulmohsen
6e84b78f22
Merge pull request #340 from arabcoders/dev
Major notification update to add initial support for apprise URLs.
2025-07-18 00:17:07 +03:00
arabcoders
112da01105 Major notification update to add initial support for apprise URLs. 2025-07-18 00:10:14 +03:00
Abdulmohsen
78002ef99a
Merge pull request #339 from arabcoders/dev
add preset name to history/queue
2025-07-16 17:37:28 +03:00
arabcoders
c1763f9e3b add preset to history/queue 2025-07-16 16:16:44 +03:00
Abdulmohsen
043b019bde
Merge pull request #338 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Fix color contrast on pause button in dark mode. Closes #337
2025-07-15 19:01:30 +03:00
arabcoders
5cc86ade7d Fix color contrast on pause button in dark mode. Closes #337 2025-07-15 16:05:03 +03:00
Abdulmohsen
f66980c3bf
Merge pull request #336 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Add the ability to disable temp file handling
2025-07-14 23:06:38 +03:00
arabcoders
47b5967c58 Add the ability to disable temp file handling 2025-07-14 23:00:41 +03:00
Abdulmohsen
f4ec06cef9
Merge pull request #334 from arabcoders/dev
Make it possible to cancel download and keep the downloaded tmp data
2025-07-11 19:58:33 +03:00
arabcoders
dfe1acbec4 minor design update 2025-07-11 19:46:41 +03:00
arabcoders
5e14c232d7 move condition to ts 2025-07-11 18:53:43 +03:00
arabcoders
9d24fc01b4 Make it possible to cancel download and retain the temp data, ref #332 2025-07-11 18:18:45 +03:00
arabcoders
f782cad213 minor download form improvements. 2025-07-10 01:02:38 +03:00
arabcoders
a8b318b40e Refactor confirmation messages for consistency across components 2025-07-09 19:14:19 +03:00
arabcoders
347f99adb6 Set default value for auto_start in TaskForm component 2025-07-08 22:54:05 +03:00
Abdulmohsen
4c11e178b2
Merge pull request #333 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Added the ability to add items in paused state
2025-07-08 22:25:42 +03:00
arabcoders
543d7137f4 update external packages 2025-07-08 22:14:59 +03:00
arabcoders
9ff9735a2f Added the ability to add item in paused state 2025-07-08 22:13:45 +03:00
arabcoders
1239643f97 minor update to store fields 2025-07-08 17:58:56 +03:00
arabcoders
61cbd4aec8 update default.vue to use ts 2025-07-08 16:42:02 +03:00
arabcoders
e7dad280cd change default opacity to 0.95 2025-07-08 16:12:28 +03:00
arabcoders
ed77a59b42 clean up language usage. 2025-07-08 16:11:03 +03:00
Abdulmohsen
49cce85cfe
Merge pull request #331 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
support running in pywebview in windows.
2025-07-07 18:48:28 +03:00
arabcoders
37dab36fc9 support running in pywebview in windows. 2025-07-07 18:37:11 +03:00
Abdulmohsen
034ff25e76
Merge pull request #330 from arabcoders/dev
split windows and nix oses packaging.
2025-07-07 16:39:36 +03:00
arabcoders
7a557027be split windows and nix oses packaging. 2025-07-07 16:39:05 +03:00
Abdulmohsen
a324ed8ffd
Merge pull request #329 from arabcoders/dev
remove sha256sum generation
2025-07-07 16:28:43 +03:00
arabcoders
2c473614e2 remove sha256sum generation 2025-07-07 16:28:14 +03:00
Abdulmohsen
1169eea47b
Merge pull request #328 from arabcoders/dev
fix
2025-07-07 16:23:39 +03:00
arabcoders
ea9c54738e fix 2025-07-07 16:21:00 +03:00
Abdulmohsen
cc4b9032b0
Merge pull request #327 from arabcoders/dev
followup fix for artifacts release
2025-07-07 16:17:53 +03:00
arabcoders
69c4216b46 followup fix for artifacts release 2025-07-07 16:16:10 +03:00
Abdulmohsen
0edac6c93e
Merge pull request #326 from arabcoders/dev
fix artifact version tagging
2025-07-07 15:55:45 +03:00
arabcoders
cdac8500c7 fix artifact version tagging 2025-07-07 15:55:06 +03:00
Abdulmohsen
27e70b6df8
Merge pull request #325 from arabcoders/dev
correctly upload artifacts
2025-07-06 20:07:31 +03:00
arabcoders
354a7b3ed0 followup fix for webview builds 2025-07-06 20:03:31 +03:00
arabcoders
9536e025aa correctly upload artifacts 2025-07-06 19:52:49 +03:00
Abdulmohsen
eb3ac026ba
Merge pull request #324 from arabcoders/dev
fix release updater
2025-07-06 19:40:18 +03:00
arabcoders
8081eb60d4 fix release updater 2025-07-06 19:39:49 +03:00
483 changed files with 105697 additions and 64816 deletions

108
.github/workflows/build-pr.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: build-pr
permissions:
contents: read
on:
pull_request:
branches:
- master
- dev
- testing
paths-ignore:
- "**.md"
- ".github/ISSUE_TEMPLATE/**"
env:
BUN_VERSION: latest
NODE_VERSION: 22
PYTHON_VERSION: "3.13"
jobs:
test:
name: Run Tests & Build Validation
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
run: pip install uv
- name: Install Python dependencies
run: uv sync
- name: Run Python linting
run: uv run ruff check app/
- name: Run Python tests
run: uv run pytest app/tests/ -v --tb=short
- name: Cache bun installation
id: cache-bun
uses: actions/cache@v4
with:
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 }}
- 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: bun install --frozen-lockfile
- name: Prepare frontend (nuxt prepare)
working-directory: ui
env:
NODE_ENV: development
run: bun nuxt prepare
- name: Run frontend linting
working-directory: ui
run: bun run lint
- name: Run frontend type checking
working-directory: ui
run: bun run typecheck
- name: Run frontend tsc
working-directory: ui
run: bun run lint:tsc
- name: Run frontend tests
working-directory: ui
run: bun run test:ci
- name: Build frontend
working-directory: ui
run: bun run generate
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Validate Docker build
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: false
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
provenance: false

View file

@ -1,4 +1,4 @@
name: delete builds name: delete-builds
on: on:
workflow_dispatch: workflow_dispatch:

View file

@ -1,123 +1,140 @@
name: build name: build-docker-containers
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
logLevel:
description: "Log level"
required: true
default: "warning"
type: choice
options:
- info
- warning
- debug
build: build:
description: "Build" description: "Build and publish native multi-arch image"
required: false
default: false
type: boolean type: boolean
default: true
update_readme: update_readme:
description: "Update Readme" description: "Also sync DockerHub README"
required: false
default: false
type: boolean type: boolean
default: false
push: push:
branches: branches:
- dev - dev
- master - master
- testing
tags: tags:
- "v*" - "v*"
paths-ignore:
- "**.md"
- ".github/**"
pull_request:
branches:
- "master"
paths-ignore: paths-ignore:
- "**.md" - "**.md"
- ".github/ISSUE_TEMPLATE/**" - ".github/ISSUE_TEMPLATE/**"
env: env:
REGISTRY: ${{ vars.REGISTRY != '' && vars.REGISTRY || '' }}
BUILD_AMD64: ${{ vars.BUILD_AMD64 != 'false' && 'true' || 'false' }}
BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}
DOCKERHUB_SLUG: arabcoders/ytptube DOCKERHUB_SLUG: arabcoders/ytptube
GHCR_SLUG: ghcr.io/arabcoders/ytptube GHCR_SLUG: ghcr.io/arabcoders/ytptube
PNPM_VERSION: 10 BUN_VERSION: latest
NODE_VERSION: 20 NODE_VERSION: 22
PYTHON_VERSION: "3.13"
jobs: jobs:
build-pr: validate-build-config:
name: Validate Build Configuration
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' permissions: {}
steps: steps:
- name: Checkout - name: Check if at least one architecture is enabled
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_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: Install frontend dependencies & Build
working-directory: ui
run: | run: |
pnpm install --production --prefer-offline --frozen-lockfile if [ "${{ vars.BUILD_AMD64 }}" = "false" ] && [ "${{ vars.BUILD_ARM64 }}" = "false" ]; then
pnpm run generate echo "::error::Both BUILD_AMD64 and BUILD_ARM64 are disabled. At least one architecture must be enabled."
exit 1
fi
echo "Build configuration is valid"
echo "BUILD_AMD64: ${{ vars.BUILD_AMD64 != 'false' && 'true' || 'false' }}"
echo "BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}"
- name: Set up QEMU test:
uses: docker/setup-qemu-action@v3 name: Run Tests & Prepare Frontend
needs: [validate-build-config]
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build commit to check.
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: false
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, mode=max, scope=${{ github.workflow }}
push-build:
runs-on: ubuntu-latest
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build == 'true')
permissions: permissions:
packages: write contents: read
contents: write runs-on: ubuntu-latest
env:
PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install pnpm - name: Set up Python
uses: pnpm/action-setup@v4 uses: actions/setup-python@v5
with: with:
version: ${{ env.PNPM_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
run: pip install uv
- name: Install Python dependencies
run: uv sync
- name: Run Python linting
run: uv run ruff check app/
- name: Run Python tests
run: uv run pytest app/tests/ -v --tb=short
- name: Cache bun installation
id: cache-bun
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: "ui/pnpm-lock.yaml"
- name: Install frontend dependencies & Build - name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install frontend dependencies
working-directory: ui working-directory: ui
run: | env:
pnpm install --production --prefer-offline --frozen-lockfile NODE_ENV: development
pnpm run generate run: bun install --frozen-lockfile
- name: Prepare frontend (nuxt prepare)
working-directory: ui
env:
NODE_ENV: development
run: bun nuxt prepare
- name: Run frontend linting
working-directory: ui
run: bun run lint
- name: Run frontend type checking
working-directory: ui
run: bun run typecheck
- name: Run frontend tsc
working-directory: ui
run: bun run lint:tsc
- name: Run frontend tests
working-directory: ui
run: bun run test:ci
- name: Remove dev dependencies
working-directory: ui
env:
NODE_ENV: production
run: bun install --frozen-lockfile --production --ignore-scripts
- name: Build frontend
working-directory: ui
env:
NODE_ENV: production
run: bun run generate
- name: Install GitPython - name: Install GitPython
run: pip install gitpython run: pip install gitpython
@ -126,6 +143,37 @@ jobs:
run: | run: |
python3 .github/scripts/generate_changelog.py -p . -f ./ui/exported/CHANGELOG.json python3 .github/scripts/generate_changelog.py -p . -f ./ui/exported/CHANGELOG.json
- name: Upload frontend build
uses: actions/upload-artifact@v4
if: env.REGISTRY == ''
with:
name: frontend-build
path: ui/exported/
retention-days: 1
docker-build-amd64:
name: Build Container (amd64)
runs-on: ubuntu-latest
needs: [test, validate-build-config]
if: vars.BUILD_AMD64 != 'false'
permissions:
packages: write
contents: write
env:
ARCH: amd64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download frontend build
uses: actions/download-artifact@v4
if: env.REGISTRY == ''
with:
name: frontend-build
path: ui/exported/
- name: Update app/library/version.py - name: Update app/library/version.py
run: | run: |
VERSION="${GITHUB_REF##*/}" VERSION="${GITHUB_REF##*/}"
@ -133,9 +181,7 @@ jobs:
DATE=$(date -u +"%Y%m%d") DATE=$(date -u +"%Y%m%d")
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | sed 's/\//-/g') BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | sed 's/\//-/g')
echo "Current version: ${VERSION}, Branch: ${BRANCH}, SHA: ${SHA}, Date: ${DATE}" echo "APP_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "APP_VERSION=${VERSION}" >> $"$GITHUB_ENV"
echo "APP_SHA=${SHA}" >> "$GITHUB_ENV" echo "APP_SHA=${SHA}" >> "$GITHUB_ENV"
echo "APP_DATE=${DATE}" >> "$GITHUB_ENV" echo "APP_DATE=${DATE}" >> "$GITHUB_ENV"
echo "APP_BRANCH=${BRANCH}" >> "$GITHUB_ENV" echo "APP_BRANCH=${BRANCH}" >> "$GITHUB_ENV"
@ -147,28 +193,36 @@ jobs:
-e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \ -e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \
app/library/version.py app/library/version.py
- name: Set up QEMU echo "Updated version info:"
uses: docker/setup-qemu-action@v3 cat app/library/version.py
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKERHUB_SLUG }} name=${{ env.DOCKERHUB_SLUG }},enable=${{ env.REGISTRY == '' }}
${{ env.GHCR_SLUG }} name=${{ env.GHCR_SLUG }},enable=${{ env.REGISTRY == '' }}
tags: | name=${{ env.REGISTRY }}/${{ github.repository }},enable=${{ env.REGISTRY != '' }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch
type=ref,event=tag
flavor: | flavor: |
latest=false latest=false
suffix=-${{ env.ARCH }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Login to Private Registry
uses: docker/login-action@v3
if: env.REGISTRY != ''
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GIT_TOKEN && secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
if: env.REGISTRY == ''
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -176,68 +230,259 @@ jobs:
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
if: env.REGISTRY == ''
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: ${{ env.PLATFORMS }} platforms: linux/${{ env.ARCH }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }} cache-to: type=gha,mode=max,scope=${{ github.workflow }}
cache-to: type=gha, mode=max, scope=${{ github.workflow }} cache-from: type=gha,scope=${{ github.workflow }}
provenance: true
docker-build-arm64:
name: Build Container (arm64)
runs-on: ubuntu-latest
needs: [test, validate-build-config]
if: vars.BUILD_ARM64 != 'false'
permissions:
packages: write
contents: write
env:
ARCH: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download frontend build
uses: actions/download-artifact@v4
if: env.REGISTRY == ''
with:
name: frontend-build
path: ui/exported/
- name: Update app/library/version.py
run: |
VERSION="${GITHUB_REF##*/}"
SHA=$(git rev-parse HEAD)
DATE=$(date -u +"%Y%m%d")
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | 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 \
-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
echo "Updated version info:"
cat app/library/version.py
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
name=${{ env.DOCKERHUB_SLUG }},enable=${{ env.REGISTRY == '' }}
name=${{ env.GHCR_SLUG }},enable=${{ env.REGISTRY == '' }}
name=${{ env.REGISTRY }}/${{ github.repository }},enable=${{ env.REGISTRY != '' }}
flavor: |
latest=false
suffix=-${{ env.ARCH }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Login to Private Registry
uses: docker/login-action@v3
if: env.REGISTRY != ''
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GIT_TOKEN && secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
if: env.REGISTRY == ''
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
if: env.REGISTRY == ''
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/${{ env.ARCH }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
cache-from: type=gha,scope=${{ github.workflow }}
provenance: true
docker-publish-manifest:
name: Publish multi-arch manifest
runs-on: ubuntu-latest
needs: [docker-build-amd64, docker-build-arm64]
if: |
!cancelled() &&
(needs.docker-build-amd64.result == 'success' || needs.docker-build-arm64.result == 'success')
permissions:
packages: write
contents: write
steps:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
name=${{ env.DOCKERHUB_SLUG }},enable=${{ env.REGISTRY == '' }}
name=${{ env.GHCR_SLUG }},enable=${{ env.REGISTRY == '' }}
name=${{ env.REGISTRY }}/${{ github.repository }},enable=${{ env.REGISTRY != '' }}
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Login to Private Registry
uses: docker/login-action@v3
if: env.REGISTRY != ''
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GIT_TOKEN && secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
if: env.REGISTRY == ''
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
if: env.REGISTRY == ''
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifest list
shell: bash
run: |
IFS=$'\n'
BUILD_AMD64="${{ env.BUILD_AMD64 }}"
BUILD_ARM64="${{ env.BUILD_ARM64 }}"
for tag in $(echo "${{ steps.meta.outputs.tags }}"); do
echo "Creating manifest for ${tag}"
IMAGES=()
if [ "$BUILD_AMD64" = "true" ]; then
IMAGES+=("${tag}-amd64")
fi
if [ "$BUILD_ARM64" = "true" ]; then
IMAGES+=("${tag}-arm64")
fi
if [ ${#IMAGES[@]} -eq 0 ]; then
echo "::error::No architectures enabled for manifest creation"
exit 1
fi
docker buildx imagetools create --tag "$tag" "${IMAGES[@]}"
done
- name: Overwrite GitHub release notes - name: Overwrite GitHub release notes
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v') && env.REGISTRY == ''
uses: actions/github-script@v6 uses: actions/github-script@v6
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
const tag = context.ref.replace('refs/tags/', ''); 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, owner: context.repo.owner,
repo: context.repo.repo, 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) { if (!release) {
core.setFailed(`Release with tag ${tag} not found`); core.setFailed(`Release with tag ${tag} not found`);
return; 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({ const { data: comparison } = await github.rest.repos.compareCommits({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
base: latestRelease.data[1]?.tag_name || '', // fallback to second latest tag base: baseTag,
head: tag, 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.commits.map( const changelog = commits
c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]} by @${c.commit.author.name}` .map(c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`)
).join('\n'); .join('\n');
if (!changelog) { if (!changelog) {
core.setFailed('No commits found for the changelog'); core.setFailed('No commits found for the changelog');
return; 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({ await github.rest.repos.updateRelease({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
release_id: release.id, release_id: release.id,
body: changelog body: newBody,
}); });
dockerhub-sync-readme: dockerhub-sync-readme:
name: DockerHub README sync
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@ -245,6 +490,7 @@ jobs:
steps: steps:
- name: Sync README - name: Sync README
uses: docker://lsiodev/readme-sync:latest uses: docker://lsiodev/readme-sync:latest
if: env.REGISTRY == ''
env: env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View file

@ -1,4 +1,4 @@
name: Build WebView wrappers name: native-build
on: on:
workflow_dispatch: workflow_dispatch:
@ -6,49 +6,102 @@ on:
tag: tag:
required: true required: true
description: "Ref to build from (e.g. v1.0.0)" description: "Ref to build from (e.g. v1.0.0)"
useWorkflowSpec:
env: type: boolean
PYTHON_VERSION: 3.11 default: false
PNPM_VERSION: 10 description: "Use app.spec from workflow branch instead of tag"
NODE_VERSION: 20 push:
tags:
- "v*"
jobs: jobs:
build: build:
if: ${{ vars.REGISTRY == '' }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-latest
arch: arm64
- os: macos-latest
arch: arm64
- os: windows-latest
arch: amd64
- os: windows-latest
arch: arm64
permissions: permissions:
packages: write packages: write
contents: write contents: write
strategy:
matrix: env:
os: [ubuntu-latest, windows-latest, macos-latest] PYTHON_VERSION: "3.13"
runs-on: ${{ matrix.os }} BUN_VERSION: latest
NODE_VERSION: 22
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
steps: steps:
- name: Checkout source repo - name: Checkout source repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.inputs.tag }} ref: ${{ env.TAG_NAME }}
- name: Cache Chocolatey packages - name: Overwrite app.spec from workflow branch
if: matrix.os == 'windows-latest' if: ${{ github.event.inputs.useWorkflowSpec == 'true' }}
uses: actions/cache@v4 uses: actions/checkout@v4
with: with:
path: C:\ProgramData\chocolatey\lib ref: ${{ github.ref }}
key: choco-${{ runner.os }}-qt6 sparse-checkout: |
restore-keys: | app.spec
choco-${{ runner.os }}- sparse-checkout-cone-mode: false
path: temp-spec
- name: Install Qt (Windows) - name: Replace app.spec with version from workflow branch
if: matrix.os == 'windows-latest' if: ${{ github.event.inputs.useWorkflowSpec == 'true' }}
run: choco install -y qt6-base-dev 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 - name: Cache Python venv
id: cache-python id: cache-python
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: .venv path: .venv
key: uv-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }} key: uv-${{ runner.os }}-${{ matrix.arch }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }}
restore-keys: | restore-keys: |
uv-${{ runner.os }}-${{ env.PYTHON_VERSION }}- uv-${{ runner.os }}-${{ matrix.arch }}-${{ env.PYTHON_VERSION }}-
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
@ -57,54 +110,116 @@ jobs:
architecture: "x64" architecture: "x64"
- name: Install Python dependencies - name: Install Python dependencies
shell: bash
run: | run: |
pip install uv pip install uv
uv venv --system-site-packages --relocatable if [ ! -d .venv ]; then
uv sync --link-mode=copy --active uv venv --system-site-packages --relocatable
fi
uv sync --link-mode=copy --active --extra installer
- name: Install PyInstaller + Qt backend (Windows) - name: Cache bun installation
if: matrix.os == 'windows-latest' id: cache-bun
run: | uses: actions/cache@v4
uv pip install pyinstaller pywebview QtPy PySide6
- name: Install PyInstaller + Qt backend (Other OSs)
if: matrix.os != 'windows-latest'
run: |
uv pip install pyinstaller pywebview[qt]
- name: Install pnpm
uses: pnpm/action-setup@v4
with: 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 - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: "ui/pnpm-lock.yaml"
- name: Build frontend - name: Build frontend
working-directory: ui working-directory: ui
shell: bash
run: | run: |
pnpm install --production --prefer-offline --frozen-lockfile bun install --production --prefer-offline --frozen-lockfile
pnpm run generate bun run generate
- name: Build native binary - 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: | 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 uv run pyinstaller ./app.spec
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: app-${{ matrix.os }}-${{ github.event.inputs.tag }} 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 [ ! -f "dist/YTPTube/YTPTube" ]; then
echo "Missing dist/YTPTube/YTPTube"
find dist -maxdepth 3 -type f | head -n 200
exit 1
fi
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\YTPTube\YTPTube.exe")) {
Write-Host "Missing dist\YTPTube\YTPTube.exe"
Get-ChildItem -Recurse dist | Select-Object -First 200 | Format-Table -AutoSize
exit 1
}
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 - name: Upload to GitHub Release
if: startsWith(github.ref, 'refs/tags/') if: startsWith(env.TAG_NAME, 'v')
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
files: dist/*
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.TAG_NAME }}
files: release/*.zip

View file

@ -42,7 +42,7 @@ jobs:
VER=$(uv pip list --outdated | awk '$1 == "yt-dlp" {print $3}') VER=$(uv pip list --outdated | awk '$1 == "yt-dlp" {print $3}')
if [ -n "$VER" ]; then if [ -n "$VER" ]; then
echo "YTLDLP_VER=${VER}" >> "$GITHUB_OUTPUT" echo "YTLDLP_VER=${VER}" >> "$GITHUB_OUTPUT"
uv pip install --upgrade yt-dlp uv pip install --upgrade yt-dlp[default]
uv sync --upgrade uv sync --upgrade
UPDATED=true UPDATED=true
else else

4
.gitignore vendored
View file

@ -36,3 +36,7 @@ __pycache__
.idea .idea
dist dist
build build
version.txt
test_impl.py
./eslint.config.js
.pytest_cache

29
.vscode/launch.json vendored
View file

@ -1,11 +1,8 @@
{ {
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Node: Nuxt3", "name": "UI: Dev",
"request": "launch", "request": "launch",
"runtimeArgs": [ "runtimeArgs": [
"run", "run",
@ -13,39 +10,41 @@
"--port", "--port",
"8082" "8082"
], ],
"runtimeExecutable": "pnpm", "runtimeExecutable": "bun",
"type": "node", "type": "node",
"cwd": "${workspaceFolder}/ui", "cwd": "${workspaceFolder}/ui",
"env": { "env": {
"NUXT_API_URL": "http://localhost:8081/api/", "NUXT_API_URL": "http://localhost:8081/api/",
"NUXT_PUBLIC_WSS": ":8081/", "NUXT_PUBLIC_WSS": ":8081/"
}, },
"console": "internalConsole", "console": "internalConsole",
"outputCapture": "std", "outputCapture": "std"
}, },
{ {
"name": "Python: main.py", "name": "Python: main.py",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "app/main.py", "program": "app/main.py",
"console": "internalConsole", "console": "integratedTerminal",
"justMyCode": true, "justMyCode": true,
"subProcess": true,
"env": { "env": {
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
"YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads",
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
"PYDEVD_DISABLE_FILE_VALIDATION": "1", "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", "request": "launch",
"runtimeArgs": [ "runtimeArgs": [
"run", "run",
"generate" "generate"
], ],
"runtimeExecutable": "pnpm", "runtimeExecutable": "bun",
"type": "node", "type": "node",
"cwd": "${workspaceFolder}/ui", "cwd": "${workspaceFolder}/ui",
"console": "internalConsole", "console": "internalConsole",
@ -65,14 +64,14 @@
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
"YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads",
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
"YTP_LOG_LEVEL": "DEBUG", "YTP_LOG_LEVEL": "DEBUG"
} }
}, },
{ {
"name": "Python: Attach To Process", "name": "Python: Attach To Process",
"type": "debugpy", "type": "debugpy",
"request": "attach", "request": "attach",
"processId": "${command:pickProcess}", "processId": "${command:pickProcess}"
}, },
{ {
"name": "Python: Attach To Container", "name": "Python: Attach To Container",
@ -89,6 +88,6 @@
} }
], ],
"justMyCode": true "justMyCode": true
} },
] ]
} }

124
.vscode/settings.json vendored
View file

@ -2,108 +2,9 @@
"files.exclude": { "files.exclude": {
"**/__pycache__": true "**/__pycache__": true
}, },
"autopep8.args": [ "editor.rulers": [120],
"--max-line-length", "cSpell.enabled": false,
"120", "css.styleSheets": ["ui/app/assets/css/*.css"],
"--experimental"
],
"editor.rulers": [
120
],
"cSpell.words": [
"aconvert",
"ahas",
"ahash",
"aiocron",
"anyio",
"arrowless",
"attl",
"autonumber",
"brotlicffi",
"consoletitle",
"cookiesfrombrowser",
"copyts",
"cronsim",
"datas",
"dateparser",
"daterange",
"defusedxml",
"dotenv",
"edgechromium",
"engineio",
"euuo",
"faststart",
"finaldir",
"flac",
"forcejson",
"forceprint",
"fribidi",
"getpid",
"gpac",
"hiddenimports",
"hookspath",
"httpx",
"levelno",
"libcurl",
"libjavascriptcoregtk",
"libwebkit",
"libx",
"matchtitle",
"matroska",
"mbed",
"mccabe",
"MEIPASS",
"Microformat",
"microformats",
"mkvtoolsnix",
"movflags",
"mpegts",
"msvideo",
"multidict",
"muxdelay",
"nodesc",
"noprogress",
"onefile",
"pathex",
"platformdirs",
"plexmatch",
"postprocessor",
"preferredcodec",
"preferredquality",
"printtraffic",
"pycryptodome",
"pyinstaller",
"pypi",
"pyside",
"pywebview",
"qtpy",
"quicktime",
"rejecttitle",
"remux",
"rtime",
"smhd",
"socketio",
"timespec",
"tmpfilename",
"ungroup",
"upgrader",
"urandom",
"urlsafe",
"usegmt",
"vcodec",
"vconvert",
"writedescription",
"xerror"
],
"css.styleSheets": [
"ui/assets/css/*.css"
],
"spellright.language": [
"en"
],
"spellright.documentTypes": [
"latex"
],
"search.exclude": { "search.exclude": {
"ui/.nuxt": true, "ui/.nuxt": true,
"ui/.output": true, "ui/.output": true,
@ -111,6 +12,23 @@
"ui/exported": true, "ui/exported": true,
"ui/node_modules": true, "ui/node_modules": true,
"ui/.out": true, "ui/.out": true,
"**/.venv": true, "**/.venv": true
},
"eslint.format.enable": false,
"eslint.ignoreUntitled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"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"
} }
} }

3168
API.md

File diff suppressed because it is too large Load diff

149
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,149 @@
# Contributing to YTPTube
YTPTube is a **personal-first project**. While contributions are welcome, **all final decisions rest with the maintainer**.
## Core Principles
* **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.
**Opening a PR without prior approval will result in immediate closure without review.**
---
## Contribution Process
### 1. Start a Discussion
Before writing any code, **propose your idea** through one of these channels:
* Open a **GitHub Issue** for bug fixes or small features
* Start a **GitHub Discussion** for larger changes or questions
**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
### 2. Wait for Approval
* 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.
### 3. Develop Your Changes
**Branch from `dev`:**
```bash
git checkout dev
git pull origin dev
git checkout -b feature/descriptive-name
```
**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
### 4. Submit a Pull Request
**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
**PR template checklist:**
- [ ] Discussed and approved beforehand
- [ ] Targets `dev` branch
- [ ] Tests added/updated and passing
- [ ] Linting passes
- [ ] Documentation updated (if needed)
---
## Automatic Rejections
The following will be **closed immediately without review**:
* 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
---
## AI-Assisted Development
AI tools are **permitted** as development aids, but **you remain fully responsible** for all submitted code.
### 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 contributions must include appropriate tests:
* **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 should be clear, maintainable, and actually validate the intended behavior.
---
## Questions?
* 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, you agree that your code will be licensed under the project's **MIT License**.
Thank you for respecting this contribution process. It helps maintain project quality and the maintainer's sanity.

View file

@ -1,11 +1,15 @@
# syntax=docker/dockerfile:1.4
FROM node:lts-alpine AS node_builder FROM node:lts-alpine AS node_builder
WORKDIR /app WORKDIR /app
COPY ui ./ 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-alpine AS python_builder FROM python:3.13-bookworm AS python_builder
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8 ENV LC_ALL=C.UTF-8
@ -13,18 +17,21 @@ ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONFAULTHANDLER=1 ENV PYTHONFAULTHANDLER=1
ENV PIP_NO_CACHE_DIR=off ENV PIP_NO_CACHE_DIR=off
ENV PIP_CACHE_DIR=/root/.cache/pip ENV PIP_CACHE_DIR=/root/.cache/pip
ENV UV_CACHE_DIR=/root/.cache/pip ENV UV_CACHE_DIR=/root/.cache/uv
ENV DEBIAN_FRONTEND=noninteractive
ENV UV_INSTALL_DIR=/usr/bin
# Install dependencies COPY --from=astral/uv:latest /uv /usr/bin/
RUN apk add --update coreutils curl gcc g++ musl-dev libffi-dev openssl-dev curl make && pip install uv
WORKDIR /opt/ WORKDIR /opt/
COPY ./pyproject.toml ./uv.lock ./ COPY ./pyproject.toml ./uv.lock ./
RUN --mount=type=cache,target=/root/.cache/pip uv venv --system-site-packages --relocatable ./python && \ RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache \
VIRTUAL_ENV=/opt/python uv sync --link-mode=copy --active --mount=type=cache,target=/root/.cache/uv,id=uv-cache \
uv venv --system-site-packages --relocatable ./python && \
VIRTUAL_ENV=/opt/python uv sync --no-dev --link-mode=copy --active
FROM python:3.13-alpine FROM python:3.13-slim
ARG TZ=UTC ARG TZ=UTC
ARG USER_ID=1000 ARG USER_ID=1000
@ -40,24 +47,40 @@ ENV PYDEVD_DISABLE_FILE_VALIDATION=1
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONFAULTHANDLER=1 ENV PYTHONFAULTHANDLER=1
RUN mkdir /config /downloads && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \ ARG DEBIAN_FRONTEND=noninteractive
apk add --update --no-cache bash mkvtoolnix patch aria2 coreutils curl shadow sqlite tzdata libmagic ffmpeg rtmpdump fribidi && \
useradd -u ${USER_ID:-1000} -U -d /app -s /bin/bash app && \ RUN sed -i -E '/^Suites:[[:space:]]*trixie[[:space:]]+trixie-updates$/ {n; s/^(Components:[[:space:]]*)main([[:space:]]*|$)/\1main contrib non-free\2/}' /etc/apt/sources.list.d/debian.sources && \
rm -rf /var/cache/apk/* mkdir /config /downloads && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \
apt-get update && \
ARCH="$(dpkg --print-architecture)" && \
EXTRA_PACKAGES="" && \
if [ "$ARCH" = "amd64" ]; then EXTRA_PACKAGES="intel-media-va-driver-non-free i965-va-driver libmfx-gen1.2"; fi && \
apt-get install -y --no-install-recommends locales procps \
bash mkvtoolnix patch aria2 curl ca-certificates xz-utils git sqlite3 tzdata file libmagic1 vainfo ${EXTRA_PACKAGES} \
&& useradd -u ${USER_ID:-1000} -U -d /app -s /bin/bash app && \
sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=en_US.UTF-8 \
mkdir -p /opt/bin && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh / COPY entrypoint.sh /
COPY --chown=app:app yt-dlp /opt/bin/yt-dlp
RUN sed -i 's/\r$//g' /entrypoint.sh && chmod +x /entrypoint.sh
COPY --chown=app:app ./app /app/app COPY --chown=app:app ./app /app/app
COPY --chown=app:app --from=node_builder /app/exported /app/ui/exported COPY --chown=app:app --from=node_builder /app/exported /app/ui/exported
COPY --chown=app:app --from=python_builder /opt/python /opt/python COPY --chown=app:app --from=python_builder /opt/python /opt/python
COPY --chown=app:app --from=ghcr.io/arabcoders/alpine-mp4box /usr/bin/mp4box /usr/bin/mp4box COPY --from=ghcr.io/arabcoders/alpine-mp4box /usr/bin/mp4box /usr/bin/mp4box
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffmpeg /usr/bin/ffmpeg
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffprobe /usr/bin/ffprobe
COPY --from=denoland/deno:latest /usr/bin/deno /usr/bin/deno
COPY --chown=app:app ./healthcheck.sh /usr/local/bin/healthcheck COPY --chown=app:app ./healthcheck.sh /usr/local/bin/healthcheck
ENV PATH="/opt/python/bin:$PATH" ENV PATH="/opt/bin:/opt/python/bin:$PATH"
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
RUN chown -R app:app /config /downloads && chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box RUN sed -i 's/\r$//g' /entrypoint.sh && chmod +x /entrypoint.sh && chown -R app:app /config /downloads && \
chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box /usr/bin/ffmpeg /usr/bin/ffprobe /usr/bin/deno /opt/bin/yt-dlp
VOLUME /config VOLUME /config
VOLUME /downloads VOLUME /downloads
@ -68,7 +91,7 @@ USER app
WORKDIR /tmp WORKDIR /tmp
HEALTHCHECK --interval=10s --timeout=20s --start-period=10s --retries=3 CMD [ "/usr/local/bin/healthcheck" ] HEALTHCHECK --interval=10s --timeout=20s --start-period=60s --retries=3 CMD [ "/usr/local/bin/healthcheck" ]
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

758
FAQ.md Normal file
View file

@ -0,0 +1,758 @@
# Environment variables
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.
<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 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
## Simple bookmarklet
```javascript
javascript:(() => { const url = "https://ytp.example.org"; const preset = "default"; const mUrl = new URL(url);mUrl.pathname="/api/history/add";mUrl.searchParams.set("url",document.location.href);mUrl.searchParams.set("preset",preset);fetch(mUrl,{method: "GET"}).then(j => j.json()).then(json =>alert(json.message)).catch(err =>alert(err)); })()
```
Change the the variable `url` and `preset` variables to match your YTPTube instance and preset name.
> [!NOTE]
> The bookmarklet should be served from https page, otherwise, some browsers will block the request. for mixed content.
## Browser stores
- For Firefox via [Firefox Store](https://addons.mozilla.org/en-US/firefox/addon/ytptube-extension/)
- For Chrome/Chromium Browsers via [Chrome Store](https://chromewebstore.google.com/detail/ytptube-extension/kiepfnpeflemfokokgjiaelddchglfil)
## iOS Shortcuts
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.
- `username:password` replace this with your own username & password or leave it empty if you don't have authentication enabled.
This shortcut is powerful, as it's allow you to select your preset on the fly pulled directly from your instance.
Combined with the new and powerful presets system, you could add presets for specific websites that need cookies,
and use that preset to download directly from your iOS device.
### Advanced iOS Shortcut
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.
# Authentication
To enable basic authentication, set the `YTP_AUTH_USERNAME` and `YTP_AUTH_PASSWORD` environment variables. And restart
the container. This will prompt the user to enter the username and password before accessing the web interface/API.
As this is a simple basic authentication, if your browser doesn't show the prompt, you can use the following URL
`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:
- "OSError: [Errno 5] I/O error"
- "OSError: [Errno 18] Cross-device link: '/tmp/random_id/name.webm' -> '/downloads/name.webm'
- "Operation not permitted: '/downloads/name.webm'
This indicates an error with your mounts and how they interact with the container. So, the basic solution is to do the following:
```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_TEMP_PATH=/downloads/tmp
- YTP_DOWNLOAD_PATH=/downloads/files
ports:
- "8081:8081"
volumes:
- ./config:/config:rw
- ./downloads:/downloads:rw
```
Then run the following command to create the necessary directories and start the container:
```bash
mkdir -p ./config && mkdir -p ./downloads/{tmp,files} && docker compose -f compose.yaml up -d
```
Reference: [Issue #363](https://github.com/arabcoders/ytptube/issues/363)
# I want to use link with playlist but only download the video not all the videos in the playlist?
Simply create a preset, and in the `Command options for yt-dlp` field set `--no-playlist`. Then select the preset
whenever the link includes a playlist id.
> [!NOTE]
> You can also do the same via advanced options `Command options for yt-dlp` field, but presets are more convenient.
# Install specific yt-dlp version?
You can force specific version of `yt-dlp` by setting the `YTP_YTDLP_VERSION` environment variable for example
```env
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 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 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 definition must contain a single JSON object with the following keys:
```json5
{
"name": "example", // Friendly identifier shown in logs
"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"
"options": {
"url": "http://selenium:4444/wd/hub", // Selenium-only: remote hub URL
"arguments": ["--headless", "--disable-gpu"],
"wait_for": { "type": "css", "expression": ".article" },
"wait_timeout": 15,
"page_load_timeout": 60
}
},
"request": { // Optional HTTP settings
"method": "GET", // GET or POST
"url": "https://example.com/articles/latest", // Override the task URL if needed
"headers": { "User-Agent": "MyAgent/1.0" },
"params": { "page": 1 },
"data": null,
"json_data": null,
"timeout": 30
},
"response": { // Optional: how to interpret the body
"type": "html" // "html" (default) or "json"
},
"parse": {
"items": { // Optional container for per-item extraction
"selector": ".columns .card", // Defaults to CSS; set "type": "xpath" to use XPath
"fields": {
"link": { // Required inside fields: the per-item URL
"type": "css",
"expression": ".card-header a",
"attribute": "href"
},
"title": {
"type": "css",
"expression": ".card-header a",
"attribute": "text"
},
"poet": {
"type": "css",
"expression": "footer .card-footer-item:first-child a",
"attribute": "text"
}
}
},
"page_title": { // Optional global field outside the container
"type": "css",
"expression": "title",
"attribute": "text"
}
}
}
```
For JSON endpoints, switch the response format and use `jsonpath` selectors:
```json5
{
...
"response": { "type": "json" },
"parse": {
"items": {
"type": "jsonpath",
"selector": "items",
"fields": {
"link": { "type": "jsonpath", "expression": "url" },
"title": { "type": "jsonpath", "expression": "title" }
}
}
}
}
```
### Parsing rules
- Every definition must provide a `link` field either at the top level or inside `parse.items.fields`. Other fields are optional metadata attached to the queued item.
- CSS and XPath rules may specify `attribute`:
- `text` / `inner_text` applies `normalize-space()`.
- `html` / `outer_html` returns the raw HTML fragment.
- Any other value reads that attribute from the element. When omitted, the handler uses text and, for `link`, falls
back to `href` automatically.
- Regex rules scan the HTML fragment associated with the current scope (page-level or container). Set `attribute` to a named/numbered capture group or rely on the first group.
- `post_filter` lets you run a final regex on the extracted value and pick a named (`value`) group.
- When you declare `parse.items`, each matching container is processed independently so missing fields in one card do not shift values for the rest.
- For JSON responses (`response.type = "json"`), set the container `type` and field `type` to `jsonpath` and supply [JMESPath](https://jmespath.org/) expressions. Relative values are resolved against each container object and converted to strings automatically.
### Fetch engines
- **httpx (default)**: supports custom headers, query params, JSON/body payloads, proxy inherited from the task preset,
and optional timeout.
- **selenium**: uses a remote Chrome session. Provide the hub URL under `engine.options.url`; only Chrome is supported at
the moment. Optional keys: `arguments` (list or string), `wait_for` (type `css`/`xpath` + `expression`), `wait_timeout`,
and `page_load_timeout`.
> [!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.
# How to generate POT tokens?
You need a pot provider server we already have the extractor `bgutil-ytdlp-pot-provider` pre-installed in the container.
You can simply do the following to enable the support for it.
```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
ports:
- "8081:8081"
volumes:
- ./config:/config:rw
- ./downloads:/downloads:rw
tmpfs:
- /tmp
depends_on:
- bgutil_provider
bgutil_provider:
init: true
image: brainicism/bgutil-ytdlp-pot-provider:latest
container_name: bgutil_provider
restart: unless-stopped
```
Then simply create a new preset, and in the `Command options for yt-dlp` field set the following:
```bash
--extractor-args "youtubepot-bgutilhttp:base_url=http://bgutil_provider:4416"
```
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 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.
In order to debug and troubleshoot them, it's advised to try using the yt-dlp binary directly first, bypassing the UI,
and once that is working, importing the options that worked for you into a new `preset`.
## 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. Or from download form
by clicking `advanced options` button than the yellow terminal icon `Run directly in console`.
## Via CLI
Assuming your YTPTube container is called `ytptube`, run the following on your docker host to get a shell inside the container:
```bash
docker exec -ti ytptube bash
cd /downloads
yt-dlp ....
```
Once there, you can use the yt-dlp command freely.
# Run behind reverse proxy.
It's advisable to run YTPTube behind a reverse proxy, if better authentication and/or HTTPS support are required.
## Caddy http server
The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com).
```caddyfile
# If you are using sub-domain.
# make sure to change "ytptube:8081" to the actual name of your YTPTube container/port.
ytp.example.org {
reverse_proxy ytptube:8081
}
# If you are using sub-folder, for example: https://example.org/ytptube/
# Also make sure to set the `YTP_BASE_PATH` environment variable to `/ytptube/`
# make sure to change "ytptube:8081" to the actual name of your YTPTube container/port.
example.org {
redir /ytptube /ytptube/
route /ytptube/* {
reverse_proxy ytptube:8081
}
}
```
# How to autoload yt-dlp plugins?
Loading yt-dlp plugins in YTPTube is quite simple, we already have everything setup for you. simply, create a folder
inside the `/config` directory named `yt-dlp` so, the path will be `/config/yt-dlp`. then follow
[yt-dlp plugins docs](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#plugins) to know how to install the plugins.
Once you have installed the plugins, restart the container and the plugins will be auto-loaded on demand.
# How to load random backgrounds from WatchState or any other source?
YTPTube can be configured to pull random background images from different sources, including `WatchState` which is another
project of mine, simply change the `YTP_PICTURES_BACKENDS` environment variable to the following url
```env
YTP_PICTURES_BACKENDS=https://watchstate.ip/v1/api/system/images/background?apikey=[api_key]
```
Where `[api_key]` is the api key you get from your WatchState instance.
# How to use share folder or external storage as download target?
You can simply mount the share folder as target for `/downloads` path, but in some cases you might face issues with permissions or
cross-device link errors. To avoid these issues, you can mount the share folder as a named volume, and then mount the
named volume to `/downloads/smb` or `/downloads/nfs`.
```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
ports:
- "8081:8081"
volumes:
# Config must be mounted locally as read-write sqlite doesn't support network mounts.
- ./config:/config:rw
# Mount a local directory
- ./downloads:/downloads/local:rw
# Mount the NFS share
- nfs-data:/downloads/nfs:rw
# Mount the SMB share
- smb-data:/downloads/smb:rw
tmpfs:
- /tmp
volumes:
nfs-data:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.3,rw,nfsvers=4 # <--- Change server IP and options
device: ":/exported/path" # <--- Remote NFS path
smb-data:
driver: local
driver_opts:
type: cifs
o: username=my_username,password=my_password,vers=3.0,uid=1000,gid=1000,file_mode=0777,dir_mode=0777 # <--- Change options to fit your needs
device: "//10.0.0.3/public" # <--- Remote SMB path
```
If you prefer, you can bypass YTPTube `download_path` and set it to `/` and completely manage your own mounts. However,
please be aware that the file browser feature will expose whatever `download_path` is set to. **So, if you set it to `/`,
the file browser will expose the entire container filesystem.**
# The origin of the project.
The project first started as a fork [meTube](https://github.com/alexta69/metube), since then it has been completely
rewritten and redesigned. The original project was a great starting point, but it didn't align with my vision for the
project and what i wanted to achieve with it.
# How to use hardware acceleration for video transcoding?
As the container is rootless, we cannot do the necessary changes to the container to enable hardware acceleration.
However, We do have the drivers and ffmpeg already installed and the CPU transcoding should work regardless. To enable
hardware acceleration You need to alter your `compose.yaml` file to mount the necessary devices to the container. Here
is an example of how to do it for debian based systems.
```yaml
services:
ytptube:
........ # see above for the rest of the configuration
devices:
# mount the dri devices to the container if you only have one gpu you can simply do the following
- /dev/dri:/dev/dri
# Otherwise, selectively mount the devices you need.
- /dev/dri/card0 # Intel GPU device
- /dev/dri/renderD128 # Intel GPU render node
group_add:
# Add the necessary groups to the container to access the gpu devices.
- 44 # it might be different on your system.
- 105 # it might be different on your system.
```
This setup should work for at VAAPI encoding in `x86_64` containers.
> [!NOTE]
> Your `video`, `render` group id might be different from mine, you can run the follow command in docker host server to get the group ids for both groups.
```bash
cat /etc/group | grep -E 'render|video'
video:x:44:your_docker_username
render:x:105:your_docker_username
```
In my docker host the group id for `video` is `44` and for `render` is `105`. change what needed in the `compose.yaml`
file to match your setup.
If for some reason the initial test for GPU encoding fails, YTPTube will fallback to software encoding. You can force
software encoding by setting the `YTP_STREAMER_VCODEC` environment variable to `libx264`. If you want to force GPU encoding, set the
`YTP_STREAMER_VCODEC` environment variable to one of the supported GPU codecs, for example `h264_vaapi` or `h264_nvenc` depending on your GPU.
For more information about the supported codecs, please refer to the [SegmentEncoders.py](app/library/SegmentEncoders.py) file.
If GPU encoding fails and software encoding is used, you will have to restart the container to try GPU encoding again.
as we only test for GPU encoding once on first video stream.
# Allowing internal URLs requests
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.
# How to setup CI on Gitea?
The docker container builder already support self-hosted repositories like Gitea, you simply need to define two things at your repository settings.
1. Create a secret named `GIT_TOKEN` and set it to your Gitea personal access token.
2. Create a variable named `REGISTRY` and set it to your docker registry, for example `gitea.domain.org`.
Thats it, the `main.yml` will now disable the docker/github container registries, and use your Gitea repository instead. It will follow the usual
naming, your container name will be named `REGISTRY/ytptube` and the tags will be the same as the ones used in the github registry.
Unfortunately, the `native-builder.yml` workflow doesn't support self-hosted repositories at the moment.
# Getting No space left on device error
If you encounter this error: `OSError: [Errno 28] No space left on device` This indicates that either
the `/tmp` or `/downloads` directory has run out of available space.
This issue commonly occurs when:
- `/tmp` is mounted as `tmpfs` (memory-based storage)
- Your system has limited RAM
- You're downloading large video files
Since videos are temporarily stored in `/tmp` before being moved to the final download location, memory-based storage
may be insufficient for large downloads.
To fix the issue, modify your `compose.yaml` to use a disk-based directory for temporary files:
```yaml
services:
ytptube:
user: "${UID:-1000}:${UID:-1000}"
image: ghcr.io/arabcoders/ytptube:latest
container_name: ytptube
restart: unless-stopped
ports:
- "8081:8081"
volumes:
- ./config:/config:rw
- ./downloads:/downloads/local:rw
- ./temp:/tmp:rw
```
> [!NOTE]
> Replace the `tmpfs` mount with a local directory volume (`./temp:/tmp:rw`). This allows temporary files to use disk space instead of RAM.
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
premieres, usually streams will contain a loading screen of say, 1-5 minutes before the actual video content starts
playing. By default we wait for 5min + the duration of the video before starting the download to ensure we get the full video without
the loading screen. However, you can override the behavior by setting the following environment variable:
```env
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.

View file

@ -1,4 +1,4 @@
Copyright (c) 2024 ArabCoders Copyright (c) 2025 ArabCoders
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

322
README.md
View file

@ -1,59 +1,71 @@
# YTPTube # YTPTube
![Build Status](https://github.com/ArabCoders/ytptube/actions/workflows/main.yml/badge.svg) ![Build Status](https://github.com/arabcoders/ytptube/actions/workflows/main.yml/badge.svg)
![MIT License](https://img.shields.io/github/license/arabcoders/ytptube.svg)
![Docker Pull](https://img.shields.io/docker/pulls/arabcoders/ytptube.svg)
![gchr Pull](https://ghcr-badge.elias.eu.org/shield/arabcoders/ytptube/ytptube)
**YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from **YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from
YouTube and other video platforms easier and more user-friendly. It supports downloading playlists, channels, and video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and
live streams, and includes features like scheduling downloads, sending notifications, and a built-in video player. includes features like scheduling downloads, sending notifications, and built-in video player.
![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/master/sc_short.png) # Screenshots
Example of the regular view interface.
![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/dev/sc_short.jpg)
Example of the Simple mode interface.
![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/dev/sc_simple.jpg)
# YTPTube Features. # YTPTube Features.
* Multi-downloads support. * Multi-download support.
* Random beautiful background. `can be disabled or source changed`. * Random beautiful background.
* Can handle live streams. * Handles live and upcoming streams.
* Scheduler to queue channels or playlists to be downloaded automatically at a specified time. * A dual view mode for both technical and non-technical users.
* Send notification to targets based on selected events. * Schedule channels or playlists to be downloaded automatically with support for creating custom download feeds from non-supported sites. See [Feeds documentation](FAQ.md#how-can-i-monitor-sites-without-rss-feeds).
* Support per link `cli options` & `cookies`. * Send notification to targets based on selected events. includes [Apprise](https://github.com/caronc/apprise?tab=readme-ov-file#readme) support.
* Queue multiple URLs separated by comma. * Support per link options.
* Presets system to re-use frequently used yt-dlp options. * Support for limits per extractor and overall global limit.
* Simple file browser. `Disabled by default`. * Queue multiple URLs at once.
* A built in video player **with support for sidecar external subtitles**. * Powerful presets system for applying `yt-dlp` options. with a pre-made preset for media servers users.
* New `POST /api/history` endpoint that allow one or multiple links to be sent at the same time. * A simple file browser.
* New `GET /api/history/add?url=http://..` endpoint that allow to add single item via GET request. * A built in video player **with support for sidecar external subtitles**. `Require ffmpeg to be in PATH in non-docker setups`.
* Modern frontend UI.
* SQLite as database backend.
* Basic authentication support. * Basic authentication support.
* Support for curl_cffi, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation) * Supports `curl-cffi`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation). `In docker only`.
* Support basic mode for WebUI for non-technical users, which hides most of the normal features from view. * Bundled `pot provider plugin`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide). `In docker only`.
* Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box. * 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 upcoming live stream re-queue. * Automatic updates for `yt-dlp` and custom `pip` packages. `In docker only`.
* Apply `yt-dlp` options per custom defined conditions. * 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. * 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).
### Side project Please read the [FAQ](FAQ.md) for more information.
We have a side project related to YTPTube, which takes YTPTube source code and build an executable for Windows, macOS and Linux.
The project is in early stages, and might not even work yet. However keep an eye at [This build action](https://github.com/arabcoders/ytptube/actions/workflows/native-build.yml) for builds, simply select last successful build and download the executable for your platform. Help us test out the platforms
and report any issues you might find. We only have windows/linux machines to test on, so we need your help to test it out on macOS.
# Installation # 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 ## Run using docker command
```bash ```bash
mkdir -p ./{config,downloads} && docker run -d --rm --user "$UID:${GID-$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 \ -p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
ghcr.io/arabcoders/ytptube:latest ghcr.io/arabcoders/ytptube:latest
``` ```
Then you can access the WebUI at `http://localhost:8081`. ## Run using podman
> [!NOTE] ```bash
> If you are using `podman` instead of `docker`, you can use the same command, but you need to change the user to `0:0` mkdir -p ./{config,downloads/{files,tmp}} && podman run -itd --rm --userns=keep-id --name ytptube \
> it will appears to be running as root, but it will run as the user who started the container. -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 ## Using compose file
@ -62,211 +74,52 @@ The following is an example of a `compose.yaml` file that can be used to run YTP
```yaml ```yaml
services: services:
ytptube: 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 image: ghcr.io/arabcoders/ytptube:latest
container_name: ytptube container_name: ytptube
restart: unless-stopped restart: unless-stopped
environment:
- YTP_TEMP_PATH=/downloads/tmp
- YTP_DOWNLOAD_PATH=/downloads/files
ports: ports:
- "8081:8081" - "8081:8081"
volumes: volumes:
- ./config:/config:rw - ./config:/config:rw
- ./downloads:/downloads:rw - ./downloads:/downloads:rw
tmpfs:
- /tmp
``` ```
> [!IMPORTANT] > [!IMPORTANT]
> Make sure to change the `user` line to match your user id and group id > 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 ```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`. 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 ## Unraid
For `Unraid` users You can install the `Community Applications` plugin, and search for **ytptube** it comes For `Unraid` users You can install the `Community Applications` plugin, and search for **ytptube** it comes
pre-configured. pre-configured.
# Browser extensions & bookmarklets
## Simple bookmarklet
```javascript
javascript:(() => { const url = "https://ytptube.example.org"; const preset = "default"; const mUrl = new URL(url); mUrl.pathname = "/api/history"; fetch(mUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: document.location.href, preset: preset }) }).then(res => alert(res.ok ? "URL sent!" : "Failed to send URL.")); })()
```
Change the the variable `url` and `preset` variables to match your YTPTube instance and preset name.
## Browser stores
- For Firefox via [Firefox Store](https://addons.mozilla.org/en-US/firefox/addon/ytptube-extension/)
- For Chrome/Chromium Browsers via [Chrome Store](https://chromewebstore.google.com/detail/ytptube-extension/kiepfnpeflemfokokgjiaelddchglfil)
## 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 have to edit the shortcut and replace the following:
- `https://ytp.example.org` with your YTPTube instance.
- `username:password` replace this with your own username & password or leave it empty if you don't have authentication enabled.
This shortcut is powerful, as it's allow you to select your preset on the fly pulled directly from your instance.
Combined with the new and powerful presets system, you could add presets for specific websites that need cookies,
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
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.
# Run behind reverse proxy.
It's advisable to run YTPTube behind a reverse proxy, if better authentication and/or HTTPS support are required.
## Caddy http server
The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com).
```caddyfile
# If you are using sub-domain.
# make sure to change "ytptube:8081" to the actual name of your YTPTube container/port.
ytp.example.org {
reverse_proxy ytptube:8081
}
# If you are using sub-folder, for example: https://example.org/ytptube/
# Also make sure to set the `YTP_BASE_PATH` environment variable to `/ytptube/`
# make sure to change "ytptube:8081" to the actual name of your YTPTube container/port.
example.org {
redir /ytptube /ytptube/
route /ytptube/* {
reverse_proxy ytptube:8081
}
}
```
# Updating yt-dlp
The engine which powers the actual video downloads in YTPTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video
sites regularly updated, frequent updates of yt-dlp are required to keep up.
We have added the `YTP_YTDLP_AUTO_UPDATE` environment variable, which is enabled by default. This feature allows the
container to automatically update `yt-dlp` to the latest version whenever the container starts. If a new version is
available, it will be downloaded and applied automatically. To disable this automatic update, set the
`YTP_YTDLP_AUTO_UPDATE` environment variable to `false`.
We will no longer release new versions of YTPTube for every new version of yt-dlp.
# Troubleshooting and submitting issues
Before asking a question or submitting an issue for YTPTube, please remember that YTPTube is only a thin 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.
In order to debug and troubleshoot them, it's advised to try using the yt-dlp binary directly first, bypassing the UI,
and once that is working, importing the options that worked for you into a new `preset` or `ytdlp.cli` file.
## 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.
## Via CLI
Assuming your YTPTube container is called `ytptube`, run the following on your docker host to get a shell inside the container:
```bash
docker exec -ti ytptube bash
cd /downloads
yt-dlp ....
```
Once there, you can use the yt-dlp command freely.
# ytdlp.cli file
The `config/ytdlp.cli`, is a command line options file for `yt-dlp` it will be globally applied to all downloads.
We strongly recommend not use this file for options that aren't **truly global**, everything that can be done via the
file can also be done via the presets which is dynamic can be altered per download. Example of good global options
are to be used for all downloads are:
```bash
--continue --windows-filenames --live-from-start
```
Everything else can be done via the presets, and it's more flexible and easier to manage.
# Authentication
To enable basic authentication, set the `YTP_AUTH_USERNAME` and `YTP_AUTH_PASSWORD` environment variables. And restart
the container. This will prompt the user to enter the username and password before accessing the web interface/API.
As this is a simple basic authentication, if your browser doesn't show the prompt, you can use the following URL
`http://username:password@your_ytptube_url:port`
# Basic mode
What does the basic mode do? it hides the the following features from the WebUI.
### Header
It disables everything except the `settings button` and `reload` button.
### Add form
* Disable Everything except the `URL` and `Add`.
* The form will always be visible and un-collapsible.
* The preset will be the default preset, which can be specified via `YTP_DEFAULT_PRESET` environment variable.
* The output template will be the default template which can be specified via `YTP_OUTPUT_TEMPLATE` environment variable.
* The download path will be the default download path which can be specified via `YTP_DOWNLOAD_PATH` environment variable.
### Queue & History
Disables the `Information` button.
# API Documentation # API Documentation
For simple API documentation, you can refer to the [API documentation](API.md). For simple API documentation, you can refer to the [API documentation](API.md).
# How to autoload yt-dlp plugins?
Loading yt-dlp plugins in YTPTube is quite simple, we already have everything setup for you. simply, create a folder
inside the `/config` directory named `yt-dlp` so, the path will be `/config/yt-dlp`. then follow
[yt-dlp plugins docs](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#plugins) to know how to install the plugins.
Once you have installed the plugins, restart the container and the plugins will be auto-loaded on demand.
# How to load random backgrounds from WatchState or any other source?
YTPTube can be configured to pull random background images from different sources, including `WatchState` which is another
project of mine, simply change the `YTP_PICTURES_BACKENDS` environment variable to the following url
```env
YTP_PICTURES_BACKENDS=https://watchstate.ip/v1/api/system/images/background?apikey=[api_key]
```
Where `[api_key]` is the api key you get from your WatchState instance.
# The origin of the project.
The project first started as a fork [meTube](https://github.com/alexta69/metube), since then it has been completely
rewritten and redesigned. The original project was a great starting point, but it didn't align with my vision for the
project and what i wanted to achieve with it.
# Disclaimer # Disclaimer
This project is not affiliated with YouTube, yt-dlp, or any other service. It's a personal project that was created to This project is not affiliated with yt-dlp or any other service.
make downloading videos from the internet easier. It's not intended to be used for piracy or any other illegal activities.
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.
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 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 # Social contact
@ -276,50 +129,3 @@ the question. keep in mind it's solo project, as such it might take me a bit of
# Donation # Donation
If you feel like donating and appreciate my work, you can do so by donating to children charity. For example [Make-A-Wish](https://worldwish.org). If you feel like donating and appreciate my work, you can do so by donating to children charity. For example [Make-A-Wish](https://worldwish.org).
# Environment variables
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 |
| ------------------------------ | ------------------------------------------------------------------ | ---------------------------------- |
| 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 | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| 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_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `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 codec to use for in-browser streaming | `libx264` |
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
| 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_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
| 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 | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| 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_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_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` |

113
app.spec
View file

@ -1,45 +1,82 @@
import importlib.metadata
import os import os
import platform import re
import sys import sys
import tomllib import tomllib
block_cipher = None block_cipher = None
machine = platform.machine().lower() MANUAL_MAP = {
binaries = [] "bgutil_ytdlp_pot_provider": ["yt_dlp_plugins"],
if sys.platform.startswith("linux"): "python-dotenv": ["dotenv"],
if machine in ("x86_64", "amd64"): }
libdir = "/usr/lib/x86_64-linux-gnu"
elif machine in ("aarch64", "arm64"):
libdir = "/usr/lib/aarch64-linux-gnu"
else:
libdir = "/usr/lib"
binaries = []
elif sys.platform == "darwin":
binaries = []
elif sys.platform.startswith("win"):
# Windows DLLs
binaries = []
with open("./uv.lock", "rb") as f:
lock = tomllib.load(f)
hidden = [ def top_level_modules(dist_name: str) -> list[str]:
*{pkg["name"] for pkg in lock.get("package", [])}, manual: list[str] | None = MANUAL_MAP.get(dist_name) or MANUAL_MAP.get(dist_name.replace("-", "_"))
"aiohttp", if manual:
"socketio", return manual
"engineio",
try:
dist = importlib.metadata.distribution(dist_name)
top_level: list[str] = []
for f in dist.files or []:
if f.name == "top_level.txt":
txt = (dist.locate_file(f)).read_text().splitlines()
top_level.extend([line.strip() for line in txt if line.strip()])
break
if not top_level:
for f in dist.files or []:
if f.parent == "" and f.suffix == "" and "." not in f.name:
top_level.append(f.name) # noqa: PERF401
if top_level:
return sorted(set(top_level))
except Exception:
pass
return [dist_name.replace("-", "_")]
def parse_pyproject(path: str = "pyproject.toml") -> set[str]:
with open(path, "rb") as f:
data = tomllib.load(f)
reqs = set(data["project"]["dependencies"])
for extra in data["project"].get("optional-dependencies", {}).values():
reqs.update(extra)
return {re.split(r"[ ;<>=]", r, 1)[0] for r in reqs} # noqa: B034
dist_names = parse_pyproject()
hidden: list[str] = []
for dist in dist_names:
if sys.platform != "win32" and dist in ("python-magic-bin", "tzdata"):
continue
if sys.platform == "win32" and dist == "python-magic":
continue
hidden.extend(top_level_modules(dist))
hidden += [
"engineio.async_drivers.aiohttp", "engineio.async_drivers.aiohttp",
"socketio.async_drivers.aiohttp", "socketio.async_drivers.aiohttp",
"app" "socketio", # python-socketio top-level
"engineio",
"aiohttp",
"dotenv",
"app",
] ]
hidden = [f.replace("-", "_") for f in hidden] hidden = sorted(set(hidden))
a = Analysis( # noqa: F821 # type: ignore a = Analysis( # noqa: F821 # type: ignore
["app/native.py"], ["app/local.py"],
pathex=[os.getcwd()], pathex=[os.getcwd()],
binaries=binaries, binaries=[],
datas=[ datas=[
("ui/exported", "ui/exported"), ("ui/exported", "ui/exported"),
("app/", "app/"), ("app/", "app/"),
@ -47,23 +84,29 @@ a = Analysis( # noqa: F821 # type: ignore
hiddenimports=hidden, hiddenimports=hidden,
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[], # do NOT exclude libstdc++.so.6
cipher=block_cipher, cipher=block_cipher,
) )
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # type: ignore # noqa: F821 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, pyz,
a.scripts, a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name="YTPTube", name="YTPTube",
debug=False, debug=False,
strip=False, strip=False,
upx=True, console=True,
console=False, # Turn on to True if you want a console window for debugging.
icon="ui/public/favicon.ico", 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",
) )

View file

@ -0,0 +1,13 @@
import os
import sys
from pathlib import Path
if base_dir := os.environ.get("YTP_CONFIG_PATH"):
base_dir = Path(base_dir)
user_site = base_dir / f"python{sys.version_info.major}.{sys.version_info.minor}-packages"
if not user_site.exists():
user_site.mkdir(parents=True, exist_ok=True)
if user_site.is_dir() and str(user_site) not in sys.path:
sys.path.insert(0, str(user_site))

25
app/conftest.py Normal file
View 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()

View file

@ -0,0 +1 @@
"""Conditions Feature"""

View file

@ -0,0 +1,5 @@
from app.features.conditions.repository import ConditionsRepository
def get_conditions_repo() -> ConditionsRepository:
return ConditionsRepository.get_instance()

View 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,
)

View 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)

View 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

View 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)

View 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

View 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

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

View 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

View 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

View 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)

View 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)
)

View file

@ -0,0 +1 @@
"""DL Fields Feature"""

View file

@ -0,0 +1,5 @@
from app.features.dl_fields.repository import DLFieldsRepository
def get_dl_fields_repo() -> DLFieldsRepository:
return DLFieldsRepository.get_instance()

View 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

View 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)

View 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

View 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)

View 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

View 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)

View 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"

View 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"

View file

@ -0,0 +1,5 @@
from app.features.notifications.repository import NotificationsRepository
def get_notifications_repo() -> NotificationsRepository:
return NotificationsRepository.get_instance()

View 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

View 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)

View 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

View 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)

View 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

View 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)}

View file

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

View file

@ -0,0 +1 @@
"""Presets feature module."""

View 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,
},
]

View 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()

View 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

View 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)

View 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

View 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)

View 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

View 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

View 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"

View 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())

View file

@ -7,36 +7,14 @@ import functools
import json import json
import operator import operator
import os import os
import subprocess # qa: ignore
from pathlib import Path 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 = get_logger()
# parameter-less decorator
def async_lru_cache_decorator(async_function):
@functools.lru_cache
def cached_async_function(*args, **kwargs):
coroutine = async_function(*args, **kwargs)
return asyncio.ensure_future(coroutine)
return cached_async_function
# decorator with options
def async_lru_cache(*lru_cache_args, **lru_cache_kwargs):
def async_lru_cache_decorator(async_function):
@functools.lru_cache(*lru_cache_args, **lru_cache_kwargs)
def cached_async_function(*args, **kwargs):
coroutine = async_function(*args, **kwargs)
return asyncio.ensure_future(coroutine)
return cached_async_function
return async_lru_cache_decorator
class FFProbeError(Exception):
pass
class FFStream: class FFStream:
@ -59,21 +37,26 @@ class FFStream:
def __repr__(self): def __repr__(self):
if "codec_long_name" not in self.__dict__: 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(): 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(): if self.is_audio():
return ( return (
f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, channels: {self.channels} ({self.channel_layout}), " f"<Stream: #{index} [{codec_type}] {codec_long_name}, "
"{sample_rate}Hz> " 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(): 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): def is_audio(self):
""" """
@ -253,8 +236,8 @@ class FFProbeResult:
} }
@async_lru_cache(maxsize=512) @timed_lru_cache(ttl_seconds=300, max_size=128)
async def ffprobe(file: str) -> FFProbeResult: async def ffprobe(file: Path | str) -> FFProbeResult:
""" """
Run ffprobe on a file and return the parsed data as a dictionary. Run ffprobe on a file and return the parsed data as a dictionary.
@ -265,33 +248,42 @@ async def ffprobe(file: str) -> FFProbeResult:
dict: A dictionary containing the parsed data. dict: A dictionary containing the parsed data.
""" """
f = Path(file) if isinstance(file, str) else file
if not f.exists():
msg = f"No such media file '{file}'."
raise OSError(msg)
try: try:
async with await anyio.open_file(os.devnull, "w") as tempf: proc = await asyncio.create_subprocess_exec(
await asyncio.create_subprocess_exec("ffprobe", "-h", stdout=tempf, stderr=tempf) "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: except FileNotFoundError as e:
msg = "ffprobe not found." msg = "ffprobe not found."
raise OSError(msg) from e raise OSError(msg) from e
if not Path(file).exists(): args: list[str] = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", str(f)]
msg = f"No such media file '{file}'."
raise OSError(msg)
args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", file]
p = await asyncio.create_subprocess_exec( p = await asyncio.create_subprocess_exec(
"ffprobe", "ffprobe",
*args, *args,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
) )
exitCode = await p.wait() exitCode: int = await p.wait()
data, err = await p.communicate() data, err = await p.communicate()
if 0 == exitCode: if 0 == exitCode:
parsed: dict = json.loads(data.decode("utf-8")) parsed: dict = json.loads(data.decode("utf-8"))
else: else:
msg = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'" msg: str = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'"
raise FFProbeError(msg) raise FFProbeError(msg)
result = FFProbeResult() result = FFProbeResult()

View file

@ -2,8 +2,8 @@ import math
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from .ffprobe import ffprobe from app.features.streaming.library.ffprobe import ffprobe
from .Utils import StreamingError from app.features.streaming.types import StreamingError
class M3u8: class M3u8:
@ -39,7 +39,7 @@ class M3u8:
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0") m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD") 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) splits: int = math.ceil(duration / self.duration)
segmentParams: dict = {} segmentParams: dict = {}

View file

@ -1,19 +1,20 @@
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from .ffprobe import ffprobe from app.features.streaming.library.ffprobe import ffprobe
from .Utils import StreamingError, get_file_sidecar from app.features.streaming.types import StreamingError
from app.library.Utils import get_file_sidecar
class Playlist: class Playlist:
_url: str = None
def __init__(self, download_path: Path, url: str): def __init__(self, download_path: Path, url: str):
self.url: str = url self.url: str = url
"The base URL for the playlist."
self.download_path: Path = download_path self.download_path: Path = download_path
"The path where files are downloaded."
async def make(self, file: Path) -> str: 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: try:
ff = await ffprobe(file) ff = await ffprobe(file)
@ -42,6 +43,6 @@ class Playlist:
) )
playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}") playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}")
playlist.append(f"{self.url}api/player/m3u8/video/{quote(ref)}.m3u8") playlist.append(f"{self.url}api/player/m3u8/video/{quote(str(ref))}.m3u8")
return "\n".join(playlist) return "\n".join(playlist)

View file

@ -0,0 +1,325 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any, Protocol
@lru_cache(maxsize=1)
def detect_qsv_capabilities() -> dict[str, dict[str, bool]]:
"""
Detects QSV encode capability for each relevant codec.
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],
capture_output=True,
text=True,
check=False,
)
out: str = result.stdout + result.stderr
except Exception:
return {}
caps = {}
for line in out.splitlines():
line: str = line.strip()
parts: list[str] = line.split(":")
if len(parts) < 2:
continue
prof, ep_str = parts[0].strip(), parts[1].strip()
if "H264" in prof:
key = "h264"
elif "HEVC" in prof:
key = "hevc"
elif "VP9" in prof:
key = "vp9"
else:
continue
if key not in caps:
caps[key] = {"full": False, "lp": False}
if "EncSlice " in ep_str:
caps[key]["full"] = True
if "EncSliceLP" in ep_str:
caps[key]["lp"] = True
return caps
@lru_cache(maxsize=1)
def has_dri_devices() -> bool:
"""
Check if there are any /dev/dri devices.
Returns:
bool: True if there are any /dev/dri devices, False otherwise.
"""
try:
dri = Path("/dev/dri")
if not dri.exists() or not dri.is_dir():
return False
for _ in dri.iterdir():
return True
return False
except Exception:
return False
@lru_cache(maxsize=1)
def ffmpeg_encoders() -> set[str]:
"""
Return a set of available ffmpeg encoders.
Returns:
set[str]: A set of available ffmpeg encoder names.
"""
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"],
capture_output=True,
text=True,
check=False,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
out: str = (result.stdout or "") + (result.stderr or "")
except Exception:
return set()
encoders: set[str] = set()
if not out:
return encoders
for name in SUPPORTED_CODECS:
if name in out:
encoders.add(name)
return encoders
def select_encoder(configured: str) -> str:
"""
Select a concrete encoder.
Args:
configured (str): The configured encoder name, or empty for auto-detect.
Returns:
str: The selected concrete encoder name.
"""
from app.library.config import SUPPORTED_CODECS
configured = (configured or "").strip()
avail: set[str] = ffmpeg_encoders()
if configured and configured in avail:
return configured
for name in SUPPORTED_CODECS:
if name in avail:
return name
return "libx264"
class EncoderBuilder(Protocol):
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
"""Encoder-specific input/global args that must appear before '-i'."""
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
"""Append encoder-specific video/output args and return the list."""
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]
class SoftwareBuilder(_BaseBuilder):
codec_name = "libx264"
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
return super().add_video_args(["-pix_fmt", "yuv420p", *args], ctx)
class NvencBuilder(_BaseBuilder):
codec_name = "h264_nvenc"
class AmfBuilder(_BaseBuilder):
codec_name = "h264_amf"
class AppleVideoToolboxBuilder(_BaseBuilder):
codec_name = "h264_videotoolbox"
class VaapiBuilder(_BaseBuilder):
codec_name = "h264_vaapi"
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
ctx = ctx or {}
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
has_dri: bool = bool(ctx.get("has_dri", False))
device: str = ctx.get("vaapi_device", "/dev/dri/renderD128")
if is_linux and has_dri:
return ["-hwaccel", "vaapi", "-vaapi_device", str(device)]
return []
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
ctx = ctx or {}
new_args: list[str] = list(args)
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
has_dri: bool = bool(ctx.get("has_dri", False))
if is_linux and has_dri:
new_args += [
# Ensure frames are in VAAPI-compatible format and uploaded
"-vf",
"format=nv12,hwupload",
# Optional quality/preset flags similar to user's working config
"-crf",
"23",
"-preset:v",
"fast",
"-level",
"4.1",
"-profile:v",
"main",
]
return super().add_video_args(new_args)
class QsvBuilder(_BaseBuilder):
codec_name = "h264_qsv"
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
ctx = ctx or {}
args = []
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
has_dri: bool = bool(ctx.get("has_dri", False))
device: str = ctx.get("vaapi_device", "/dev/dri/renderD128")
if is_linux and has_dri:
args: list[str] = [
"-init_hw_device",
f"qsv=hw:{device}",
"-hwaccel",
"qsv",
"-hwaccel_output_format",
"qsv",
"-filter_hw_device",
"hw",
]
return args
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
ctx = ctx or {}
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
has_dri: bool = bool(ctx.get("has_dri", False))
if is_linux and has_dri:
if ctx.get("qsv", {}).get("full", False):
return [
*args,
"-vf",
"vpp_qsv=w=trunc(iw/2)*2:h=trunc(ih/2)*2:format=nv12",
"-codec:v",
self.codec_name,
"-global_quality",
"24",
"-rc_mode",
"cqp",
]
return [
*args,
"-vf",
"vpp_qsv=w=trunc(iw/2)*2:h=trunc(ih/2)*2:format=nv12",
"-codec:v",
self.codec_name,
"-low_power",
"1",
"-rc_mode",
"cbr",
"-b:v",
"3M",
"-maxrate",
"3M",
"-bufsize",
"6M",
]
return super().add_video_args(args, ctx)
def get_builder_for_codec(codec: str) -> EncoderBuilder:
"""
Return an EncoderBuilder instance for the given concrete codec name.
Args:
codec (str): The concrete codec name.
Returns:
EncoderBuilder: An instance of the corresponding EncoderBuilder.
"""
soft = SoftwareBuilder()
return {
"libx264": soft,
"h264_nvenc": NvencBuilder(),
"h264_amf": AmfBuilder(),
"h264_qsv": QsvBuilder(),
"h264_videotoolbox": AppleVideoToolboxBuilder(),
"h264_vaapi": VaapiBuilder(),
}.get(codec, soft)
def encoder_fallback_chain(codec: str) -> tuple[str, ...]:
"""
Return a fallback chain for the given codec.
Args:
codec (str): The concrete codec name.
Returns:
tuple[str, ...]: A tuple of codec names representing the fallback chain.
"""
chains: dict[str, list[str]] = {
"h264_qsv": ["h264_vaapi", "libx264"],
"h264_vaapi": ["libx264"],
"h264_nvenc": ["libx264"],
"h264_amf": ["libx264"],
"h264_videotoolbox": ["libx264"],
"libx264": [],
}
return tuple(chains.get(codec, chains["libx264"]))

View file

@ -0,0 +1,287 @@
import asyncio
import os
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 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 .segment_encoders import EncoderBuilder
LOG = get_logger()
class Segments:
"""
Build and stream MPEG-TS segments using ffmpeg with optional on-the-fly
transcoding. The class can auto-detect an available hardware encoder on the
first video segment and cache that choice for subsequent segments to avoid
repeated probing.
"""
# Cache of selected video encoder across all segment instances
_cached_vcodec: ClassVar[str | None] = None
_cache_initialized: ClassVar[bool] = False
def __init__(self, download_path: str, index: int, duration: float, vconvert: bool, aconvert: bool):
config: Config = Config.get_instance()
self.download_path: str = download_path
"The path where files are downloaded."
self.index = int(index)
"The index of the segment."
self.duration = float(duration)
"The duration of the segment."
self.vconvert = bool(vconvert)
"Whether to convert video."
self.aconvert = bool(aconvert)
"Whether to convert audio."
# Default to configured codec; can be replaced by auto-detection
self.vcodec: str = config.streamer_vcodec
"The video codec to use."
self.acodec: str = config.streamer_acodec
"The audio codec to use."
# sadly due to unforeseen circumstances, we have to convert the video for now.
self.vconvert = True
"Whether to convert video."
self.aconvert = True
"Whether to convert audio."
self.attempted: set[str] = set()
"The set of attempted codecs."
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
input_path = stream_input or file
startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}"
ctx = {
"is_linux": sys.platform.startswith("linux"),
"has_dri": has_dri_devices(),
"vaapi_device": Config.get_instance().vaapi_device,
"qsv": {"full": False, "lp": False},
}
caps: dict[str, dict[str, bool]] = detect_qsv_capabilities()
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
if self.vconvert and ff and hasattr(ff, "has_video") and ff.has_video():
builder: EncoderBuilder = get_builder_for_codec(s_codec)
else:
builder = None
ctx: dict = {}
# Collect encoder-specific input/global flags that must precede the input
input_args: list[str] = []
if builder:
input_args = builder.input_args(ctx)
fargs: list[str] = [
"-xerror",
"-hide_banner",
"-loglevel",
"error",
# input trimming before the input file
"-ss",
str(startTime),
"-t",
str(f"{self.duration:.6f}"),
"-copyts",
# hardware/global input options must come before -i
*input_args,
"-i",
f"file:{input_path}",
"-map_metadata",
"-1",
]
v_args: list[str] = []
if builder:
v_args = builder.add_video_args(["-g", "52", "-map", "0:v:0", "-strict", "-2"], ctx)
else:
v_args += ["-codec:v", "copy"]
fargs += v_args
if ff and ff.has_audio():
fargs += ["-map", "0:a:0", "-codec:a", self.acodec if self.aconvert else "copy"]
fargs += ["-sn", "-muxdelay", "0", "-f", "mpegts", "pipe:1"]
return fargs
async def _run(self, resp: web.StreamResponse, file: Path, args: list[str]) -> tuple[bool, int, bool, str]:
proc: Process = await asyncio.create_subprocess_exec(
"ffmpeg",
*args,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
client_disconnected_local = False
wrote_any = False
stderr_buf = bytearray()
async def _drain_stderr() -> None:
try:
assert proc.stderr is not None
while True:
chunk: bytes = await proc.stderr.read(4096)
if not chunk:
break
stderr_buf.extend(chunk)
except Exception:
# best-effort only
pass
stderr_task: asyncio.Task[None] = asyncio.create_task(_drain_stderr())
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:
break
wrote_any = True
try:
await resp.write(chunk)
except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError, ConnectionError):
LOG.warning("Client disconnected or connection reset while writing.")
client_disconnected_local = True
break
except asyncio.CancelledError:
LOG.warning("Client disconnected. Terminating ffmpeg.")
client_disconnected_local = True
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except TimeoutError:
LOG.warning(
"Segment stream for '%s' did not stop ffmpeg in time after the client disconnected; killing it.",
file,
)
proc.kill()
raise
except ConnectionResetError:
LOG.warning("Connection reset by peer. Skipping further writes.")
client_disconnected_local = True
finally:
# Ensure process is stopped when client disconnected, to avoid hangs
if client_disconnected_local:
proc.terminate()
try:
rc: int = await asyncio.wait_for(proc.wait(), timeout=5)
except TimeoutError:
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)
except Exception:
rc = -1
else:
# Normal termination path with a safety timeout
try:
rc = await asyncio.wait_for(proc.wait(), timeout=30)
except TimeoutError:
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)
except Exception:
rc = -1
try:
await asyncio.wait_for(stderr_task, timeout=1)
except Exception:
pass
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 = 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("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))]
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, 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 client_disconnected:
return
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()

View 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]

View 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)

View 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,
)

View file

@ -0,0 +1,185 @@
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 = 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.features.streaming.library.ffprobe import FFProbeResult, ffprobe
# Mock subprocess to avoid actual ffprobe execution
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
# Mock the subprocess result
mock_process = AsyncMock()
mock_process.wait.return_value = 0
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}}', b"")
mock_subprocess.return_value = mock_process
with patch("anyio.open_file") as mock_open_file:
mock_file = AsyncMock()
mock_open_file.return_value.__aenter__.return_value = mock_file
result = await ffprobe(str(self.test_file))
assert isinstance(result, FFProbeResult)
@pytest.mark.asyncio
async def test_ffprobe_with_nonexistent_file(self):
"""Test ffprobe with a non-existent file."""
from app.features.streaming.library.ffprobe import ffprobe
nonexistent_file = Path(self.temp_dir) / "does_not_exist.mp4"
with pytest.raises(OSError, match="No such media file"):
await ffprobe(str(nonexistent_file))
@pytest.mark.asyncio
async def test_ffprobe_caching_behavior(self):
"""Test that ffprobe results are cached with enhanced async timed_lru_cache."""
from app.features.streaming.library.ffprobe import ffprobe
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
ffprobe.cache_clear()
# 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
mock_process = AsyncMock()
mock_process.wait.return_value = 0
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}, "streams": []}', b"")
return mock_process
mock_subprocess.side_effect = create_mock_process
with patch("anyio.open_file") as mock_open_file:
mock_file = AsyncMock()
mock_open_file.return_value.__aenter__.return_value = mock_file
# First call should execute the function
result1 = await ffprobe(str(self.test_file))
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 isinstance(result2.metadata, dict)
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"
)
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.features.streaming.library.ffprobe import ffprobe
# Mock subprocess to avoid actual ffprobe execution
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
mock_process = AsyncMock()
mock_process.wait.return_value = 0
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}}', b"")
mock_subprocess.return_value = mock_process
with patch("anyio.open_file") as mock_open_file:
mock_file = AsyncMock()
mock_open_file.return_value.__aenter__.return_value = mock_file
# Test with Path object
result = await ffprobe(self.test_file)
assert result.metadata == {"duration": "10.0"}
def test_ffprobe_result_properties(self):
"""Test FFProbeResult object properties."""
from app.features.streaming.library.ffprobe import FFStream, FFProbeResult
result = FFProbeResult()
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}
)
audio_stream = FFStream({"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2})
result.video.append(video_stream)
result.audio.append(audio_stream)
assert len(result.video) == 1
assert len(result.audio) == 1
assert result.video[0].is_video()
assert result.audio[0].is_audio()
def test_stream_object_methods(self):
"""Test Stream object methods."""
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"}
)
assert video_stream.is_video()
assert not video_stream.is_audio()
assert not video_stream.is_subtitle()
assert not video_stream.is_attachment()
# Test audio stream
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()
assert not audio_stream.is_subtitle()
assert not audio_stream.is_attachment()
# Test subtitle stream
subtitle_stream = FFStream({"codec_type": "subtitle", "codec_name": "subrip"})
assert not subtitle_stream.is_video()
assert not subtitle_stream.is_audio()
assert subtitle_stream.is_subtitle()
assert not subtitle_stream.is_attachment()
# Test attachment stream
attachment_stream = FFStream({"codec_type": "attachment", "codec_name": "ttf"})
assert not attachment_stream.is_video()
assert not attachment_stream.is_audio()
assert not attachment_stream.is_subtitle()
assert attachment_stream.is_attachment()

View file

@ -0,0 +1,146 @@
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote
import pytest
from app.features.streaming.library.m3u8 import M3u8
from app.features.streaming.types import StreamingError
class _Stream:
def __init__(self, v: bool, a: bool, codec: str):
self.codec_name = codec
self._v = v
self._a = a
def is_video(self) -> bool:
return self._v
def is_audio(self) -> bool:
return self._a
@pytest.mark.asyncio
async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "dl"
base.mkdir()
media = base / "dir with space" / "file.mp4"
media.parent.mkdir()
media.write_text("x")
async def fake_ffprobe(_file: Path):
return SimpleNamespace(
metadata={"duration": "12"},
streams=lambda: [
_Stream(v=True, a=False, codec="h264"),
_Stream(v=False, a=True, codec="aac"),
],
)
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
m3 = M3u8(download_path=base, url="http://host/")
out = await m3.make_stream(media)
lines = out.splitlines()
# Headers
assert lines[0] == "#EXTM3U"
assert lines[1] == "#EXT-X-VERSION:3"
assert lines[2] == "#EXT-X-TARGETDURATION:6"
assert lines[3] == "#EXT-X-MEDIA-SEQUENCE:0"
assert lines[4] == "#EXT-X-PLAYLIST-TYPE:VOD"
# Two segments: 0 (no params), 1 (sd present)
rel = quote(str(Path("dir with space/file.mp4")))
assert lines[5] == "#EXTINF:6.000000,"
assert lines[6] == f"http://host/api/player/segments/0/{rel}.ts"
assert lines[7] == "#EXTINF:6.000000,"
assert lines[8].startswith(f"http://host/api/player/segments/1/{rel}.ts?")
assert "sd=6.000000" in lines[8]
assert lines[9] == "#EXT-X-ENDLIST"
@pytest.mark.asyncio
async def test_make_stream_transcode_flags(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "dl"
base.mkdir()
media = base / "v.mp4"
media.write_text("x")
async def fake_ffprobe(_file: Path):
return SimpleNamespace(
metadata={"duration": "13"},
streams=lambda: [
_Stream(v=True, a=False, codec="hevc"),
_Stream(v=False, a=True, codec="flac"),
],
)
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
m3 = M3u8(download_path=base, url="https://s/")
out = await m3.make_stream(media)
lines = out.splitlines()
# 3 segments, last has 1.000000 duration and includes vc/ac + sd
rel = quote(str(Path("v.mp4")))
# First
assert lines[5] == "#EXTINF:6.000000,"
assert lines[6].startswith(f"https://s/api/player/segments/0/{rel}.ts?")
assert "vc=1" in lines[6]
assert "ac=1" in lines[6]
# Second
assert lines[7] == "#EXTINF:6.000000,"
assert lines[8].startswith(f"https://s/api/player/segments/1/{rel}.ts?")
assert "vc=1" in lines[8]
assert "ac=1" in lines[8]
# Third
assert lines[9] == "#EXTINF:1.000000,"
assert lines[10].startswith(f"https://s/api/player/segments/2/{rel}.ts?")
assert "vc=1" in lines[10]
assert "ac=1" in lines[10]
assert "sd=1.000000" in lines[10]
assert lines[11] == "#EXT-X-ENDLIST"
@pytest.mark.asyncio
async def test_make_stream_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "dl"
base.mkdir()
media = base / "v.mp4"
media.write_text("x")
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={})
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"):
await m3.make_stream(media)
@pytest.mark.asyncio
async def test_make_subtitle(tmp_path: Path) -> None:
base = tmp_path / "dl"
base.mkdir()
sub = base / "dir" / "cap.srt"
sub.parent.mkdir()
sub.write_text("x")
m3 = M3u8(download_path=base, url="http://h/")
out = await m3.make_subtitle(sub, duration=42.5)
lines = out.splitlines()
assert lines[0] == "#EXTM3U"
assert lines[1] == "#EXT-X-VERSION:3"
assert lines[2] == "#EXT-X-TARGETDURATION:6"
assert lines[3] == "#EXT-X-MEDIA-SEQUENCE:0"
assert lines[4] == "#EXT-X-PLAYLIST-TYPE:VOD"
assert lines[5] == "#EXTINF:42.5,"
rel = quote(str(Path("dir/cap.srt")))
assert lines[6] == f"http://h/api/player/subtitle/{rel}.vtt"
assert lines[7] == "#EXT-X-ENDLIST"

View file

@ -0,0 +1,118 @@
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote
import pytest
from app.features.streaming.library.playlist import Playlist
from app.features.streaming.types import StreamingError
@pytest.mark.asyncio
async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Setup paths
base = tmp_path / "downloads"
base.mkdir()
# Use a relative subpath with spaces to validate quoting behavior
media = base / "My Video.mp4"
media.write_text("x")
# Mock ffprobe to return duration
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={"duration": "60"})
monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe)
# No sidecar subtitles
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.features.streaming.library.playlist.quote", lambda v: _std_quote(str(v)))
pl = Playlist(download_path=base, url="http://localhost/")
out = await pl.make(media)
lines = out.splitlines()
assert lines[0] == "#EXTM3U"
# No subtitles group when none present
assert lines[1] == "#EXT-X-STREAM-INF:PROGRAM-ID=1"
# Final line should point to video endpoint with quoted relative path
expected_ref = quote(str(Path("My Video.mp4")))
assert lines[2] == f"http://localhost/api/player/m3u8/video/{expected_ref}.m3u8"
@pytest.mark.asyncio
async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "downloads"
base.mkdir()
media = base / "dir" / "file.mp4"
media.parent.mkdir()
media.write_text("x")
# ffprobe returns numeric duration
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={"duration": "12.5"})
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")
sub2 = media.with_name("another sub.srt")
sidecar = {
"subtitle": [
{"lang": "en", "file": sub1, "name": "English"},
{"lang": "fr", "file": sub2, "name": "French"},
]
}
monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: sidecar)
from urllib.parse import quote as _std_quote
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)
lines = out.splitlines()
assert lines[0] == "#EXTM3U"
# Two EXT-X-MEDIA lines for subtitles
assert lines[1].startswith("#EXT-X-MEDIA:TYPE=SUBTITLES,")
assert 'NAME="English"' in lines[1]
assert 'LANGUAGE="en"' in lines[1]
assert "duration=12.5" in lines[1]
# URI uses the file name relative to base, replacing the name with sidecar file name and quoted
expected_uri1 = quote(str(Path("dir").joinpath(sub1.name)))
assert f"/subtitle/{expected_uri1}.m3u8" in lines[1]
assert lines[2].startswith("#EXT-X-MEDIA:TYPE=SUBTITLES,")
assert 'NAME="French"' in lines[2]
assert 'LANGUAGE="fr"' in lines[2]
expected_uri2 = quote(str(Path("dir").joinpath(sub2.name)))
assert f"/subtitle/{expected_uri2}.m3u8" in lines[2]
# Stream info with SUBTITLES group
assert lines[3] == '#EXT-X-STREAM-INF:PROGRAM-ID=1,SUBTITLES="subs"'
# Final video URL
expected_ref = quote(str(Path("dir/file.mp4")))
assert lines[4] == f"https://server/api/player/m3u8/video/{expected_ref}.m3u8"
@pytest.mark.asyncio
async def test_make_playlist_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "downloads"
base.mkdir()
media = base / "file.mp4"
media.write_text("x")
# ffprobe missing duration
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={})
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/")
with pytest.raises(StreamingError, match="Unable to get"):
await pl.make(media)

View file

@ -0,0 +1,422 @@
import asyncio
import logging
from pathlib import Path
from typing import Any
import pytest
from aiohttp import web
from app.features.streaming.library.segments import Segments
from app.tests.helpers import get_test_system_temp_root
class DummyFF:
def __init__(self, v: bool, a: bool) -> None:
self._v: bool = v
self._a: bool = a
def has_video(self) -> bool:
return self._v
def has_audio(self) -> bool:
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
media = tmp_path / "file.mp4"
media.write_bytes(b"data")
# Patch ffprobe to report both video and audio
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
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)
captured_args: list[list[str]] = []
class _EmptyProc(_FakeProc):
def __init__(self) -> None:
super().__init__([b""])
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
captured_args.append(list(args[1:]))
return _EmptyProc()
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
await seg.stream(media, _FakeResp())
assert captured_args, "ffmpeg was not invoked"
args = captured_args[0]
tmp_file = _ffmpeg_input_path(args)
# Start time is duration * index with 6 decimals for non-zero index
assert "-ss" in args
assert args[args.index("-ss") + 1] == f"{5.5 * 2:.6f}"
# Duration formatting
assert "-t" in args
assert args[args.index("-t") + 1] == f"{5.5:.6f}"
# Input uses file:<symlink>
assert "-i" in args
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
assert "-codec:v" in args
assert "-codec:a" in args
# Output format: ensure -f mpegts and pipe:1 present
assert args[-1] == "pipe:1"
assert "-f" in args
assert args[args.index("-f") + 1] == "mpegts"
@pytest.mark.asyncio
async def test_build_ffmpeg_args_audio_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
media = tmp_path / "file.mp3"
media.write_bytes(b"data")
async def fake_ffprobe(_file: Path):
return DummyFF(v=False, a=True)
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)
captured_args: list[list[str]] = []
class _EmptyProc2(_FakeProc):
def __init__(self) -> None:
super().__init__([b""])
async def fake_create_subprocess_exec2(*args: Any, **_kwargs: Any):
captured_args.append(list(args[1:]))
return _EmptyProc2()
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec2)
await seg.stream(media, _FakeResp())
assert captured_args, "ffmpeg was not invoked"
args = captured_args[0]
# Start at 0 for index 0
assert "-ss" in args
assert args[args.index("-ss") + 1] == f"{0:.6f}"
# Should not include video mapping for audio-only file
assert "0:v:0" not in args
# Should include audio mapping and codec
assert "-map" in args
assert "0:a:0" in args
assert "-codec:a" in args
class _FakeStdout:
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = chunks
async def read(self, _size: int) -> bytes:
if not self._chunks:
return b""
return self._chunks.pop(0)
class _FakeProc:
def __init__(self, chunks: list[bytes]) -> None:
self.stdout = _FakeStdout(chunks)
self.stderr = _FakeStdout([])
self.terminated = False
self.killed = False
self._rc = 0
async def wait(self) -> int:
return self._rc
def terminate(self) -> None:
self.terminated = True
def kill(self) -> None:
self.killed = True
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 | bytearray | memoryview, *_args: Any, **_kwargs: Any) -> None:
if self._exc:
raise self._exc
self.data.extend(data)
async def write_eof(self, *_args: Any, **_kwargs: Any) -> None:
self.eof = True
class _FakeProcFail(_FakeProc):
def __init__(self, err: bytes = b"") -> None:
# no stdout data, immediate failure
super().__init__([b""])
self.stderr = _FakeStdout([err, b""])
self._rc = 1
@pytest.mark.asyncio
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.features.streaming.library.segments.ffprobe", fake_ffprobe)
# Simulate no /dev/dri present but GPU encoders otherwise available
monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: False)
monkeypatch.setattr(
"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.features.streaming.library.segments import Segments as _Seg
_Seg._cached_vcodec = None
_Seg._cache_initialized = False
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
# Make preferred list try GPUs first
seg.vcodec = "" # empty configured value triggers GPU->software preference
captured_args: list[list[str]] = []
class _EmptyProc3(_FakeProc):
def __init__(self) -> None:
super().__init__([b""])
async def fake_create_subprocess_exec3(*args: Any, **_kwargs: Any):
captured_args.append(list(args[1:]))
return _EmptyProc3()
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec3)
await seg.stream(media, _FakeResp())
assert captured_args, "ffmpeg was not invoked"
args = captured_args[0]
# Expect software encoder selected
# Expect that a software attempt occurs eventually; initial selection may be HW
assert "-codec:v" in args
@pytest.mark.asyncio
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.features.streaming.library.segments.ffprobe", fake_ffprobe)
# Allow GPU usage and advertise an NVENC encoder so first pick is GPU
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")
proc_ok = _FakeProc([b"gpu-fallback-", b"ok"]) # after fallback we stream this
calls: list[int] = []
captured_args: list[list[str]] = []
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
# args[1:] is the argv for ffmpeg (first element is 'ffmpeg')
captured_args.append(list(args[1:]))
calls.append(1)
return proc_fail if len(calls) == 1 else proc_ok
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
# reset encoder cache to ensure we try GPU first
from app.features.streaming.library.segments import Segments as _Seg
_Seg._cached_vcodec = None
_Seg._cache_initialized = False
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
# Encourage GPU preference
seg.vcodec = "" # empty -> try GPUs first
resp = _FakeResp()
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" 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)
assert len(captured_args) >= 2
second = captured_args[1]
assert "-codec:v" in second
assert second[second.index("-codec:v") + 1] == "libx264"
@pytest.mark.asyncio
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.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.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")
proc_ok = _FakeProc([b"ok"]) # after fallback we stream this
captured_args: list[list[str]] = []
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
captured_args.append(list(args[1:]))
return proc_fail if len(captured_args) == 1 else proc_ok
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
# reset cache
from app.features.streaming.library.segments import Segments as _Seg
_Seg._cached_vcodec = None
_Seg._cache_initialized = False
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
seg.vcodec = "intel"
resp = _FakeResp()
await seg.stream(tmp_path / "file.mp4", resp)
# First call had QSV codec and QSV flags
first = captured_args[0]
assert "-codec:v" in first
assert first[first.index("-codec:v") + 1] == "h264_qsv"
assert "-init_hw_device" in first
assert "qsv=hw:/dev/dri/renderD128" in first
assert "-filter_hw_device" in first
assert first[first.index("-filter_hw_device") + 1] == "hw"
assert "-vf" in first
assert "vpp_qsv" in first[first.index("-vf") + 1]
# Second call (fallback) must switch codec to a safe fallback
second = captured_args[1]
assert "-codec:v" in second
fallback_codec = second[second.index("-codec:v") + 1]
assert fallback_codec in {"h264_vaapi", "libx264"}
if fallback_codec == "libx264":
# No HW flags for software, ensure pix_fmt is set
assert "-init_hw_device" not in second
assert "-filter_hw_device" not in second
if "-vf" in second:
vf_val = second[second.index("-vf") + 1]
assert "scale_qsv" not in vf_val
assert "hwupload" not in vf_val
assert "-pix_fmt" in second
assert second[second.index("-pix_fmt") + 1] == "yuv420p"
else:
# VAAPI path: should include vaapi flags
assert "-vaapi_device" in second
assert "-hwaccel" in second
assert "vaapi" in second
@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.features.streaming.library.segments.ffprobe", fake_ffprobe)
# Process that yields two chunks and then EOF
proc = _FakeProc([b"abc", b"def", b""])
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(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
@pytest.mark.asyncio
async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail
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(fail_with=ConnectionResetError())
await seg.stream(tmp_path / "file.mp4", resp)
# 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
async def test_stream_cancelled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
proc = _FakeProc([b"abc"]) # only one chunk
async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any):
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)
# Fail with CancelledError on write to hit inner disconnection branch
resp = _FakeResp(fail_with=asyncio.CancelledError())
await seg.stream(tmp_path / "file.mp4", resp)
# Inner branch treats it as client disconnected; no EOF and we terminate ffmpeg
assert resp.eof is False
assert proc.terminated is True

View 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"

View 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

View 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

View 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."""

View file

@ -0,0 +1 @@
"""Tasks Feature."""

View file

@ -0,0 +1 @@
"""Tasks Definitions Feature."""

View file

@ -0,0 +1,5 @@
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
def get_task_definitions_repo() -> TaskDefinitionsRepository:
return TaskDefinitionsRepository.get_instance()

View file

@ -0,0 +1 @@
"""Handlers package for task definitions."""

View 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,
)

View 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

View file

@ -0,0 +1,313 @@
import hashlib
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.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 = get_logger()
CACHE: Cache = Cache()
class RssGenericHandler(BaseHandler):
FEED_PATTERN: re.Pattern[str] = re.compile(
r"\.(rss|atom)(\?.*)?$|handler=rss",
re.IGNORECASE,
)
@staticmethod
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: HandleTask,
params: dict,
parsed: dict[str, str],
) -> tuple[str, list[dict[str, str]], int]:
"""
Fetch the feed and return raw entries.
Args:
task (Task): The task containing the feed URL.
params (dict): The ytdlp options.
parsed (dict): The parsed URL components (contains 'url' key).
Returns:
tuple[str, list[dict[str, str]], int]: The feed URL, list of entry dictionaries, and entry count.
"""
from defusedxml.ElementTree import fromstring
feed_url: str = parsed["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()
root: Element = fromstring(response.text)
# Define namespaces for different feed formats
ns: dict[str, str] = {
"atom": "http://www.w3.org/2005/Atom",
"rss": "http://www.rssboard.org/specification",
"content": "http://purl.org/rss/1.0/modules/content/",
"media": "http://search.yahoo.com/mrss/",
}
items: list[dict[str, str]] = []
real_count = 0
# Try to parse as Atom feed first
entries = root.findall("atom:entry", ns)
if 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:
link_elem = entry.find("atom:link", ns)
url: str = ""
if link_elem is not None and link_elem.get("href"):
url = link_elem.get("href", "")
if not url:
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)
title: str = title_elem.text if title_elem is not None and title_elem.text else ""
pub_elem: Element | None = entry.find("atom:published", ns)
published: str = pub_elem.text if pub_elem is not None and pub_elem.text else ""
real_count += 1
items.append({"url": url, "title": title, "published": published})
else:
# Try to parse as RSS feed
rss_items = root.findall(".//item")
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)
url: str = ""
link_elem = item.find("link")
if link_elem is not None and link_elem.text:
url = link_elem.text
else:
# Try media:content
media_elem = item.find("media:content", ns)
if media_elem is not None and media_elem.get("url"):
url = media_elem.get("url", "")
else:
# Try enclosure
enclosure_elem = item.find("enclosure")
if enclosure_elem is not None and enclosure_elem.get("url"):
url = enclosure_elem.get("url", "")
if not url:
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")
title: str = title_elem.text if title_elem is not None and title_elem.text else ""
pub_elem = item.find("pubDate")
published: str = pub_elem.text if pub_elem is not None and pub_elem.text else ""
real_count += 1
items.append({"url": url, "title": title, "published": published})
return feed_url, items, real_count
@staticmethod
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.
"""
parsed: dict[str, str] | None = RssGenericHandler.parse(task.url)
if not parsed:
return TaskFailure(message="Unrecognized RSS/Atom feed URL.")
params: dict = task.get_ytdlp_opts().get_all()
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(
"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] = []
for entry in items:
if not (url := entry.get("url")):
continue
# Try to get static archive ID first
id_dict: dict[str, str | None] = get_archive_id(url=url)
archive_id: str | None = id_dict.get("archive_id")
# If static archive_id fails, try to fetch it via yt-dlp (like generic.py)
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:
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(
"Task '%s' could not generate a static archive ID. Fetching it with yt-dlp.",
task.name,
extra={"task_name": task.name, "url": url},
)
(info, _) = await fetch_info(
config=params,
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={"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={"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, Any] = {k: v for k, v in entry.items() if k not in {"url", "title", "published"}}
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={"feed_url": feed_url, "entry_count": real_count},
)
@staticmethod
def parse(url: str) -> dict[str, str] | None:
"""
Parse URL for valid RSS/Atom feed.
Args:
url (str): The URL to parse.
Returns:
dict[str, str] | None: A dictionary with 'url' key if valid RSS/Atom feed, None otherwise.
"""
if not isinstance(url, str) or not url:
return None
return {"url": url} if RssGenericHandler.FEED_PATTERN.search(url) else None
@staticmethod
def tests() -> list[tuple[str, bool]]:
"""
Test cases for the URL parser.
Returns:
list[tuple[str, bool]]: A list of tuples containing the URL and expected result.
"""
return [
("https://www.example.com/test.rss", True),
("https://www.example.com/test.atom", True),
("https://www.example.com/test.atom#handler=rss", True),
("https://www.example.com/test.atom?handler=rss", True),
("https://www.example.com/feed.rss?version=2.0", True),
("https://www.example.com/test.xml", False),
("https://www.example.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw", False),
("https://www.example.com/playlist?list=PLBCF2DAC6FFB574DE", False),
("https://www.example.com/user/SomeUser", False),
("https://example.com/feed.ATOM", True),
("https://example.com/feed.RSS", True),
]

View file

@ -0,0 +1,252 @@
import re
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 = get_logger()
class TverHandler(BaseHandler):
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/",
}
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?P<id>sr[a-z0-9_]+)$")
@staticmethod
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
async def _get_session_tokens() -> dict[str, str] | None:
"""
Create a session and retrieve platform_uid and platform_token.
Returns:
dict[str, str] | None: Dictionary with platform_uid and platform_token, or None on failure.
"""
try:
response = await TverHandler.request(
url=TverHandler.SESSION_API,
headers=TverHandler.HEADERS,
method="POST",
data={"device_type": "pc"},
)
response.raise_for_status()
data = response.json()
platform_uid = data.get("result", {}).get("platform_uid")
platform_token = data.get("result", {}).get("platform_token")
if not platform_uid or not platform_token:
LOG.warning("Failed to extract platform_uid and platform_token from session response.")
return None
return {"platform_uid": platform_uid, "platform_token": platform_token}
except Exception as 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: HandleTask,
params: dict,
series_id: str,
) -> tuple[str, list[dict[str, str]], bool]:
"""
Fetch episodes from tver series API.
Args:
task (Task): The task containing the tver series URL.
params (dict): The ytdlp options.
series_id (str): The tver series ID.
Returns:
tuple[str, list[dict[str, str]], bool]: The feed URL, list of episodes, and whether items were found.
"""
tokens = await TverHandler._get_session_tokens()
if not tokens:
msg = "Could not obtain tver session tokens"
raise RuntimeError(msg)
feed_url = TverHandler.SERIES_API.format(id=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,
headers=TverHandler.HEADERS,
ytdlp_opts=params,
params={
"platform_uid": tokens["platform_uid"],
"platform_token": tokens["platform_token"],
},
)
response.raise_for_status()
data = response.json()
items: list[dict[str, str]] = []
has_items = False
# Parse episodes from result.contents[0].contents
try:
contents = data.get("result", {}).get("contents", [])
if not contents:
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 {}
episodes = season_block.get("contents", [])
for episode_data in episodes:
if "episode" != episode_data.get("type"):
continue
content = episode_data.get("content", {})
episode_id = content.pop("id", None)
if not episode_id:
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}"
title = content.pop("title", "")
id_dict = get_archive_id(url)
archive_id = id_dict.get("archive_id")
if not archive_id:
LOG.warning(
"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
has_items = True
items.append(
{"id": episode_id, "url": url, "title": title, "archive_id": archive_id, "metadata": content}
)
except Exception as 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: 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.")
params: dict = task.get_ytdlp_opts().get_all()
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(
"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] = []
for entry in items:
if not (url := entry.get("url")):
continue
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})
@staticmethod
def parse(url: str) -> str | None:
"""
Parse URL to extract series ID.
Args:
url (str): The url to check.
Returns:
str | None: The parsed ID if successful, None otherwise.
"""
match: re.Match[str] | None = TverHandler.RX.match(url)
return match.group("id") if match else None
@staticmethod
def tests() -> list[tuple[str, bool]]:
"""
Test cases for the URL parser.
Returns:
list[tuple[str, bool]]: A list of tuples containing the URL and expected result.
"""
return [
("https://tver.jp/series/sr1jg44pbb", True),
("https://www.tver.jp/series/sr1jg44pbb", True),
("https://m.tver.jp/series/sr_test_id", True),
("http://tver.jp/series/sr123abc456", True),
("https://tver.jp/videos/sr123abc456", False),
("https://youtube.com/watch?v=123", False),
]

View file

@ -0,0 +1,164 @@
import re
from typing import TYPE_CHECKING
from xml.etree.ElementTree import Element
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 = get_logger()
class TwitchHandler(BaseHandler):
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
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: HandleTask,
params: dict,
handle_name: str,
) -> tuple[str, list[dict[str, str]], bool]:
from defusedxml.ElementTree import fromstring
feed_url: str = TwitchHandler.FEED.format(handle=handle_name)
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()
root: Element[str] = fromstring(response.text)
items: list[dict[str, str]] = []
has_items = False
for entry in root.findall("channel/item"):
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(
"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(
"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")
title_elem: Element[str] | None = entry.find("title")
title: str = title_elem.text.strip() if title_elem is not None and title_elem.text else ""
has_items = True
id_dict = get_archive_id(url)
archive_id: str | None = id_dict.get("archive_id")
if not archive_id:
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})
return feed_url, items, has_items
@staticmethod
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.")
params: dict = task.get_ytdlp_opts().get_all()
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(
"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] = []
for entry in items:
if not (url := entry.get("url")):
continue
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})
@staticmethod
def parse(url: str) -> str | None:
"""
Parse twitch URL to extract the channel.
Args:
url (str): The url to check.
Returns:
str | None: The parsed ID if successful, None otherwise.
"""
match: re.Match[str] | None = TwitchHandler.RX.match(url)
return match.group("id") if match else None
@staticmethod
def tests() -> list[tuple[str, bool]]:
"""
Test cases for the URL parser.
Returns:
list[tuple[str, bool]]: A list of tuples containing the URL and expected result.
"""
return [
("https://www.twitch.tv/test_username", True),
("https://twitch.tv/test_username", True),
("http://m.twitch.tv/test_username,", True),
("https://www.twitch.tv/test_username/", True),
("https://twitch.tv/test_username/", True),
("http://m.twitch.tv/test_username/,", True),
("twitch.tv/test_username/", False),
]

View file

@ -0,0 +1,184 @@
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.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 = get_logger()
class YoutubeHandler(BaseHandler):
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})|)/?$"
)
PLAYLIST_REGEX: re.Pattern[str] = re.compile(
r"^https?://(?:www\.)?youtube\.com/(?:playlist\?list=(?P<id>[A-Za-z0-9_-]+)|).*$"
)
@staticmethod
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: HandleTask, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
"""
Fetch the feed and return raw entries.
Args:
task (Task): The task containing the YouTube URL.
params (dict): The ytdlp options.
parsed (dict): The parsed URL components.
Returns:
tuple[str, list[dict[str, str]], int]: The feed URL, list of
"""
from defusedxml.ElementTree import fromstring
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
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()
root: Element[str] = fromstring(response.text)
ns: dict[str, str] = {
"atom": "http://www.w3.org/2005/Atom",
"yt": "http://www.youtube.com/xml/schemas/2015",
}
items: list[dict[str, str]] = []
real_count = 0
for entry in root.findall("atom:entry", ns):
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(
"'%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}"
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(
"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)
title: str = title_elem.text if title_elem is not None and title_elem.text else ""
pub_elem: Element[str] | None = entry.find("atom:published", ns)
published: str = pub_elem.text if pub_elem is not None and pub_elem.text else ""
real_count += 1
items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id})
return feed_url, items, real_count
@staticmethod
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.")
params: dict = task.get_ytdlp_opts().get_all()
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(
"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] = []
for entry in items:
if not (url := entry.get("url")):
continue
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))
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "entry_count": real_count})
@staticmethod
def parse(url: str) -> dict[str, str] | None:
"""
Parse YouTube channel or playlist URL.
Args:
url (str): The YouTube URL to parse.
Returns:
{'type': 'channel', 'id': <channel-id>}
{'type': 'playlist', 'id': <playlist-id>}
None if the URL is neither.
"""
if (m := YoutubeHandler.CHANNEL_REGEX.match(url)) and m.group("id"):
return {"type": "channel_id", "id": m.group("id")}
if (m := YoutubeHandler.PLAYLIST_REGEX.match(url)) and m.group("id"):
return {"type": "playlist_id", "id": m.group("id")}
return None
@staticmethod
def tests() -> list[tuple[str, bool]]:
"""
Test cases for the URL parser.
Returns:
list[tuple[str, bool]]: A list of tuples containing the URL and expected result.
"""
return [
("https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN", True),
("https://youtube.com/c/MyCustomName", False),
("https://youtube.com/user/SomeUser123", False),
("https://youtube.com/@SomeHandle", False),
("https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ", True),
("https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ", True),
("https://youtube.com/watch?v=foo", False),
]

View 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

View 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)

View 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

View 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

View 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,
)

View 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

View 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

View file

@ -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"},
]

View file

@ -0,0 +1,268 @@
from pathlib import Path
import pytest
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:
def __init__(self, text: str):
self.text = text
def raise_for_status(self) -> None:
return None
class DummyOpts:
def __init__(self, data):
self._data = data
def get_all(self):
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."""
@pytest.mark.parametrize(("url", "expected"), RssGenericHandler.tests())
def test_url_parsing(self, url: str, expected: bool):
"""Test URL parsing against all defined test cases."""
result = RssGenericHandler.parse(url)
is_matched = result is not None
assert is_matched == expected, f"URL '{url}' expected {expected}, got {is_matched}"
@pytest.mark.parametrize(("url", "expected"), RssGenericHandler.tests())
def test_returns_url_dict_on_match(self, url: str, expected: bool):
"""Test that parse returns dict with 'url' key for valid feeds."""
result = RssGenericHandler.parse(url)
if expected:
assert result is not None
assert "url" in result
assert result["url"] == url
else:
assert result is None
class TestRssHandlerExtraction:
"""Test RSS feed extraction and parsing."""
@pytest.mark.asyncio
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">
<title>Example Feed</title>
<entry>
<title>Video 1</title>
<link href="https://www.youtube.com/watch?v=abc123" rel="alternate" />
<published>2024-01-01T00:00:00Z</published>
</entry>
<entry>
<title>Video 2</title>
<link href="https://www.youtube.com/watch?v=def456" rel="alternate" />
<published>2024-01-02T00:00:00Z</published>
</entry>
</feed>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(atom_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
task = HandleTask(
id=1,
name="Test Atom Feed",
url="https://example.com/feed.atom",
preset="default",
)
result = await RssGenericHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 2
assert result.items[0].title == "Video 1"
assert result.items[0].url == "https://www.youtube.com/watch?v=abc123"
assert result.items[1].title == "Video 2"
assert result.items[1].url == "https://www.youtube.com/watch?v=def456"
assert result.metadata["entry_count"] == 2
@pytest.mark.asyncio
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">
<channel>
<title>Example Channel</title>
<item>
<title>Video 1</title>
<link>https://www.youtube.com/watch?v=abc123</link>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
</item>
<item>
<title>Video 2</title>
<link>https://www.youtube.com/watch?v=def456</link>
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
</item>
</channel>
</rss>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(rss_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
task = HandleTask(
id=1,
name="Test RSS Feed",
url="https://example.com/feed.rss",
preset="default",
)
result = await RssGenericHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 2
assert result.items[0].title == "Video 1"
assert result.items[0].url == "https://www.youtube.com/watch?v=abc123"
assert result.items[1].title == "Video 2"
assert result.items[1].url == "https://www.youtube.com/watch?v=def456"
assert result.metadata["entry_count"] == 2
@pytest.mark.asyncio
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">
<entry>
<link href="https://www.youtube.com/watch?v=abc123" />
</entry>
</feed>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(atom_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
task = HandleTask(
id=1,
name="Test rss Feed",
url="https://example.com/feed.atom",
preset="default",
)
assert await RssGenericHandler.can_handle(task) is True
non_feed_task = HandleTask(
id=1,
name="YouTube Video",
url="https://www.youtube.com/watch?v=abc123",
preset="default",
)
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: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Test handling of empty feed."""
empty_feed = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Empty Channel</title>
</channel>
</rss>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(empty_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
task = HandleTask(
id=1,
name="Empty Feed",
url="https://example.com/feed.rss",
preset="default",
)
result = await RssGenericHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 0
assert result.metadata["entry_count"] == 0
@pytest.mark.asyncio
async def test_invalid_feed_url(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Test handling of invalid feed URL."""
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(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
task = HandleTask(
id=1,
name="Invalid Feed",
url="https://example.com/feed.rss",
preset="default",
)
result = await RssGenericHandler.extract(task)
assert isinstance(result, TaskFailure)
@pytest.mark.asyncio
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">
<channel>
<item>
<title>No URL Item</title>
</item>
<item>
<title>Valid Item</title>
<link>https://www.youtube.com/watch?v=abc123</link>
</item>
</channel>
</rss>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
task = HandleTask(
id=1,
name="Feed with Missing URLs",
url="https://example.com/feed.rss",
preset="default",
)
result = await RssGenericHandler.extract(task)
# Should only include the item with URL
assert isinstance(result, TaskResult)
assert len(result.items) == 1
assert result.items[0].title == "Valid Item"

View 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"

View file

@ -0,0 +1,165 @@
from pathlib import Path
import pytest
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:
def __init__(self, data: dict):
self.data = data
def json(self):
return self.data
def raise_for_status(self) -> None:
return None
class DummyOpts:
def __init__(self, data):
self._data = data
def get_all(self):
return self._data
@pytest.mark.asyncio
async def test_tver_handler_extract(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Test tver handler extraction of episodes from series."""
session_response = {
"result": {
"platform_uid": "test_uid_123",
"platform_token": "test_token_456",
}
}
episodes_response = {
"result": {
"contents": [
{
"seasonTitle": "本編",
"hasNext": False,
"contents": [
{
"type": "episode",
"content": {
"id": "eppcxhym1e",
"version": 12,
"title": "木村拓哉がタクシー 運転手!目黒蓮が木村と二人旅",
"seriesID": "sr1jg44pbb",
"endAt": 1766404799,
"broadcastDateLabel": "11月24日(月)放送 分",
"isNHKContent": False,
"isSubtitle": False,
"ribbonID": 0,
"seriesTitle": "ウルトラタクシー",
"isAvailable": True,
"broadcasterName": "TBS",
"productionProviderName": "TBS",
"isEndingSoon": False,
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/eppcxhym1e.jpg?v=84b0ee80",
},
"resume": {"lastViewedDuration": 0, "contentDuration": 3796, "completed": False},
"isFavorite": False,
"isGood": False,
"isLater": False,
"goodCount": 5553,
},
{
"type": "episode",
"content": {
"id": "epejwb9mvx",
"version": 9,
"title": "木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!",
"seriesID": "sr1jg44pbb",
"endAt": 1764590399,
"broadcastDateLabel": "11月24日(月)放送分",
"isNHKContent": False,
"isSubtitle": False,
"ribbonID": 0,
"seriesTitle": "ウルトラタクシー",
"isAvailable": True,
"broadcasterName": "TBS",
"productionProviderName": "TBS",
"isEndingSoon": False,
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/epejwb9mvx.jpg?v=ab478281",
},
"resume": {"lastViewedDuration": 0, "contentDuration": 1896, "completed": False},
"isFavorite": False,
"isGood": False,
"isLater": False,
"goodCount": 1298,
},
],
}
],
"seasons": [
{
"type": "season",
"content": {"id": "sshbu1ycq8", "version": 2, "title": "本編", "season_extended": None},
}
],
}
}
call_count = 0
async def fake_request(url: str, **kwargs): # noqa: ARG001
nonlocal call_count
call_count += 1
if "browser/create" in url:
return DummyResponse(session_response)
if "callSeriesEpisodes" in url:
return DummyResponse(episodes_response)
msg = f"Unexpected URL: {url}"
raise RuntimeError(msg)
archive_path = tmp_path / "archive.txt"
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)
assert isinstance(result, TaskResult)
assert len(result.items) == 2
assert result.items[0].url == "https://tver.jp/episodes/eppcxhym1e"
assert "木村拓哉がタクシー 運転手!目黒蓮が木村と二人旅" == result.items[0].title
assert result.items[1].url == "https://tver.jp/episodes/epejwb9mvx"
assert (
"木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!"
== result.items[1].title
)
assert result.metadata.get("has_entries") is True
assert "callSeriesEpisodes" in result.metadata.get("feed_url", "")
assert call_count == 2
@pytest.mark.parametrize(("url", "should_match"), TverHandler.tests())
def test_parse(url: str, should_match: bool) -> None:
"""Test tver URL parsing."""
result = TverHandler.parse(url)
if should_match:
assert result is not None
assert result.startswith("sr")
else:
assert result is None
@pytest.mark.asyncio
async def test_can_handle() -> None:
"""Test tver handler can_handle method."""
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

View file

@ -0,0 +1,62 @@
from pathlib import Path
import pytest
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:
def __init__(self, text: str):
self.text = text
def raise_for_status(self) -> None:
return None
class DummyOpts:
def __init__(self, data):
self._data = data
def get_all(self):
return self._data
@pytest.mark.asyncio
async def test_twitch_handler_inspect(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
feed = """
<rss><channel>
<item>
<link>https://www.twitch.tv/videos/111</link>
<title>First VOD</title>
</item>
<item>
<link>https://www.twitch.tv/videos/222</link>
<title>Second VOD</title>
</item>
</channel></rss>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(feed)
archive_path = tmp_path / "archive.txt"
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",
)
result = await TwitchHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 2
assert result.items[0].url == "https://www.twitch.tv/videos/111"
assert result.items[0].title == "First VOD"
assert result.metadata.get("has_entries") is True

View file

@ -0,0 +1,66 @@
from pathlib import Path
import pytest
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:
def __init__(self, text: str):
self.text = text
def raise_for_status(self) -> None:
return None
class DummyOpts:
def __init__(self, data):
self._data = data
def get_all(self):
return self._data
@pytest.mark.asyncio
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>
<yt:videoId>abc123</yt:videoId>
<title>First Video</title>
<published>2024-01-01T00:00:00Z</published>
</entry>
<entry>
<yt:videoId>def456</yt:videoId>
<title>Second Video</title>
<published>2024-01-02T00:00:00Z</published>
</entry>
</feed>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(feed)
archive_path = tmp_path / "archive.txt"
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",
)
result = await YoutubeHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 2
first = result.items[0]
assert first.url == "https://www.youtube.com/watch?v=abc123"
assert first.title == "First Video"
assert first.metadata.get("published") == "2024-01-01T00:00:00Z"
assert result.metadata.get("entry_count") == 2

Some files were not shown because too many files have changed in this diff Show more