From e07a37622d405fe4cc0a584ee3cdcc5e47ec139e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 03:28:45 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20feedback=20round=202=20=E2=80=94=20langu?= =?UTF-8?q?age,=20subscriptions,=20feed=20scope,=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/config.py | 1 + backend/app/routes/feed.py | 18 ++++++++++--- backend/app/scheduler.py | 11 ++++++++ backend/app/sync/autotag.py | 39 +++++++++++++++------------ backend/app/sync/runner.py | 21 +++++++++++++++ frontend/src/App.tsx | 17 +++++++++++- frontend/src/components/Sidebar.tsx | 4 +-- frontend/src/components/VideoCard.tsx | 11 +++++--- frontend/src/lib/toast.ts | 2 +- 9 files changed, 97 insertions(+), 27 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index a0bc1cf..f496a10 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 761e2b8..f7c6f1e 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index cc3ef1a..b9cc23a 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -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") diff --git a/backend/app/sync/autotag.py b/backend/app/sync/autotag.py index c1a44f7..f6023a7 100644 --- a/backend/app/sync/autotag.py +++ b/backend/app/sync/autotag.py @@ -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]: diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index 03bf8d3..c20de95 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d8fef7..4c375dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,11 +24,26 @@ const DEFAULT_FILTERS: FeedFilters = { 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() { const [theme, setThemeState] = useState(() => loadLocalTheme()); - const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [filters, setFiltersState] = useState(loadFilters); const [view, setView] = useState<"grid" | "list">("grid"); + function setFilters(next: FeedFilters) { + setFiltersState(next); + localStorage.setItem(FILTERS_KEY, JSON.stringify(next)); + } + useEffect(() => applyTheme(theme), [theme]); const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4355995..5ec01e8 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -31,7 +31,7 @@ function TagChip({