fix: feedback round 2 — language, subscriptions, feed scope, UX

- Language detection: classify one cleaned+concatenated blob (strip emoji,
  @mentions, #tags, numbers, punctuation); fixes caps/emoji-heavy channels
  (e.g. Nessaj -> Hungarian, no more bogus Chinese/Korean)
- Feed now joins the user's subscriptions, so unsubscribing on YouTube removes a
  channel from the feed; periodic subscription re-sync job picks up changes
- Watched/Saved/Hidden views ignore the Shorts/live default-hiding so the full
  set is visible (fixes hidden videos missing from the Hidden view)
- Persist feed filters + search across reloads (localStorage)
- 3D polish: cards lift with shadow on hover; chips/buttons get depth and a
  press effect; undo toast lasts longer
This commit is contained in:
npeter83 2026-06-11 03:28:45 +02:00
parent 8c245e986f
commit e07a37622d
9 changed files with 97 additions and 27 deletions

View file

@ -55,6 +55,7 @@ class Settings(BaseSettings):
# Number of recent video titles sampled per channel for language detection. # Number of recent video titles sampled per channel for language detection.
autotag_title_sample: int = 40 autotag_title_sample: int = 40
autotag_interval_minutes: int = 30 autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360
# live_status values hidden from the feed by default. Completed-stream VODs # live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible. # ("was_live") are real watchable content and stay visible.
feed_default_hidden_live: str = "live,upcoming" feed_default_hidden_live: str = "live,upcoming"

View file

@ -6,7 +6,7 @@ from sqlalchemy.orm import Session, aliased
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import Channel, ChannelTag, Tag, User, Video, VideoState from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
router = APIRouter(prefix="/api", tags=["feed"]) router = APIRouter(prefix="/api", tags=["feed"])
@ -78,14 +78,26 @@ def get_feed(
status_expr, status_expr,
) )
.join(Channel, Channel.id == Video.channel_id) .join(Channel, Channel.id == Video.channel_id)
# Only channels this user is subscribed to (and hasn't hidden).
.join(
Subscription,
and_(
Subscription.channel_id == Video.channel_id,
Subscription.user_id == user.id,
Subscription.hidden.is_(False),
),
)
.outerjoin( .outerjoin(
state, and_(state.video_id == Video.id, state.user_id == user.id) state, and_(state.video_id == Video.id, state.user_id == user.id)
) )
) )
if not include_shorts: # In the explicit Watched/Saved/Hidden views, show the complete set regardless of
# the Shorts / live default-hiding (so e.g. a hidden Short still shows up there).
explicit_view = show in ("watched", "saved", "hidden")
if not include_shorts and not explicit_view:
query = query.where(Video.is_short.is_(False)) query = query.where(Video.is_short.is_(False))
if not include_live: if not include_live and not explicit_view:
query = query.where(Video.live_status.notin_(HIDDEN_LIVE)) query = query.where(Video.live_status.notin_(HIDDEN_LIVE))
if channel_id: if channel_id:
query = query.where(Video.channel_id == channel_id) query = query.where(Video.channel_id == channel_id)

View file

@ -13,6 +13,7 @@ from app.sync.runner import (
run_recent_backfill, run_recent_backfill,
run_rss_poll, run_rss_poll,
run_shorts, run_shorts,
run_subscription_resync,
) )
logger = logging.getLogger("subfeed.scheduler") logger = logging.getLogger("subfeed.scheduler")
@ -58,6 +59,10 @@ def _shorts_job() -> None:
_job("shorts", run_shorts) _job("shorts", run_shorts)
def _subscriptions_job() -> None:
_job("subscriptions", run_subscription_resync)
def start_scheduler() -> None: def start_scheduler() -> None:
global _scheduler global _scheduler
if not settings.scheduler_enabled or _scheduler is not None: if not settings.scheduler_enabled or _scheduler is not None:
@ -87,6 +92,12 @@ def start_scheduler() -> None:
minutes=settings.shorts_probe_interval_minutes, minutes=settings.shorts_probe_interval_minutes,
id="shorts", id="shorts",
) )
scheduler.add_job(
_subscriptions_job,
"interval",
minutes=settings.subscriptions_resync_minutes,
id="subscriptions",
)
scheduler.start() scheduler.start()
_scheduler = scheduler _scheduler = scheduler
logger.info("scheduler started") logger.info("scheduler started")

View file

