kryten-webqueue 0.30.0__tar.gz → 0.32.0__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.30.0 → kryten_webqueue-0.32.0}/CHANGELOG.md +21 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/config.example.json +1 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/app.py +7 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/db/_catalog.py +128 -4
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/db/_connection.py +33 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/config.py +7 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/playlists/fire.py +18 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/playlists/importer.py +21 -3
- kryten_webqueue-0.32.0/kryten_webqueue/queue/completion.py +93 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/queue/poller.py +8 -1
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_playlists.py +9 -3
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/catalog.py +10 -4
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/pages.py +8 -4
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/js/admin-playlists.js +13 -5
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/js/admin.js +6 -1
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/admin/playlists.html +2 -1
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/pyproject.toml +1 -1
- kryten_webqueue-0.32.0/tests/test_recently_played_hide.py +311 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/.gitignore +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/README.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/tests/test_search_facets.py +0 -0
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.32.0] — 2026-07-09
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Hide recently-played items from the public catalog.** Regular users no longer see a title in browse/search for a configurable window after it has actually finished playing. Admins (rank ≥ 3) always see every title. The window is set by `catalog_recently_played_hide_days` (default `21`; `0` disables).
|
|
13
|
+
- **Based on genuine play-completion, not queue time.** A new `CompletionRecorder` runs in the poll loop and records a completion only when the now-playing pointer advances off an item that played past ~50% of its duration (`play_completions` table). This means an item that was pay-to-queued and then **refunded/removed by an admin before it played never counts as played**, and an early admin *skip* of the currently-playing item doesn't either.
|
|
14
|
+
- **Mutable playlists (TV-show collections) are handled as a unit.** Short (< 1 hour) episodes of a mutable playlist are governed by playlist position instead of the day window: each hides as it plays, and reaching the playlist's **last item** releases the whole collection at once (`playlist_item_played` table). This lets admins append S2/S3 to a show without re-hiding S1 piecemeal or over-playing season 1. Longer items (e.g. a movie) inside a mutable playlist still follow the normal completion + day-window rule.
|
|
15
|
+
- **Firing a mutable playlist skips episodes already played in the current pass.** `fire_schedule` (scheduled or manual "fire now"), including the appended fallback playlist, now omits already-played episodes so a re-fire continues where the pass left off instead of replaying from the start. Once the last item plays the pass resets and the next fire loads the full playlist again. Immutable/promo playlists are unaffected.
|
|
16
|
+
- **Manual "import playlist to live queue" honors the same skip, with an override.** Importing a saved playlist now skips already-played episodes by default (matching automated firing); pass `?full=1` on the import endpoint to force-load the entire list regardless of pass state. Automated use (a playlist attached to a schedule as a fallback) always observes the played/non-played rules. The playlist editor exposes both as **Import to Live** (skip played) and **Load Entire List** (full) buttons.
|
|
17
|
+
- Promos are unaffected — promo/immutable-playlist items are already excluded from the public catalog.
|
|
18
|
+
|
|
19
|
+
[0.32.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.0
|
|
20
|
+
|
|
21
|
+
## [0.31.0] — 2026-07-04
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Moderation list "Remove" button now works.** The button was rendered with `onclick="removeModEntry(${JSON.stringify(e.username)})"` — `JSON.stringify` wraps the value in double quotes that immediately terminate the `onclick="…"` attribute, so browsers reported *"unexpected end of input at admin:1:16"* and the function was never called. The button now uses a `data-mod-rm-username` attribute with a delegated `click` listener on the stable `#mod-entries-list` container, consistent with how the "Moderate…" buttons in the Recent Users table work. The existing `confirm()` dialog inside `removeModEntry` is unaffected and will now prompt as intended.
|
|
26
|
+
|
|
27
|
+
[0.31.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.31.0
|
|
28
|
+
|
|
8
29
|
## [0.30.0] — 2026-07-04
|
|
9
30
|
|
|
10
31
|
### Fixed
|
|
@@ -16,6 +16,7 @@ from .jobs import JobManager
|
|
|
16
16
|
from .catalog.images import CoverArtResolver
|
|
17
17
|
from .queue.shadow import QueueShadow
|
|
18
18
|
from .queue.poller import StatePoller
|
|
19
|
+
from .queue.completion import CompletionRecorder
|
|
19
20
|
from .queue.race_poller import RacePoller
|
|
20
21
|
from .queue.presence import PresenceRefundMonitor
|
|
21
22
|
from .promos.director import PromoDirector
|
|
@@ -138,6 +139,11 @@ async def lifespan(app: FastAPI):
|
|
|
138
139
|
)
|
|
139
140
|
app.state.promo_director = promo_director
|
|
140
141
|
|
|
142
|
+
# Play-completion recorder (poller-driven; records genuine completions that
|
|
143
|
+
# drive hiding recently-played catalog items from regular users).
|
|
144
|
+
completion_recorder = CompletionRecorder(db=db)
|
|
145
|
+
app.state.completion_recorder = completion_recorder
|
|
146
|
+
|
|
141
147
|
# State poller
|
|
142
148
|
poller = StatePoller(
|
|
143
149
|
api_gate=api_gate,
|
|
@@ -146,6 +152,7 @@ async def lifespan(app: FastAPI):
|
|
|
146
152
|
db=db,
|
|
147
153
|
interval=config.state_poll_interval_sec,
|
|
148
154
|
promo_director=promo_director,
|
|
155
|
+
completion_recorder=completion_recorder,
|
|
149
156
|
)
|
|
150
157
|
await poller.start()
|
|
151
158
|
app.state.poller = poller
|
|
@@ -72,6 +72,40 @@ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
|
|
|
72
72
|
return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
|
|
76
|
+
"""SQL fragment (+ params) hiding recently-played items from regular users.
|
|
77
|
+
|
|
78
|
+
Two independent signals hide an item; either one is enough:
|
|
79
|
+
|
|
80
|
+
1. **Time window** — the item has a genuine ``play_completions`` row within
|
|
81
|
+
the last ``days`` days. A completion is only recorded when an item
|
|
82
|
+
actually played past a threshold (see ``CompletionRecorder``), so an item
|
|
83
|
+
that was queued and then refunded/removed before playing never counts.
|
|
84
|
+
|
|
85
|
+
2. **Mutable-playlist pass** — the item is a short (<1h) episode of a mutable
|
|
86
|
+
(TV-show) playlist that has played in the current pass and not yet reset
|
|
87
|
+
(``playlist_item_played``). These are governed by playlist position, not
|
|
88
|
+
the day window, so a whole show collection releases together when its last
|
|
89
|
+
item plays.
|
|
90
|
+
|
|
91
|
+
The fragment is prefixed with ``AND`` so it can be appended to an existing
|
|
92
|
+
WHERE clause referencing the catalog row under ``alias``. Caller must ensure
|
|
93
|
+
``days > 0`` before applying (a non-positive window disables both signals).
|
|
94
|
+
"""
|
|
95
|
+
sql = f"""
|
|
96
|
+
AND {alias}.friendly_token NOT IN (
|
|
97
|
+
SELECT pc.media_id FROM play_completions pc
|
|
98
|
+
WHERE pc.media_type = 'cm'
|
|
99
|
+
AND pc.completed_at >= datetime('now', ?)
|
|
100
|
+
)
|
|
101
|
+
AND {alias}.friendly_token NOT IN (
|
|
102
|
+
SELECT pip.media_id FROM playlist_item_played pip
|
|
103
|
+
WHERE pip.media_type = 'cm'
|
|
104
|
+
)
|
|
105
|
+
"""
|
|
106
|
+
return sql, [f"-{int(days)} days"]
|
|
107
|
+
|
|
108
|
+
|
|
75
109
|
def _facet_filter(alias: str, category: str | None, tag: str | None) -> tuple[str, list]:
|
|
76
110
|
"""SQL fragment (+ params) AND-filtering by a category slug and/or tag name.
|
|
77
111
|
|
|
@@ -130,7 +164,7 @@ class _CatalogMixin:
|
|
|
130
164
|
|
|
131
165
|
# --- Catalog ---
|
|
132
166
|
|
|
133
|
-
async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
|
|
167
|
+
async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default", recently_played_days: int = 0) -> list[dict]:
|
|
134
168
|
query = """
|
|
135
169
|
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url
|
|
136
170
|
FROM catalog c
|
|
@@ -145,6 +179,10 @@ class _CatalogMixin:
|
|
|
145
179
|
excl_sql, excl_params = _hidden_exclusion("c")
|
|
146
180
|
query += excl_sql
|
|
147
181
|
params.extend(excl_params)
|
|
182
|
+
if recently_played_days > 0:
|
|
183
|
+
rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
|
|
184
|
+
query += rp_sql
|
|
185
|
+
params.extend(rp_params)
|
|
148
186
|
if category:
|
|
149
187
|
query += """
|
|
150
188
|
AND c.friendly_token IN (
|
|
@@ -180,7 +218,7 @@ class _CatalogMixin:
|
|
|
180
218
|
params.extend([per_page, (page - 1) * per_page])
|
|
181
219
|
return await self._fetch_all(query, params)
|
|
182
220
|
|
|
183
|
-
async def browse_count(self, *, category: str | None = None, tag: str | None = None, show_hidden: bool = False) -> int:
|
|
221
|
+
async def browse_count(self, *, category: str | None = None, tag: str | None = None, show_hidden: bool = False, recently_played_days: int = 0) -> int:
|
|
184
222
|
query = """
|
|
185
223
|
SELECT COUNT(*) as cnt FROM catalog c
|
|
186
224
|
WHERE c.friendly_token NOT IN (
|
|
@@ -194,6 +232,10 @@ class _CatalogMixin:
|
|
|
194
232
|
excl_sql, excl_params = _hidden_exclusion("c")
|
|
195
233
|
query += excl_sql
|
|
196
234
|
params.extend(excl_params)
|
|
235
|
+
if recently_played_days > 0:
|
|
236
|
+
rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
|
|
237
|
+
query += rp_sql
|
|
238
|
+
params.extend(rp_params)
|
|
197
239
|
if category:
|
|
198
240
|
query += """
|
|
199
241
|
AND c.friendly_token IN (
|
|
@@ -216,7 +258,7 @@ class _CatalogMixin:
|
|
|
216
258
|
return row["cnt"] if row else 0
|
|
217
259
|
|
|
218
260
|
async def search(self, query_text: str, *, category: str | None = None, tag: str | None = None,
|
|
219
|
-
page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
|
|
261
|
+
page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default", recently_played_days: int = 0) -> list[dict]:
|
|
220
262
|
sql = """
|
|
221
263
|
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
|
|
222
264
|
rank AS relevance
|
|
@@ -234,6 +276,10 @@ class _CatalogMixin:
|
|
|
234
276
|
excl_sql, excl_params = _hidden_exclusion("c")
|
|
235
277
|
sql += excl_sql
|
|
236
278
|
params.extend(excl_params)
|
|
279
|
+
if recently_played_days > 0:
|
|
280
|
+
rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
|
|
281
|
+
sql += rp_sql
|
|
282
|
+
params.extend(rp_params)
|
|
237
283
|
# Category/tag facets AND with the text match (same subqueries browse()
|
|
238
284
|
# uses), so a search can be narrowed by the selected facets.
|
|
239
285
|
facet_sql, facet_params = _facet_filter("c", category, tag)
|
|
@@ -247,7 +293,7 @@ class _CatalogMixin:
|
|
|
247
293
|
return await self._fetch_all(sql, params)
|
|
248
294
|
|
|
249
295
|
async def search_count(self, query_text: str, *, category: str | None = None, tag: str | None = None,
|
|
250
|
-
show_hidden: bool = False) -> int:
|
|
296
|
+
show_hidden: bool = False, recently_played_days: int = 0) -> int:
|
|
251
297
|
sql = """
|
|
252
298
|
SELECT COUNT(*) as cnt
|
|
253
299
|
FROM catalog_fts fts
|
|
@@ -264,6 +310,10 @@ class _CatalogMixin:
|
|
|
264
310
|
excl_sql, excl_params = _hidden_exclusion("c")
|
|
265
311
|
sql += excl_sql
|
|
266
312
|
params.extend(excl_params)
|
|
313
|
+
if recently_played_days > 0:
|
|
314
|
+
rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
|
|
315
|
+
sql += rp_sql
|
|
316
|
+
params.extend(rp_params)
|
|
267
317
|
facet_sql, facet_params = _facet_filter("c", category, tag)
|
|
268
318
|
sql += facet_sql
|
|
269
319
|
params.extend(facet_params)
|
|
@@ -285,6 +335,80 @@ class _CatalogMixin:
|
|
|
285
335
|
async def get_item_admin(self, friendly_token: str) -> dict | None:
|
|
286
336
|
return await self._fetch_one("SELECT * FROM catalog WHERE friendly_token = ?", [friendly_token])
|
|
287
337
|
|
|
338
|
+
async def resolve_media(self, media_id: str) -> dict | None:
|
|
339
|
+
"""Resolve a now-playing identifier to its catalog row (token + duration).
|
|
340
|
+
|
|
341
|
+
The live now-playing payload carries the manifest URL as ``id`` for 'cm'
|
|
342
|
+
items (not the friendly_token), so match on either column.
|
|
343
|
+
"""
|
|
344
|
+
if not media_id:
|
|
345
|
+
return None
|
|
346
|
+
return await self._fetch_one(
|
|
347
|
+
"SELECT friendly_token, duration_sec FROM catalog "
|
|
348
|
+
"WHERE friendly_token = ? OR manifest_url = ? LIMIT 1",
|
|
349
|
+
[media_id, media_id],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def record_play_completion(self, *, friendly_token: str, duration_sec: int | None,
|
|
353
|
+
media_type: str = "cm") -> None:
|
|
354
|
+
"""Record a genuine play-completion, routing it to the correct hide rule.
|
|
355
|
+
|
|
356
|
+
Short (<1h) episodes that belong to a mutable (TV-show) playlist are
|
|
357
|
+
governed by playlist position instead of the day window: each such
|
|
358
|
+
completion marks the episode played for the current pass, and reaching
|
|
359
|
+
the playlist's last item (MAX position) clears the whole pass so the
|
|
360
|
+
collection releases together. Everything else (movies, standalone clips,
|
|
361
|
+
long items even inside a mutable playlist) gets a time-boxed
|
|
362
|
+
``play_completions`` row instead.
|
|
363
|
+
"""
|
|
364
|
+
rows = await self._fetch_all(
|
|
365
|
+
"""
|
|
366
|
+
SELECT spi.playlist_id AS playlist_id, spi.position AS position,
|
|
367
|
+
(SELECT MAX(position) FROM saved_playlist_items
|
|
368
|
+
WHERE playlist_id = spi.playlist_id) AS max_pos
|
|
369
|
+
FROM saved_playlist_items spi
|
|
370
|
+
JOIN saved_playlists sp ON sp.id = spi.playlist_id
|
|
371
|
+
WHERE spi.media_id = ? AND spi.media_type = ?
|
|
372
|
+
AND sp.is_immutable = 0 AND sp.promo_type IS NULL
|
|
373
|
+
""",
|
|
374
|
+
[friendly_token, media_type],
|
|
375
|
+
)
|
|
376
|
+
governed = False
|
|
377
|
+
if duration_sec is not None and duration_sec < 3600:
|
|
378
|
+
for r in rows:
|
|
379
|
+
governed = True
|
|
380
|
+
if r["position"] == r["max_pos"]:
|
|
381
|
+
# Last item of the pass reached — release the whole collection.
|
|
382
|
+
await self._execute(
|
|
383
|
+
"DELETE FROM playlist_item_played WHERE playlist_id = ?",
|
|
384
|
+
[r["playlist_id"]],
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
await self._execute(
|
|
388
|
+
"INSERT OR REPLACE INTO playlist_item_played "
|
|
389
|
+
"(playlist_id, position, media_type, media_id) VALUES (?, ?, ?, ?)",
|
|
390
|
+
[r["playlist_id"], r["position"], media_type, friendly_token],
|
|
391
|
+
)
|
|
392
|
+
if not governed:
|
|
393
|
+
await self._execute(
|
|
394
|
+
"INSERT INTO play_completions (media_type, media_id) VALUES (?, ?)",
|
|
395
|
+
[media_type, friendly_token],
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
async def get_playlist_played_media_ids(self, playlist_id: int) -> set[str]:
|
|
399
|
+
"""media_ids already played in the current pass of a mutable playlist.
|
|
400
|
+
|
|
401
|
+
Backed by ``playlist_item_played`` (populated only for short episodes of
|
|
402
|
+
mutable playlists and cleared when the playlist's last item plays), so
|
|
403
|
+
firing can skip already-played episodes and continue the pass instead of
|
|
404
|
+
replaying from the start.
|
|
405
|
+
"""
|
|
406
|
+
rows = await self._fetch_all(
|
|
407
|
+
"SELECT media_id FROM playlist_item_played WHERE playlist_id = ?",
|
|
408
|
+
[playlist_id],
|
|
409
|
+
)
|
|
410
|
+
return {r["media_id"] for r in rows}
|
|
411
|
+
|
|
288
412
|
async def get_catalog_brief(self, tokens: list[str], manifest_urls: list[str]) -> dict[str, dict]:
|
|
289
413
|
"""Return a lookup of catalog metadata keyed by BOTH friendly_token and
|
|
290
414
|
manifest_url, for enriching queue-shadow items that may only carry one.
|
|
@@ -258,6 +258,39 @@ MIGRATIONS = [
|
|
|
258
258
|
);
|
|
259
259
|
CREATE INDEX IF NOT EXISTS idx_title_suggestions_status ON title_suggestions(status, created_at);
|
|
260
260
|
""",
|
|
261
|
+
# v13: Play-completion tracking that drives hiding recently-played catalog
|
|
262
|
+
# items from regular users.
|
|
263
|
+
#
|
|
264
|
+
# play_completions records a *genuine* completion (an item that reached the
|
|
265
|
+
# now-playing slot and played past a threshold), keyed by media_id
|
|
266
|
+
# (== catalog friendly_token for 'cm'). completed_at powers the time-based
|
|
267
|
+
# hide window. Items queued-then-refunded never reach now-playing, so they
|
|
268
|
+
# never land here.
|
|
269
|
+
#
|
|
270
|
+
# playlist_item_played tracks, per mutable (TV-show) playlist, which short
|
|
271
|
+
# (<1h) episodes have played in the *current pass*. Rows are cleared for a
|
|
272
|
+
# playlist the moment its last item (MAX position) plays, releasing the whole
|
|
273
|
+
# collection at once — so appending S2/S3 never re-hides S1 piecemeal.
|
|
274
|
+
"""
|
|
275
|
+
CREATE TABLE IF NOT EXISTS play_completions (
|
|
276
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
277
|
+
media_type TEXT NOT NULL,
|
|
278
|
+
media_id TEXT NOT NULL,
|
|
279
|
+
completed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
280
|
+
);
|
|
281
|
+
CREATE INDEX IF NOT EXISTS idx_play_completions_media ON play_completions(media_id);
|
|
282
|
+
CREATE INDEX IF NOT EXISTS idx_play_completions_at ON play_completions(completed_at);
|
|
283
|
+
|
|
284
|
+
CREATE TABLE IF NOT EXISTS playlist_item_played (
|
|
285
|
+
playlist_id INTEGER NOT NULL REFERENCES saved_playlists(id) ON DELETE CASCADE,
|
|
286
|
+
position INTEGER NOT NULL,
|
|
287
|
+
media_type TEXT NOT NULL,
|
|
288
|
+
media_id TEXT NOT NULL,
|
|
289
|
+
played_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
290
|
+
PRIMARY KEY (playlist_id, position)
|
|
291
|
+
);
|
|
292
|
+
CREATE INDEX IF NOT EXISTS idx_playlist_item_played_media ON playlist_item_played(media_id);
|
|
293
|
+
""",
|
|
261
294
|
]
|
|
262
295
|
|
|
263
296
|
|
|
@@ -149,6 +149,13 @@ class Config(BaseModel):
|
|
|
149
149
|
pre_fire_lock_minutes_default: int = 15
|
|
150
150
|
state_poll_interval_sec: float = 3.0
|
|
151
151
|
|
|
152
|
+
# Hide recently-played items from the public catalog (browse/search) for this
|
|
153
|
+
# many days after they actually finished playing. Admins (rank >= 3) always
|
|
154
|
+
# see them. Short (<1h) episodes of a mutable playlist are instead governed by
|
|
155
|
+
# playlist position (hidden until the playlist's last item plays). Set to 0 to
|
|
156
|
+
# disable recently-played hiding entirely.
|
|
157
|
+
catalog_recently_played_hide_days: int = 21
|
|
158
|
+
|
|
152
159
|
# Bulk playlist loading (manual import + scheduled fire). CyTube validates
|
|
153
160
|
# each queued item server-side (fetching custom manifests); adding faster
|
|
154
161
|
# than it can validate triggers a transient queueFail (surfaced by api-gate
|
|
@@ -58,6 +58,19 @@ async def fire_schedule(
|
|
|
58
58
|
|
|
59
59
|
# Load scheduled playlist items
|
|
60
60
|
items = await db.get_saved_playlist_items(playlist_id)
|
|
61
|
+
# For a mutable (TV-show) playlist, skip episodes already played in the
|
|
62
|
+
# current pass so a re-fire continues where it left off instead of
|
|
63
|
+
# replaying season 1. Immutable/promo playlists never have played rows,
|
|
64
|
+
# so this is a no-op for them.
|
|
65
|
+
played = await db.get_playlist_played_media_ids(playlist_id)
|
|
66
|
+
if played:
|
|
67
|
+
before = len(items)
|
|
68
|
+
items = [it for it in items if it["media_id"] not in played]
|
|
69
|
+
if before != len(items):
|
|
70
|
+
logger.info(
|
|
71
|
+
f"Schedule {schedule_id}: skipped {before - len(items)} already-played "
|
|
72
|
+
f"episode(s) from mutable playlist {playlist_id}"
|
|
73
|
+
)
|
|
61
74
|
total_duration = 0
|
|
62
75
|
last_item_uid = None
|
|
63
76
|
for index, item in enumerate(items):
|
|
@@ -88,6 +101,11 @@ async def fire_schedule(
|
|
|
88
101
|
fallback_id = schedule.get("fallback_playlist_id")
|
|
89
102
|
if fallback_id:
|
|
90
103
|
fallback_items = await db.get_saved_playlist_items(fallback_id)
|
|
104
|
+
# Same continuation logic as the main playlist: skip already-played
|
|
105
|
+
# episodes of the (mutable) fallback so it advances across fires.
|
|
106
|
+
fb_played = await db.get_playlist_played_media_ids(fallback_id)
|
|
107
|
+
if fb_played:
|
|
108
|
+
fallback_items = [it for it in fallback_items if it["media_id"] not in fb_played]
|
|
91
109
|
for index, item in enumerate(fallback_items):
|
|
92
110
|
if index and add_delay_sec:
|
|
93
111
|
await asyncio.sleep(add_delay_sec)
|
|
@@ -72,12 +72,30 @@ class PlaylistImporter:
|
|
|
72
72
|
self._add_max_retries = add_max_retries
|
|
73
73
|
self._promo_director = promo_director
|
|
74
74
|
|
|
75
|
-
async def import_playlist(self, playlist_id: int) -> dict:
|
|
76
|
-
"""Import
|
|
75
|
+
async def import_playlist(self, playlist_id: int, *, skip_played: bool = True) -> dict:
|
|
76
|
+
"""Import items from a saved playlist into the live queue.
|
|
77
|
+
|
|
78
|
+
By default (``skip_played=True``) already-played episodes of the current
|
|
79
|
+
pass are skipped, so a mutable (TV-show) playlist continues where it left
|
|
80
|
+
off instead of replaying from the start — the same rule automated firing
|
|
81
|
+
uses. Pass ``skip_played=False`` to force-load the entire list regardless
|
|
82
|
+
of pass state.
|
|
83
|
+
"""
|
|
77
84
|
items = await self._db.get_saved_playlist_items(playlist_id)
|
|
78
85
|
if not items:
|
|
79
86
|
return {"success": False, "error": "Playlist is empty"}
|
|
80
87
|
|
|
88
|
+
skipped = 0
|
|
89
|
+
if skip_played:
|
|
90
|
+
played = await self._db.get_playlist_played_media_ids(playlist_id)
|
|
91
|
+
if played:
|
|
92
|
+
before = len(items)
|
|
93
|
+
items = [it for it in items if it["media_id"] not in played]
|
|
94
|
+
skipped = before - len(items)
|
|
95
|
+
if not items:
|
|
96
|
+
# Everything's already been played this pass (nothing left to add).
|
|
97
|
+
return {"success": True, "added": 0, "errors": 0, "skipped": skipped}
|
|
98
|
+
|
|
81
99
|
# Suppress promo insertion for the whole load so promos aren't slotted
|
|
82
100
|
# between items while the playlist is still being built.
|
|
83
101
|
suppress_ctx = (
|
|
@@ -111,7 +129,7 @@ class PlaylistImporter:
|
|
|
111
129
|
logger.warning(f"Failed to add {item['media_id']}: {e}")
|
|
112
130
|
errors += 1
|
|
113
131
|
|
|
114
|
-
return {"success": True, "added": added, "errors": errors}
|
|
132
|
+
return {"success": True, "added": added, "errors": errors, "skipped": skipped}
|
|
115
133
|
|
|
116
134
|
|
|
117
135
|
async def import_playlist_text(db, text: str, *, mediacms_url: str | None = None) -> dict:
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Play-completion recorder.
|
|
2
|
+
|
|
3
|
+
Watches the now-playing item across poll cycles and records a completion only
|
|
4
|
+
when the now-playing pointer moves off an item that had progressed past a
|
|
5
|
+
threshold (default 50%) of its duration. This is the signal that drives hiding
|
|
6
|
+
recently-played catalog items from regular users.
|
|
7
|
+
|
|
8
|
+
Why threshold-on-advance rather than "item left the queue":
|
|
9
|
+
|
|
10
|
+
* An item queued and then **refunded/removed** by an admin is pulled before it
|
|
11
|
+
ever reaches the now-playing slot, so it is never tracked here and can't be
|
|
12
|
+
mistaken for "played".
|
|
13
|
+
* An admin **skipping the currently-playing item** early leaves its observed
|
|
14
|
+
progress below the threshold, so it also doesn't count.
|
|
15
|
+
* A natural finish leaves progress near the end (well past the threshold), so it
|
|
16
|
+
is recorded exactly once when playback advances.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_seconds(value) -> float:
|
|
25
|
+
try:
|
|
26
|
+
return float(value)
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return 0.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompletionRecorder:
|
|
32
|
+
"""Records genuine play-completions from the poll loop."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, *, db, completion_threshold: float = 0.5):
|
|
35
|
+
self._db = db
|
|
36
|
+
self._threshold = completion_threshold
|
|
37
|
+
# State of the item currently occupying the now-playing slot.
|
|
38
|
+
self._cur_key: tuple | None = None # (media_type, media_id)
|
|
39
|
+
self._cur_token: str | None = None # resolved catalog friendly_token
|
|
40
|
+
self._cur_duration: float = 0.0
|
|
41
|
+
self._max_ct: float = 0.0 # furthest observed currentTime
|
|
42
|
+
|
|
43
|
+
async def on_poll(self, now_playing: dict | None) -> None:
|
|
44
|
+
"""Called once per poll cycle with the latest now-playing payload."""
|
|
45
|
+
np = now_playing or {}
|
|
46
|
+
media_id = np.get("id")
|
|
47
|
+
media_type = np.get("type")
|
|
48
|
+
key = (media_type, media_id) if media_id else None
|
|
49
|
+
|
|
50
|
+
if key == self._cur_key:
|
|
51
|
+
# Same item still playing — track its furthest progress and fill in a
|
|
52
|
+
# duration if it wasn't known when it started.
|
|
53
|
+
ct = _to_seconds(np.get("currentTime"))
|
|
54
|
+
if ct > self._max_ct:
|
|
55
|
+
self._max_ct = ct
|
|
56
|
+
if not self._cur_duration:
|
|
57
|
+
self._cur_duration = _to_seconds(np.get("seconds") or np.get("duration"))
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Now-playing advanced (or stopped): finalize the item that just left.
|
|
61
|
+
if self._cur_key is not None:
|
|
62
|
+
await self._finalize(self._cur_token, self._cur_duration, self._max_ct)
|
|
63
|
+
|
|
64
|
+
# Begin tracking the newly-playing item.
|
|
65
|
+
self._cur_key = key
|
|
66
|
+
self._cur_token = None
|
|
67
|
+
self._max_ct = _to_seconds(np.get("currentTime"))
|
|
68
|
+
self._cur_duration = _to_seconds(np.get("seconds") or np.get("duration"))
|
|
69
|
+
if key is not None:
|
|
70
|
+
try:
|
|
71
|
+
meta = await self._db.resolve_media(media_id)
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.debug("Failed to resolve now-playing media %r", media_id, exc_info=True)
|
|
74
|
+
meta = None
|
|
75
|
+
if meta:
|
|
76
|
+
self._cur_token = meta.get("friendly_token")
|
|
77
|
+
if not self._cur_duration:
|
|
78
|
+
self._cur_duration = _to_seconds(meta.get("duration_sec"))
|
|
79
|
+
|
|
80
|
+
async def _finalize(self, token: str | None, duration: float, max_ct: float) -> None:
|
|
81
|
+
if not token:
|
|
82
|
+
return # not a resolvable catalog ('cm') item
|
|
83
|
+
if duration <= 0:
|
|
84
|
+
return # unknown duration — can't judge the threshold; don't hide
|
|
85
|
+
if max_ct < self._threshold * duration:
|
|
86
|
+
return # skipped/removed before playing enough to count as played
|
|
87
|
+
try:
|
|
88
|
+
await self._db.record_play_completion(
|
|
89
|
+
friendly_token=token,
|
|
90
|
+
duration_sec=int(duration),
|
|
91
|
+
)
|
|
92
|
+
except Exception:
|
|
93
|
+
logger.warning("Failed to record play completion for %s", token, exc_info=True)
|
|
@@ -7,13 +7,14 @@ logger = logging.getLogger(__name__)
|
|
|
7
7
|
class StatePoller:
|
|
8
8
|
"""Polls api-gate at a fixed interval to keep QueueShadow in sync."""
|
|
9
9
|
|
|
10
|
-
def __init__(self, *, api_gate, shadow, ws_manager, db=None, interval: float = 3.0, promo_director=None):
|
|
10
|
+
def __init__(self, *, api_gate, shadow, ws_manager, db=None, interval: float = 3.0, promo_director=None, completion_recorder=None):
|
|
11
11
|
self._api_gate = api_gate
|
|
12
12
|
self._shadow = shadow
|
|
13
13
|
self._ws_manager = ws_manager
|
|
14
14
|
self._db = db
|
|
15
15
|
self._interval = interval
|
|
16
16
|
self._promo_director = promo_director
|
|
17
|
+
self._completion_recorder = completion_recorder
|
|
17
18
|
self._task: asyncio.Task | None = None
|
|
18
19
|
|
|
19
20
|
async def start(self):
|
|
@@ -35,6 +36,12 @@ class StatePoller:
|
|
|
35
36
|
playlist = await self._api_gate.get_playlist()
|
|
36
37
|
now_playing = await self._api_gate.get_now_playing()
|
|
37
38
|
await self._shadow.apply_poll_result(playlist, now_playing)
|
|
39
|
+
# Record genuine play-completions (drives recently-played hiding).
|
|
40
|
+
if self._completion_recorder is not None:
|
|
41
|
+
try:
|
|
42
|
+
await self._completion_recorder.on_poll(now_playing)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.warning(f"Completion recorder error: {e}")
|
|
38
45
|
# Promo insertion runs after reconciliation, before broadcast.
|
|
39
46
|
if self._promo_director is not None:
|
|
40
47
|
try:
|
|
@@ -96,8 +96,14 @@ async def replace_items(request: Request, playlist_id: int, user: dict = Depends
|
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
@router.post("/{playlist_id}/import")
|
|
99
|
-
async def import_to_live(request: Request, playlist_id: int,
|
|
100
|
-
|
|
99
|
+
async def import_to_live(request: Request, playlist_id: int, full: int = 0,
|
|
100
|
+
user: dict = Depends(require_admin)):
|
|
101
|
+
"""Import a saved playlist into the live CyTube queue.
|
|
102
|
+
|
|
103
|
+
By default this honors the current-pass played/skip rules (a mutable TV-show
|
|
104
|
+
playlist continues where it left off). Pass ``?full=1`` to force-load the
|
|
105
|
+
entire list regardless of what's already been played this pass.
|
|
106
|
+
"""
|
|
101
107
|
from ..playlists.importer import PlaylistImporter
|
|
102
108
|
|
|
103
109
|
db = request.app.state.db
|
|
@@ -114,7 +120,7 @@ async def import_to_live(request: Request, playlist_id: int, user: dict = Depend
|
|
|
114
120
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
115
121
|
promo_director=getattr(request.app.state, "promo_director", None),
|
|
116
122
|
)
|
|
117
|
-
result = await importer.import_playlist(playlist_id)
|
|
123
|
+
result = await importer.import_playlist(playlist_id, skip_played=not bool(full))
|
|
118
124
|
return result
|
|
119
125
|
|
|
120
126
|
|
|
@@ -11,8 +11,12 @@ async def browse(request: Request, category: str | None = None, tag: str | None
|
|
|
11
11
|
user: dict = Depends(get_current_user)):
|
|
12
12
|
"""Browse catalog with optional category/tag filter."""
|
|
13
13
|
db = request.app.state.db
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
is_admin = (user.get("rank") or 0) >= 3
|
|
15
|
+
show_hidden = bool(show_hidden) and is_admin
|
|
16
|
+
# Admins always see every title; regular users have recently-played items
|
|
17
|
+
# hidden for the configured window.
|
|
18
|
+
recently_played_days = 0 if is_admin else request.app.state.config.catalog_recently_played_hide_days
|
|
19
|
+
items = await db.browse(category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort, recently_played_days=recently_played_days)
|
|
16
20
|
categories = await db.get_categories(show_hidden=show_hidden)
|
|
17
21
|
tags = await db.get_tags(show_hidden=show_hidden)
|
|
18
22
|
return {"items": items, "categories": categories, "tags": tags, "page": page, "sort": sort}
|
|
@@ -26,8 +30,10 @@ async def search(request: Request, q: str = "", category: str | None = None, tag
|
|
|
26
30
|
if not q.strip():
|
|
27
31
|
raise HTTPException(400, "Query required")
|
|
28
32
|
db = request.app.state.db
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
is_admin = (user.get("rank") or 0) >= 3
|
|
34
|
+
show_hidden = bool(show_hidden) and is_admin
|
|
35
|
+
recently_played_days = 0 if is_admin else request.app.state.config.catalog_recently_played_hide_days
|
|
36
|
+
items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort, recently_played_days=recently_played_days)
|
|
31
37
|
categories = await db.get_categories(show_hidden=show_hidden)
|
|
32
38
|
tags = await db.get_tags(show_hidden=show_hidden)
|
|
33
39
|
return {"items": items, "categories": categories, "tags": tags,
|
|
@@ -89,8 +89,11 @@ async def catalog_browse_page(request: Request, category: str | None = None,
|
|
|
89
89
|
is_admin = (user.get("rank") or 0) >= 3
|
|
90
90
|
show_hidden = bool(show_hidden) and is_admin
|
|
91
91
|
sort = sort if sort in _VALID_SORTS else "default"
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
# Admins always see every title; regular users have recently-played items
|
|
93
|
+
# hidden for the configured window.
|
|
94
|
+
recently_played_days = 0 if is_admin else request.app.state.config.catalog_recently_played_hide_days
|
|
95
|
+
items = await db.browse(category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort, recently_played_days=recently_played_days)
|
|
96
|
+
total = await db.browse_count(category=category, tag=tag, show_hidden=show_hidden, recently_played_days=recently_played_days)
|
|
94
97
|
total_pages = max(1, (total + 23) // 24)
|
|
95
98
|
categories = await db.get_categories(show_hidden=show_hidden)
|
|
96
99
|
tags = await db.get_tags(show_hidden=show_hidden)
|
|
@@ -125,8 +128,9 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
|
|
|
125
128
|
is_admin = (user.get("rank") or 0) >= 3
|
|
126
129
|
show_hidden = bool(show_hidden) and is_admin
|
|
127
130
|
sort = sort if sort in _VALID_SORTS else "default"
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
recently_played_days = 0 if is_admin else request.app.state.config.catalog_recently_played_hide_days
|
|
132
|
+
items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort, recently_played_days=recently_played_days)
|
|
133
|
+
total = await db.search_count(q, category=category, tag=tag, show_hidden=show_hidden, recently_played_days=recently_played_days)
|
|
130
134
|
total_pages = max(1, (total + 23) // 24)
|
|
131
135
|
categories = await db.get_categories(show_hidden=show_hidden)
|
|
132
136
|
tags = await db.get_tags(show_hidden=show_hidden)
|
{kryten_webqueue-0.30.0 → kryten_webqueue-0.32.0}/kryten_webqueue/static/js/admin-playlists.js
RENAMED
|
@@ -218,13 +218,21 @@ async function saveItems() {
|
|
|
218
218
|
showToast(resp.ok ? 'Saved' : 'Save failed', resp.ok ? 'success' : 'error');
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
async function importToLive() {
|
|
221
|
+
async function importToLive(full = false) {
|
|
222
222
|
if (editorId === null) return;
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
const prompt = full
|
|
224
|
+
? 'Load the ENTIRE playlist into the live CyTube queue now (ignoring what has already been played this pass)?'
|
|
225
|
+
: 'Load this playlist into the live CyTube queue now?';
|
|
226
|
+
if (!confirm(prompt)) return;
|
|
227
|
+
const url = `/admin/playlists/${editorId}/import${full ? '?full=1' : ''}`;
|
|
228
|
+
const resp = await fetch(url, {method: 'POST'});
|
|
225
229
|
const data = await resp.json().catch(() => ({}));
|
|
226
|
-
if (resp.ok && data.success)
|
|
227
|
-
|
|
230
|
+
if (resp.ok && data.success) {
|
|
231
|
+
const skipped = data.skipped ? `, skipped ${data.skipped} already-played` : '';
|
|
232
|
+
showToast(`Imported ${data.added} item(s)${data.errors ? `, ${data.errors} errors` : ''}${skipped}`);
|
|
233
|
+
} else {
|
|
234
|
+
showToast(data.error || 'Import failed', 'error');
|
|
235
|
+
}
|
|
228
236
|
}
|
|
229
237
|
|
|
230
238
|
function editMeta() {
|