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}

View File

@@ -93,9 +93,8 @@ export default function VideoPlayer() {
const [currentTime, setCurrentTime] = useState(0);
const [downloadId, setDownloadId] = useState(null);
const [switchedToLocal, setSwitchedToLocal] = useState(false);
const saveTimerRef = useRef(null);
const initiatedRef = useRef(null); // track which video we triggered download for
const initiatedRef = useRef(null);
// ── Video metadata ────────────────────────────────────────────────────────
const { data: video, refetch: refetchVideo } = useQuery({
@@ -119,14 +118,13 @@ export default function VideoPlayer() {
},
});
// When download finishes, re-fetch video to get local_file_url and auto-switch
// When download finishes, refetch video local_file_url will appear once the
// file exists on disk, which is the single source of truth for switching players
useEffect(() => {
if (dlStatus?.status === "complete" && !switchedToLocal) {
refetchVideo().then(({ data }) => {
if (data?.local_file_url) setSwitchedToLocal(true);
});
if (dlStatus?.status === "complete" && !video?.local_file_url) {
refetchVideo();
}
}, [dlStatus?.status, switchedToLocal, refetchVideo]);
}, [dlStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Trigger download on open ──────────────────────────────────────────────
const downloadMut = useMutation({
@@ -134,22 +132,16 @@ export default function VideoPlayer() {
onSuccess: (res) => {
const dl = res.data;
setDownloadId(dl.id);
// If it came back complete already (was pre-downloaded), just switch now
if (dl.status === "complete") {
refetchVideo().then(({ data }) => {
if (data?.local_file_url) setSwitchedToLocal(true);
});
}
// If already complete (pre-downloaded), refetch to get local_file_url
if (dl.status === "complete") refetchVideo();
},
});
useEffect(() => {
if (!youtubeId || initiatedRef.current === youtubeId) return;
initiatedRef.current = youtubeId;
setSwitchedToLocal(false);
setCurrentTime(0);
setDownloadId(null);
// Small delay so the modal renders before the fetch starts
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
return () => clearTimeout(t);
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -174,7 +166,6 @@ export default function VideoPlayer() {
const close = useCallback(() => {
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
setSwitchedToLocal(false);
clearTimeout(saveTimerRef.current);
}, [setParams]);
@@ -192,7 +183,9 @@ export default function VideoPlayer() {
const channelName = video?.channel_name ?? urlChannel;
const startAt = video?.watch_progress_seconds ?? 0;
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
const localUrl = switchedToLocal ? video?.local_file_url : null;
// local_file_url is only set by the backend when the file actually exists on disk
const localUrl = video?.local_file_url ?? null;
const videoLoading = !video;
return (
<div
@@ -216,8 +209,15 @@ export default function VideoPlayer() {
</button>
</div>
{/* Player — local file once ready, YouTube embed while downloading */}
{localUrl ? (
{/* Player — wait for metadata, then show local file or YouTube embed */}
{videoLoading ? (
<div className="w-full aspect-video rounded-lg bg-zinc-900 flex items-center justify-center">
<svg className="w-8 h-8 animate-spin text-zinc-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
</div>
) : localUrl ? (
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
) : (
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />