kryten-webqueue 0.31.0__tar.gz → 0.32.1__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.31.0 → kryten_webqueue-0.32.1}/CHANGELOG.md +24 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/config.example.json +1 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/app.py +7 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_catalog.py +181 -4
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_connection.py +33 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/config.py +7 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/fire.py +18 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/importer.py +21 -3
- kryten_webqueue-0.32.1/kryten_webqueue/queue/completion.py +93 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/poller.py +8 -1
- kryten_webqueue-0.32.1/kryten_webqueue/routes/admin_catalog.py +92 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_playlists.py +9 -3
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/catalog.py +10 -4
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/pages.py +8 -4
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-playlists.js +13 -5
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/playlists.html +2 -1
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/catalog/browse.html +36 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/pyproject.toml +1 -1
- kryten_webqueue-0.32.1/tests/test_recently_played_hide.py +352 -0
- kryten_webqueue-0.31.0/kryten_webqueue/routes/admin_catalog.py +0 -45
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/.gitignore +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/README.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/__init__.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_search_facets.py +0 -0
|
@@ -5,6 +5,30 @@ 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.1] — 2026-07-09
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Admin controls to test recently-played hiding in situ** without waiting for real playback:
|
|
13
|
+
- `POST /admin/catalog/{token}/mark-played` — simulates a genuine completion via the same `record_play_completion` the poll loop uses, so it also exercises the mutable-playlist pass logic (marking episodes, resetting on the last item).
|
|
14
|
+
- `POST /admin/catalog/{token}/clear-played` — removes an item's completion and playlist-pass rows so it reappears for regular users immediately.
|
|
15
|
+
- `GET /admin/catalog/recently-played/debug` — read-only snapshot of exactly what regular users are not seeing and why (time-window vs mutable-playlist pass).
|
|
16
|
+
- The admin catalog browser gains **Mark played** and **Clear played** buttons per item.
|
|
17
|
+
|
|
18
|
+
## [0.32.0] — 2026-07-09
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **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).
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
- Promos are unaffected — promo/immutable-playlist items are already excluded from the public catalog.
|
|
28
|
+
|
|
29
|
+
[0.32.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.0
|
|
30
|
+
[0.32.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.1
|
|
31
|
+
|
|
8
32
|
## [0.31.0] — 2026-07-04
|
|
9
33
|
|
|
10
34
|
### 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,133 @@ 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
|
+
|
|
412
|
+
async def clear_play_state(self, friendly_token: str, *, media_type: str = "cm") -> dict:
|
|
413
|
+
"""Remove all recently-played hide state for an item (admin test helper).
|
|
414
|
+
|
|
415
|
+
Deletes both time-boxed ``play_completions`` rows and any
|
|
416
|
+
``playlist_item_played`` (mutable-playlist pass) rows for the item, so it
|
|
417
|
+
immediately reappears in the public catalog. Returns the row counts
|
|
418
|
+
removed.
|
|
419
|
+
"""
|
|
420
|
+
pc = await self._fetch_one(
|
|
421
|
+
"SELECT COUNT(*) AS c FROM play_completions WHERE media_id = ? AND media_type = ?",
|
|
422
|
+
[friendly_token, media_type],
|
|
423
|
+
)
|
|
424
|
+
pip = await self._fetch_one(
|
|
425
|
+
"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
|
|
426
|
+
[friendly_token, media_type],
|
|
427
|
+
)
|
|
428
|
+
await self._execute(
|
|
429
|
+
"DELETE FROM play_completions WHERE media_id = ? AND media_type = ?",
|
|
430
|
+
[friendly_token, media_type],
|
|
431
|
+
)
|
|
432
|
+
await self._execute(
|
|
433
|
+
"DELETE FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
|
|
434
|
+
[friendly_token, media_type],
|
|
435
|
+
)
|
|
436
|
+
return {"completions": pc["c"] if pc else 0, "playlist_pass": pip["c"] if pip else 0}
|
|
437
|
+
|
|
438
|
+
async def get_recently_played_debug(self, days: int) -> dict:
|
|
439
|
+
"""Snapshot of what the recently-played rules currently hide (admin test).
|
|
440
|
+
|
|
441
|
+
Mirrors the two exclusion signals used by browse/search so an admin can
|
|
442
|
+
see exactly which titles a regular user would NOT see, and why.
|
|
443
|
+
"""
|
|
444
|
+
by_completion = await self._fetch_all(
|
|
445
|
+
"SELECT pc.media_id, c.title, MAX(pc.completed_at) AS last_completed "
|
|
446
|
+
"FROM play_completions pc "
|
|
447
|
+
"LEFT JOIN catalog c ON c.friendly_token = pc.media_id "
|
|
448
|
+
"WHERE pc.media_type = 'cm' AND pc.completed_at >= datetime('now', ?) "
|
|
449
|
+
"GROUP BY pc.media_id ORDER BY last_completed DESC",
|
|
450
|
+
[f"-{int(days)} days"],
|
|
451
|
+
)
|
|
452
|
+
by_playlist_pass = await self._fetch_all(
|
|
453
|
+
"SELECT pip.media_id, c.title, pip.playlist_id, sp.name AS playlist_name, pip.position "
|
|
454
|
+
"FROM playlist_item_played pip "
|
|
455
|
+
"JOIN saved_playlists sp ON sp.id = pip.playlist_id "
|
|
456
|
+
"LEFT JOIN catalog c ON c.friendly_token = pip.media_id "
|
|
457
|
+
"WHERE pip.media_type = 'cm' ORDER BY sp.name, pip.position",
|
|
458
|
+
)
|
|
459
|
+
return {
|
|
460
|
+
"window_days": days,
|
|
461
|
+
"by_completion": by_completion,
|
|
462
|
+
"by_playlist_pass": by_playlist_pass,
|
|
463
|
+
}
|
|
464
|
+
|
|
288
465
|
async def get_catalog_brief(self, tokens: list[str], manifest_urls: list[str]) -> dict[str, dict]:
|
|
289
466
|
"""Return a lookup of catalog metadata keyed by BOTH friendly_token and
|
|
290
467
|
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:
|