kryten-webqueue 0.34.4__tar.gz → 0.34.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/CHANGELOG.md +61 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/PKG-INFO +1 -1
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_catalog.py +24 -121
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_connection.py +11 -4
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_playlists.py +76 -2
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/images.py +1 -1
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/fetchurls.py +3 -1
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/motdposters.py +15 -4
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/playedmovies.py +18 -4
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/job_scheduler.py +13 -4
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/fire.py +5 -20
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/importer.py +3 -23
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/completion.py +6 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/shadow.py +27 -8
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_catalog.py +2 -4
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_job_schedules.py +6 -2
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_playlists.py +2 -8
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_schedules.py +17 -7
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-playlists.js +4 -9
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/playlists.html +1 -2
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/pyproject.toml +1 -1
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase4_live_fixes.py +132 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_recently_played_hide.py +156 -233
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.gitignore +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/README.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/config.example.json +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/__init__.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_search_facets.py +0 -0
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.34.9] - 2026-08-13
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Playlist continuation is now driven by rotation, not pass tracking.**
|
|
10
|
+
Replaced the broken `playlist_item_played` pass-continuation mechanism with
|
|
11
|
+
automatic playlist rotation: when an item plays past the 50% threshold,
|
|
12
|
+
`CompletionRecorder` calls `rotate_playlist_item_to_bottom`, which moves the
|
|
13
|
+
item to the end of every mutable playlist containing it. `fire_schedule` and
|
|
14
|
+
`PlaylistImporter` always load from position 0 — no filtering needed. Mutable
|
|
15
|
+
playlists now naturally advance across preemptions without ever getting stuck.
|
|
16
|
+
|
|
17
|
+
- **Recently-played hiding simplified.** All played items (episodes, movies,
|
|
18
|
+
everything) now record a `play_completions` time-window row. The `playlist_item_played`
|
|
19
|
+
table is no longer written to; the "release whole collection on last item"
|
|
20
|
+
behaviour is removed. The per-item time-window applies uniformly to all catalog
|
|
21
|
+
items.
|
|
22
|
+
|
|
23
|
+
- **Admin playlist UI.** "Load Entire List" button removed; "Import to Live"
|
|
24
|
+
always loads the full playlist in current order (which is the rotated order).
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
|
|
28
|
+
- `get_playlist_played_media_ids` DB method
|
|
29
|
+
- `skip_played` parameter from `PlaylistImporter.import_playlist`
|
|
30
|
+
- `?full=1` query parameter from `POST /admin/playlists/{id}/import`
|
|
31
|
+
- `by_playlist_pass` key from `GET /admin/catalog/recently-played/debug` response
|
|
32
|
+
- `playlist_pass` key from `POST /admin/catalog/{token}/clear-played` response
|
|
33
|
+
|
|
34
|
+
## [0.34.7] - 2026-08-13
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **Event lock lifts at the correct time (Bug #1).** The scheduled-event
|
|
39
|
+
pay-to-play lock was lifting ~30 minutes late when `last_item_uid` was never
|
|
40
|
+
captured (e.g. a CyTube `queue`-event timeout during bulk add left the field
|
|
41
|
+
`NULL`). With no uid anchor `_maybe_lift_event_lock` returned early and the
|
|
42
|
+
only fallback was the `estimated_end_at + 5 min` time safety net, which fires
|
|
43
|
+
roughly when the *last* item finishes rather than when it *starts*.
|
|
44
|
+
Fix: `fire_schedule` now always records `last_item_media_id` (the manifest
|
|
45
|
+
URL of the last scheduled item) even when the uid is unavailable. The lock
|
|
46
|
+
lift logic now matches on `media_id` first and falls back to `last_item_uid`,
|
|
47
|
+
so pay-to-play reopens as soon as the last scheduled item begins playing
|
|
48
|
+
regardless of whether the uid was captured. DB migration v12 adds the new
|
|
49
|
+
`last_item_media_id TEXT` column to `active_schedule`.
|
|
50
|
+
|
|
51
|
+
- **Schedule modifications apply immediately (Bug #2).** When a schedule was
|
|
52
|
+
updated via the admin UI, the APScheduler job was re-registered only if
|
|
53
|
+
`fire_at` was present in the request body. The `update_schedule` route now
|
|
54
|
+
always re-registers the job after any modification, and it reads the
|
|
55
|
+
authoritative `fire_at` from the database (not from the request body) to
|
|
56
|
+
avoid stale or mismatched values. If the updated `fire_at` is already in the
|
|
57
|
+
past, the job is not re-armed (preventing an accidental immediate re-fire).
|
|
58
|
+
|
|
59
|
+
- Cover art downloads for movies: corrected TMDB API parameter from `year` to
|
|
60
|
+
`primary_release_year` in movie search. TV searches already used the correct
|
|
61
|
+
`first_air_date_year` parameter, which is why cover art worked for TV but
|
|
62
|
+
not movies.
|
|
63
|
+
|
|
3
64
|
## [0.34.4] - 2026-08-12
|
|
4
65
|
|
|
5
66
|
### Changed
|
|
@@ -102,22 +102,13 @@ def _promo_hidden_media_subquery() -> tuple[str, list]:
|
|
|
102
102
|
def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
|
|
103
103
|
"""SQL fragment (+ params) hiding recently-played items from regular users.
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
the last ``days`` days. A completion is only recorded when an item
|
|
109
|
-
actually played past a threshold (see ``CompletionRecorder``), so an item
|
|
110
|
-
that was queued and then refunded/removed before playing never counts.
|
|
111
|
-
|
|
112
|
-
2. **Mutable-playlist pass** — the item is a short (<1h) episode of a mutable
|
|
113
|
-
(TV-show) playlist that has played in the current pass and not yet reset
|
|
114
|
-
(``playlist_item_played``). These are governed by playlist position, not
|
|
115
|
-
the day window, so a whole show collection releases together when its last
|
|
116
|
-
item plays.
|
|
105
|
+
Items with a ``play_completions`` row within the last ``days`` days are
|
|
106
|
+
hidden. A completion is only recorded when an item played past a threshold
|
|
107
|
+
(see ``CompletionRecorder``), so refunded/skipped items never hide.
|
|
117
108
|
|
|
118
109
|
The fragment is prefixed with ``AND`` so it can be appended to an existing
|
|
119
110
|
WHERE clause referencing the catalog row under ``alias``. Caller must ensure
|
|
120
|
-
``days > 0`` before applying (a non-positive window disables
|
|
111
|
+
``days > 0`` before applying (a non-positive window disables this).
|
|
121
112
|
"""
|
|
122
113
|
sql = f"""
|
|
123
114
|
AND {alias}.friendly_token NOT IN (
|
|
@@ -125,10 +116,6 @@ def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
|
|
|
125
116
|
WHERE pc.media_type = 'cm'
|
|
126
117
|
AND pc.completed_at >= datetime('now', ?)
|
|
127
118
|
)
|
|
128
|
-
AND {alias}.friendly_token NOT IN (
|
|
129
|
-
SELECT pip.media_id FROM playlist_item_played pip
|
|
130
|
-
WHERE pip.media_type = 'cm'
|
|
131
|
-
)
|
|
132
119
|
"""
|
|
133
120
|
return sql, [f"-{int(days)} days"]
|
|
134
121
|
|
|
@@ -419,23 +406,17 @@ class _CatalogMixin:
|
|
|
419
406
|
)
|
|
420
407
|
|
|
421
408
|
async def record_play_completion(
|
|
422
|
-
self,
|
|
409
|
+
self,
|
|
410
|
+
*,
|
|
411
|
+
friendly_token: str,
|
|
412
|
+
duration_sec: int | None = None,
|
|
413
|
+
media_type: str = "cm",
|
|
423
414
|
) -> None:
|
|
424
|
-
"""Record a genuine play-completion
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
the playlist's last item (MAX position) clears the whole pass so the
|
|
430
|
-
collection releases together. Everything else (movies, standalone clips,
|
|
431
|
-
long items even inside a mutable playlist) gets a time-boxed
|
|
432
|
-
``play_completions`` row instead.
|
|
433
|
-
|
|
434
|
-
Promo-pool clips are exempt entirely: they are already excluded from the
|
|
435
|
-
public catalog and must never be treated like normal (mutable/immutable)
|
|
436
|
-
playlist items, so playing one records nothing. The same exemption covers
|
|
437
|
-
any promo/bumper hidden by category or tag (e.g. ``Z Channel Promos`` /
|
|
438
|
-
``channelz``), which is how most station promos are actually classified.
|
|
415
|
+
"""Record a genuine play-completion for catalog-hide purposes.
|
|
416
|
+
|
|
417
|
+
Promo-pool clips and hidden-category/tag items are exempt. Everything
|
|
418
|
+
else gets a time-boxed ``play_completions`` row that hides the item from
|
|
419
|
+
regular users until the configured window expires.
|
|
439
420
|
"""
|
|
440
421
|
sub, sub_params = _promo_hidden_media_subquery()
|
|
441
422
|
promo = await self._fetch_one(
|
|
@@ -444,84 +425,28 @@ class _CatalogMixin:
|
|
|
444
425
|
)
|
|
445
426
|
if promo:
|
|
446
427
|
return
|
|
447
|
-
|
|
448
|
-
""
|
|
449
|
-
|
|
450
|
-
(SELECT MAX(position) FROM saved_playlist_items
|
|
451
|
-
WHERE playlist_id = spi.playlist_id) AS max_pos
|
|
452
|
-
FROM saved_playlist_items spi
|
|
453
|
-
JOIN saved_playlists sp ON sp.id = spi.playlist_id
|
|
454
|
-
WHERE spi.media_id = ? AND spi.media_type = ?
|
|
455
|
-
AND sp.is_immutable = 0 AND sp.promo_type IS NULL
|
|
456
|
-
""",
|
|
457
|
-
[friendly_token, media_type],
|
|
458
|
-
)
|
|
459
|
-
governed = False
|
|
460
|
-
if duration_sec is not None and duration_sec < 3600:
|
|
461
|
-
for r in rows:
|
|
462
|
-
governed = True
|
|
463
|
-
if r["position"] == r["max_pos"]:
|
|
464
|
-
# Last item of the pass reached — release the whole collection.
|
|
465
|
-
await self._execute(
|
|
466
|
-
"DELETE FROM playlist_item_played WHERE playlist_id = ?",
|
|
467
|
-
[r["playlist_id"]],
|
|
468
|
-
)
|
|
469
|
-
else:
|
|
470
|
-
await self._execute(
|
|
471
|
-
"INSERT OR REPLACE INTO playlist_item_played "
|
|
472
|
-
"(playlist_id, position, media_type, media_id) VALUES (?, ?, ?, ?)",
|
|
473
|
-
[r["playlist_id"], r["position"], media_type, friendly_token],
|
|
474
|
-
)
|
|
475
|
-
if not governed:
|
|
476
|
-
await self._execute(
|
|
477
|
-
"INSERT INTO play_completions (media_type, media_id) VALUES (?, ?)",
|
|
478
|
-
[media_type, friendly_token],
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
async def get_playlist_played_media_ids(self, playlist_id: int) -> set[str]:
|
|
482
|
-
"""media_ids already played in the current pass of a mutable playlist.
|
|
483
|
-
|
|
484
|
-
Backed by ``playlist_item_played`` (populated only for short episodes of
|
|
485
|
-
mutable playlists and cleared when the playlist's last item plays), so
|
|
486
|
-
firing can skip already-played episodes and continue the pass instead of
|
|
487
|
-
replaying from the start.
|
|
488
|
-
"""
|
|
489
|
-
rows = await self._fetch_all(
|
|
490
|
-
"SELECT media_id FROM playlist_item_played WHERE playlist_id = ?",
|
|
491
|
-
[playlist_id],
|
|
428
|
+
await self._execute(
|
|
429
|
+
"INSERT INTO play_completions (media_type, media_id) VALUES (?, ?)",
|
|
430
|
+
[media_type, friendly_token],
|
|
492
431
|
)
|
|
493
|
-
return {r["media_id"] for r in rows}
|
|
494
432
|
|
|
495
433
|
async def clear_play_state(
|
|
496
434
|
self, friendly_token: str, *, media_type: str = "cm"
|
|
497
435
|
) -> dict:
|
|
498
|
-
"""Remove
|
|
436
|
+
"""Remove recently-played hide state for an item (admin helper).
|
|
499
437
|
|
|
500
|
-
Deletes
|
|
501
|
-
|
|
502
|
-
immediately reappears in the public catalog. Returns the row counts
|
|
503
|
-
removed.
|
|
438
|
+
Deletes ``play_completions`` rows so the item reappears in the public
|
|
439
|
+
catalog immediately. Returns the count removed.
|
|
504
440
|
"""
|
|
505
441
|
pc = await self._fetch_one(
|
|
506
442
|
"SELECT COUNT(*) AS c FROM play_completions WHERE media_id = ? AND media_type = ?",
|
|
507
443
|
[friendly_token, media_type],
|
|
508
444
|
)
|
|
509
|
-
pip = await self._fetch_one(
|
|
510
|
-
"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
|
|
511
|
-
[friendly_token, media_type],
|
|
512
|
-
)
|
|
513
445
|
await self._execute(
|
|
514
446
|
"DELETE FROM play_completions WHERE media_id = ? AND media_type = ?",
|
|
515
447
|
[friendly_token, media_type],
|
|
516
448
|
)
|
|
517
|
-
|
|
518
|
-
"DELETE FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
|
|
519
|
-
[friendly_token, media_type],
|
|
520
|
-
)
|
|
521
|
-
return {
|
|
522
|
-
"completions": pc["c"] if pc else 0,
|
|
523
|
-
"playlist_pass": pip["c"] if pip else 0,
|
|
524
|
-
}
|
|
449
|
+
return {"completions": pc["c"] if pc else 0}
|
|
525
450
|
|
|
526
451
|
async def purge_promo_hide_state(self) -> dict:
|
|
527
452
|
"""Remove any recently-played hide state recorded for promos/bumpers.
|
|
@@ -530,34 +455,20 @@ class _CatalogMixin:
|
|
|
530
455
|
are already skipped in ``record_play_completion``; this purges rows
|
|
531
456
|
written before that exemption existed (run once at startup). Covers promo
|
|
532
457
|
pools plus hidden-category/tag promos (the usual station-promo case).
|
|
533
|
-
Returns the row
|
|
458
|
+
Returns the row count removed.
|
|
534
459
|
"""
|
|
535
460
|
sub, sub_params = _promo_hidden_media_subquery()
|
|
536
461
|
pc = await self._fetch_one(
|
|
537
462
|
f"SELECT COUNT(*) AS c FROM play_completions WHERE media_id IN ({sub})",
|
|
538
463
|
sub_params,
|
|
539
464
|
)
|
|
540
|
-
pip = await self._fetch_one(
|
|
541
|
-
f"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id IN ({sub})",
|
|
542
|
-
sub_params,
|
|
543
|
-
)
|
|
544
465
|
await self._execute(
|
|
545
466
|
f"DELETE FROM play_completions WHERE media_id IN ({sub})", sub_params
|
|
546
467
|
)
|
|
547
|
-
|
|
548
|
-
f"DELETE FROM playlist_item_played WHERE media_id IN ({sub})", sub_params
|
|
549
|
-
)
|
|
550
|
-
return {
|
|
551
|
-
"completions": pc["c"] if pc else 0,
|
|
552
|
-
"playlist_pass": pip["c"] if pip else 0,
|
|
553
|
-
}
|
|
468
|
+
return {"completions": pc["c"] if pc else 0}
|
|
554
469
|
|
|
555
470
|
async def get_recently_played_debug(self, days: int) -> dict:
|
|
556
|
-
"""Snapshot of what the recently-played rules currently hide (admin test).
|
|
557
|
-
|
|
558
|
-
Mirrors the two exclusion signals used by browse/search so an admin can
|
|
559
|
-
see exactly which titles a regular user would NOT see, and why.
|
|
560
|
-
"""
|
|
471
|
+
"""Snapshot of what the recently-played rules currently hide (admin test)."""
|
|
561
472
|
by_completion = await self._fetch_all(
|
|
562
473
|
"SELECT pc.media_id, c.title, MAX(pc.completed_at) AS last_completed "
|
|
563
474
|
"FROM play_completions pc "
|
|
@@ -566,17 +477,9 @@ class _CatalogMixin:
|
|
|
566
477
|
"GROUP BY pc.media_id ORDER BY last_completed DESC",
|
|
567
478
|
[f"-{int(days)} days"],
|
|
568
479
|
)
|
|
569
|
-
by_playlist_pass = await self._fetch_all(
|
|
570
|
-
"SELECT pip.media_id, c.title, pip.playlist_id, sp.name AS playlist_name, pip.position "
|
|
571
|
-
"FROM playlist_item_played pip "
|
|
572
|
-
"JOIN saved_playlists sp ON sp.id = pip.playlist_id "
|
|
573
|
-
"LEFT JOIN catalog c ON c.friendly_token = pip.media_id "
|
|
574
|
-
"WHERE pip.media_type = 'cm' ORDER BY sp.name, pip.position",
|
|
575
|
-
)
|
|
576
480
|
return {
|
|
577
481
|
"window_days": days,
|
|
578
482
|
"by_completion": by_completion,
|
|
579
|
-
"by_playlist_pass": by_playlist_pass,
|
|
580
483
|
}
|
|
581
484
|
|
|
582
485
|
async def get_catalog_brief(
|
|
@@ -227,7 +227,14 @@ MIGRATIONS = [
|
|
|
227
227
|
ALTER TABLE queue_shadow ADD COLUMN promo_type TEXT;
|
|
228
228
|
ALTER TABLE queue_shadow ADD COLUMN lead_in_for_uid INTEGER;
|
|
229
229
|
""",
|
|
230
|
-
# v12:
|
|
230
|
+
# v12: Media-id fallback for the event-lock auto-lift. Stores the media_id
|
|
231
|
+
# of the last scheduled item so _maybe_lift_event_lock can detect when it
|
|
232
|
+
# begins playing even when last_item_uid was never captured (e.g. a CyTube
|
|
233
|
+
# queue-event timeout during bulk add left the uid null).
|
|
234
|
+
"""
|
|
235
|
+
ALTER TABLE active_schedule ADD COLUMN last_item_media_id TEXT;
|
|
236
|
+
""",
|
|
237
|
+
# v13: Viewer feedback + movie-title suggestions, each with a simple
|
|
231
238
|
# admin-triage queue (status new|read). Title suggestions record the
|
|
232
239
|
# resolved match against TMDB/OMDB (NULL when unresolved) and whether we
|
|
233
240
|
# already have the title in the catalog (resolution + catalog_token).
|
|
@@ -257,7 +264,7 @@ MIGRATIONS = [
|
|
|
257
264
|
);
|
|
258
265
|
CREATE INDEX IF NOT EXISTS idx_title_suggestions_status ON title_suggestions(status, created_at);
|
|
259
266
|
""",
|
|
260
|
-
#
|
|
267
|
+
# v14: Play-completion tracking that drives hiding recently-played catalog
|
|
261
268
|
# items from regular users.
|
|
262
269
|
#
|
|
263
270
|
# play_completions records a *genuine* completion (an item that reached the
|
|
@@ -290,7 +297,7 @@ MIGRATIONS = [
|
|
|
290
297
|
);
|
|
291
298
|
CREATE INDEX IF NOT EXISTS idx_playlist_item_played_media ON playlist_item_played(media_id);
|
|
292
299
|
""",
|
|
293
|
-
#
|
|
300
|
+
# v15: One-time cleanup of recently-played hide state wrongly recorded for
|
|
294
301
|
# promo-pool clips. Promos are excluded from the public catalog and must
|
|
295
302
|
# never be treated like normal playlist items, so any hide state recorded for
|
|
296
303
|
# a promo clip (before the exemption in record_play_completion) is purged.
|
|
@@ -308,7 +315,7 @@ MIGRATIONS = [
|
|
|
308
315
|
WHERE sp.promo_type IS NOT NULL AND spi.media_type = 'cm'
|
|
309
316
|
);
|
|
310
317
|
""",
|
|
311
|
-
#
|
|
318
|
+
# v16: Cron-based job schedule persistence (separate from playlist_schedules,
|
|
312
319
|
# which fires playlists onto the queue). One row per job; UNIQUE on job_name.
|
|
313
320
|
# run_next_job chains another job to run immediately after this one completes.
|
|
314
321
|
"""
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
logger = logging.getLogger(__name__)
|
|
4
|
+
|
|
5
|
+
|
|
1
6
|
class _PlaylistsMixin:
|
|
2
7
|
"""Saved playlists, promo pools, schedules, and active schedule methods."""
|
|
3
8
|
|
|
@@ -159,6 +164,72 @@ class _PlaylistsMixin:
|
|
|
159
164
|
await self._db.commit()
|
|
160
165
|
return added
|
|
161
166
|
|
|
167
|
+
async def rotate_playlist_item_to_bottom(
|
|
168
|
+
self, media_id: str, media_type: str = "cm"
|
|
169
|
+
) -> int:
|
|
170
|
+
"""Move a played item to the end of every mutable playlist containing it.
|
|
171
|
+
|
|
172
|
+
Played items sink to the bottom so the next fire always loads the
|
|
173
|
+
oldest-played item last. Idempotent when the item is already at
|
|
174
|
+
MAX(position). Returns the number of playlists rotated.
|
|
175
|
+
"""
|
|
176
|
+
playlists = await self._fetch_all(
|
|
177
|
+
"""
|
|
178
|
+
SELECT spi.playlist_id
|
|
179
|
+
FROM saved_playlist_items spi
|
|
180
|
+
JOIN saved_playlists sp ON sp.id = spi.playlist_id
|
|
181
|
+
WHERE spi.media_id = ? AND spi.media_type = ?
|
|
182
|
+
AND sp.is_immutable = 0 AND sp.promo_type IS NULL
|
|
183
|
+
""",
|
|
184
|
+
[media_id, media_type],
|
|
185
|
+
)
|
|
186
|
+
rotated = 0
|
|
187
|
+
for r in playlists:
|
|
188
|
+
playlist_id = r["playlist_id"]
|
|
189
|
+
items = await self._fetch_all(
|
|
190
|
+
"SELECT * FROM saved_playlist_items WHERE playlist_id = ? ORDER BY position",
|
|
191
|
+
[playlist_id],
|
|
192
|
+
)
|
|
193
|
+
target_idx = next(
|
|
194
|
+
(
|
|
195
|
+
i
|
|
196
|
+
for i, it in enumerate(items)
|
|
197
|
+
if it["media_id"] == media_id and it["media_type"] == media_type
|
|
198
|
+
),
|
|
199
|
+
None,
|
|
200
|
+
)
|
|
201
|
+
if target_idx is None or target_idx == len(items) - 1:
|
|
202
|
+
continue # not found or already at bottom
|
|
203
|
+
# Move target to end; delete-then-reinsert avoids position UNIQUE conflicts.
|
|
204
|
+
reordered = (
|
|
205
|
+
items[:target_idx] + items[target_idx + 1 :] + [items[target_idx]]
|
|
206
|
+
)
|
|
207
|
+
await self._db.execute(
|
|
208
|
+
"DELETE FROM saved_playlist_items WHERE playlist_id=?", [playlist_id]
|
|
209
|
+
)
|
|
210
|
+
for new_pos, item in enumerate(reordered):
|
|
211
|
+
await self._db.execute(
|
|
212
|
+
"INSERT INTO saved_playlist_items "
|
|
213
|
+
"(playlist_id, position, media_type, media_id, title, duration_sec) "
|
|
214
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
215
|
+
[
|
|
216
|
+
playlist_id,
|
|
217
|
+
new_pos,
|
|
218
|
+
item["media_type"],
|
|
219
|
+
item["media_id"],
|
|
220
|
+
item.get("title"),
|
|
221
|
+
item.get("duration_sec"),
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
await self._db.execute(
|
|
225
|
+
"UPDATE saved_playlists SET updated_at=datetime('now') WHERE id=?",
|
|
226
|
+
[playlist_id],
|
|
227
|
+
)
|
|
228
|
+
await self._db.commit()
|
|
229
|
+
logger.info("Rotated %r to bottom of playlist %d", media_id, playlist_id)
|
|
230
|
+
rotated += 1
|
|
231
|
+
return rotated
|
|
232
|
+
|
|
162
233
|
async def get_most_recent_playlist(self, created_by: str) -> dict | None:
|
|
163
234
|
"""The given admin's most recently *created* saved playlist, if any."""
|
|
164
235
|
return await self._fetch_one(
|
|
@@ -242,11 +313,13 @@ class _PlaylistsMixin:
|
|
|
242
313
|
started_at: str,
|
|
243
314
|
estimated_end_at: str,
|
|
244
315
|
last_item_uid: int | None = None,
|
|
316
|
+
last_item_media_id: str | None = None,
|
|
245
317
|
):
|
|
246
318
|
await self._execute(
|
|
247
319
|
"INSERT OR REPLACE INTO active_schedule "
|
|
248
|
-
"(id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at,
|
|
249
|
-
"
|
|
320
|
+
"(id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at, "
|
|
321
|
+
"last_item_uid, last_item_media_id, lock_disabled) "
|
|
322
|
+
"VALUES (1, ?, ?, ?, ?, ?, ?, ?, 0)",
|
|
250
323
|
[
|
|
251
324
|
schedule_id,
|
|
252
325
|
playlist_id,
|
|
@@ -254,6 +327,7 @@ class _PlaylistsMixin:
|
|
|
254
327
|
started_at,
|
|
255
328
|
estimated_end_at,
|
|
256
329
|
last_item_uid,
|
|
330
|
+
last_item_media_id,
|
|
257
331
|
],
|
|
258
332
|
)
|
|
259
333
|
|
|
@@ -180,7 +180,7 @@ class CoverArtResolver:
|
|
|
180
180
|
"""Search a specific TMDB media type. Returns (poster_url_or_None, popularity)."""
|
|
181
181
|
params: dict = {"api_key": self._tmdb_key, "query": title}
|
|
182
182
|
if year and media_type == "movie":
|
|
183
|
-
params["
|
|
183
|
+
params["primary_release_year"] = year
|
|
184
184
|
elif year and media_type == "tv":
|
|
185
185
|
params["first_air_date_year"] = year
|
|
186
186
|
try:
|
{kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
@@ -1627,7 +1627,9 @@ def run(params: dict, *, config, progress=None) -> dict:
|
|
|
1627
1627
|
dry_run=dry_run,
|
|
1628
1628
|
progress=_emit,
|
|
1629
1629
|
)
|
|
1630
|
-
except
|
|
1630
|
+
except (
|
|
1631
|
+
Exception
|
|
1632
|
+
) as _pm_exc: # noqa: BLE001 - non-fatal; keep fetchurls result intact
|
|
1631
1633
|
logger.warning("played_movies sync failed: %s", _pm_exc, exc_info=True)
|
|
1632
1634
|
|
|
1633
1635
|
_emit(
|
|
@@ -210,7 +210,9 @@ def _pick_placeholder(placeholder_dir: str) -> bytes | None:
|
|
|
210
210
|
p = Path(placeholder_dir).expanduser()
|
|
211
211
|
if not p.is_dir():
|
|
212
212
|
return None
|
|
213
|
-
candidates = [
|
|
213
|
+
candidates = [
|
|
214
|
+
f for f in p.iterdir() if f.is_file() and f.suffix.lower() in _IMAGE_EXTS
|
|
215
|
+
]
|
|
214
216
|
if not candidates:
|
|
215
217
|
return None
|
|
216
218
|
return random.choice(candidates).read_bytes()
|
|
@@ -239,7 +241,11 @@ def _generate_html(night_entries: list[dict], poster_base_url: str) -> str:
|
|
|
239
241
|
|
|
240
242
|
img_url = f"{base}/{entry['filename']}"
|
|
241
243
|
imdb_id = entry.get("imdb_id") or ""
|
|
242
|
-
href =
|
|
244
|
+
href = (
|
|
245
|
+
f"https://www.imdb.com/title/{imdb_id}/"
|
|
246
|
+
if imdb_id
|
|
247
|
+
else "https://www.imdb.com/"
|
|
248
|
+
)
|
|
243
249
|
|
|
244
250
|
lines.append(
|
|
245
251
|
f' <a href="{escape(href)}" target="_blank" rel="noopener noreferrer">'
|
|
@@ -393,7 +399,10 @@ def run(params: dict, *, config, progress=None) -> dict:
|
|
|
393
399
|
entry["imdb_id"] = info["imdb_id"]
|
|
394
400
|
if dry_run:
|
|
395
401
|
logger.info(
|
|
396
|
-
"motd_posters [dry-run]: %s → %s (%s)",
|
|
402
|
+
"motd_posters [dry-run]: %s → %s (%s)",
|
|
403
|
+
title,
|
|
404
|
+
info["imdb_id"],
|
|
405
|
+
filename,
|
|
397
406
|
)
|
|
398
407
|
resolved += 1
|
|
399
408
|
else:
|
|
@@ -431,7 +440,9 @@ def run(params: dict, *, config, progress=None) -> dict:
|
|
|
431
440
|
output_path = output_dir / f"motd-{sheet_name}.html"
|
|
432
441
|
|
|
433
442
|
if dry_run:
|
|
434
|
-
logger.info(
|
|
443
|
+
logger.info(
|
|
444
|
+
"motd_posters [dry-run]: HTML snippet (not written):\n%s", html_snippet
|
|
445
|
+
)
|
|
435
446
|
else:
|
|
436
447
|
output_path.write_text(html_snippet, encoding="utf-8")
|
|
437
448
|
logger.info("motd_posters: wrote HTML snippet → %s", output_path)
|
|
@@ -161,7 +161,10 @@ def _write_rows(
|
|
|
161
161
|
|
|
162
162
|
sess_resp = requests.post(
|
|
163
163
|
f"{wb_base}/createSession",
|
|
164
|
-
headers={
|
|
164
|
+
headers={
|
|
165
|
+
"Authorization": f"Bearer {token}",
|
|
166
|
+
"Content-Type": "application/json",
|
|
167
|
+
},
|
|
165
168
|
data=json.dumps({"persistChanges": True}),
|
|
166
169
|
timeout=WRITEBACK_TIMEOUT,
|
|
167
170
|
)
|
|
@@ -169,7 +172,10 @@ def _write_rows(
|
|
|
169
172
|
if sess_resp.status_code in (200, 201):
|
|
170
173
|
sid = sess_resp.json().get("id", "")
|
|
171
174
|
|
|
172
|
-
headers = {
|
|
175
|
+
headers = {
|
|
176
|
+
"Authorization": f"Bearer {token}",
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
}
|
|
173
179
|
if sid:
|
|
174
180
|
headers["workbook-session-id"] = sid
|
|
175
181
|
|
|
@@ -261,7 +267,13 @@ def sync_played_movies(
|
|
|
261
267
|
|
|
262
268
|
if not new_rows:
|
|
263
269
|
logger.info("played_movies: all %d movie(s) already recorded", len(movies))
|
|
264
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
"found": len(movies),
|
|
272
|
+
"added": 0,
|
|
273
|
+
"skipped": skipped,
|
|
274
|
+
"failed": 0,
|
|
275
|
+
"dry_run": dry_run,
|
|
276
|
+
}
|
|
265
277
|
|
|
266
278
|
start_row = last_played_row(wb_bytes) + 1
|
|
267
279
|
|
|
@@ -282,7 +294,9 @@ def sync_played_movies(
|
|
|
282
294
|
if progress:
|
|
283
295
|
progress({"phase": "played_movies_write", "count": len(new_rows)})
|
|
284
296
|
|
|
285
|
-
ok, fail = _write_rows(
|
|
297
|
+
ok, fail = _write_rows(
|
|
298
|
+
graph_token, drive_id, item_id, new_rows, start_row, progress=progress
|
|
299
|
+
)
|
|
286
300
|
logger.info(
|
|
287
301
|
"played_movies: added %d/%d, skipped %d, failed %d",
|
|
288
302
|
ok,
|
|
@@ -68,7 +68,10 @@ class JobScheduler:
|
|
|
68
68
|
id=f"jsch_{job_name}",
|
|
69
69
|
replace_existing=True,
|
|
70
70
|
)
|
|
71
|
-
except (
|
|
71
|
+
except (
|
|
72
|
+
ValueError,
|
|
73
|
+
Exception,
|
|
74
|
+
) as exc: # noqa: BLE001 - bad stored cron must not crash startup
|
|
72
75
|
logger.warning(
|
|
73
76
|
"JobScheduler: skipping invalid cron %r for %r: %s",
|
|
74
77
|
cron_expression,
|
|
@@ -125,11 +128,17 @@ class JobScheduler:
|
|
|
125
128
|
minute, hour, day, month, day_of_week = parts
|
|
126
129
|
try:
|
|
127
130
|
CronTrigger(
|
|
128
|
-
minute=minute,
|
|
129
|
-
|
|
131
|
+
minute=minute,
|
|
132
|
+
hour=hour,
|
|
133
|
+
day=day,
|
|
134
|
+
month=month,
|
|
135
|
+
day_of_week=day_of_week,
|
|
136
|
+
timezone="UTC",
|
|
130
137
|
)
|
|
131
138
|
except Exception as exc:
|
|
132
|
-
raise ValueError(
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Invalid cron expression {cron_expression!r}: {exc}"
|
|
141
|
+
) from exc
|
|
133
142
|
|
|
134
143
|
params_json = json.dumps(params) if params is not None else None
|
|
135
144
|
await self._db.upsert_job_schedule(
|
|
@@ -63,21 +63,9 @@ async def fire_schedule(
|
|
|
63
63
|
|
|
64
64
|
# Load scheduled playlist items
|
|
65
65
|
items = await db.get_saved_playlist_items(playlist_id)
|
|
66
|
-
# For a mutable (TV-show) playlist, skip episodes already played in the
|
|
67
|
-
# current pass so a re-fire continues where it left off instead of
|
|
68
|
-
# replaying season 1. Immutable/promo playlists never have played rows,
|
|
69
|
-
# so this is a no-op for them.
|
|
70
|
-
played = await db.get_playlist_played_media_ids(playlist_id)
|
|
71
|
-
if played:
|
|
72
|
-
before = len(items)
|
|
73
|
-
items = [it for it in items if it["media_id"] not in played]
|
|
74
|
-
if before != len(items):
|
|
75
|
-
logger.info(
|
|
76
|
-
f"Schedule {schedule_id}: skipped {before - len(items)} already-played "
|
|
77
|
-
f"episode(s) from mutable playlist {playlist_id}"
|
|
78
|
-
)
|
|
79
66
|
total_duration = 0
|
|
80
67
|
last_item_uid = None
|
|
68
|
+
last_item_media_id = None
|
|
81
69
|
for index, item in enumerate(items):
|
|
82
70
|
# Throttle consecutive adds so CyTube can validate each item before
|
|
83
71
|
# the next arrives (avoids transient queueFail/422 under load).
|
|
@@ -97,6 +85,9 @@ async def fire_schedule(
|
|
|
97
85
|
and add_result.get("uid") is not None
|
|
98
86
|
):
|
|
99
87
|
last_item_uid = add_result["uid"]
|
|
88
|
+
# Always track the last scheduled item's media_id so the
|
|
89
|
+
# event-lock can lift even when the uid was not captured.
|
|
90
|
+
last_item_media_id = item.get("media_id")
|
|
100
91
|
total_duration += item.get("duration_sec", 0) or 0
|
|
101
92
|
except Exception as e:
|
|
102
93
|
logger.warning(
|
|
@@ -111,13 +102,6 @@ async def fire_schedule(
|
|
|
111
102
|
fallback_id = schedule.get("fallback_playlist_id")
|
|
112
103
|
if fallback_id:
|
|
113
104
|
fallback_items = await db.get_saved_playlist_items(fallback_id)
|
|
114
|
-
# Same continuation logic as the main playlist: skip already-played
|
|
115
|
-
# episodes of the (mutable) fallback so it advances across fires.
|
|
116
|
-
fb_played = await db.get_playlist_played_media_ids(fallback_id)
|
|
117
|
-
if fb_played:
|
|
118
|
-
fallback_items = [
|
|
119
|
-
it for it in fallback_items if it["media_id"] not in fb_played
|
|
120
|
-
]
|
|
121
105
|
for index, item in enumerate(fallback_items):
|
|
122
106
|
if index and add_delay_sec:
|
|
123
107
|
await asyncio.sleep(add_delay_sec)
|
|
@@ -150,6 +134,7 @@ async def fire_schedule(
|
|
|
150
134
|
started_at=now.isoformat(),
|
|
151
135
|
estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
|
|
152
136
|
last_item_uid=last_item_uid,
|
|
137
|
+
last_item_media_id=last_item_media_id,
|
|
153
138
|
)
|
|
154
139
|
|
|
155
140
|
# Mark schedule as fired
|