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

@@ -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} />