kryten-webqueue 0.36.7__tar.gz → 0.37.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.37.0/AGENTS.md +71 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/CHANGELOG.md +13 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/PKG-INFO +1 -1
- kryten_webqueue-0.37.0/docs/SPEC_ADMIN_ITEM_EDITOR.md +264 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_catalog.py +52 -15
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_connection.py +13 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_enrichment.py +31 -1
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/providers.py +3 -1
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/art.py +3 -4
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +3 -1
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/mediacms.py +41 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_catalog.py +101 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/catalog/browse.html +4 -0
- kryten_webqueue-0.37.0/kryten_webqueue/templates/catalog/edit_item_modal.html +332 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/catalog/item_detail.html +4 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/pyproject.toml +1 -1
- kryten_webqueue-0.37.0/scripts/AGENTS.md +98 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.gitignore +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/README.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/config.example.json +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_people.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/user/my_list.html +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/scripts/patch_mediacms_tags.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/scripts/trigger_enrich.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_classify.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_people_facets.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_providers.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_search_facets.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_title_normalization.py +0 -0
- {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_watchlist.py +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Kryten-WebQueue — Project Guidelines
|
|
2
|
+
|
|
3
|
+
Kryten-WebQueue is the **web/HTTP layer** of the Kryten ecosystem: a Netflix/Tubi-style
|
|
4
|
+
catalog browser and pay-to-play queue manager for a CyTube channel. It is a FastAPI +
|
|
5
|
+
uvicorn app that serves the public site, enriches a local catalog mirrored from **MediaCMS**,
|
|
6
|
+
and drives playback by issuing commands through **kryten-api-gate**. Unlike the NATS-only
|
|
7
|
+
microservices, this is a user-facing HTTP application with its own SQLite database.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
- **FastAPI app** (`kryten_webqueue/app.py`) — routers under `routes/`, Jinja2 `templates/`,
|
|
11
|
+
`static/` assets, WebSockets under `ws/` (queue + race views). Auth is a JWT **session
|
|
12
|
+
cookie** (`auth/session.py`); admin routes require rank ≥ 3 (`require_admin`).
|
|
13
|
+
- **Local catalog in SQLite** via `aiosqlite` (`catalog/db/`). Schema + **forward-only
|
|
14
|
+
migrations** live in `catalog/db/_connection.py` (bump the version and add a migration;
|
|
15
|
+
never rewrite an applied one). DB path comes from config (`db_path`).
|
|
16
|
+
- **Catalog sync** (`catalog/sync.py`) mirrors items + facets from MediaCMS. It bulk-pulls
|
|
17
|
+
tags/categories via `GET /api/v1/media_facets` (falls back to per-item detail on stock
|
|
18
|
+
CMS). `set_catalog_tags` is a **REPLACE** — **CMS is the source of truth for tags**.
|
|
19
|
+
- **Enrichment pipeline** (`catalog/enrichment/`): `sync → classify → title → meta → art →
|
|
20
|
+
tags → categories`. TMDB/OMDB lookups in `providers.py`; all title normalization in
|
|
21
|
+
`normalise.py` (`normalize_movie_title`). Classification is **cached** in enrichment state;
|
|
22
|
+
classify-logic changes only reach existing items on a `force` re-run.
|
|
23
|
+
- **Jobs** (`jobs/`): `JobManager` runs single-flight background jobs with **schema-validated
|
|
24
|
+
params** (`catalog_enrich`, `catalog_sync`, …) and APScheduler cron (`job_scheduler.py`).
|
|
25
|
+
The `steps` param is a **validated enum** — only fixed combos are accepted (e.g.
|
|
26
|
+
`all`, `classify,meta,art,tags`, `classify,meta`, `art`, `tags`, `title`, `sync`,
|
|
27
|
+
`classify`); arbitrary combos return HTTP 400.
|
|
28
|
+
- **External surfaces**: MediaCMS over HTTP (catalog source, art, tag/title writes) and
|
|
29
|
+
kryten-api-gate (playback commands, via `api_gate_url`/`api_gate_token`). **Working on the
|
|
30
|
+
MediaCMS instance and our local patches: see [scripts/AGENTS.md](scripts/AGENTS.md).**
|
|
31
|
+
|
|
32
|
+
## Build and Test
|
|
33
|
+
Run from the repo root (uv-managed):
|
|
34
|
+
- Install deps: `uv sync`
|
|
35
|
+
- Format: `uv run black .`
|
|
36
|
+
- Lint (autofix): `uv run ruff check --fix .`
|
|
37
|
+
- Types: `uv run mypy kryten_webqueue`
|
|
38
|
+
- Tests: `uv run pytest` (add `--cov=kryten_webqueue --cov-report=term-missing` for coverage)
|
|
39
|
+
|
|
40
|
+
Run all four before committing. Do not bypass checks (`--no-verify`).
|
|
41
|
+
|
|
42
|
+
## Conventions
|
|
43
|
+
- Python 3.12+, flat `kryten_webqueue/` package, 100% `async`/`await`, Pydantic v2 config,
|
|
44
|
+
FastAPI + uvicorn. black/ruff use their **default** line length (no override configured).
|
|
45
|
+
pytest `asyncio_mode = "auto"`.
|
|
46
|
+
- Config is JSON with auto-discovery: `--config` flag → `/etc/kryten-webqueue/config.json` →
|
|
47
|
+
`./config.json`. Keep `config.example.json` in sync; never hardcode secrets, keys, or
|
|
48
|
+
MediaCMS/api-gate URLs. Notable keys: `secret_key` (JWT), `port` (default 2010),
|
|
49
|
+
`api_gate_url`/`api_gate_token`, `mediacms_url`/`mediacms_token`,
|
|
50
|
+
`mediacms_manage_all_media`, `tmdb_api_key`/`omdb_api_key`, `db_path`.
|
|
51
|
+
- **High-stakes surfaces**: DB migrations, enrichment classify/title logic (affects the whole
|
|
52
|
+
catalog; needs a `force` re-run to re-apply), MediaCMS write paths, and catalog sync
|
|
53
|
+
(REPLACE semantics). Flag and version changes to these.
|
|
54
|
+
- Version lives only in `pyproject.toml [project] version`. Update `CHANGELOG.md`
|
|
55
|
+
(Keep-a-Changelog + SemVer, ISO dates) for every versioned change.
|
|
56
|
+
- Commit prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `ci:`.
|
|
57
|
+
Branches: `feature/…`, `fix/…`.
|
|
58
|
+
|
|
59
|
+
## Deployment
|
|
60
|
+
Deployed via **pipx** on `grindhouse.local` as the `kryten-webqueue` systemd service
|
|
61
|
+
(uvicorn on `port`, behind nginx; `deploy/` ships the unit + nginx config). Release flow:
|
|
62
|
+
1. Bump `version` in `pyproject.toml`, update `CHANGELOG.md`.
|
|
63
|
+
2. Commit, `git tag vX.Y.Z`, `git push && git push origin vX.Y.Z` (also published to PyPI).
|
|
64
|
+
3. Install the tag and cycle the service:
|
|
65
|
+
```
|
|
66
|
+
ssh kryten@grindhouse.local "sudo systemctl stop kryten-webqueue; \
|
|
67
|
+
pipx runpip kryten-webqueue install 'git+https://github.com/grobertson/kryten-webqueue.git@vX.Y.Z'; \
|
|
68
|
+
sudo systemctl start kryten-webqueue; systemctl is-active kryten-webqueue"
|
|
69
|
+
```
|
|
70
|
+
Trigger enrichment/sync jobs via `POST /admin/jobs/catalog_enrich/run` (admin session
|
|
71
|
+
required; jobs are single-flight — restart the service to clear a stuck run).
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.37.0] - 2026-08-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Admin manual item editor.** Admins can now directly edit catalog item metadata
|
|
10
|
+
via an "Edit" button on catalog tiles and detail pages. The modal editor allows
|
|
11
|
+
editing both core metadata (title, description, duration) and advanced enrichment
|
|
12
|
+
state (content_type, lookup fields, hosted show, TV episode details). Edits can
|
|
13
|
+
optionally sync back to MediaCMS and trigger re-enrichment. All changes are logged
|
|
14
|
+
to an audit trail (`item_edit_log` table) tracking who edited what and when. This
|
|
15
|
+
complements the existing re-enrich button for cases where auto-enrichment cannot
|
|
16
|
+
fix problematic metadata.
|
|
17
|
+
|
|
5
18
|
## [0.36.7] - 2026-08-14
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Admin Manual Item Editor — Implementation Spec
|
|
2
|
+
|
|
3
|
+
**Version**: 1.0
|
|
4
|
+
**Status**: Implementing
|
|
5
|
+
**Date**: 2026-08-14
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Add an admin-only manual edit capability for catalog items, appearing on both list view tiles and item detail pages. This complements the existing re-enrich button by allowing admins to directly fix metadata that can't be automatically corrected.
|
|
12
|
+
|
|
13
|
+
## User Requirements
|
|
14
|
+
|
|
15
|
+
Based on user feedback:
|
|
16
|
+
1. **Include enrichment state editing in V1** — allow editing classification fields
|
|
17
|
+
2. **Include edit audit logging in V1** — track who edited what and when
|
|
18
|
+
3. **"Re-enrich on save"** — optional checkbox that triggers the standard enrichment job for the edited item
|
|
19
|
+
4. **Skip tag/category editing** — these are better managed on the MediaCMS side
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
### What Fields Are Editable?
|
|
26
|
+
|
|
27
|
+
**Core Metadata** (always editable):
|
|
28
|
+
- `title` — display title
|
|
29
|
+
- `description` — synopsis/overview
|
|
30
|
+
- `duration_sec` — runtime in seconds
|
|
31
|
+
|
|
32
|
+
**Enrichment State** (advanced):
|
|
33
|
+
- `content_type` — classification (movie, tv_episode, hosted_movie, riffed_movie, archive, unknown)
|
|
34
|
+
- `lookup_title` — normalized title for TMDB/OMDB search
|
|
35
|
+
- `lookup_year` — year for metadata lookup
|
|
36
|
+
- `hosted_show` — for hosted movies (Svengoolie, MST3K, JBB, TLDI, etc.)
|
|
37
|
+
- `tv_show`, `tv_season`, `tv_episode_num` — for TV episodes
|
|
38
|
+
|
|
39
|
+
### Sync Strategy
|
|
40
|
+
|
|
41
|
+
- **Title/Description → MediaCMS**: Optional via "Sync to MediaCMS" checkbox (default: checked)
|
|
42
|
+
- **Enrichment State**: Local catalog only (not a MediaCMS concern)
|
|
43
|
+
- **Re-enrich trigger**: Optional via "Re-enrich after save" checkbox — runs the standard `catalog_enrich` job for this single item
|
|
44
|
+
|
|
45
|
+
### Audit Trail
|
|
46
|
+
|
|
47
|
+
New `item_edit_log` table:
|
|
48
|
+
```sql
|
|
49
|
+
CREATE TABLE item_edit_log (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
friendly_token TEXT NOT NULL,
|
|
52
|
+
username TEXT NOT NULL,
|
|
53
|
+
field_name TEXT NOT NULL,
|
|
54
|
+
old_value TEXT,
|
|
55
|
+
new_value TEXT,
|
|
56
|
+
edited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
57
|
+
);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Implementation Plan
|
|
63
|
+
|
|
64
|
+
### Files to Create
|
|
65
|
+
|
|
66
|
+
1. **`kryten_webqueue/templates/catalog/edit_item_modal.html`**
|
|
67
|
+
- Modal dialog with form for editing
|
|
68
|
+
- Pre-populated with current data
|
|
69
|
+
- Two sections: Core Metadata + Advanced (enrichment state)
|
|
70
|
+
|
|
71
|
+
### Files to Modify
|
|
72
|
+
|
|
73
|
+
1. **Backend**
|
|
74
|
+
- `kryten_webqueue/routes/admin_catalog.py`
|
|
75
|
+
- Add `PATCH /admin/catalog/{friendly_token}/edit`
|
|
76
|
+
- Validate, update catalog + enrichment state
|
|
77
|
+
- Log changes to audit trail
|
|
78
|
+
- Optionally sync to MediaCMS
|
|
79
|
+
- Optionally trigger re-enrichment job
|
|
80
|
+
|
|
81
|
+
- `kryten_webqueue/catalog/db/_connection.py`
|
|
82
|
+
- Add migration v21: `item_edit_log` table
|
|
83
|
+
|
|
84
|
+
- `kryten_webqueue/catalog/db/_enrichment.py`
|
|
85
|
+
- Add `update_enrichment_state(token, fields)` method
|
|
86
|
+
|
|
87
|
+
- `kryten_webqueue/catalog/db/_catalog.py`
|
|
88
|
+
- Add `log_item_edit(token, username, field, old, new)` method
|
|
89
|
+
- Add `get_item_edit_history(token, limit)` method
|
|
90
|
+
|
|
91
|
+
- `kryten_webqueue/catalog/mediacms.py`
|
|
92
|
+
- Add `update_item(token, title, description)` method
|
|
93
|
+
|
|
94
|
+
2. **Frontend**
|
|
95
|
+
- `kryten_webqueue/templates/catalog/browse.html`
|
|
96
|
+
- Add "Edit" button in admin actions
|
|
97
|
+
- Include modal template
|
|
98
|
+
|
|
99
|
+
- `kryten_webqueue/templates/catalog/item_detail.html`
|
|
100
|
+
- Add "Edit" button in admin actions
|
|
101
|
+
- Include modal template
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## API Specification
|
|
106
|
+
|
|
107
|
+
### `PATCH /admin/catalog/{friendly_token}/edit`
|
|
108
|
+
|
|
109
|
+
**Auth**: Admin only (rank >= 3)
|
|
110
|
+
|
|
111
|
+
**Request Body**:
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"title": "New Title (1980)",
|
|
115
|
+
"description": "Updated description...",
|
|
116
|
+
"duration_sec": 5400,
|
|
117
|
+
"content_type": "movie",
|
|
118
|
+
"lookup_title": "New Title",
|
|
119
|
+
"lookup_year": "1980",
|
|
120
|
+
"hosted_show": null,
|
|
121
|
+
"tv_show": null,
|
|
122
|
+
"tv_season": null,
|
|
123
|
+
"tv_episode_num": null,
|
|
124
|
+
"sync_to_cms": true,
|
|
125
|
+
"re_enrich": false
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Response** (success):
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"success": true,
|
|
133
|
+
"changes_logged": 3,
|
|
134
|
+
"cms_synced": true,
|
|
135
|
+
"enrich_job_id": null
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Response** (with re-enrich):
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"success": true,
|
|
143
|
+
"changes_logged": 2,
|
|
144
|
+
"cms_synced": false,
|
|
145
|
+
"enrich_job_id": 42
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## UI Design
|
|
152
|
+
|
|
153
|
+
### Modal Layout
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
┌─────────────────────────────────────────────────┐
|
|
157
|
+
│ Edit Item ✕ │
|
|
158
|
+
├─────────────────────────────────────────────────┤
|
|
159
|
+
│ │
|
|
160
|
+
│ Core Metadata │
|
|
161
|
+
│ ┌───────────────────────────────────────────┐ │
|
|
162
|
+
│ │ Title: [New Title (1980) ]│ │
|
|
163
|
+
│ └───────────────────────────────────────────┘ │
|
|
164
|
+
│ ┌───────────────────────────────────────────┐ │
|
|
165
|
+
│ │ Description: │ │
|
|
166
|
+
│ │ [Multi-line textarea... ]│ │
|
|
167
|
+
│ │ [ ]│ │
|
|
168
|
+
│ └───────────────────────────────────────────┘ │
|
|
169
|
+
│ Duration (sec): [5400 ] │
|
|
170
|
+
│ │
|
|
171
|
+
│ ────────────────────────────────────────── │
|
|
172
|
+
│ │
|
|
173
|
+
│ Advanced (Enrichment State) │
|
|
174
|
+
│ Content Type: [movie ▼] │
|
|
175
|
+
│ Lookup Title: [New Title ] │
|
|
176
|
+
│ Lookup Year: [1980 ] │
|
|
177
|
+
│ Hosted Show: [ ] (e.g., Svengoolie) │
|
|
178
|
+
│ │
|
|
179
|
+
│ TV Episode (if applicable): │
|
|
180
|
+
│ Show: [ ] Season: [ ] Episode: [ ]│
|
|
181
|
+
│ │
|
|
182
|
+
│ ────────────────────────────────────────── │
|
|
183
|
+
│ │
|
|
184
|
+
│ ☑ Sync title/description to MediaCMS │
|
|
185
|
+
│ ☐ Re-enrich after save (runs classify, title, │
|
|
186
|
+
│ meta, art, tags for this item) │
|
|
187
|
+
│ │
|
|
188
|
+
│ [ Save ] [ Cancel ] │
|
|
189
|
+
│ │
|
|
190
|
+
└─────────────────────────────────────────────────┘
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Security
|
|
196
|
+
|
|
197
|
+
- **Authorization**: All edits require rank >= 3 (admin)
|
|
198
|
+
- **Input Validation**:
|
|
199
|
+
- Title: 1-500 chars, required
|
|
200
|
+
- Description: max 5000 chars
|
|
201
|
+
- Duration: positive integer or null
|
|
202
|
+
- Content type: enum validation
|
|
203
|
+
- Years: 4-digit integers or null
|
|
204
|
+
- **SQL Injection**: Parameterized queries throughout
|
|
205
|
+
- **Audit Trail**: Every field change logged with username
|
|
206
|
+
- **CSRF**: Session cookie + same-origin credentials
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Testing Strategy
|
|
211
|
+
|
|
212
|
+
### Unit Tests
|
|
213
|
+
|
|
214
|
+
- `test_edit_item_core_metadata` — edit title/description/duration
|
|
215
|
+
- `test_edit_item_enrichment_state` — edit content_type/lookup fields
|
|
216
|
+
- `test_edit_item_audit_log` — verify changes are logged
|
|
217
|
+
- `test_edit_item_cms_sync` — verify MediaCMS update
|
|
218
|
+
- `test_edit_item_re_enrich` — verify job triggered
|
|
219
|
+
- `test_edit_item_unauthorized` — 403 for non-admin
|
|
220
|
+
- `test_edit_item_validation` — 400 for invalid data
|
|
221
|
+
|
|
222
|
+
### Manual Testing
|
|
223
|
+
|
|
224
|
+
- [ ] Edit button visible to admins only
|
|
225
|
+
- [ ] Modal opens with pre-filled data
|
|
226
|
+
- [ ] Core metadata edits save and reflect
|
|
227
|
+
- [ ] Enrichment state edits save and reflect
|
|
228
|
+
- [ ] CMS sync checkbox works
|
|
229
|
+
- [ ] Re-enrich checkbox triggers job
|
|
230
|
+
- [ ] Audit log records all changes
|
|
231
|
+
- [ ] Toast notifications work
|
|
232
|
+
- [ ] Form validation prevents bad data
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Rollout
|
|
237
|
+
|
|
238
|
+
1. Implement all changes on feature branch
|
|
239
|
+
2. Run full test suite (black, ruff, mypy, pytest)
|
|
240
|
+
3. Manual QA on local dev instance
|
|
241
|
+
4. Update version in `pyproject.toml` → `0.37.0`
|
|
242
|
+
5. Update `CHANGELOG.md`
|
|
243
|
+
6. Commit, tag `v0.37.0`, push to GitHub
|
|
244
|
+
7. Publish to PyPI (automated via GitHub)
|
|
245
|
+
8. **DO NOT DEPLOY** — long-running job in progress
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Future Enhancements (V2)
|
|
250
|
+
|
|
251
|
+
- Edit history viewer (show all past edits for an item)
|
|
252
|
+
- Bulk edit mode (edit multiple items at once)
|
|
253
|
+
- Tag/category editing (requires CMS bulk API improvements)
|
|
254
|
+
- Revert to previous version (undo an edit)
|
|
255
|
+
- Export edit log as CSV
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Open Questions (Resolved)
|
|
260
|
+
|
|
261
|
+
1. ✅ Include enrichment state in V1? → **Yes**
|
|
262
|
+
2. ✅ Include audit logging in V1? → **Yes**
|
|
263
|
+
3. ✅ How should re-enrich work? → **Simple: trigger standard job for single item**
|
|
264
|
+
4. ✅ Include tag editing? → **No, defer to CMS side**
|
|
@@ -793,21 +793,26 @@ class _CatalogMixin:
|
|
|
793
793
|
|
|
794
794
|
async def update_catalog(self, friendly_token: str, row: dict):
|
|
795
795
|
"""Update catalog item with partial or full field set.
|
|
796
|
-
|
|
796
|
+
|
|
797
797
|
Only fields present in *row* are updated. Always adds friendly_token
|
|
798
798
|
and updates updated_at to current time when any field changes.
|
|
799
799
|
"""
|
|
800
800
|
if not row:
|
|
801
801
|
return
|
|
802
|
-
|
|
802
|
+
|
|
803
803
|
# Build SET clause from provided fields
|
|
804
804
|
allowed = {
|
|
805
|
-
"title",
|
|
806
|
-
"
|
|
805
|
+
"title",
|
|
806
|
+
"description",
|
|
807
|
+
"duration_sec",
|
|
808
|
+
"manifest_url",
|
|
809
|
+
"thumbnail_url",
|
|
810
|
+
"added_at",
|
|
811
|
+
"synced_at",
|
|
807
812
|
}
|
|
808
813
|
updates = []
|
|
809
814
|
params = {"friendly_token": friendly_token}
|
|
810
|
-
|
|
815
|
+
|
|
811
816
|
for key in row:
|
|
812
817
|
if key in allowed:
|
|
813
818
|
# added_at uses COALESCE to preserve existing if None
|
|
@@ -816,20 +821,21 @@ class _CatalogMixin:
|
|
|
816
821
|
else:
|
|
817
822
|
updates.append(f"{key}=:{key}")
|
|
818
823
|
params[key] = row[key]
|
|
819
|
-
|
|
824
|
+
|
|
820
825
|
# Always update updated_at when modifying
|
|
821
826
|
updates.append("updated_at=:updated_at")
|
|
822
827
|
params["updated_at"] = row.get("synced_at") or params.get("synced_at")
|
|
823
828
|
if not params["updated_at"]:
|
|
824
829
|
from datetime import datetime, UTC
|
|
830
|
+
|
|
825
831
|
params["updated_at"] = datetime.now(UTC).isoformat()
|
|
826
|
-
|
|
832
|
+
|
|
827
833
|
if not updates:
|
|
828
834
|
return
|
|
829
|
-
|
|
835
|
+
|
|
830
836
|
sql = f"UPDATE catalog SET {', '.join(updates)} WHERE friendly_token=:friendly_token"
|
|
831
837
|
await self._db.execute(sql, params)
|
|
832
|
-
|
|
838
|
+
|
|
833
839
|
# Rebuild FTS for this row if title or description changed
|
|
834
840
|
if "title" in row or "description" in row:
|
|
835
841
|
await self._db.execute(
|
|
@@ -850,10 +856,10 @@ class _CatalogMixin:
|
|
|
850
856
|
|
|
851
857
|
async def delete_stale_catalog_items(self, sync_started_at: str) -> int:
|
|
852
858
|
"""Delete catalog items that weren't updated during the sync.
|
|
853
|
-
|
|
859
|
+
|
|
854
860
|
Items with synced_at older than sync_started_at are no longer present
|
|
855
861
|
in MediaCMS and should be removed. Returns count of deleted items.
|
|
856
|
-
|
|
862
|
+
|
|
857
863
|
Foreign key CASCADE will automatically remove related entries from:
|
|
858
864
|
- catalog_fts
|
|
859
865
|
- catalog_categories
|
|
@@ -866,23 +872,23 @@ class _CatalogMixin:
|
|
|
866
872
|
[sync_started_at],
|
|
867
873
|
)
|
|
868
874
|
tokens = [row[0] for row in await cursor.fetchall()]
|
|
869
|
-
|
|
875
|
+
|
|
870
876
|
if not tokens:
|
|
871
877
|
return 0
|
|
872
|
-
|
|
878
|
+
|
|
873
879
|
# Delete from FTS first (not automatically cascaded)
|
|
874
880
|
placeholders = ",".join("?" * len(tokens))
|
|
875
881
|
await self._db.execute(
|
|
876
882
|
f"DELETE FROM catalog_fts WHERE friendly_token IN ({placeholders})",
|
|
877
883
|
tokens,
|
|
878
884
|
)
|
|
879
|
-
|
|
885
|
+
|
|
880
886
|
# Delete from catalog (cascades to join tables via ON DELETE CASCADE)
|
|
881
887
|
await self._db.execute(
|
|
882
888
|
f"DELETE FROM catalog WHERE friendly_token IN ({placeholders})",
|
|
883
889
|
tokens,
|
|
884
890
|
)
|
|
885
|
-
|
|
891
|
+
|
|
886
892
|
await self._db.commit()
|
|
887
893
|
return len(tokens)
|
|
888
894
|
|
|
@@ -1049,6 +1055,37 @@ class _CatalogMixin:
|
|
|
1049
1055
|
return True
|
|
1050
1056
|
return False
|
|
1051
1057
|
|
|
1058
|
+
# --- Item edit audit log -----------------------------------------------
|
|
1059
|
+
|
|
1060
|
+
async def log_item_edit(
|
|
1061
|
+
self,
|
|
1062
|
+
friendly_token: str,
|
|
1063
|
+
username: str,
|
|
1064
|
+
field_name: str,
|
|
1065
|
+
old_value: str | int | None,
|
|
1066
|
+
new_value: str | int | None,
|
|
1067
|
+
) -> None:
|
|
1068
|
+
"""Log a manual admin edit to the audit trail."""
|
|
1069
|
+
# Convert to strings for storage
|
|
1070
|
+
old_str = str(old_value) if old_value is not None else None
|
|
1071
|
+
new_str = str(new_value) if new_value is not None else None
|
|
1072
|
+
|
|
1073
|
+
await self._execute(
|
|
1074
|
+
"INSERT INTO item_edit_log (friendly_token, username, field_name, old_value, new_value) "
|
|
1075
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
1076
|
+
[friendly_token, username, field_name, old_str, new_str],
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
async def get_item_edit_history(
|
|
1080
|
+
self, friendly_token: str, limit: int = 50
|
|
1081
|
+
) -> list[dict]:
|
|
1082
|
+
"""Retrieve edit history for an item, most recent first."""
|
|
1083
|
+
return await self._fetch_all(
|
|
1084
|
+
"SELECT * FROM item_edit_log WHERE friendly_token = ? "
|
|
1085
|
+
"ORDER BY edited_at DESC LIMIT ?",
|
|
1086
|
+
[friendly_token, limit],
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1052
1089
|
async def cleanup_expired_otps(self):
|
|
1053
1090
|
await self._execute(
|
|
1054
1091
|
"DELETE FROM otps WHERE expires_at < datetime('now') OR used=1"
|
|
@@ -399,6 +399,19 @@ MIGRATIONS = [
|
|
|
399
399
|
"""
|
|
400
400
|
ALTER TABLE item_enrichment_state ADD COLUMN last_categories_at TEXT;
|
|
401
401
|
""",
|
|
402
|
+
# v21: Item edit audit log (track manual admin edits)
|
|
403
|
+
"""
|
|
404
|
+
CREATE TABLE IF NOT EXISTS item_edit_log (
|
|
405
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
406
|
+
friendly_token TEXT NOT NULL,
|
|
407
|
+
username TEXT NOT NULL,
|
|
408
|
+
field_name TEXT NOT NULL,
|
|
409
|
+
old_value TEXT,
|
|
410
|
+
new_value TEXT,
|
|
411
|
+
edited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
412
|
+
);
|
|
413
|
+
CREATE INDEX IF NOT EXISTS idx_item_edit_log_token ON item_edit_log(friendly_token, edited_at DESC);
|
|
414
|
+
""",
|
|
402
415
|
]
|
|
403
416
|
|
|
404
417
|
|
|
@@ -55,6 +55,33 @@ class _EnrichmentMixin:
|
|
|
55
55
|
[*safe.values(), token],
|
|
56
56
|
)
|
|
57
57
|
|
|
58
|
+
async def update_enrichment_state(self, token: str, fields: dict) -> None:
|
|
59
|
+
"""Update specific enrichment state fields for an item.
|
|
60
|
+
|
|
61
|
+
Unlike save_enrichment_state (which is for the enrichment pipeline),
|
|
62
|
+
this is for manual admin edits and only accepts a subset of fields.
|
|
63
|
+
"""
|
|
64
|
+
if not fields:
|
|
65
|
+
return
|
|
66
|
+
allowed = {
|
|
67
|
+
"content_type",
|
|
68
|
+
"hosted_show",
|
|
69
|
+
"lookup_title",
|
|
70
|
+
"lookup_year",
|
|
71
|
+
"tv_show",
|
|
72
|
+
"tv_season",
|
|
73
|
+
"tv_episode_num",
|
|
74
|
+
}
|
|
75
|
+
safe = {k: v for k, v in fields.items() if k in allowed}
|
|
76
|
+
if not safe:
|
|
77
|
+
return
|
|
78
|
+
await self.ensure_enrichment_state(token)
|
|
79
|
+
sets = ", ".join(f"{k} = ?" for k in safe)
|
|
80
|
+
await self._execute(
|
|
81
|
+
f"UPDATE item_enrichment_state SET {sets} WHERE friendly_token = ?",
|
|
82
|
+
[*safe.values(), token],
|
|
83
|
+
)
|
|
84
|
+
|
|
58
85
|
async def get_catalog_for_enrichment(
|
|
59
86
|
self,
|
|
60
87
|
*,
|
|
@@ -113,10 +140,13 @@ class _EnrichmentMixin:
|
|
|
113
140
|
return json.loads(raw)
|
|
114
141
|
except (ValueError, TypeError) as exc:
|
|
115
142
|
import logging
|
|
143
|
+
|
|
116
144
|
logger = logging.getLogger(__name__)
|
|
117
145
|
token = (row or {}).get("friendly_token", "?")
|
|
118
146
|
logger.warning(
|
|
119
147
|
"Failed to parse meta_json for %s: %s. Raw value (first 200 chars): %r",
|
|
120
|
-
token,
|
|
148
|
+
token,
|
|
149
|
+
exc,
|
|
150
|
+
raw[:200] if isinstance(raw, str) else raw,
|
|
121
151
|
)
|
|
122
152
|
return None
|
{kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/providers.py
RENAMED
|
@@ -173,7 +173,9 @@ class TMDBProvider:
|
|
|
173
173
|
first_air = tv_show.get("first_air_date", "")
|
|
174
174
|
meta.year = first_air[:4] if first_air else None
|
|
175
175
|
if tv_show.get("poster_path"):
|
|
176
|
-
meta.poster_url =
|
|
176
|
+
meta.poster_url = (
|
|
177
|
+
f"https://image.tmdb.org/t/p/w780{tv_show['poster_path']}"
|
|
178
|
+
)
|
|
177
179
|
return meta
|
|
178
180
|
except (httpx.HTTPError, json.JSONDecodeError, ValueError) as exc:
|
|
179
181
|
logger.debug("TMDB TV show search error for %r: %s", show, exc)
|
{kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/art.py
RENAMED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
5
|
import hashlib
|
|
7
6
|
import io
|
|
8
7
|
import logging
|
|
@@ -115,12 +114,12 @@ class ArtStep:
|
|
|
115
114
|
|
|
116
115
|
async def _resolve_poster(self, cls: ItemClassification) -> str | None:
|
|
117
116
|
"""Query TMDB then OMDB using lookup_title (the key fix for hosted movies).
|
|
118
|
-
|
|
117
|
+
|
|
119
118
|
For TV episodes, search for the show poster (not episode-specific art).
|
|
120
119
|
"""
|
|
121
120
|
if not cls.lookup_title:
|
|
122
121
|
return None
|
|
123
|
-
|
|
122
|
+
|
|
124
123
|
# TV episodes: get the show poster
|
|
125
124
|
if cls.content_type == "tv_episode":
|
|
126
125
|
meta = await self._tmdb.search_tv_show(cls.lookup_title)
|
|
@@ -128,7 +127,7 @@ class ArtStep:
|
|
|
128
127
|
return meta.poster_url
|
|
129
128
|
meta = await self._omdb.search_tv_show(cls.lookup_title)
|
|
130
129
|
return meta.poster_url
|
|
131
|
-
|
|
130
|
+
|
|
132
131
|
# Movies and other content: search as movie
|
|
133
132
|
meta = await self._tmdb.search_movie(cls.lookup_title, cls.lookup_year)
|
|
134
133
|
if meta.poster_url:
|
|
@@ -122,3 +122,44 @@ class MediaCMSClient:
|
|
|
122
122
|
)
|
|
123
123
|
await self._restore_owner(client, friendly_token, owner)
|
|
124
124
|
return ok
|
|
125
|
+
|
|
126
|
+
async def update_item(
|
|
127
|
+
self,
|
|
128
|
+
friendly_token: str,
|
|
129
|
+
*,
|
|
130
|
+
title: str | None = None,
|
|
131
|
+
description: str | None = None,
|
|
132
|
+
) -> bool:
|
|
133
|
+
"""Update item title and/or description in MediaCMS.
|
|
134
|
+
|
|
135
|
+
Returns True if the remote write succeeded.
|
|
136
|
+
"""
|
|
137
|
+
if not title and not description:
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
async with httpx.AsyncClient(headers=self._headers, timeout=_TIMEOUT) as client:
|
|
141
|
+
media = await self._get_media(client, friendly_token)
|
|
142
|
+
if media is None:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
owner = media.get("user")
|
|
146
|
+
payload = {}
|
|
147
|
+
if title is not None:
|
|
148
|
+
payload["title"] = title
|
|
149
|
+
if description is not None:
|
|
150
|
+
payload["description"] = description
|
|
151
|
+
|
|
152
|
+
resp = await client.put(
|
|
153
|
+
f"{self._base}/api/v1/media/{friendly_token}",
|
|
154
|
+
data=payload,
|
|
155
|
+
)
|
|
156
|
+
ok = resp.status_code in (200, 201)
|
|
157
|
+
if not ok:
|
|
158
|
+
logger.warning(
|
|
159
|
+
"MediaCMS update for %s failed: HTTP %s — %s",
|
|
160
|
+
friendly_token,
|
|
161
|
+
resp.status_code,
|
|
162
|
+
resp.text[:200],
|
|
163
|
+
)
|
|
164
|
+
await self._restore_owner(client, friendly_token, owner)
|
|
165
|
+
return ok
|