kryten-webqueue 0.34.16__tar.gz → 0.35.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.34.16 → kryten_webqueue-0.35.0}/CHANGELOG.md +36 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/app.py +9 -1
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/__init__.py +10 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_connection.py +22 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/db/_enrichment.py +114 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/__init__.py +30 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/classify.py +343 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/normalise.py +105 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/pipeline.py +235 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/providers.py +401 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/report.py +44 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/__init__.py +8 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/art.py +172 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/categories.py +13 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/meta.py +262 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/sync.py +35 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/tags.py +142 -0
- kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/title.py +126 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/manager.py +12 -2
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/tasks.py +91 -17
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_catalog.py +25 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/catalog/item_detail.html +18 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/pyproject.toml +1 -1
- kryten_webqueue-0.35.0/tests/test_classify.py +129 -0
- kryten_webqueue-0.35.0/tests/test_providers.py +120 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.gitignore +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/README.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/config.example.json +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_people.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/user/my_list.html +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_people_facets.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_search_facets.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_title_normalization.py +0 -0
- {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_watchlist.py +0 -0
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.35.0] - 2026-08-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Unified Catalog Enrichment Pipeline** (`kryten_webqueue/catalog/enrichment/`).
|
|
10
|
+
Consolidates `enrichtitles`, `enrichmeta`, `enrichtv`, and the sync-time art
|
|
11
|
+
resolver into a single `CatalogEnrichmentPipeline` with composable, named steps.
|
|
12
|
+
|
|
13
|
+
**Steps:** `sync` → `classify` → `title` → `meta` → `art` → `tags` →
|
|
14
|
+
`categories` (stub, deferred).
|
|
15
|
+
|
|
16
|
+
**Key fixes:**
|
|
17
|
+
- Art step uses `classification.lookup_title` (the extracted film title, e.g.
|
|
18
|
+
`"Phantom of the Mall (1989)"`) instead of the raw CMS title (`"Phantom of
|
|
19
|
+
the Mall Svengoolie"`). Svengoolie, MonsterVision, Last Drive-In, Rifftrax,
|
|
20
|
+
and MST3K items can now resolve TMDB posters correctly.
|
|
21
|
+
- Single `HOSTED_SHOW_REGISTRY` replaces three scattered `_HOSTED_PATTERNS`
|
|
22
|
+
lists; hosted-show detection is now authoritative in `classify.py`.
|
|
23
|
+
- Single `normalise.py` replaces five separate title-cleaning implementations.
|
|
24
|
+
- `meta_json` cached in `item_enrichment_state` carries `poster_url` so art
|
|
25
|
+
and meta share one TMDB call (no double lookup).
|
|
26
|
+
- Meta step populates `catalog_people` / `catalog_studios` tables from TMDB
|
|
27
|
+
credits automatically.
|
|
28
|
+
|
|
29
|
+
**New job:** `catalog_enrich` — full or partial pipeline; params: `steps`,
|
|
30
|
+
`tokens`, `force`, `dry_run`, `limit`, `min_score`.
|
|
31
|
+
|
|
32
|
+
**Backward-compat wrappers:** `enrichtitles`, `enrichmeta`, `enrichtv` now
|
|
33
|
+
delegate to the new pipeline (`steps=classify,title` / `classify,meta`).
|
|
34
|
+
|
|
35
|
+
**Re-enrich button** on the item detail admin panel — force-runs all
|
|
36
|
+
enrichment steps for a single item without touching the cron schedule.
|
|
37
|
+
|
|
38
|
+
**DB migration v19:** `item_enrichment_state` table — per-item cache of
|
|
39
|
+
classification, TMDB/OMDB result, and per-step timestamps.
|
|
40
|
+
|
|
5
41
|
## [0.34.16] - 2026-08-13
|
|
6
42
|
|
|
7
43
|
### Added
|
|
@@ -92,7 +92,7 @@ async def lifespan(app: FastAPI):
|
|
|
92
92
|
app.state.catalog_sync = catalog_sync
|
|
93
93
|
|
|
94
94
|
# Generic background job runner (records run history to job_runs)
|
|
95
|
-
job_manager = JobManager(db, api_gate=api_gate, config=config)
|
|
95
|
+
job_manager = JobManager(db, api_gate=api_gate, config=config, cover_art=cover_art)
|
|
96
96
|
|
|
97
97
|
async def _catalog_sync_job(params, ctx):
|
|
98
98
|
# catalog_sync registers with no schema; params/ctx are ignored. The
|
|
@@ -112,9 +112,11 @@ async def lifespan(app: FastAPI):
|
|
|
112
112
|
enrichtitles_job,
|
|
113
113
|
enrichmeta_job,
|
|
114
114
|
enrichtv_job,
|
|
115
|
+
catalog_enrich_job,
|
|
115
116
|
fetch_job,
|
|
116
117
|
fetchurls_job,
|
|
117
118
|
motd_posters_job,
|
|
119
|
+
CATALOG_ENRICH_SCHEMA,
|
|
118
120
|
ENRICHTITLES_SCHEMA,
|
|
119
121
|
ENRICHMETA_SCHEMA,
|
|
120
122
|
ENRICHTV_SCHEMA,
|
|
@@ -123,6 +125,12 @@ async def lifespan(app: FastAPI):
|
|
|
123
125
|
MOTD_POSTERS_SCHEMA,
|
|
124
126
|
)
|
|
125
127
|
|
|
128
|
+
job_manager.register(
|
|
129
|
+
"catalog_enrich",
|
|
130
|
+
catalog_enrich_job,
|
|
131
|
+
label="Catalog Enrichment Pipeline",
|
|
132
|
+
schema=CATALOG_ENRICH_SCHEMA,
|
|
133
|
+
)
|
|
126
134
|
job_manager.register(
|
|
127
135
|
"enrichtitles",
|
|
128
136
|
enrichtitles_job,
|
|
@@ -10,6 +10,7 @@ from ._queue import _QueueMixin
|
|
|
10
10
|
from ._feedback import _FeedbackMixin
|
|
11
11
|
from ._watchlist import _WatchlistMixin
|
|
12
12
|
from ._people import _PeopleMixin
|
|
13
|
+
from ._enrichment import _EnrichmentMixin
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class Database(
|
|
@@ -19,6 +20,7 @@ class Database(
|
|
|
19
20
|
_FeedbackMixin,
|
|
20
21
|
_WatchlistMixin,
|
|
21
22
|
_PeopleMixin,
|
|
23
|
+
_EnrichmentMixin,
|
|
22
24
|
_DBBase,
|
|
23
25
|
):
|
|
24
26
|
"""Async SQLite database wrapper."""
|
|
@@ -30,3 +32,11 @@ __all__ = [
|
|
|
30
32
|
"HIDDEN_CATEGORY_NAMES",
|
|
31
33
|
"HIDDEN_TAG_NAMES",
|
|
32
34
|
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Database",
|
|
39
|
+
"HIDDEN_ITEM_TAG",
|
|
40
|
+
"HIDDEN_CATEGORY_NAMES",
|
|
41
|
+
"HIDDEN_TAG_NAMES",
|
|
42
|
+
]
|
{kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_connection.py
RENAMED
|
@@ -373,6 +373,28 @@ MIGRATIONS = [
|
|
|
373
373
|
);
|
|
374
374
|
CREATE INDEX IF NOT EXISTS idx_catalog_studios_token ON catalog_studios(friendly_token);
|
|
375
375
|
""",
|
|
376
|
+
# v19: Per-item enrichment pipeline state cache.
|
|
377
|
+
"""
|
|
378
|
+
CREATE TABLE IF NOT EXISTS item_enrichment_state (
|
|
379
|
+
friendly_token TEXT PRIMARY KEY,
|
|
380
|
+
content_type TEXT,
|
|
381
|
+
hosted_show TEXT,
|
|
382
|
+
lookup_title TEXT,
|
|
383
|
+
lookup_year TEXT,
|
|
384
|
+
tv_show TEXT,
|
|
385
|
+
tv_season INTEGER,
|
|
386
|
+
tv_episode_num INTEGER,
|
|
387
|
+
description_score INTEGER,
|
|
388
|
+
tmdb_id TEXT,
|
|
389
|
+
imdb_id TEXT,
|
|
390
|
+
meta_json TEXT,
|
|
391
|
+
last_classify_at TEXT,
|
|
392
|
+
last_title_at TEXT,
|
|
393
|
+
last_meta_at TEXT,
|
|
394
|
+
last_art_at TEXT,
|
|
395
|
+
last_tags_at TEXT
|
|
396
|
+
);
|
|
397
|
+
""",
|
|
376
398
|
]
|
|
377
399
|
|
|
378
400
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Enrichment pipeline state — per-item caching of classification + metadata."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _EnrichmentMixin:
|
|
7
|
+
"""item_enrichment_state CRUD methods."""
|
|
8
|
+
|
|
9
|
+
async def ensure_enrichment_state(self, token: str) -> None:
|
|
10
|
+
"""Create a skeleton enrichment-state row if none exists."""
|
|
11
|
+
await self._db.execute(
|
|
12
|
+
"INSERT OR IGNORE INTO item_enrichment_state (friendly_token) VALUES (?)",
|
|
13
|
+
[token],
|
|
14
|
+
)
|
|
15
|
+
await self._db.commit()
|
|
16
|
+
|
|
17
|
+
async def get_enrichment_state(self, token: str) -> dict | None:
|
|
18
|
+
return await self._fetch_one(
|
|
19
|
+
"SELECT * FROM item_enrichment_state WHERE friendly_token = ?", [token]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
async def save_enrichment_state(self, token: str, **fields) -> None:
|
|
23
|
+
"""Upsert enrichment state fields for a token.
|
|
24
|
+
|
|
25
|
+
Safely handles any subset of columns; unknown keys are silently ignored.
|
|
26
|
+
"""
|
|
27
|
+
if not fields:
|
|
28
|
+
return
|
|
29
|
+
allowed = {
|
|
30
|
+
"content_type",
|
|
31
|
+
"hosted_show",
|
|
32
|
+
"lookup_title",
|
|
33
|
+
"lookup_year",
|
|
34
|
+
"tv_show",
|
|
35
|
+
"tv_season",
|
|
36
|
+
"tv_episode_num",
|
|
37
|
+
"description_score",
|
|
38
|
+
"tmdb_id",
|
|
39
|
+
"imdb_id",
|
|
40
|
+
"meta_json",
|
|
41
|
+
"last_classify_at",
|
|
42
|
+
"last_title_at",
|
|
43
|
+
"last_meta_at",
|
|
44
|
+
"last_art_at",
|
|
45
|
+
"last_tags_at",
|
|
46
|
+
}
|
|
47
|
+
safe = {k: v for k, v in fields.items() if k in allowed}
|
|
48
|
+
if not safe:
|
|
49
|
+
return
|
|
50
|
+
await self.ensure_enrichment_state(token)
|
|
51
|
+
sets = ", ".join(f"{k} = ?" for k in safe)
|
|
52
|
+
await self._execute(
|
|
53
|
+
f"UPDATE item_enrichment_state SET {sets} WHERE friendly_token = ?",
|
|
54
|
+
[*safe.values(), token],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def get_catalog_for_enrichment(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
step: str,
|
|
61
|
+
tokens: list[str] | None = None,
|
|
62
|
+
force: bool = False,
|
|
63
|
+
limit: int | None = None,
|
|
64
|
+
) -> list[dict]:
|
|
65
|
+
"""Return catalog rows joined with enrichment state, filtered to items that
|
|
66
|
+
need the given step.
|
|
67
|
+
|
|
68
|
+
In normal mode an item needs a step when ``last_{step}_at IS NULL``.
|
|
69
|
+
``force=True`` returns all items regardless.
|
|
70
|
+
"""
|
|
71
|
+
step_col = f"last_{step}_at"
|
|
72
|
+
sql = """
|
|
73
|
+
SELECT c.friendly_token, c.title, c.duration_sec,
|
|
74
|
+
c.cover_art_path, c.cover_art_source, c.thumbnail_url,
|
|
75
|
+
c.description, c.manifest_url,
|
|
76
|
+
e.content_type, e.hosted_show, e.lookup_title, e.lookup_year,
|
|
77
|
+
e.tv_show, e.tv_season, e.tv_episode_num,
|
|
78
|
+
e.description_score, e.tmdb_id, e.imdb_id, e.meta_json,
|
|
79
|
+
e.last_classify_at, e.last_title_at, e.last_meta_at,
|
|
80
|
+
e.last_art_at, e.last_tags_at
|
|
81
|
+
FROM catalog c
|
|
82
|
+
LEFT JOIN item_enrichment_state e ON e.friendly_token = c.friendly_token
|
|
83
|
+
"""
|
|
84
|
+
params: list = []
|
|
85
|
+
conditions: list[str] = []
|
|
86
|
+
|
|
87
|
+
if tokens:
|
|
88
|
+
ph = ",".join("?" * len(tokens))
|
|
89
|
+
conditions.append(f"c.friendly_token IN ({ph})")
|
|
90
|
+
params.extend(tokens)
|
|
91
|
+
|
|
92
|
+
if not force:
|
|
93
|
+
conditions.append(f"(e.{step_col} IS NULL)")
|
|
94
|
+
|
|
95
|
+
if conditions:
|
|
96
|
+
sql += " WHERE " + " AND ".join(conditions)
|
|
97
|
+
|
|
98
|
+
sql += " ORDER BY c.title"
|
|
99
|
+
|
|
100
|
+
if limit:
|
|
101
|
+
sql += " LIMIT ?"
|
|
102
|
+
params.append(limit)
|
|
103
|
+
|
|
104
|
+
return await self._fetch_all(sql, params)
|
|
105
|
+
|
|
106
|
+
def parse_meta_json(self, row: dict) -> dict | None:
|
|
107
|
+
"""Deserialise the cached meta_json field from an enrichment row."""
|
|
108
|
+
raw = (row or {}).get("meta_json")
|
|
109
|
+
if not raw:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return json.loads(raw)
|
|
113
|
+
except (ValueError, TypeError):
|
|
114
|
+
return None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .pipeline import CatalogEnrichmentPipeline, ALL_STEPS
|
|
2
|
+
from .classify import (
|
|
3
|
+
classify_item,
|
|
4
|
+
ItemClassification,
|
|
5
|
+
HostedInfo,
|
|
6
|
+
HostedShowEntry,
|
|
7
|
+
HOSTED_SHOW_REGISTRY,
|
|
8
|
+
)
|
|
9
|
+
from .providers import TMDBProvider, OMDBProvider, MovieMetadata, merge_metadata
|
|
10
|
+
from .normalise import normalize_and_clean, normalize_leading_year, strip_extension
|
|
11
|
+
from .report import EnrichmentReport, StepResult
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CatalogEnrichmentPipeline",
|
|
15
|
+
"ALL_STEPS",
|
|
16
|
+
"classify_item",
|
|
17
|
+
"ItemClassification",
|
|
18
|
+
"HostedInfo",
|
|
19
|
+
"HostedShowEntry",
|
|
20
|
+
"HOSTED_SHOW_REGISTRY",
|
|
21
|
+
"TMDBProvider",
|
|
22
|
+
"OMDBProvider",
|
|
23
|
+
"MovieMetadata",
|
|
24
|
+
"merge_metadata",
|
|
25
|
+
"normalize_and_clean",
|
|
26
|
+
"normalize_leading_year",
|
|
27
|
+
"strip_extension",
|
|
28
|
+
"EnrichmentReport",
|
|
29
|
+
"StepResult",
|
|
30
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Item classification — hosted-show detection, content-type determination.
|
|
2
|
+
|
|
3
|
+
Single authoritative source for HOSTED_SHOW_REGISTRY and all classification
|
|
4
|
+
logic, replacing scattered _HOSTED_PATTERNS lists in enrichmeta.py and images.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from .normalise import normalize_and_clean, strip_extension
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Hosted show registry
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class HostedShowEntry:
|
|
21
|
+
pattern: re.Pattern
|
|
22
|
+
show_name: str
|
|
23
|
+
content_type: str # "hosted_movie" | "riffed_movie"
|
|
24
|
+
title_treatment: str # "keep" | "reformat"
|
|
25
|
+
reformat_template: str | None
|
|
26
|
+
cms_tag: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
HOSTED_SHOW_REGISTRY: list[HostedShowEntry] = [
|
|
30
|
+
HostedShowEntry(
|
|
31
|
+
re.compile(r"Joe\s*Bob'?s?\s+Drive[\s\-]*In\s+Theater", re.I),
|
|
32
|
+
"Joe Bob's Drive-In Theater",
|
|
33
|
+
"hosted_movie",
|
|
34
|
+
"keep",
|
|
35
|
+
None,
|
|
36
|
+
"joebobbriggs",
|
|
37
|
+
),
|
|
38
|
+
HostedShowEntry(
|
|
39
|
+
re.compile(r"JBBTLDI|Joe\s*Bob\s+TLDI", re.I),
|
|
40
|
+
"The Last Drive-In with Joe Bob Briggs",
|
|
41
|
+
"hosted_movie",
|
|
42
|
+
"keep",
|
|
43
|
+
None,
|
|
44
|
+
"lastdrivein",
|
|
45
|
+
),
|
|
46
|
+
HostedShowEntry(
|
|
47
|
+
re.compile(r"(?:The\s+)?Last\s+Drive[\s\-]*In", re.I),
|
|
48
|
+
"The Last Drive-In with Joe Bob Briggs",
|
|
49
|
+
"hosted_movie",
|
|
50
|
+
"keep",
|
|
51
|
+
None,
|
|
52
|
+
"lastdrivein",
|
|
53
|
+
),
|
|
54
|
+
HostedShowEntry(
|
|
55
|
+
re.compile(r"Monster\s*Vision", re.I),
|
|
56
|
+
"MonsterVision with Joe Bob Briggs",
|
|
57
|
+
"hosted_movie",
|
|
58
|
+
"keep",
|
|
59
|
+
None,
|
|
60
|
+
"monstervision",
|
|
61
|
+
),
|
|
62
|
+
HostedShowEntry(
|
|
63
|
+
re.compile(r"Svengoolie", re.I),
|
|
64
|
+
"Svengoolie",
|
|
65
|
+
"hosted_movie",
|
|
66
|
+
"keep",
|
|
67
|
+
None,
|
|
68
|
+
"svengoolie",
|
|
69
|
+
),
|
|
70
|
+
HostedShowEntry(
|
|
71
|
+
re.compile(r"Riff\s*Trax(?:\s+Live)?", re.I),
|
|
72
|
+
"RiffTrax",
|
|
73
|
+
"riffed_movie",
|
|
74
|
+
"reformat",
|
|
75
|
+
"RiffTrax Presents: {title} ({year})",
|
|
76
|
+
"rifftrax",
|
|
77
|
+
),
|
|
78
|
+
HostedShowEntry(
|
|
79
|
+
re.compile(r"MST3K|Mystery\s*Science\s*Theater", re.I),
|
|
80
|
+
"Mystery Science Theater 3000",
|
|
81
|
+
"riffed_movie",
|
|
82
|
+
"keep",
|
|
83
|
+
None,
|
|
84
|
+
"mst3k",
|
|
85
|
+
),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# TV episode detection
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
_TV_RE = re.compile(
|
|
93
|
+
r"(?:[Ss]\d{1,2}\s*[Ee]\d{1,2}"
|
|
94
|
+
r"|\b\d{1,2}[Xx]\d{2,3}\b"
|
|
95
|
+
r"|\b[Ss]eason\s*\d+"
|
|
96
|
+
r"|\b[Ee]pisode\s*\d+"
|
|
97
|
+
r"|\b[Ee][Pp]\.?\s*\d+"
|
|
98
|
+
r")"
|
|
99
|
+
)
|
|
100
|
+
_SXE_RE = re.compile(r"[Ss](\d{1,2})[Ee](\d{1,2})")
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Archive detection (broadcast dates, wrestling, etc.)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
_ARCHIVE_RE = re.compile(
|
|
107
|
+
r"(?:\b(?:19|20)\d{2}-\d{2}-\d{2}\b" # ISO date
|
|
108
|
+
r"|\b\d{1,2}-\d{1,2}-(?:19|20)?\d{2}\b" # MM-DD-YY
|
|
109
|
+
r"|\bWWF\b|\bWCW\b|\bNWA\b|\bAWA\b" # wrestling promotions
|
|
110
|
+
r"|Complete\s+Broadcast"
|
|
111
|
+
r"|Original\s+Broadcast"
|
|
112
|
+
r")",
|
|
113
|
+
re.I,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Hosted extraction helpers (adapted from enrichmeta.py detect_hosted)
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
_EXT_RE = re.compile(r"\.\w{2,5}$")
|
|
121
|
+
_YEAR_PAREN_RE = re.compile(r"\((\d{4})\)")
|
|
122
|
+
_YEAR_BRACKET_RE = re.compile(r"[\[\]{}]\s*(\d{4})\s*[\[\]{}]")
|
|
123
|
+
_YEAR_BARE_RE = re.compile(r"\b((?:19|20)\d{2})\b")
|
|
124
|
+
_DATE_RE = re.compile(
|
|
125
|
+
r"(?:(?:19|20)\d{2}-\d{1,2}-\d{1,2})|(?:\d{1,2}-\d{1,2}-(?:19|20)?\d{2})"
|
|
126
|
+
)
|
|
127
|
+
_EPISODE_CODE_RE = re.compile(r"[SW]\d+[eE]\d+", re.I)
|
|
128
|
+
_SHOW_YEAR_PREFIX_RE = re.compile(
|
|
129
|
+
r"^\s*\(\d{4}\)\s*S\d+-Wk\s*\d+\s*Film\s*\d+\s*[-\u2013\u2014]?\s*", re.I
|
|
130
|
+
)
|
|
131
|
+
_WEEK_PREFIX_RE = re.compile(
|
|
132
|
+
r"(?:S\d+-)?(?:Wk|Week)\s*\d+\s*[-\u2013\u2014]?\s*(?:(?:Film|Movie)\s*\d+\s*[-\u2013\u2014]?\s*)?",
|
|
133
|
+
re.I,
|
|
134
|
+
)
|
|
135
|
+
_LEADING_EPNUM_RE = re.compile(r"^\s*\d{1,2}\s*[-\u2013\u2014]?\s+")
|
|
136
|
+
_SCENE_HOSTED_RE = re.compile(
|
|
137
|
+
r"\[?(?:720p|480p|1080p|who(?:do)?dude\w*|CG|Hybrid)\]?", re.I
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class HostedInfo:
|
|
143
|
+
show_name: str
|
|
144
|
+
movie_title: str
|
|
145
|
+
movie_year: str | None
|
|
146
|
+
cms_tag: str = ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_hosted_title(raw: str, entry: HostedShowEntry) -> HostedInfo:
|
|
150
|
+
"""Strip show name from raw title and extract the underlying movie title + year."""
|
|
151
|
+
title = _EXT_RE.sub("", raw.strip())
|
|
152
|
+
|
|
153
|
+
# remove the matched show pattern
|
|
154
|
+
m = entry.pattern.search(title)
|
|
155
|
+
if m:
|
|
156
|
+
title = title[: m.start()] + title[m.end() :]
|
|
157
|
+
|
|
158
|
+
# remove any remaining registry patterns (handles double-show mentions)
|
|
159
|
+
for e in HOSTED_SHOW_REGISTRY:
|
|
160
|
+
title = e.pattern.sub("", title)
|
|
161
|
+
|
|
162
|
+
title = _SCENE_HOSTED_RE.sub("", title)
|
|
163
|
+
title = title.replace("_", " ")
|
|
164
|
+
title = _DATE_RE.sub("", title)
|
|
165
|
+
title = _SHOW_YEAR_PREFIX_RE.sub("", title)
|
|
166
|
+
title = _EPISODE_CODE_RE.sub("", title)
|
|
167
|
+
title = _WEEK_PREFIX_RE.sub("", title)
|
|
168
|
+
title = re.sub(r"^[\s\-\u2013\u2014:,]+", "", title)
|
|
169
|
+
title = _LEADING_EPNUM_RE.sub("", title)
|
|
170
|
+
title = re.sub(r"^[\s\-\u2013\u2014:,]+", "", title)
|
|
171
|
+
title = _LEADING_EPNUM_RE.sub("", title)
|
|
172
|
+
|
|
173
|
+
year: str | None = None
|
|
174
|
+
m = _YEAR_PAREN_RE.search(title)
|
|
175
|
+
if m:
|
|
176
|
+
year = m.group(1)
|
|
177
|
+
title = title[: m.start()] + title[m.end() :]
|
|
178
|
+
else:
|
|
179
|
+
m = _YEAR_BRACKET_RE.search(title)
|
|
180
|
+
if m:
|
|
181
|
+
year = m.group(1)
|
|
182
|
+
title = title[: m.start()] + title[m.end() :]
|
|
183
|
+
else:
|
|
184
|
+
m = _YEAR_BARE_RE.search(title)
|
|
185
|
+
if m and 1920 <= int(m.group(1)) <= 2030:
|
|
186
|
+
year = m.group(1)
|
|
187
|
+
title = title[: m.start()] + title[m.end() :]
|
|
188
|
+
|
|
189
|
+
title = re.sub(r"\[\s*\]|\(\s*\)", "", title)
|
|
190
|
+
title = re.sub(r"^[\s\-\u2013\u2014:,]+", "", title)
|
|
191
|
+
title = re.sub(r"[\s\-\u2013\u2014:,]+$", "", title)
|
|
192
|
+
title = re.sub(r"\s*-\s*\d+$", "", title)
|
|
193
|
+
title = re.sub(r"\s+", " ", title).strip()
|
|
194
|
+
|
|
195
|
+
return HostedInfo(
|
|
196
|
+
show_name=entry.show_name,
|
|
197
|
+
movie_title=title or raw,
|
|
198
|
+
movie_year=year,
|
|
199
|
+
cms_tag=entry.cms_tag,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# ItemClassification
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class ItemClassification:
|
|
210
|
+
friendly_token: str
|
|
211
|
+
raw_title: str
|
|
212
|
+
content_type: (
|
|
213
|
+
str # movie | tv_episode | hosted_movie | riffed_movie | archive | unknown
|
|
214
|
+
)
|
|
215
|
+
hosted: HostedInfo | None
|
|
216
|
+
lookup_title: str # used for TMDB/OMDB queries and art resolution
|
|
217
|
+
lookup_year: str | None
|
|
218
|
+
genre_hints: list[str] = field(default_factory=list)
|
|
219
|
+
tv_show: str | None = None
|
|
220
|
+
tv_season: int | None = None
|
|
221
|
+
tv_episode: int | None = None
|
|
222
|
+
duration_sec: int = 0
|
|
223
|
+
description_score: int = 0
|
|
224
|
+
has_real_art: bool = False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# YouTube "Title | English Full Movie | Genre1 Genre2" pattern
|
|
228
|
+
_YT_FULL_MOVIE_RE = re.compile(
|
|
229
|
+
r"^(?P<title>.+?)\s*\|\s*(?:\w+\s+)?Full\s+Movie\s*(?:\|\s*(?P<genres>.+))?$", re.I
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def classify_item(
|
|
234
|
+
friendly_token: str,
|
|
235
|
+
raw_title: str,
|
|
236
|
+
duration_sec: int,
|
|
237
|
+
*,
|
|
238
|
+
cover_art_source: str | None = None,
|
|
239
|
+
description: str | None = None,
|
|
240
|
+
description_score: int = 0,
|
|
241
|
+
) -> ItemClassification:
|
|
242
|
+
"""Classify a single catalog item. Pure function; no DB access."""
|
|
243
|
+
has_real_art = (cover_art_source or "") in ("tmdb", "omdb")
|
|
244
|
+
|
|
245
|
+
# 1. Hosted / riffed — check registry first
|
|
246
|
+
for entry in HOSTED_SHOW_REGISTRY:
|
|
247
|
+
if entry.pattern.search(raw_title):
|
|
248
|
+
info = _extract_hosted_title(raw_title, entry)
|
|
249
|
+
return ItemClassification(
|
|
250
|
+
friendly_token=friendly_token,
|
|
251
|
+
raw_title=raw_title,
|
|
252
|
+
content_type=entry.content_type,
|
|
253
|
+
hosted=info,
|
|
254
|
+
lookup_title=info.movie_title,
|
|
255
|
+
lookup_year=info.movie_year,
|
|
256
|
+
duration_sec=duration_sec,
|
|
257
|
+
description_score=description_score,
|
|
258
|
+
has_real_art=has_real_art,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# 2. TV episode
|
|
262
|
+
if _TV_RE.search(raw_title):
|
|
263
|
+
tv_show: str | None = None
|
|
264
|
+
tv_season: int | None = None
|
|
265
|
+
tv_ep: int | None = None
|
|
266
|
+
m = _SXE_RE.search(raw_title)
|
|
267
|
+
if m:
|
|
268
|
+
tv_season = int(m.group(1))
|
|
269
|
+
tv_ep = int(m.group(2))
|
|
270
|
+
tv_show = raw_title[: m.start()].strip(" -–—")
|
|
271
|
+
return ItemClassification(
|
|
272
|
+
friendly_token=friendly_token,
|
|
273
|
+
raw_title=raw_title,
|
|
274
|
+
content_type="tv_episode",
|
|
275
|
+
hosted=None,
|
|
276
|
+
lookup_title=tv_show or raw_title,
|
|
277
|
+
lookup_year=None,
|
|
278
|
+
tv_show=tv_show,
|
|
279
|
+
tv_season=tv_season,
|
|
280
|
+
tv_episode=tv_ep,
|
|
281
|
+
duration_sec=duration_sec,
|
|
282
|
+
description_score=description_score,
|
|
283
|
+
has_real_art=has_real_art,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# 3. Archive (broadcast recordings, wrestling)
|
|
287
|
+
if duration_sec > 1800 and _ARCHIVE_RE.search(raw_title):
|
|
288
|
+
return ItemClassification(
|
|
289
|
+
friendly_token=friendly_token,
|
|
290
|
+
raw_title=raw_title,
|
|
291
|
+
content_type="archive",
|
|
292
|
+
hosted=None,
|
|
293
|
+
lookup_title=raw_title,
|
|
294
|
+
lookup_year=None,
|
|
295
|
+
duration_sec=duration_sec,
|
|
296
|
+
description_score=description_score,
|
|
297
|
+
has_real_art=has_real_art,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# 4. Movie (>= 30 min)
|
|
301
|
+
if duration_sec >= 1800:
|
|
302
|
+
yt = _YT_FULL_MOVIE_RE.match(raw_title.strip())
|
|
303
|
+
if yt:
|
|
304
|
+
clean_title = yt.group("title").strip()
|
|
305
|
+
genres = (yt.group("genres") or "").split() if yt.group("genres") else []
|
|
306
|
+
return ItemClassification(
|
|
307
|
+
friendly_token=friendly_token,
|
|
308
|
+
raw_title=raw_title,
|
|
309
|
+
content_type="movie",
|
|
310
|
+
hosted=None,
|
|
311
|
+
lookup_title=clean_title,
|
|
312
|
+
lookup_year=None,
|
|
313
|
+
genre_hints=genres,
|
|
314
|
+
duration_sec=duration_sec,
|
|
315
|
+
description_score=description_score,
|
|
316
|
+
has_real_art=has_real_art,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
clean_title, year = normalize_and_clean(raw_title)
|
|
320
|
+
return ItemClassification(
|
|
321
|
+
friendly_token=friendly_token,
|
|
322
|
+
raw_title=raw_title,
|
|
323
|
+
content_type="movie",
|
|
324
|
+
hosted=None,
|
|
325
|
+
lookup_title=clean_title,
|
|
326
|
+
lookup_year=year,
|
|
327
|
+
duration_sec=duration_sec,
|
|
328
|
+
description_score=description_score,
|
|
329
|
+
has_real_art=has_real_art,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# 5. Unknown / short
|
|
333
|
+
return ItemClassification(
|
|
334
|
+
friendly_token=friendly_token,
|
|
335
|
+
raw_title=raw_title,
|
|
336
|
+
content_type="unknown",
|
|
337
|
+
hosted=None,
|
|
338
|
+
lookup_title=raw_title,
|
|
339
|
+
lookup_year=None,
|
|
340
|
+
duration_sec=duration_sec,
|
|
341
|
+
description_score=description_score,
|
|
342
|
+
has_real_art=has_real_art,
|
|
343
|
+
)
|