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.
autotag_title_sample: int = 40
autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360
# live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible.
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.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"])
@ -78,14 +78,26 @@ def get_feed(
status_expr,
)
.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(
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))
if not include_live:
if not include_live and not explicit_view:
query = query.where(Video.live_status.notin_(HIDDEN_LIVE))
if 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_rss_poll,
run_shorts,
run_subscription_resync,
)
logger = logging.getLogger("subfeed.scheduler")
@ -58,6 +59,10 @@ def _shorts_job() -> None:
_job("shorts", run_shorts)
def _subscriptions_job() -> None:
_job("subscriptions", run_subscription_resync)
def start_scheduler() -> None:
global _scheduler
if not settings.scheduler_enabled or _scheduler is not None:
@ -87,6 +92,12 @@ def start_scheduler() -> None:
minutes=settings.shorts_probe_interval_minutes,
id="shorts",
)
scheduler.add_job(
_subscriptions_job,
"interval",
minutes=settings.subscriptions_resync_minutes,
id="subscriptions",
)
scheduler.start()
_scheduler = scheduler
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
regenerated freely; user tags are never touched here.
"""
from collections import Counter
import re
from sqlalchemy import and_, exists, func, select
from sqlalchemy.orm import Session
@ -145,9 +145,18 @@ def map_topic_slug(slug: str) -> str | None:
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]:
if channel.default_language:
return channel.default_language.split("-")[0].lower(), 0.99
titles = (
db.execute(
select(Video.title)
@ -158,20 +167,16 @@ def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None,
.scalars()
.all()
)
# Majority vote over individual titles is more robust than one concatenated blob
# (short/technical titles otherwise skew the detector).
votes: Counter[str] = Counter()
for title in titles:
cleaned = (title or "").strip()
if len(cleaned) < 8:
continue
lang, _conf = _classify(cleaned)
votes[lang] += 1
if not votes:
return None, 0.0
lang, count = votes.most_common(1)[0]
total = sum(votes.values())
return lang, count / total
# Detect over one cleaned, concatenated blob — more context and far less skew from
# short, emoji/caps-heavy titles than per-title voting.
blob = " ".join(_clean_title(t) for t in titles).strip()
if len(blob) >= 15:
lang, confidence = _classify(blob)
return lang, float(confidence)
# Sparse text: fall back to the channel's declared language if any.
if channel.default_language:
return channel.default_language.split("-")[0].lower(), 0.6
return None, 0.0
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.config import settings
from app.models import Channel, OAuthToken, User
from app.sync.subscriptions import import_subscriptions
from app.sync.videos import (
backfill_channel_deep,
backfill_channel_recent,
@ -65,6 +66,26 @@ def run_shorts(db: Session) -> dict:
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(
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
) -> dict: