kryten-webqueue 0.33.3__tar.gz → 0.33.4__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 (134) hide show
  1. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/CHANGELOG.md +12 -0
  2. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/config.example.json +11 -0
  4. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/api_gate/client.py +13 -0
  5. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/app.py +22 -0
  6. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/config.py +19 -0
  7. kryten_webqueue-0.33.4/kryten_webqueue/jobs/rehost_emotes.py +289 -0
  8. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/pyproject.toml +1 -1
  9. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/.github/workflows/gitleaks.yml +0 -0
  10. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/.github/workflows/python-publish.yml +0 -0
  11. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/.github/workflows/release.yml +0 -0
  12. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/.gitignore +0 -0
  13. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/.gitleaks.toml +0 -0
  14. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/.pre-commit-config.yaml +0 -0
  15. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/AGENT-WORKFLOW-GUIDE.md +0 -0
  16. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/README.md +0 -0
  17. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  25. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  26. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/Log for incorrect promo insertion problem.txt +0 -0
  27. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  28. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/PRE_PLAN_GAPS.md +0 -0
  29. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/PRODUCT_PLAN.md +0 -0
  30. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  31. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/docs/UX_POLISH_PLAN.md +0 -0
  32. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/__init__.py +0 -0
  33. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/__main__.py +0 -0
  34. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/api_gate/__init__.py +0 -0
  35. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/auth/__init__.py +0 -0
  36. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/auth/otp.py +0 -0
  37. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/auth/rate_limit.py +0 -0
  38. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/auth/session.py +0 -0
  39. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/__init__.py +0 -0
  40. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/db/__init__.py +0 -0
  41. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  42. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/db/_connection.py +0 -0
  43. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  44. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  45. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/db/_queue.py +0 -0
  46. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/images.py +0 -0
  47. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/mediacms.py +0 -0
  48. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/catalog/sync.py +0 -0
  49. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/__init__.py +0 -0
  50. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  51. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  52. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  53. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  54. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  55. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  56. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  57. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  58. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/jobs/__init__.py +0 -0
  59. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  60. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/jobs/manager.py +0 -0
  61. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/jobs/tasks.py +0 -0
  62. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/logging_config.py +0 -0
  63. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/playlists/__init__.py +0 -0
  64. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/playlists/bulk_add.py +0 -0
  65. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/playlists/fire.py +0 -0
  66. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/playlists/importer.py +0 -0
  67. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/playlists/ordering.py +0 -0
  68. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/playlists/scheduler.py +0 -0
  69. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/promos/__init__.py +0 -0
  70. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/promos/director.py +0 -0
  71. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/queue/__init__.py +0 -0
  72. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/queue/completion.py +0 -0
  73. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/queue/ordering.py +0 -0
  74. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/queue/poller.py +0 -0
  75. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/queue/presence.py +0 -0
  76. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/queue/race_poller.py +0 -0
  77. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/queue/shadow.py +0 -0
  78. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/__init__.py +0 -0
  79. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_catalog.py +0 -0
  80. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_feedback.py +0 -0
  81. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_jobs.py +0 -0
  82. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_moderation.py +0 -0
  83. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_playlists.py +0 -0
  84. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_promos.py +0 -0
  85. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_queue.py +0 -0
  86. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/admin_schedules.py +0 -0
  87. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/auth.py +0 -0
  88. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/catalog.py +0 -0
  89. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/feedback.py +0 -0
  90. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/pages.py +0 -0
  91. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/queue.py +0 -0
  92. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/routes/user.py +0 -0
  93. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/static/css/main.css +0 -0
  94. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  95. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/static/js/admin-promos.js +0 -0
  96. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  97. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  98. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/static/js/admin.js +0 -0
  99. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/static/js/main.js +0 -0
  100. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/admin/index.html +0 -0
  101. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/admin/playlists.html +0 -0
  102. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/admin/promos.html +0 -0
  103. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  104. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/admin/schedules.html +0 -0
  105. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/auth/login.html +0 -0
  106. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/base.html +0 -0
  107. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/catalog/browse.html +0 -0
  108. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  109. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  110. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/feedback/index.html +0 -0
  111. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/queue/index.html +0 -0
  112. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/race.html +0 -0
  113. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/templates/user/dashboard.html +0 -0
  114. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/ws/__init__.py +0 -0
  115. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/ws/handler.py +0 -0
  116. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/kryten_webqueue/ws/manager.py +0 -0
  117. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/__init__.py +0 -0
  118. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_config_persistence.py +0 -0
  119. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_feedback.py +0 -0
  120. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_fetchurls_sharepoint.py +0 -0
  121. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_phase1.py +0 -0
  122. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_phase2_jobs.py +0 -0
  123. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_phase3_jobs.py +0 -0
  124. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_phase4_live_fixes.py +0 -0
  125. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_playlist_import.py +0 -0
  126. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_presence_refund.py +0 -0
  127. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_promo_director.py +0 -0
  128. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_promo_pool_exclusion.py +0 -0
  129. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_queue_announce.py +0 -0
  130. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_race_poller.py +0 -0
  131. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_recently_played_hide.py +0 -0
  132. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_save_results_to_playlist.py +0 -0
  133. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_schedule_lock.py +0 -0
  134. {kryten_webqueue-0.33.3 → kryten_webqueue-0.33.4}/tests/test_search_facets.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.33.4] - 2026-08-07
