kryten-webqueue 0.4.0__tar.gz → 0.4.2__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.4.0 → kryten_webqueue-0.4.2}/PKG-INFO +1 -1
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/app.py +6 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/catalog/db.py +5 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/catalog/images.py +86 -30
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/pyproject.toml +1 -1
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/.gitignore +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/README.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/config.example.json +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -182,4 +182,10 @@ def create_app(config: Config) -> FastAPI:
|
|
|
182
182
|
if static_dir.exists():
|
|
183
183
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
184
184
|
|
|
185
|
+
# Cover art images (nginx serves this in production, but also mount here
|
|
186
|
+
# so uvicorn handles it directly when nginx isn't in front)
|
|
187
|
+
image_dir = Path(config.image_dir)
|
|
188
|
+
image_dir.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
app.mount("/images", StaticFiles(directory=str(image_dir)), name="images")
|
|
190
|
+
|
|
185
191
|
return app
|
|
@@ -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
|
|
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/
|
|
78
|
-
params=
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.4.0 → kryten_webqueue-0.4.2}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|