@ -5,7 +5,7 @@ detection over a sample of recent video titles. Topics are mapped from YouTube's
topicDetails categories and the channel's dominant video category. System tags are topicDetails categories and the channel's dominant video category. System tags are
regenerated freely; user tags are never touched here. regenerated freely; user tags are never touched here.
""" """
from collections import Counter import re
from sqlalchemy import and_, exists, func, select from sqlalchemy import and_, exists, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -145,9 +145,18 @@ def map_topic_slug(slug: str) -> str | None:
return _TOPIC_SLUGS.get(slug) return _TOPIC_SLUGS.get(slug)
def _clean_title(title: str | None) -> str:
"""Strip emojis, @mentions, #tags, URLs, numbers and punctuation so the language
detector sees actual words, not caps/emoji-heavy noise."""
text = title or ""
text = re.sub(r"http\S+", " ", text)
text = re.sub(r"[@#]\w+", " ", text)
text = re.sub(r"\d+", " ", text)
text = re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE).replace("_", " ")
return " ".join(w for w in text.split() if len(w) > 1)
def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None, float]: def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None, float]:
if channel.default_language:
return channel.default_language.split("-")[0].lower(), 0.99
titles = ( titles = (
db.execute( db.execute(
select(Video.title) select(Video.title)
@ -158,20 +167,16 @@ def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None,
.scalars() .scalars()
.all() .all()
) )
# Majority vote over individual titles is more robust than one concatenated blob # Detect over one cleaned, concatenated blob — more context and far less skew from
# (short/technical titles otherwise skew the detector). # short, emoji/caps-heavy titles than per-title voting.
votes: Counter[str] = Counter() blob = " ".join(_clean_title(t) for t in titles).strip()
for title in titles: if len(blob) >= 15:
cleaned = (title or "").strip() lang, confidence = _classify(blob)
if len(cleaned) < 8: return lang, float(confidence)
continue # Sparse text: fall back to the channel's declared language if any.
lang, _conf = _classify(cleaned) if channel.default_language:
votes[lang] += 1 return channel.default_language.split("-")[0].lower(), 0.6
if not votes: return None, 0.0
return None, 0.0
lang, count = votes.most_common(1)[0]
total = sum(votes.values())
return lang, count / total
def compute_channel_topics(db: Session, channel: Channel) -> set[str]: def compute_channel_topics(db: Session, channel: Channel) -> set[str]:

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app import quota from app import quota
from app.config import settings from app.config import settings
from app.models import Channel, OAuthToken, User from app.models import Channel, OAuthToken, User
from app.sync.subscriptions import import_subscriptions
from app.sync.videos import ( from app.sync.videos import (
backfill_channel_deep, backfill_channel_deep,
backfill_channel_recent, backfill_channel_recent,
@ -65,6 +66,26 @@ def run_shorts(db: Session) -> dict:
return run_shorts_classification(db) return run_shorts_classification(db)
def run_subscription_resync(db: Session) -> dict:
"""Re-import every user's subscriptions so unsubscribes and new subscriptions on
YouTube are reflected automatically."""
users = (
db.execute(
select(User)
.join(OAuthToken)
.where(OAuthToken.refresh_token_enc.is_not(None))
)
.scalars()
.all()
)
for user in users:
try:
import_subscriptions(db, user)
except Exception:
db.rollback()
return {"users": len(users)}
def run_recent_backfill( def run_recent_backfill(
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
) -> dict: ) -> dict:

View file

@ -24,11 +24,26 @@ const DEFAULT_FILTERS: FeedFilters = {
show: "unwatched", show: "unwatched",
}; };
const FILTERS_KEY = "subfeed.filters";
function loadFilters(): FeedFilters {
try {
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
} catch {
return DEFAULT_FILTERS;
}
}
export default function App() { export default function App() {
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme()); const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFilters] = useState<FeedFilters>(DEFAULT_FILTERS); const [filters, setFiltersState] = useState<FeedFilters>(loadFilters);
const [view, setView] = useState<"grid" | "list">("grid"); const [view, setView] = useState<"grid" | "list">("grid");
function setFilters(next: FeedFilters) {
setFiltersState(next);
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
}
useEffect(() => applyTheme(theme), [theme]); useEffect(() => applyTheme(theme), [theme]);
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });

View file

@ -31,7 +31,7 @@ function TagChip({
<button <button
onClick={onClick} onClick={onClick}
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`} title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
className={`text-xs px-2.5 py-1 rounded-full border transition ${ className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
active active
? "bg-accent text-accent-fg border-accent" ? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-fg hover:border-accent" : "bg-card border-border text-fg hover:border-accent"
@ -77,7 +77,7 @@ export default function Sidebar({
<button <button
key={s.id} key={s.id}
onClick={() => setFilters({ ...filters, show: s.id })} onClick={() => setFilters({ ...filters, show: s.id })}
className={`text-xs py-1.5 rounded-lg border transition ${ className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
filters.show === s.id filters.show === s.id
? "bg-accent text-accent-fg border-accent" ? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent" : "bg-card border-border hover:border-accent"

View file

@ -56,7 +56,7 @@ function Thumb({ video, className }: { video: Video; className?: string }) {
rel="noreferrer" rel="noreferrer"
onClick={() => video.status === "new" && undefined} onClick={() => video.status === "new" && undefined}
className={clsx( className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border", "block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
className className
)} )}
> >
@ -120,7 +120,7 @@ export default function VideoCard({
return ( return (
<div <div
className={clsx( className={clsx(
"group flex gap-3 p-2 rounded-xl hover:bg-card transition", "group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
watched && "opacity-55" watched && "opacity-55"
)} )}
> >
@ -144,7 +144,12 @@ export default function VideoCard({
} }
return ( return (
<div className={clsx("group", watched && "opacity-55")}> <div
className={clsx(
"group transition-transform duration-150 hover:-translate-y-1",
watched && "opacity-55"
)}
>
<Thumb video={video} className="aspect-video" /> <Thumb video={video} className="aspect-video" />
<div className="flex gap-3 mt-2"> <div className="flex gap-3 mt-2">
{video.channel_thumbnail && ( {video.channel_thumbnail && (

View file

@ -20,7 +20,7 @@ export function toast(message: string, action?: ToastAction): number {
const id = counter++; const id = counter++;
items = [...items, { id, message, action }]; items = [...items, { id, message, action }];
emit(); emit();
setTimeout(() => dismiss(id), 6000); setTimeout(() => dismiss(id), 9000);
return id; return id;
} }