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:
parent
8c245e986f
commit
e07a37622d
9 changed files with 97 additions and 27 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue