diff --git a/backend/routers/videos.py b/backend/routers/videos.py index 25dbe42..ee86c91 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -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} diff --git a/frontend/src/components/VideoPlayer.jsx b/frontend/src/components/VideoPlayer.jsx index c010d06..ae70df0 100644 --- a/frontend/src/components/VideoPlayer.jsx +++ b/frontend/src/components/VideoPlayer.jsx @@ -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 (
- {/* Player — local file once ready, YouTube embed while downloading */} - {localUrl ? ( + {/* Player — wait for metadata, then show local file or YouTube embed */} + {videoLoading ? ( +
+ + + + +
+ ) : localUrl ? ( ) : (