kryten-webqueue 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/PKG-INFO +1 -1
  2. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/db.py +5 -0
  3. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/images.py +86 -30
  4. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/pyproject.toml +1 -1
  5. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/.github/workflows/python-publish.yml +0 -0
  6. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/.github/workflows/release.yml +0 -0
  7. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/.gitignore +0 -0
  8. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/README.md +0 -0
  9. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/config.example.json +0 -0
  10. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/deploy/kryten-webqueue.service +0 -0
  11. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/deploy/nginx-queue.conf +0 -0
  12. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  13. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/docs/IMPL_API_GATE.md +0 -0
  14. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/docs/IMPL_ECONOMY.md +0 -0
  15. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  16. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/docs/IMPL_ROBOT.md +0 -0
  17. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/docs/PRE_PLAN_GAPS.md +0 -0
  18. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/docs/PRODUCT_PLAN.md +0 -0
  19. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/__init__.py +0 -0
  20. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/__main__.py +0 -0
  21. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  22. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/api_gate/client.py +0 -0
  23. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/app.py +0 -0
  24. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/__init__.py +0 -0
  25. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/otp.py +0 -0
  26. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  27. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/session.py +0 -0
  28. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/__init__.py +0 -0
  29. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/sync.py +0 -0
  30. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/config.py +0 -0
  31. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/__init__.py +0 -0
  32. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/fire.py +0 -0
  33. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/importer.py +0 -0
  34. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  35. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/__init__.py +0 -0
  36. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/ordering.py +0 -0
  37. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/poller.py +0 -0
  38. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/shadow.py +0 -0
  39. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/__init__.py +0 -0
  40. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  41. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  42. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  43. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/auth.py +0 -0
  44. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/catalog.py +0 -0
  45. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/pages.py +0 -0
  46. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/queue.py +0 -0
  47. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/user.py +0 -0
  48. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/static/css/main.css +0 -0
  49. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/static/js/main.js +0 -0
  50. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/admin/index.html +0 -0
  51. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  52. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  53. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  54. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/auth/login.html +0 -0
  55. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/base.html +0 -0
  56. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  57. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/queue/index.html +0 -0
  58. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  59. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/ws/__init__.py +0 -0
  60. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/ws/handler.py +0 -0
  61. {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.1}/kryten_webqueue/ws/manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -164,6 +164,11 @@ MIGRATIONS = [
164
164
  );
165
165
  CREATE INDEX IF NOT EXISTS idx_queue_history_user ON queue_history(username);
