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.
Files changed (130) hide show
  1. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/CHANGELOG.md +24 -0
  2. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/config.example.json +1 -0
  4. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/app.py +7 -0
  5. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_catalog.py +181 -4
  6. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_connection.py +33 -0
  7. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/config.py +7 -0
  8. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/fire.py +18 -0
  9. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/importer.py +21 -3
  10. kryten_webqueue-0.32.1/kryten_webqueue/queue/completion.py +93 -0
  11. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/poller.py +8 -1
  12. kryten_webqueue-0.32.1/kryten_webqueue/routes/admin_catalog.py +92 -0
  13. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_playlists.py +9 -3
  14. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/catalog.py +10 -4
  15. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/pages.py +8 -4
  16. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-playlists.js +13 -5
  17. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/playlists.html +2 -1
  18. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/catalog/browse.html +36 -0
  19. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/pyproject.toml +1 -1
  20. kryten_webqueue-0.32.1/tests/test_recently_played_hide.py +352 -0
  21. kryten_webqueue-0.31.0/kryten_webqueue/routes/admin_catalog.py +0 -45
  22. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/.github/workflows/python-publish.yml +0 -0
  23. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/.github/workflows/release.yml +0 -0
  24. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/.gitignore +0 -0
  25. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/README.md +0 -0
  26. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/deploy/kryten-webqueue.service +0 -0
  27. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/deploy/nginx-queue.conf +0 -0
  28. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  29. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_API_GATE.md +0 -0
  30. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_ECONOMY.md +0 -0
  31. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  32. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/IMPL_ROBOT.md +0 -0
  33. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  34. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  35. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
  36. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  37. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/PRE_PLAN_GAPS.md +0 -0
  38. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/PRODUCT_PLAN.md +0 -0
  39. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  40. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/docs/UX_POLISH_PLAN.md +0 -0
  41. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/__init__.py +0 -0
  42. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/__main__.py +0 -0
  43. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  44. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/api_gate/client.py +0 -0
  45. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/__init__.py +0 -0
  46. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/otp.py +0 -0
  47. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  48. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/auth/session.py +0 -0
  49. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/__init__.py +0 -0
  50. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/__init__.py +0 -0
  51. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  52. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  53. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/db/_queue.py +0 -0
  54. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/images.py +0 -0
  55. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  56. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/catalog/sync.py +0 -0
  57. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/__init__.py +0 -0
  58. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  59. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  60. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  61. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  62. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  63. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  64. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  65. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  66. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/__init__.py +0 -0
  67. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  68. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/manager.py +0 -0
  69. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/jobs/tasks.py +0 -0
  70. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/logging_config.py +0 -0
  71. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/__init__.py +0 -0
  72. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  73. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/ordering.py +0 -0
  74. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  75. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/promos/__init__.py +0 -0
  76. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/promos/director.py +0 -0
  77. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/__init__.py +0 -0
  78. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/ordering.py +0 -0
  79. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/presence.py +0 -0
  80. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/race_poller.py +0 -0
  81. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/queue/shadow.py +0 -0
  82. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/__init__.py +0 -0
  83. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_feedback.py +0 -0
  84. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  85. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_moderation.py +0 -0
  86. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  87. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  88. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  89. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/auth.py +0 -0
  90. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/feedback.py +0 -0
  91. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/queue.py +0 -0
  92. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/routes/user.py +0 -0
  93. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/css/main.css +0 -0
  94. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
  95. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  96. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  97. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/admin.js +0 -0
  98. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/static/js/main.js +0 -0
  99. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/index.html +0 -0
  100. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  101. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  102. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  103. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/auth/login.html +0 -0
  104. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/base.html +0 -0
  105. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  106. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  107. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/feedback/index.html +0 -0
  108. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/queue/index.html +0 -0
  109. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/race.html +0 -0
  110. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  111. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/ws/__init__.py +0 -0
  112. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/ws/handler.py +0 -0
  113. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/kryten_webqueue/ws/manager.py +0 -0
  114. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/__init__.py +0 -0
  115. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_config_persistence.py +0 -0
  116. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_feedback.py +0 -0
  117. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_fetchurls_sharepoint.py +0 -0
  118. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase1.py +0 -0
  119. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase2_jobs.py +0 -0
  120. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase3_jobs.py +0 -0
  121. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_phase4_live_fixes.py +0 -0
  122. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_playlist_import.py +0 -0
  123. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_presence_refund.py +0 -0
  124. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_promo_director.py +0 -0
  125. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_promo_pool_exclusion.py +0 -0
  126. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_queue_announce.py +0 -0
  127. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_race_poller.py +0 -0
  128. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_save_results_to_playlist.py +0 -0
  129. {kryten_webqueue-0.31.0 → kryten_webqueue-0.32.1}/tests/test_schedule_lock.py +0 -0
  130. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.31.0
3
+ Version: 0.32.1
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -60,6 +60,7 @@
60
60
  "catalog_sync_interval_hours": 4,
61
61
  "pre_fire_lock_minutes_default": 15,
62
62
  "state_poll_interval_sec": 3.0,
63
+ "catalog_recently_played_hide_days": 21,
63
64
 
64
65
  "playlist_bulk_add_delay_sec": 0.5,
65
66
  "playlist_bulk_add_max_retries": 2,
@@ -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 all items from a saved playlist into the live queue."""
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: