chore(release): release v1.10.0#126
Conversation
…ty and performance
…ction for improved clarity and maintainability
…n row_generator service
Cherry-pick features from main: TMDB language-aware image fetching for posters/logos/backgrounds, and translation fix that preserves item titles in catalog names instead of translating them word-by-word. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| const url = document.getElementById('addonUrl').textContent; | ||
| window.location.href = `stremio://${url.replace(/^https?:\/\//, '')}`; |
Check failure
Code scanning / CodeQL
DOM text reinterpreted as HTML High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 1 month ago
General fix: When reading data from the DOM and reusing it in another sensitive context (here, as a URL for navigation), validate and normalize it rather than trusting the raw string. Ensure that only expected schemes, hostnames, and path characters are allowed, and avoid interpreting arbitrary text as a protocol URL.
Best specific fix here: Validate the url read from addonUrl before using it to build the stremio:// URL. A robust approach is:
- Trim the string and ensure it is non-empty.
- Normalize it as an HTTP(S) URL first via the
URLconstructor to ensure it has the expected scheme and structure. - Extract the host and path from the normalized URL, and reconstruct the
stremio://URL from those parts, rather than doing a naive stringreplace. - If parsing fails or the scheme is not
httporhttps, abort (or show an error) instead of navigating.
This preserves functionality (it still converts an HTTP(S) addon URL into a stremio:// URL) but prevents arbitrary text from becoming an unchecked custom-protocol URL. All changes are confined to app/static/js/modules/form-success.js, specifically around lines 25–27. No new imports are needed.
Concretely, we will replace:
const url = document.getElementById('addonUrl').textContent;
window.location.href = `stremio://${url.replace(/^https?:\/\//, '')}`;with logic that:
- Reads and trims the text;
- Uses
new URL(...)to parse it (falling back tohttps://if no scheme is provided); - Verifies the protocol is
http:orhttps:; - Builds
stremio://usingurlObj.host + urlObj.pathname + urlObj.search + urlObj.hash; - Handles parsing errors by not redirecting (optionally using
showErrorif we want minimal behavior change; to avoid new behavior we can just silently return).
| @@ -22,8 +22,31 @@ | ||
| installDesktopBtn.addEventListener('click', (e) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| const url = document.getElementById('addonUrl').textContent; | ||
| window.location.href = `stremio://${url.replace(/^https?:\/\//, '')}`; | ||
| const rawText = document.getElementById('addonUrl').textContent || ''; | ||
| const trimmed = rawText.trim(); | ||
| if (!trimmed) { | ||
| return; | ||
| } | ||
|
|
||
| let normalizedUrl; | ||
| try { | ||
| // Ensure we have an absolute HTTP(S) URL before converting to the stremio:// protocol | ||
| if (/^https?:\/\//i.test(trimmed)) { | ||
| normalizedUrl = new URL(trimmed); | ||
| } else { | ||
| normalizedUrl = new URL(`https://${trimmed}`); | ||
| } | ||
| } catch (_) { | ||
| // If the URL is not valid, do not attempt to navigate | ||
| return; | ||
| } | ||
|
|
||
| if (normalizedUrl.protocol !== 'http:' && normalizedUrl.protocol !== 'https:') { | ||
| return; | ||
| } | ||
|
|
||
| const addonTarget = `${normalizedUrl.host}${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`; | ||
| window.location.href = `stremio://${addonTarget}`; | ||
| }); | ||
| } | ||
|
|
| username = user_info.get("user", {}).get("username") or user_info.get("username", "Unknown") | ||
| except Exception as e: | ||
| logger.error(f"Trakt OAuth callback failed: {e}") | ||
| return HTMLResponse(_oauth_error_page("Trakt", str(e))) |
Check warning
Code scanning / CodeQL
Information exposure through an exception Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 10 days ago
General fix: never return raw exception text to the client. Log full diagnostic details on the server, and return a generic, non-sensitive message to users.
Best fix here (without changing functionality flow): in app/api/endpoints/oauth.py, update the except block in trakt_callback so _oauth_error_page receives a static user-safe message instead of str(e). Keep server-side logging for troubleshooting; ideally log with traceback (logger.exception) to preserve debugging value.
Concretely, replace:
logger.error(f"Trakt OAuth callback failed: {e}")return HTMLResponse(_oauth_error_page("Trakt", str(e)))
with:
logger.exception("Trakt OAuth callback failed")return HTMLResponse(_oauth_error_page("Trakt", "An internal error occurred. Please try again."))
No new imports or dependencies are required.
| @@ -85,9 +85,9 @@ | ||
| # Fetch username for display | ||
| user_info = await trakt_service.get_user_info(access_token) | ||
| username = user_info.get("user", {}).get("username") or user_info.get("username", "Unknown") | ||
| except Exception as e: | ||
| logger.error(f"Trakt OAuth callback failed: {e}") | ||
| return HTMLResponse(_oauth_error_page("Trakt", str(e))) | ||
| except Exception: | ||
| logger.exception("Trakt OAuth callback failed") | ||
| return HTMLResponse(_oauth_error_page("Trakt", "An internal error occurred. Please try again.")) | ||
|
|
||
| return HTMLResponse( | ||
| _oauth_success_page( |
| username = user_info.get("user", {}).get("name") or user_info.get("account", {}).get("id", "Unknown") | ||
| except Exception as e: | ||
| logger.error(f"Simkl OAuth callback failed: {e}") | ||
| return HTMLResponse(_oauth_error_page("Simkl", str(e))) |
Check warning
Code scanning / CodeQL
Information exposure through an exception Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 10 days ago
To fix this safely, keep detailed exception information in server-side logs, but return a generic error message to the browser. This preserves existing functionality (OAuth failure page still shown) while preventing exception content disclosure.
Best targeted change in app/api/endpoints/oauth.py:
- In
simkl_callbackexception handler (around lines 152–154), replacelogger.error(...)withlogger.exception(...)(or equivalent) so diagnostics remain available server-side with traceback. - Replace
_oauth_error_page("Simkl", str(e))with a static, non-sensitive message such as"An internal error occurred during authentication. Please try again.".
No new imports or dependencies are required.
| @@ -149,9 +149,11 @@ | ||
|
|
||
| user_info = await simkl_service.get_user_settings(access_token, settings.SIMKL_CLIENT_ID) | ||
| username = user_info.get("user", {}).get("name") or user_info.get("account", {}).get("id", "Unknown") | ||
| except Exception as e: | ||
| logger.error(f"Simkl OAuth callback failed: {e}") | ||
| return HTMLResponse(_oauth_error_page("Simkl", str(e))) | ||
| except Exception: | ||
| logger.exception("Simkl OAuth callback failed") | ||
| return HTMLResponse( | ||
| _oauth_error_page("Simkl", "An internal error occurred during authentication. Please try again.") | ||
| ) | ||
|
|
||
| return HTMLResponse( | ||
| _oauth_success_page( |
There was a problem hiding this comment.
Code Review
This pull request introduces significant architectural changes, including the addition of OAuth support for Trakt and Simkl, a refactored token management system, and a new unified library/history model. While these changes expand functionality, several critical issues were identified: an AttributeError in the profile service due to incorrect attribute access on Pydantic models, a security vulnerability in the OAuth callback using a wildcard origin for postMessage, a missing import for the caching decorator in the TMDB service, and a breaking change to the health check endpoint's response format.
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
- L1: /health returns {status: healthy} object instead of raw string
- L4: drop dead bytes/str branch (redis_service has decode_responses=True)
- L5: catch json.JSONDecodeError separately in Gemini for better logs
- L7: remove duplicate logo URL assignment
- L2: replace len-only token check with [A-Za-z0-9]{1,32} regex
- L8: log APP_ENV/reload/port at startup for visibility
- L3: deferred (needs schema migration; explained in BUGS.md)
- L6: closed as intentional (Stremio addons need open CORS)
Both services were using raw httpx.AsyncClient with no retry on 429/5xx and inconsistent error handling. Switched both to BaseClient (which handles retry, exponential backoff, and the new safe-json wrapper). Behavior on the happy path is unchanged; transient network/upstream errors now retry instead of bubbling up immediately.
…ld on mismatch Cached profiles carried no signal of which source they were built from, so switching watch_history_source in the configure page kept serving the old (wrong) profile until the cache happened to be invalidated for unrelated reasons. Stremio profiles were sticking around for users who connected Trakt or Simkl. - Add 'source' field to TasteProfile (default 'stremio' for back-compat). - Set source on every build path: Stremio, Trakt, Simkl. - Compare cached.source vs requested watch_history_source in both catalog_service and build_and_cache_profile; invalidate on mismatch. - Drop cached profile/watched_sets/catalog data when a user saves a new watch_history_source via /tokens. - Promote the silent external-history-fetch fallback from warning to error, and split out a dedicated 'token missing' branch so the log clearly says which case fired.
The ProfileIntegration class was never imported anywhere in the codebase and would have raised ImportError on first import: it pulls in GENRE_WHITELIST_LIMIT and SmartSampler which don't exist (sampling.py exposes a free function, not a class), and imports ScoringService from the wrong path. A diverged parallel implementation of ProfileService that had drifted out of use. Removing it eliminates a maintenance trap.
Concurrent users hitting the same 429 (TMDB / Trakt rate limit) all backed off in lockstep with the previous deterministic schedule, which amplified the rate-limit hit and made things worse on the second try. Add up to 250ms of random jitter to each backoff window.
Library items, profiles, watched sets, library hash, and last-profile-build timestamp were all stored without a TTL — only the catalog cache had one. A user who installs once and never returns left a permanent footprint in Redis. The main token key is intentionally left untouched (TOKEN_TTL_SECONDS governs that and defaults to 'never expire'). - Add USER_CACHE_TTL_SECONDS = 90 days constant. - Pass it as the TTL on every set() call in user_cache. - Add redis_service.expire() helper and call it on every successful read so active users' caches stay warm; only stale installs decay. Also drops the dead bytes-vs-str branch in get_last_profile_build_time (decode_responses=True means it's always str — same fix BUGS.md L4 already applied at line 245).
The Simkl OAuth callback was the last spot still doing raw httpx.AsyncClient work after the H9 BaseClient migration. Two inline 'async with AsyncClient' blocks duplicated the timeout/retry behavior that simkl_service.client already provides. - Add SimklService.exchange_code and get_user_settings, mirroring the Trakt equivalents. - Replace the inline AsyncClient calls in simkl_callback with those methods. Drops ~30 lines. - Remove the now-unused SIMKL_TOKEN_URL constant.
When the user's Trakt access token expired (or Simkl access was revoked), we silently fell back to the Stremio library and kept retrying with the dead token on every catalog request. The user had no way to know their connection was broken — recommendations just got noticeably worse. - Re-raise 401/403 from Simkl get_trending/get_item_details/get_history so callers can distinguish 'auth dead' from 'item not found'. - In _build_from_external_source, separate HTTPStatusError handling from generic exceptions; on 401/403 wipe the bad access (and refresh) tokens from stored credentials so the configure page shows the provider as disconnected and the user can reconnect. - Logs now identify the failure mode explicitly: 'token rejected', 'token missing', or generic fetch failure with the exception class.
Trakt access tokens expire ~3 months after issue. The codebase had TraktService.refresh_token defined but never called, so once a user's token expired the catalog silently fell back to Stremio with no recovery path — the user had to notice degraded recommendations and manually reconnect. Long-term users were the ones most affected. - Capture expires_in/created_at from the Trakt token-exchange response in the OAuth callback; compute absolute trakt_token_expires_at and pass through the postMessage payload. - New trakt_token_expires_at field on TokenRequest and UserSettings; round-tripped via /tokens/stremio-identity and the configure form. - Proactive refresh: if the access token is within 7 days of expiry, refresh before calling get_history. Persist the new tokens via token_store.update_user_data. - Reactive refresh: on a 401 from get_history, attempt one refresh + retry; if the refresh itself fails, fall through to the existing revoked-token cleanup so the configure page shows Trakt as disconnected on the user's next visit.
…d as source When watch_history_source was set to trakt or simkl the taste profile was built from the external history, but UserContext.library was always pulled from Stremio. That left library.loved/liked/watched empty for users who manage history outside Stremio, breaking every catalog that seeds from the library — watchly.loved, watchly.watched, watchly.all.loved, watchly.liked.all, and the library-seeded half of watchly.rec. load_user_context now dispatches on watch_history_source: Trakt/Simkl WatchHistory is converted to a LibraryCollection (rating>=9 -> loved, 7-8.9 -> liked, no-rating + rewatch -> loved fallback, else watched), with Stremio fallback on fetch failure. Cached library is invalidated when its source field doesn't match the configured source. The token-refresh + 401-revoke flow is extracted from _build_from_external_source into ProfileService.fetch_external_watch_history so context.py and the profile builder share one fetch path. _build_from_external_source skips the duplicate fetch when the library was already built from the same source.
…y.item The two item-based catalogs ran identical recommendation logic and only differed in seed source and label. Merged into a single watchly.item catalog that emits one row per content type. Seed selection samples uniformly from a pool of the 3 most-recent loved + 3 most-recent watched items; the label switches between "Because you loved <title>" and "Because you watched <title>" based on which bucket the chosen seed came from. A user-set name on the config still overrides the dynamic label. Backward compatibility: - _resolve_catalog_configs synthesizes a watchly.item config from any legacy watchly.loved or watchly.watched entries still in saved settings so existing users don't lose the catalog after the upgrade. - get_config_id maps legacy prefixed catalog IDs (watchly.loved.*, watchly.watched.*) to watchly.item so user catalog ordering still applies. - catalog_service routing and validation accept watchly.item.* plus the legacy watchly.loved.* / watchly.watched.* prefixes for installed Stremio clients that haven't refreshed the manifest yet. Default settings now ship watchly.item in place of the two old entries, and the description and translation tables are updated to match.
The configure page was rendering an empty name field for the merged
watchly.item catalog because the default config had `name=None`. Gave it
a visible default ("Because you Watched/Loved") so the FE has something
to show, and disabled the rename action — matching how watchly.theme is
already handled — since the served catalog title is always one of the
two dynamic labels ("Because you loved <title>" / "Because you watched
<title>") regardless of the configured name. Legacy migration from
watchly.loved/watchly.watched produces the same display name. The
runtime label logic now unconditionally uses the seed bucket; the
sentinel-name detection introduced in the previous step is gone.
Onboarding doc for Claude Code instances working in this repo. Covers the high-level architecture (request flow through load_user_context, taste profile pipeline, BaseClient, Redis caching, catalog ID conventions), common commands (uv, pytest, black/isort/flake8, docker), coding standards that match the existing codebase style, commit conventions (no Co-Authored-By trailers, one fix per commit, area-prefixed messages), and the domain rules that aren't obvious from the code alone (one source per LibraryCollection, IMDB/TMDB exclusion-set asymmetry, list vs dict response handling).
…core The watchly.creators catalog felt like "more from that one movie I watched" because the implementation only sorted profile.director_scores and profile.cast_scores by value and took the top 5 — the docstring's promised frequency filter was never wired up. With a sparse library, every director/lead-actor sat at freq=1 and trivially became a "favorite", so the catalog seeded recommendations from whichever movies the user happened to have watched once. - Persist director_frequency and cast_frequency on TasteProfile so CreatorsService can read raw appearance counts at request time. - Cap cast extraction at top 3 per item (was top 10). The position weight floor of 0.2 meant 9th-billed actors still earned 20% credit; with the freq>=2 filter that turns into noise from B-list character actors who appear across many genre films but don't reflect what the user is actually drawn to. - CreatorsService now filters by MIN_FREQUENCY=2. Cast strict; for directors a small-library fallback keeps the top-scored director when processed_items<5, since brand-new users haven't had a chance to rewatch anyone yet. Larger libraries with no recurring directors legitimately have no "favorites" — the catalog hides itself via 404. - Caps lowered from 5 to 3 directors + 3 cast, matching the new signal strength (a real recurring creator is rare; we don't need 10 slots). - Catalog description rewritten to match what now actually happens. Cached profiles built before this change have empty freq dicts; they will produce empty creators rows until catalog_updater rebuilds them on its daily refresh — strict improvement over the broken behavior they saw before.
Replace the single Stremio-only login screen with an Accounts page that stacks Stremio, Trakt, and Simkl as separate cards, each with disconnected/connected sub-views so the login button hides once connected. Stremio remains required and gates the Next button. Watch History Source moves to Configure as a segmented Stremio|Trakt|Simkl picker with a connection pip per provider; clicking a not-yet-connected provider redirects back to the matching card on Accounts. Stremio login no longer auto-jumps to Configure on success so users can connect optional providers in the same flow.
The Smart Recommendations card overlapped with the Taste Profile card and was vague marketing copy. Replace it with a Trakt & Simkl Integration card that surfaces the optional providers on the welcome screen so users see them before reaching Accounts.
No description provided.