166
166
  """,
167
+ # v3: Clear all cached cover art to force a full repoll (fixes TMDB poster/person
168
+ # preference and /static/images -> /images path correction)
169
+ """
170
+ UPDATE catalog SET cover_art_path = NULL, cover_art_source = NULL;
171
+ """,
167
172
  ]
168
173
 
169
174
 
@@ -1,13 +1,31 @@
1
- import httpx
1
+ import asyncio
2
+ import hashlib
3
+ import io
2
4
  import logging
5
+ import re
3
6
  from pathlib import Path
7
+
8
+ import httpx
4
9
  from PIL import Image
5
- import io
6
- import hashlib
7
10
 
8
11
  logger = logging.getLogger(__name__)
9
12
 
10
13
 
14
+ def _clean_title(title: str) -> tuple[str, str | None]:
15
+ """Return (cleaned_title, year_or_None) stripping common noise."""
16
+ # Extract 4-digit year in parens or at end: "Title (2019)" or "Title 2019"
17
+ year = None
18
+ m = re.search(r"\b((?:19|20)\d{2})\b", title)
19
+ if m:
20
+ year = m.group(1)
21
+ # Remove year, episode tags, resolution tags, etc.
22
+ cleaned = re.sub(r"\s*[\(\[]?(?:19|20)\d{2}[\)\]]?", "", title)
23
+ cleaned = re.sub(r"\s*[Ss]\d{1,2}[Ee]\d{1,2}.*", "", cleaned)
24
+ cleaned = re.sub(r"\s*\b(?:720p|1080p|2160p|4K|HDR|BluRay|BDRip|WEB[-.]?DL|HDTV)\b.*", "", cleaned, flags=re.IGNORECASE)
25
+ cleaned = cleaned.strip(" .-")
26
+ return cleaned or title, year
27
+
28
+
11
29
  class CoverArtResolver:
12
30
  """Downloads and caches cover art for catalog items."""
13
31
 
@@ -37,9 +55,9 @@ class CoverArtResolver:
37
55
  logger.warning("No TMDB or OMDB API keys configured — cover art lookup skipped")
38
56
  return None
39
57
 
40
- # Try TMDB first
41
58
  image_url = None
42
59
  source = None
60
+
43
61
  if self._tmdb_key:
44
62
  image_url = await self._search_tmdb(title)
45
63
  if image_url:
@@ -47,6 +65,7 @@ class CoverArtResolver:
47
65
  logger.debug(f"TMDB found art for {friendly_token!r}: {title!r}")
48
66
  else:
49
67
  logger.debug(f"TMDB found no art for {friendly_token!r}: {title!r}")
68
+
50
69
  if not image_url and self._omdb_key:
51
70
  image_url = await self._search_omdb(title)
52
71
  if image_url:
@@ -55,10 +74,15 @@ class CoverArtResolver:
55
74
  else:
56
75
  logger.debug(f"OMDB found no art for {friendly_token!r}: {title!r}")
57
76
 
77
+ # Last resort: use the MediaCMS thumbnail already in the DB
78
+ if not image_url and existing and existing.get("thumbnail_url"):
79
+ image_url = existing["thumbnail_url"]
80
+ source = "thumbnail"
81
+ logger.debug(f"Falling back to thumbnail for {friendly_token!r}")
82
+
58
83
  if not image_url:
59
84
  return None
60
85
 
61
- # Download and generate responsive variants
62
86
  try:
63
87
  resp = await self._client.get(image_url)
64
88
  if resp.status_code != 200:
@@ -72,40 +96,72 @@ class CoverArtResolver:
72
96
  return None
73
97
 
74
98
  async def _search_tmdb(self, title: str) -> str | None:
99
+ """Search TMDB for a poster: tries movie+TV in parallel, retries with cleaned title."""
100
+ result = await self._tmdb_search_both(title)
101
+ if result:
102
+ return result
103
+ # Retry with cleaned title if it differs
104
+ cleaned, year = _clean_title(title)
105
+ if cleaned != title:
106
+ result = await self._tmdb_search_both(cleaned, year=year)
107
+ return result
108
+
109
+ async def _tmdb_search_both(self, title: str, year: str | None = None) -> str | None:
110
+ """Search TMDB movie and TV endpoints in parallel, pick best poster by popularity."""
111
+ movie_task = asyncio.create_task(self._tmdb_search_type("movie", title, year))
112
+ tv_task = asyncio.create_task(self._tmdb_search_type("tv", title, year))
113
+ movie_hit, tv_hit = await asyncio.gather(movie_task, tv_task)
114
+
115
+ # Pick whichever has a poster; prefer movie if both have one and similar popularity
116
+ candidates = [h for h in (movie_hit, tv_hit) if h and h[0]]
117
+ if not candidates:
118
+ return None
119
+ # Sort by popularity descending, return the best poster URL
120
+ candidates.sort(key=lambda h: h[1], reverse=True)
121
+ return candidates[0][0]
122
+
123
+ async def _tmdb_search_type(self, media_type: str, title: str, year: str | None = None) -> tuple[str | None, float]:
124
+ """Search a specific TMDB media type. Returns (poster_url_or_None, popularity)."""
125
+ params: dict = {"api_key": self._tmdb_key, "query": title}
126
+ if year and media_type == "movie":
127
+ params["year"] = year
128
+ elif year and media_type == "tv":
129
+ params["first_air_date_year"] = year
75
130
  try:
76
131
  resp = await self._client.get(
77
- "https://api.themoviedb.org/3/search/multi",
78
- params={"api_key": self._tmdb_key, "query": title},
132
+ f"https://api.themoviedb.org/3/search/{media_type}",
133
+ params=params,
79
134
  )
80
135
  if resp.status_code != 200:
81
- logger.warning(f"TMDB API returned {resp.status_code} for {title!r}")
82
- return None
136
+ logger.warning(f"TMDB {media_type} search returned {resp.status_code} for {title!r}")
137
+ return None, 0.0
83
138
  results = resp.json().get("results", [])
84
- # Prefer movie/tv results (have poster_path); skip person results
85
139
  for result in results:
86
- if result.get("media_type") in ("movie", "tv"):
87
- poster = result.get("poster_path")
88
- if poster:
89
- return f"https://image.tmdb.org/t/p/w500{poster}"
140
+ poster = result.get("poster_path")
141
+ if poster:
142
+ # w780 gives better quality than w500
143
+ url = f"https://image.tmdb.org/t/p/w780{poster}"
144
+ return url, float(result.get("popularity", 0))
90
145
  except Exception as e:
91
- logger.warning(f"TMDB search error for {title!r}: {e}")
92
- return None
146
+ logger.warning(f"TMDB {media_type} search error for {title!r}: {e}")
147
+ return None, 0.0
93
148
 
94
149
  async def _search_omdb(self, title: str) -> str | None:
95
- try:
96
- resp = await self._client.get(
97
- "https://www.omdbapi.com/",
98
- params={"apikey": self._omdb_key, "t": title},
99
- )
100
- if resp.status_code != 200:
101
- logger.warning(f"OMDB API returned {resp.status_code} for {title!r}")
102
- return None
103
- data = resp.json()
104
- poster = data.get("Poster")
105
- if poster and poster != "N/A":
106
- return poster
107
- except Exception as e:
108
- logger.warning(f"OMDB search error for {title!r}: {e}")
150
+ cleaned, year = _clean_title(title)
151
+ for t in dict.fromkeys([title, cleaned]): # try original then cleaned, deduped
152
+ params: dict = {"apikey": self._omdb_key, "t": t}
153
+ if year:
154
+ params["y"] = year
155
+ try:
156
+ resp = await self._client.get("https://www.omdbapi.com/", params=params)
157
+ if resp.status_code != 200:
158
+ continue
159
+ data = resp.json()
160
+ poster = data.get("Poster")
161
+ if poster and poster != "N/A":
162
+ return poster
163
+ except Exception as e:
164
+ logger.warning(f"OMDB search error for {t!r}: {e}")
109
165
  return None
110
166
 
111
167
  async def _save_responsive(self, friendly_token: str, data: bytes, source: str, db) -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.4.0"
3
+ version = "0.4.1"
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"