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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user