Auto-schedule daily discovery + fix Find More UX + expand query diversity
Auto-discovery daemon: - Runs every hour, triggers full discovery for any user whose last run was >23 hours ago. First check is 5 minutes after startup. - Tracks run time in user_settings.last_discovery_run (new column). - Manual Find More also stamps last_discovery_run. Discovery status endpoint (GET /api/discovery/status): - Returns pending_count (unseen queue size) and last_run timestamp. - Shown in the Discover page header so users know queue state at a glance. Find More UX fix: - Was: kick background task, wait 8 seconds, refetch (task takes minutes). - Now: button shows "Queued ✓" on success with an explanatory banner telling the user it takes a few minutes and also runs daily automatically. Query diversity: - Added "best [category] channels" serendipity queries to crawl_by_search. - Limit raised from 25 to 30 queries per run. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -131,6 +131,7 @@ def on_startup():
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)""",
|
)""",
|
||||||
"ALTER TABLE user_videos ADD COLUMN feed_shown_count INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE user_videos ADD COLUMN feed_shown_count INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE user_settings ADD COLUMN last_discovery_run DATETIME DEFAULT NULL",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
db.execute(text(col_sql))
|
db.execute(text(col_sql))
|
||||||
@@ -217,6 +218,48 @@ def on_startup():
|
|||||||
|
|
||||||
threading.Thread(target=_auto_sync_daemon, daemon=True).start()
|
threading.Thread(target=_auto_sync_daemon, daemon=True).start()
|
||||||
|
|
||||||
|
def _auto_discovery_daemon():
|
||||||
|
import time as _time
|
||||||
|
from datetime import datetime as _dt, timedelta as _td
|
||||||
|
from sqlalchemy import text as _text
|
||||||
|
from .services.discovery import run_full_discovery
|
||||||
|
|
||||||
|
# Wait 5 minutes after startup before the first check so the app can
|
||||||
|
# finish initialising and existing enrichment tasks can settle.
|
||||||
|
_time.sleep(300)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
rows = db.execute(_text("""
|
||||||
|
SELECT u.id AS user_id,
|
||||||
|
COALESCE(us.discovery_regions, 'US,SE') AS discovery_regions,
|
||||||
|
us.last_discovery_run
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_settings us ON u.id = us.user_id
|
||||||
|
""")).mappings().all()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
last = row["last_discovery_run"]
|
||||||
|
if last is None or (_dt.utcnow() - _dt.fromisoformat(str(last))) > _td(hours=23):
|
||||||
|
uid = row["user_id"]
|
||||||
|
regions = [r.strip().upper() for r in (row["discovery_regions"] or "US,SE").split(",") if r.strip()]
|
||||||
|
run_full_discovery(db, uid, regions)
|
||||||
|
db.execute(
|
||||||
|
_text("UPDATE user_settings SET last_discovery_run = :now WHERE user_id = :uid"),
|
||||||
|
{"now": _dt.utcnow(), "uid": uid},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_time.sleep(3600) # check every hour, run if >23 h since last run
|
||||||
|
|
||||||
|
threading.Thread(target=_auto_discovery_daemon, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
|
|||||||
@@ -170,10 +170,17 @@ def refresh_discovery(
|
|||||||
user_id = current_user.id
|
user_id = current_user.id
|
||||||
|
|
||||||
def _run_discovery():
|
def _run_discovery():
|
||||||
|
from datetime import datetime
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
|
from sqlalchemy import text as _text
|
||||||
fresh_db = SessionLocal()
|
fresh_db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
run_full_discovery(fresh_db, user_id, regions)
|
run_full_discovery(fresh_db, user_id, regions)
|
||||||
|
fresh_db.execute(
|
||||||
|
_text("UPDATE user_settings SET last_discovery_run = :now WHERE user_id = :uid"),
|
||||||
|
{"now": datetime.utcnow(), "uid": user_id},
|
||||||
|
)
|
||||||
|
fresh_db.commit()
|
||||||
finally:
|
finally:
|
||||||
fresh_db.close()
|
fresh_db.close()
|
||||||
|
|
||||||
@@ -251,6 +258,23 @@ def dismiss_discovery_video(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def discovery_status(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from ..models import UserSettings
|
||||||
|
s = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
||||||
|
pending = db.execute(
|
||||||
|
text("SELECT COUNT(*) AS n FROM discovery_queue WHERE user_id = :uid AND seen = 0"),
|
||||||
|
{"uid": current_user.id},
|
||||||
|
).mappings().first()
|
||||||
|
return {
|
||||||
|
"last_run": s.last_discovery_run.isoformat() if s and s.last_discovery_run else None,
|
||||||
|
"pending_count": pending["n"] if pending else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/community", response_model=list[dict])
|
@router.get("/community", response_model=list[dict])
|
||||||
def community_shelf(
|
def community_shelf(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -264,8 +264,12 @@ def crawl_by_search(db: Session, user_id: int):
|
|||||||
if followed_names:
|
if followed_names:
|
||||||
sampled_names = random.sample(followed_names, min(15, len(followed_names)))
|
sampled_names = random.sample(followed_names, min(15, len(followed_names)))
|
||||||
|
|
||||||
# Combine: tags (most signal) + channel names (broad reach) + categories (fallback)
|
# Serendipity queries: "best [category] channels" — surfaces curated list videos
|
||||||
queries = list(dict.fromkeys(top_tags + sampled_names + top_cats))[:25]
|
# which then get their channel indexed; broadens discovery beyond direct tag matches.
|
||||||
|
serendipity = [f"best {cat} channels" for cat in top_cats[:3]]
|
||||||
|
|
||||||
|
# Combine: tags (most signal) + channel names (broad reach) + serendipity + categories
|
||||||
|
queries = list(dict.fromkeys(top_tags + sampled_names + serendipity + top_cats))[:30]
|
||||||
if not queries:
|
if not queries:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export const followDiscovery = (channelId) =>
|
|||||||
export const dismissDiscovery = (channelId) =>
|
export const dismissDiscovery = (channelId) =>
|
||||||
api.post(`/discovery/${channelId}/dismiss`);
|
api.post(`/discovery/${channelId}/dismiss`);
|
||||||
export const refreshDiscovery = () => api.post("/discovery/refresh");
|
export const refreshDiscovery = () => api.post("/discovery/refresh");
|
||||||
|
export const getDiscoveryStatus = () => api.get("/discovery/status");
|
||||||
export const getCommunityShelf = () => api.get("/discovery/community");
|
export const getCommunityShelf = () => api.get("/discovery/community");
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
getDiscovery, getDiscoveryVideos,
|
getDiscovery, getDiscoveryVideos,
|
||||||
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
|
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
|
||||||
|
getDiscoveryStatus,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import VideoCard from "../components/VideoCard";
|
import VideoCard from "../components/VideoCard";
|
||||||
import { scrollToTop } from "../utils/scroll";
|
import { scrollToTop } from "../utils/scroll";
|
||||||
@@ -213,12 +214,20 @@ export default function DiscoveryPage() {
|
|||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: discStatus } = useQuery({
|
||||||
|
queryKey: ["discovery-status"],
|
||||||
|
queryFn: () => getDiscoveryStatus().then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const refreshMut = useMutation({
|
const refreshMut = useMutation({
|
||||||
mutationFn: refreshDiscovery,
|
mutationFn: refreshDiscovery,
|
||||||
onSuccess: () => setTimeout(() => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["discovery"] });
|
// Discovery runs as a background job and takes several minutes.
|
||||||
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
// Invalidate status immediately so the "queued" state shows, then
|
||||||
}, 8000),
|
// re-check every 2 minutes until results land.
|
||||||
|
qc.invalidateQueries({ queryKey: ["discovery-status"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDismissVideo = (video) => {
|
const handleDismissVideo = (video) => {
|
||||||
@@ -237,12 +246,24 @@ export default function DiscoveryPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
|
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
|
||||||
|
{discStatus && (
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{discStatus.pending_count > 0
|
||||||
|
? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued`
|
||||||
|
: "Queue empty"}
|
||||||
|
{discStatus.last_run
|
||||||
|
? ` · last refreshed ${new Date(discStatus.last_run + "Z").toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`
|
||||||
|
: " · never refreshed"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => refreshMut.mutate()}
|
onClick={() => refreshMut.mutate()}
|
||||||
disabled={refreshMut.isPending}
|
disabled={refreshMut.isPending || refreshMut.isSuccess}
|
||||||
className="flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
className="shrink-0 flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{refreshMut.isPending && (
|
{refreshMut.isPending && (
|
||||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
@@ -250,13 +271,13 @@ export default function DiscoveryPage() {
|
|||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{refreshMut.isPending ? "Searching…" : "Find more"}
|
{refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{refreshMut.isSuccess && !refreshMut.isPending && (
|
{refreshMut.isSuccess && (
|
||||||
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-300">
|
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-400">
|
||||||
Searching YouTube for new channels — results will appear in a few seconds.
|
Discovery is running in the background — it searches YouTube using your tags and interests and takes a few minutes. New channels will appear when it finishes. It also runs automatically every day.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user