Fix affinity scoring, add completion signal, seamless local player switch

Ranked feed — affinity was broken:
- Was looking up user_tag_affinity by v.category (e.g. "Science & Technology")
  but affinity is stored using fine-grained video tags ("linux", "rust", etc.)
- Now uses SUM across all matching affinities: category OR any tag found in the
  video's tags JSON via instr() — up to 5 matches to prevent runaway scores

Ranked feed — completion rate now influences channel scoring:
- Added avg_completion_pct to channel_stats CTE (AVG of completion_percent)
- Channels where you finish videos score higher; channels you bail on score lower
- Defaults to 50% (neutral) for channels with no tracked completions

Progress endpoint — backend auto-watched safety net:
- If completion_percent reaches ≥90% on a video >60s, mark watched automatically
- Catches cases where browser closes before the 10s debounce fires
- Guards against double-calling _update_affinity with not prev_watched check

VideoPlayer — seamless local file switch:
- Removed switchedToLocal state which caused a race condition: video loaded with
  local_file_url already set but flag was still false, requiring a page refresh
- local_file_url from the backend is the single source of truth (backend gates
  it with os.path.exists so it only appears when the file is actually on disk)
- Show spinner while video metadata loads, then immediately show local player
  if file exists — no YouTube flash for already-downloaded videos
- After download completes, single refetchVideo() picks up the new URL and
  React re-renders directly into local player

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mattias Tall
2026-05-26 16:30:29 +02:00
parent c00d5c7595
commit fc05a40f02
2 changed files with 45 additions and 28 deletions

View File

@@ -253,7 +253,8 @@ def home_feed(
v.channel_id,
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum,
AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct
FROM videos v
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
GROUP BY v.channel_id
@@ -271,16 +272,19 @@ def home_feed(
COALESCE(uv.queued, 0) AS queued,
uv.rating AS rating,
NULL AS file_path,
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 6.0
+ COALESCE(cs.liked_count, 0) * 12.0
+ COALESCE(cs.rating_sum, 0) * 8.0) * :w_channel
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
+ COALESCE(cs.liked_count, 0) * 10.0
+ COALESCE(cs.rating_sum, 0) * 8.0
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08) * :w_channel
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
+ COALESCE((
SELECT uta.score FROM user_tag_affinity uta
SELECT COALESCE(SUM(uta.score), 0)
FROM user_tag_affinity uta
WHERE uta.user_id = :user_id
AND uta.tag = LOWER(COALESCE(v.category, ''))
LIMIT 1
), 0) * 3.0 * :w_affinity
AND (uta.tag = LOWER(COALESCE(v.category, ''))
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
LIMIT 5
), 0) * :w_affinity
AS score,
ROW_NUMBER() OVER (
PARTITION BY v.channel_id
@@ -898,6 +902,19 @@ def update_progress(
if pct < 0.20:
_update_affinity(db, current_user.id, video, -0.5)
# Backend safety net: auto-mark watched at ≥90% completion even if the frontend
# didn't send watched=True (e.g. browser closed before debounce fired)
if (not prev_watched and not uv.watched
and uv.completion_percent is not None and uv.completion_percent >= 90
and video.duration_seconds and video.duration_seconds > 60):
uv.watched = True
_update_affinity(db, current_user.id, video, +2.0)
dl = db.query(Download).filter_by(
user_id=current_user.id, video_id=video_id, status="complete"
).filter(Download.pending_delete_at.is_(None)).first()
if dl:
dl.pending_delete_at = datetime.utcnow() + timedelta(days=7)
db.commit()
return {"ok": True}