4
+
5
+ ### Added
6
+ - `rehost_emotes` job: fetches the current channel emote list, downloads any
7
+ images not yet hosted on `dropsugar.co`, places them in
8
+ `/home/mediacms.io/mediacms/static/emotes/{bare_name}{ext}` with `www-data`
9
+ group ownership, updates each emote URL via api-gate, and saves timestamped
10
+ before/after JSON backups in `~/emote_backups/`. Runnable on demand from
11
+ Admin → Jobs or automatically on a configurable interval (default 24 h).
12
+ New config section `emote_rehost` (see `config.example.json`).
13
+ - `ApiGateClient.get_emotes()` and `ApiGateClient.update_emote()` methods.
14
+
3
15
  All notable changes to this project will be documented in this file.
4
16
 
5
17
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.33.3
3
+ Version: 0.33.4
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
@@ -65,5 +65,16 @@
65
65
  "playlist_bulk_add_delay_sec": 0.5,
66
66
  "playlist_bulk_add_max_retries": 2,
67
67
 
68
+ "emote_rehost": {
69
+ "enabled": true,
70
+ "rehost_domain": "dropsugar.co",
71
+ "static_dir": "/home/mediacms.io/mediacms/static/emotes",
72
+ "base_url": "https://www.dropsugar.co/static/emotes",
73
+ "backup_dir": "/home/kryten/emote_backups",
74
+ "check_interval_hours": 24.0,
75
+ "download_max_retries": 5,
76
+ "inter_emote_delay_sec": 2.0
77
+ },
78
+
68
79
  "prometheus_port": 28292
69
80
  }
@@ -1,4 +1,5 @@
1
1
  import httpx
2
+ from urllib.parse import quote
2
3
 
3
4
 
4
5
  class ApiGateClient:
@@ -189,6 +190,18 @@ class ApiGateClient:
189
190
  async def moderator_ping(self) -> dict:
190
191
  return await self.get("/moderator/ping")
191
192
 
193
+ # --- Emotes ---
194
+
195
+ async def get_emotes(self) -> list[dict]:
196
+ result = await self.get("/emotes/")
197
+ return result.get("emotes", [])
198
+
199
+ async def update_emote(self, name: str, image: str) -> dict:
200
+ """Update a single emote URL. name may include the '#' prefix."""
201
+ return await self.put(
202
+ f"/emotes/{quote(name, safe='')}", json={"image": image}
203
+ )
204
+
192
205
  async def moderator_health(self) -> dict:
193
206
  return await self.get("/moderator/health")
194
207
 
@@ -144,6 +144,14 @@ async def lifespan(app: FastAPI):
144
144
  label="Fetch URLs (weekend workbook)",
145
145
  schema=FETCHURLS_SCHEMA,
146
146
  )
147
+ from .jobs.rehost_emotes import rehost_emotes_job, REHOST_EMOTES_SCHEMA
148
+
149
+ job_manager.register(
150
+ "rehost_emotes",
151
+ rehost_emotes_job,
152
+ label="Rehost Emotes (download & serve from dropsugar.co)",
153
+ schema=REHOST_EMOTES_SCHEMA,
154
+ )
147
155
  app.state.job_manager = job_manager
148
156
 
149
157
  # WebSocket manager
@@ -271,10 +279,24 @@ async def lifespan(app: FastAPI):
271
279
  except Exception as e:
272
280
  logger.error(f"Immutability expiry error: {e}")
273
281
 
