Skip to content

chore(release): release v1.10.0#126

Open
TimilsinaBimal wants to merge 73 commits intomainfrom
dev
Open

chore(release): release v1.10.0#126
TimilsinaBimal wants to merge 73 commits intomainfrom
dev

Conversation

@TimilsinaBimal
Copy link
Copy Markdown
Owner

No description provided.

TimilsinaBimal and others added 17 commits March 1, 2026 13:14
…ction for improved clarity and maintainability
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
watchly Ready Ready Preview, Comment May 7, 2026 7:42am

@TimilsinaBimal TimilsinaBimal changed the title Dev chore(release): release v1.10.0 Apr 2, 2026
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

DOM text
is reinterpreted as HTML without escaping meta-characters.

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:

  1. Trim the string and ensure it is non-empty.
  2. Normalize it as an HTTP(S) URL first via the URL constructor to ensure it has the expected scheme and structure.
  3. Extract the host and path from the normalized URL, and reconstruct the stremio:// URL from those parts, rather than doing a naive string replace.
  4. If parsing fails or the scheme is not http or https, 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 to https:// if no scheme is provided);
  • Verifies the protocol is http: or https:;
  • Builds stremio:// using urlObj.host + urlObj.pathname + urlObj.search + urlObj.hash;
  • Handles parsing errors by not redirecting (optionally using showError if we want minimal behavior change; to avoid new behavior we can just silently return).

Suggested changeset 1
app/static/js/modules/form-success.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/static/js/modules/form-success.js b/app/static/js/modules/form-success.js
--- a/app/static/js/modules/form-success.js
+++ b/app/static/js/modules/form-success.js
@@ -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}`;
         });
     }
 
EOF
@@ -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}`;
});
}

Copilot is powered by AI and may make mistakes. Always verify output.
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

Stack trace information
flows to this location and may be exposed to an external user.

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.

Suggested changeset 1
app/api/endpoints/oauth.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/api/endpoints/oauth.py b/app/api/endpoints/oauth.py
--- a/app/api/endpoints/oauth.py
+++ b/app/api/endpoints/oauth.py
@@ -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(
EOF
@@ -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(
Copilot is powered by AI and may make mistakes. Always verify output.
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

Stack trace information
flows to this location and may be exposed to an external user.

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_callback exception handler (around lines 152–154), replace logger.error(...) with logger.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.

Suggested changeset 1
app/api/endpoints/oauth.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/api/endpoints/oauth.py b/app/api/endpoints/oauth.py
--- a/app/api/endpoints/oauth.py
+++ b/app/api/endpoints/oauth.py
@@ -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(
EOF
@@ -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(
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/services/profile/service.py Outdated
Comment thread app/api/endpoints/oauth.py Outdated
Comment thread app/services/tmdb/service.py
Comment thread app/api/endpoints/health.py Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Nuvio or Trakt support possible? [FEATURE] Integrate Trakt and Simkl Watch history recommendations

2 participants