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,
|
v.channel_id,
|
||||||
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
||||||
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_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
|
FROM videos v
|
||||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||||
GROUP BY v.channel_id
|
GROUP BY v.channel_id
|
||||||
@@ -271,16 +272,19 @@ def home_feed(
|
|||||||
COALESCE(uv.queued, 0) AS queued,
|
COALESCE(uv.queued, 0) AS queued,
|
||||||
uv.rating AS rating,
|
uv.rating AS rating,
|
||||||
NULL AS file_path,
|
NULL AS file_path,
|
||||||
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 6.0
|
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
|
||||||
+ COALESCE(cs.liked_count, 0) * 12.0
|
+ COALESCE(cs.liked_count, 0) * 10.0
|
||||||
+ COALESCE(cs.rating_sum, 0) * 8.0) * :w_channel
|
+ 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
|
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
||||||
+ COALESCE((
|
+ 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
|
WHERE uta.user_id = :user_id
|
||||||
AND uta.tag = LOWER(COALESCE(v.category, ''))
|
AND (uta.tag = LOWER(COALESCE(v.category, ''))
|
||||||
LIMIT 1
|
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
|
||||||
), 0) * 3.0 * :w_affinity
|
LIMIT 5
|
||||||
|
), 0) * :w_affinity
|
||||||
AS score,
|
AS score,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY v.channel_id
|
PARTITION BY v.channel_id
|
||||||
@@ -898,6 +902,19 @@ def update_progress(
|
|||||||
if pct < 0.20:
|
if pct < 0.20:
|
||||||
_update_affinity(db, current_user.id, video, -0.5)
|
_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()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ export default function VideoPlayer() {
|
|||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [downloadId, setDownloadId] = useState(null);
|
const [downloadId, setDownloadId] = useState(null);
|
||||||
const [switchedToLocal, setSwitchedToLocal] = useState(false);
|
|
||||||
const saveTimerRef = useRef(null);
|
const saveTimerRef = useRef(null);
|
||||||
const initiatedRef = useRef(null); // track which video we triggered download for
|
const initiatedRef = useRef(null);
|
||||||
|
|
||||||
// ── Video metadata ────────────────────────────────────────────────────────
|
// ── Video metadata ────────────────────────────────────────────────────────
|
||||||
const { data: video, refetch: refetchVideo } = useQuery({
|
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(() => {
|
useEffect(() => {
|
||||||
if (dlStatus?.status === "complete" && !switchedToLocal) {
|
if (dlStatus?.status === "complete" && !video?.local_file_url) {
|
||||||
refetchVideo().then(({ data }) => {
|
refetchVideo();
|
||||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [dlStatus?.status, switchedToLocal, refetchVideo]);
|
}, [dlStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ── Trigger download on open ──────────────────────────────────────────────
|
// ── Trigger download on open ──────────────────────────────────────────────
|
||||||
const downloadMut = useMutation({
|
const downloadMut = useMutation({
|
||||||
@@ -134,22 +132,16 @@ export default function VideoPlayer() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const dl = res.data;
|
const dl = res.data;
|
||||||
setDownloadId(dl.id);
|
setDownloadId(dl.id);
|
||||||
// If it came back complete already (was pre-downloaded), just switch now
|
// If already complete (pre-downloaded), refetch to get local_file_url
|
||||||
if (dl.status === "complete") {
|
if (dl.status === "complete") refetchVideo();
|
||||||
refetchVideo().then(({ data }) => {
|
|
||||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
||||||
initiatedRef.current = youtubeId;
|
initiatedRef.current = youtubeId;
|
||||||
setSwitchedToLocal(false);
|
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setDownloadId(null);
|
setDownloadId(null);
|
||||||
// Small delay so the modal renders before the fetch starts
|
|
||||||
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -174,7 +166,6 @@ export default function VideoPlayer() {
|
|||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
||||||
setSwitchedToLocal(false);
|
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
}, [setParams]);
|
}, [setParams]);
|
||||||
|
|
||||||
@@ -192,7 +183,9 @@ export default function VideoPlayer() {
|
|||||||
const channelName = video?.channel_name ?? urlChannel;
|
const channelName = video?.channel_name ?? urlChannel;
|
||||||
const startAt = video?.watch_progress_seconds ?? 0;
|
const startAt = video?.watch_progress_seconds ?? 0;
|
||||||
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -216,8 +209,15 @@ export default function VideoPlayer() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Player — local file once ready, YouTube embed while downloading */}
|
{/* Player — wait for metadata, then show local file or YouTube embed */}
|
||||||
{localUrl ? (
|
{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} />
|
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
|
||||||
) : (
|
) : (
|
||||||
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
||||||
|
|||||||
Reference in New Issue
Block a user