282
+ async def _emote_rehost_loop():
283
+ interval_hours = config.emote_rehost.check_interval_hours
284
+ if not config.emote_rehost.enabled or interval_hours <= 0:
285
+ return
286
+ interval = interval_hours * 3600
287
+ # Mirror catalog_sync pattern: don't run immediately on startup.
288
+ while True:
289
+ await asyncio.sleep(interval)
290
+ try:
291
+ await job_manager.run("rehost_emotes", triggered_by="scheduler")
292
+ except Exception as e:
293
+ logger.exception("Emote rehost periodic run error: %s", e)
294
+
274
295
  bg_tasks = [
275
296
  asyncio.create_task(_catalog_sync_loop()),
276
297
  asyncio.create_task(_otp_cleanup_loop()),
277
298
  asyncio.create_task(_immutability_expiry_loop()),
299
+ asyncio.create_task(_emote_rehost_loop()),
278
300
  ]
279
301
 
280
302
  logger.info(
@@ -3,6 +3,24 @@ from pydantic import BaseModel, PrivateAttr
3
3
  import json
4
4
 
5
5
 
6
+ class EmoteRehostConfig(BaseModel):
7
+ """Settings for the emote rehost job."""
8
+
9
+ enabled: bool = True
10
+ # Emotes whose image URL contains this string are considered already rehosted.
11
+ rehost_domain: str = "dropsugar.co"
12
+ # Local directory where rehosted image files are written.
13
+ static_dir: str = "/home/mediacms.io/mediacms/static/emotes"
14
+ # Public base URL served from static_dir; final URL: {base_url}/{bare_name}{ext}
15
+ base_url: str = "https://www.dropsugar.co/static/emotes"
16
+ # Directory for timestamped backup JSON files (created if absent).
17
+ backup_dir: str = "/home/kryten/emote_backups"
18
+ # Background check interval in hours; 0 disables the periodic loop.
19
+ check_interval_hours: float = 24.0
20
+ download_max_retries: int = 5
21
+ inter_emote_delay_sec: float = 2.0
22
+
23
+
6
24
  class FetchUrlsConfig(BaseModel):
7
25
  """Settings for the fetchurls job.
8
26
 
@@ -130,6 +148,7 @@ class Config(BaseModel):
130
148
  # Jobs (optional; jobs whose config/deps are absent fail fast at run time)
131
149
  fetch_cookies_path: str = "" # optional yt-dlp cookies for gated sources
132
150
  fetchurls: FetchUrlsConfig = FetchUrlsConfig()
151
+ emote_rehost: EmoteRehostConfig = EmoteRehostConfig()
133
152
 
134
153
  # Presence-based cancel/refund of pending paid items
135
154
  presence_refund: PresenceRefundConfig = PresenceRefundConfig()
@@ -0,0 +1,289 @@
1
+ """Emote rehost job — download externally-hosted emotes and serve from dropsugar.co.
2
+
3
+ Processes emotes whose image URLs are not already on the configured rehost
4
+ domain. For each, aggressively downloads the image, places it at
5
+ ``{static_dir}/{bare_name}{ext}`` with www-data group ownership, and pushes
6
+ the new URL back to CyTube via api-gate.
7
+
8
+ Emote names in the channel JSON include the ``#`` prefix (e.g. ``#behold``);
9
+ filenames always use the bare name without ``#`` (e.g. ``behold.gif``).
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import os
16
+ import random
17
+ import shutil
18
+ import time
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from urllib.parse import unquote, urlparse
22
+
23
+ import requests
24
+ from requests.adapters import HTTPAdapter
25
+ from urllib3.util.retry import Retry
26
+
27
+ from .manager import JobError
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ REHOST_EMOTES_SCHEMA: list[dict] = []
32
+
33
+ _USER_AGENTS = [
34
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
35
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
36
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
37
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/120.0.0.0 Safari/537.36",
38
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
39
+ ]
40
+
41
+
42
+ def _detect_ext(url: str, content_type: str | None) -> str:
43
+ """Determine file extension from Content-Type or URL path; default .gif."""
44
+ if content_type:
45
+ ct = content_type.lower()
46
+ if "gif" in ct:
47
+ return ".gif"
48
+ if "png" in ct:
49
+ return ".png"
50
+ if "jpeg" in ct or "jpg" in ct:
51
+ return ".jpg"
52
+ if "webp" in ct:
53
+ return ".webp"
54
+ path = unquote(urlparse(url).path).lower()
55
+ for ext in (".gif", ".png", ".jpg", ".jpeg", ".webp"):
56
+ if path.endswith(ext):
57
+ return ".jpg" if ext == ".jpeg" else ext
58
+ return ".gif"
59
+
60
+
61
+ def _make_session() -> requests.Session:
62
+ session = requests.Session()
63
+ retry = Retry(
64
+ total=5,
65
+ backoff_factor=2,
66
+ status_forcelist=[429, 500, 502, 503, 504],
67
+ allowed_methods=["HEAD", "GET"],
68
+ )
69
+ adapter = HTTPAdapter(max_retries=retry)
70
+ session.mount("https://", adapter)
71
+ session.mount("http://", adapter)
72
+ return session
73
+
74
+
75
+ def _set_permissions(path: Path) -> None:
76
+ """Set mode 0644 and group to www-data (best-effort; logs on failure)."""
77
+ path.chmod(0o644)
78
+ try:
79
+ import grp # type: ignore[import] # Unix only
80
+
81
+ gid = grp.getgrnam("www-data").gr_gid # type: ignore[attr-defined]
82
+ os.chown(path, -1, gid) # type: ignore[attr-defined]
83
+ except (ImportError, KeyError, PermissionError) as exc:
84
+ logger.warning("Could not set www-data group on %s: %s", path, exc)
85
+
86
+
87
+ def _place_emote(
88
+ url: str, bare_name: str, static_dir: Path, max_retries: int
89
+ ) -> str | None:
90
+ """Download url → static_dir/{bare_name}{ext} with correct permissions.
91
+
92
+ Returns the file extension (e.g. ``.gif``) on success, ``None`` if all
93
+ strategies are exhausted. Blocking — must be called via asyncio.to_thread.
94
+ """
95
+ base = static_dir / bare_name
96
+ tmp = base.parent / f"{base.name}.tmp"
97
+ session = _make_session()
98
+
99
+ try:
100
+ # Primary strategy: resumable, rotating User-Agent, progressive timeout
101
+ for attempt in range(max_retries):
102
+ try:
103
+ headers: dict[str, str] = {
104
+ "User-Agent": random.choice(_USER_AGENTS),
105
+ "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
106
+ "DNT": "1",
107
+ }
108
+ if tmp.exists():
109
+ headers["Range"] = f"bytes={tmp.stat().st_size}-"
110
+
111
+ timeout = min(60 + attempt * 30, 180)
112
+ resp = session.get(url, headers=headers, timeout=timeout, stream=True)
113
+ resp.raise_for_status()
114
+
115
+ ext = _detect_ext(url, resp.headers.get("content-type"))
116
+ mode = "ab" if "Range" in headers and resp.status_code == 206 else "wb"
117
+ with open(tmp, mode) as fh:
118
+ for chunk in resp.iter_content(chunk_size=8192):
119
+ if chunk:
120
+ fh.write(chunk)
121
+
122
+ final = base.with_suffix(ext)
123
+ shutil.move(str(tmp), str(final))
124
+ _set_permissions(final)
125
+ return ext
126
+
127
+ except requests.exceptions.RequestException as exc:
128
+ logger.debug(
129
+ "Attempt %d/%d for %s: %s", attempt + 1, max_retries, url, exc
130
+ )
131
+ time.sleep(2**attempt + random.uniform(0, 1))
132
+ except Exception as exc:
133
+ logger.debug(
134
+ "Unexpected error attempt %d/%d for %s: %s",
135
+ attempt + 1,
136
+ max_retries,
137
+ url,
138
+ exc,
139
+ )
140
+ tmp.unlink(missing_ok=True)
141
+ time.sleep(2**attempt + random.uniform(0, 1))
142
+
143
+ # Fallback: minimal headers, non-streaming
144
+ for attempt in range(2):
145
+ try:
146
+ resp = requests.get(
147
+ url,
148
+ headers={"User-Agent": "curl/7.68.0", "Accept": "*/*"},
149
+ timeout=120,
150
+ )
151
+ resp.raise_for_status()
152
+ ext = _detect_ext(url, resp.headers.get("content-type"))
153
+ final = base.with_suffix(ext)
154
+ final.write_bytes(resp.content)
155
+ _set_permissions(final)
156
+ return ext
157
+ except Exception as exc:
158
+ logger.debug("Fallback attempt %d for %s: %s", attempt + 1, url, exc)
159
+ time.sleep(2)
160
+
161
+ return None
162
+
163
+ finally:
164
+ session.close()
165
+ tmp.unlink(missing_ok=True)
166
+
167
+
168
+ def _write_json(path: Path, data: list[dict]) -> None:
169
+ """Atomically write data to path as indented JSON."""
170
+ tmp = path.with_suffix(".tmp")
171
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
172
+ tmp.replace(path)
173
+
174
+
175
+ async def rehost_emotes_job(params: dict, ctx) -> dict:
176
+ """Rehost externally-hosted channel emotes to dropsugar.co."""
177
+ cfg = ctx.config.emote_rehost
178
+ api = ctx.api_gate
179
+
180
+ try:
181
+ emotes = await api.get_emotes()
182
+ except Exception as exc:
183
+ raise JobError(f"Failed to fetch emotes from api-gate: {exc}") from exc
184
+
185
+ if not emotes:
186
+ return {
187
+ "total_emotes": 0,
188
+ "already_rehosted": 0,
189
+ "attempted": 0,
190
+ "succeeded": 0,
191
+ "failed": 0,
192
+ "pushed": 0,
193
+ "failed_emotes": [],
194
+ }
195
+
196
+ # Back up current state before making any changes
197
+ backup_dir = Path(cfg.backup_dir)
198
+ await asyncio.to_thread(backup_dir.mkdir, parents=True, exist_ok=True)
199
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
200
+ await asyncio.to_thread(
201
+ _write_json, backup_dir / f"emotes-{stamp}-before.json", emotes
202
+ )
203
+
204
+ to_rehost = [e for e in emotes if cfg.rehost_domain not in e.get("image", "")]
205
+ already = len(emotes) - len(to_rehost)
206
+ await ctx.progress(
207
+ {"step": "fetched", "total": len(emotes), "to_rehost": len(to_rehost)}
208
+ )
209
+
210
+ if not to_rehost:
211
+ result: dict = {
212
+ "total_emotes": len(emotes),
213
+ "already_rehosted": already,
214
+ "attempted": 0,
215
+ "succeeded": 0,
216
+ "failed": 0,
217
+ "pushed": 0,
218
+ "failed_emotes": [],
219
+ }
220
+ await ctx.progress({"step": "complete", **result})
221
+ return result
222
+
223
+ static_dir = Path(cfg.static_dir)
224
+ await asyncio.to_thread(static_dir.mkdir, parents=True, exist_ok=True)
225
+
226
+ succeeded: list[str] = []
227
+ failed: list[str] = []
228
+ # mutable working copy keyed by name for building the after-backup
229
+ updated = {e["name"]: dict(e) for e in emotes}
230
+
231
+ for i, emote in enumerate(to_rehost, 1):
232
+ name = emote["name"] # e.g. "#behold" (includes #)
233
+ url = emote["image"]
234
+ bare = name.lstrip("#") # "behold" → used as filename root
235
+
236
+ ext = await asyncio.to_thread(
237
+ _place_emote, url, bare, static_dir, cfg.download_max_retries
238
+ )
239
+
240
+ if ext is None:
241
+ logger.warning(
242
+ "All download strategies failed for emote %s (%s)", name, url
243
+ )
244
+ failed.append(name)
245
+ await ctx.progress(
246
+ {"step": "failed", "emote": name, "done": i, "total": len(to_rehost)}
247
+ )
248
+ await asyncio.sleep(cfg.inter_emote_delay_sec)
249
+ continue
250
+
251
+ new_url = f"{cfg.base_url.rstrip('/')}/{bare}{ext}"
252
+ updated[name]["image"] = new_url
253
+
254
+ try:
255
+ await api.update_emote(name, new_url)
256
+ succeeded.append(name)
257
+ await ctx.progress(
258
+ {"step": "pushed", "emote": name, "done": i, "total": len(to_rehost)}
259
+ )
260
+ except Exception as exc:
261
+ logger.error("Failed to push emote %s to api-gate: %s", name, exc)
262
+ failed.append(name)
263
+ await ctx.progress(
264
+ {
265
+ "step": "push_failed",
266
+ "emote": name,
267
+ "done": i,
268
+ "total": len(to_rehost),
269
+ "error": str(exc),
270
+ }
271
+ )
272
+
273
+ await asyncio.sleep(cfg.inter_emote_delay_sec)
274
+
275
+ await asyncio.to_thread(
276
+ _write_json, backup_dir / f"emotes-{stamp}-after.json", list(updated.values())
277
+ )
278
+
279
+ result = {
280
+ "total_emotes": len(emotes),
281
+ "already_rehosted": already,
282
+ "attempted": len(to_rehost),
283
+ "succeeded": len(succeeded),
284
+ "failed": len(failed),
285
+ "pushed": len(succeeded),
286
+ "failed_emotes": failed,
287
+ }
288
+ await ctx.progress({"step": "complete", **result})
289
+ return result
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.33.3"
3
+ version = "0.33.4"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"