Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-19 04:02:45 +02:00
commit 022bab3518
41 changed files with 860 additions and 105 deletions

View file

@ -1 +1 @@
0.10.0 0.11.0

View file

@ -0,0 +1,28 @@
"""cache each user's own YouTube channel id
Revision ID: 0019_user_yt_channel_id
Revises: 0018_maintenance_batch_setting
Create Date: 2026-06-19
Adds users.yt_channel_id: the user's own YouTube channel id, resolved lazily from
channels.list?mine=true and cached. Used to exclude the user's own channel from the
playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet.
Purely additive.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0019_user_yt_channel_id"
down_revision: Union[str, None] = "0018_maintenance_batch_setting"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("yt_channel_id", sa.String(length=32), nullable=True))
def downgrade() -> None:
op.drop_column("users", "yt_channel_id")

View file

@ -28,6 +28,9 @@ class User(Base):
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024)) avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
# cached. Used to exclude their own channel from playlist discovery. NULL = unresolved.
yt_channel_id: Mapped[str | None] = mapped_column(String(32))
# The single shared "demo" account: signed into without Google OAuth (via a whitelisted # The single shared "demo" account: signed into without Google OAuth (via a whitelisted
# email on the login page), has no OAuth token / YouTube scope, and its per-user state is # email on the login page), has no OAuth token / YouTube scope, and its per-user state is
# shared by everyone who enters this way. Exactly one such row is expected. # shared by everyone who enters this way. Exactly one such row is expected.

View file

@ -1,6 +1,9 @@
"""Channel manager: the user's subscriptions with their personal overrides (priority, """Channel manager: the user's subscriptions with their personal overrides (priority,
hidden, tags) and per-channel sync state. Read + local-write only YouTube-side writes hidden, tags) and per-channel sync state. Read + local-write only YouTube-side writes
(unsubscribe) live behind the optional write scope and are added in a later phase.""" (unsubscribe) live behind the optional write scope and are added in a later phase."""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -8,13 +11,25 @@ from sqlalchemy.orm import Session
from app import quota from app import quota
from app.auth import current_user, has_write_scope from app.auth import current_user, has_write_scope
from app.db import get_db from app.db import get_db
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video from app.models import (
Channel,
ChannelTag,
Playlist,
PlaylistItem,
Subscription,
Tag,
User,
Video,
)
from app.routes.admin import admin_user from app.routes.admin import admin_user
from app.sync.runner import run_recent_backfill from app.sync.runner import run_recent_backfill
from app.sync.subscriptions import apply_channel_details
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/channels", tags=["channels"]) router = APIRouter(prefix="/api/channels", tags=["channels"])
log = logging.getLogger("subfeed.api")
@router.get("") @router.get("")
def list_channels( def list_channels(
@ -107,6 +122,89 @@ def list_channels(
] ]
# Cap how many stub channels we enrich per discovery call, to bound first-load latency and
# quota (channels.list = 1 unit / 50). Anything beyond is enriched on subsequent loads.
DISCOVERY_ENRICH_CAP = 200
def _discovery_rows(db: Session, user: User) -> list:
"""The user's playlist channels they don't subscribe to (and that aren't their own),
each with how many playlist videos come from it and across how many playlists."""
subscribed = select(Subscription.channel_id).where(Subscription.user_id == user.id)
q = (
select(
Channel,
func.count(func.distinct(PlaylistItem.video_id)),
func.count(func.distinct(Playlist.id)),
)
.select_from(PlaylistItem)
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
.join(Video, Video.id == PlaylistItem.video_id)
.join(Channel, Channel.id == Video.channel_id)
.where(Playlist.user_id == user.id, Channel.id.not_in(subscribed))
)
if user.yt_channel_id:
q = q.where(Channel.id != user.yt_channel_id)
return db.execute(
q.group_by(Channel.id).order_by(
func.count(func.distinct(PlaylistItem.video_id)).desc(),
func.lower(Channel.title),
)
).all()
@router.get("/discovery")
def discover_channels(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
"""Channels that appear in the user's playlists but that they don't subscribe to (their
own channel excluded). The base list is a local join over the shared catalog. We also
enrich each channel's metadata up front (title/thumbnail/subscriber count) so the user
can judge a channel BEFORE subscribing but we do NOT pull its videos. Most-present
channels come first; subscribing is a separate action."""
# Resolve & cache the user's own channel id so it can be excluded — you don't subscribe
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it.
if user.yt_channel_id is None and user.token is not None and not user.is_demo:
try:
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
own = yt.get_my_channel_id()
if own:
user.yt_channel_id = own
db.commit()
except YouTubeError as exc:
log.warning("discovery: own-channel resolve failed (user %s): %s", user.id, exc)
rows = _discovery_rows(db, user)
# Enrich stub channels' metadata (uses the API key — no write scope needed). Re-read
# the rows afterwards so the response carries the fresh subscriber counts/thumbnails.
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
if need:
try:
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
apply_channel_details(db, yt.get_channels(need))
db.commit()
rows = _discovery_rows(db, user)
except YouTubeError as exc:
log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc)
db.rollback()
return [
{
"id": ch.id,
"title": ch.title,
"handle": ch.handle,
"thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count,
"video_count": ch.video_count,
"playlist_video_count": int(vid_count),
"playlist_count": int(pl_count),
"details_synced": ch.details_synced_at is not None,
}
for ch, vid_count, pl_count in rows
]
def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription: def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
sub = db.execute( sub = db.execute(
select(Subscription).where( select(Subscription).where(
@ -181,6 +279,53 @@ def reset_backfill(
return {"id": channel_id, "reset": True} return {"id": channel_id, "reset": True}
@router.post("/{channel_id}/subscribe")
def subscribe(
channel_id: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Subscribe to a channel discovered from the user's playlists. Gated behind the
optional write scope (YouTube subscriptions.insert). Creates the local subscription
with YouTube's returned resource id and enriches the channel's metadata if it's still
a stub, so the new row renders properly in the manager. It does NOT pull the channel's
videos the scheduler picks those up on its next run, like any subscribed channel."""
if not has_write_scope(user):
raise HTTPException(
status_code=403,
detail="Enable playlist editing in Settings to subscribe on YouTube.",
)
channel = db.get(Channel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Unknown channel")
existing = db.execute(
select(Subscription).where(
Subscription.user_id == user.id, Subscription.channel_id == channel_id
)
).scalar_one_or_none()
if existing is not None:
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
try:
with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt:
yt_sub_id = yt.insert_subscription(channel_id)
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up
# properly right away; no video pull here.
if channel.details_synced_at is None:
apply_channel_details(db, yt.get_channels([channel_id]))
except YouTubeError as exc:
raise HTTPException(status_code=502, detail=f"YouTube subscribe failed: {exc}")
db.add(
Subscription(
user_id=user.id,
channel_id=channel_id,
yt_subscription_id=yt_sub_id or None,
subscribed_at=datetime.now(timezone.utc),
)
)
db.commit()
return {"subscribed": channel_id}
@router.delete("/{channel_id}/subscription") @router.delete("/{channel_id}/subscription")
def unsubscribe( def unsubscribe(
channel_id: str, channel_id: str,

View file

@ -13,6 +13,7 @@ from app.sync.runner import (
run_rss_poll, run_rss_poll,
) )
from app.sync.subscriptions import import_subscriptions from app.sync.subscriptions import import_subscriptions
from app.scheduler import running_job_ids
router = APIRouter(prefix="/api/sync", tags=["sync"]) router = APIRouter(prefix="/api/sync", tags=["sync"])
@ -170,6 +171,9 @@ def my_status(
"quota_used_today": quota.units_used_today(db), "quota_used_today": quota.units_used_today(db),
"quota_remaining_today": quota.remaining_today(db), "quota_remaining_today": quota.remaining_today(db),
"paused": state.is_sync_paused(db), "paused": state.is_sync_paused(db),
# True only while a job that advances this view (channel sync) is actually running,
# so the header shows live activity rather than a perpetual spinner over pending work.
"sync_active": bool({"backfill", "rss_poll"} & running_job_ids()),
} }

View file

@ -272,6 +272,13 @@ def _is_running(job_id: str) -> bool:
return bool(_activity.get(job_id, {}).get("running")) return bool(_activity.get(job_id, {}).get("running"))
def running_job_ids() -> set[str]:
"""Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the
header's sync indicator — reflect real activity instead of just pending-work counts."""
with _activity_lock:
return {jid for jid, a in _activity.items() if a.get("running")}
def trigger_job(job_id: str, actor_id: int | None = None) -> str: def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job immediately in a background thread, independent of its interval schedule. """Run a job immediately in a background thread, independent of its interval schedule.
The wrapper handles its own DB session, activity tracking and pause-skip, so the live The wrapper handles its own DB session, activity tracking and pause-skip, so the live

View file

@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
log = logging.getLogger("subfeed.autotag") log = logging.getLogger("subfeed.autotag")
from app import progress
from app.config import settings from app.config import settings
from app.models import Channel, ChannelTag, Tag, Video from app.models import Channel, ChannelTag, Tag, Video
@ -270,13 +271,14 @@ def run_autotag_all(db: Session, only_missing: bool = False) -> dict:
) )
channels = db.execute(query).scalars().all() channels = db.execute(query).scalars().all()
tagged = 0 tagged = 0
for channel in channels: for i, channel in enumerate(channels, 1):
try: try:
apply_channel_autotags(db, channel) apply_channel_autotags(db, channel)
tagged += 1 tagged += 1
except Exception: except Exception:
db.rollback() db.rollback()
log.exception("Auto-tagging failed for channel %s", channel.id) log.exception("Auto-tagging failed for channel %s", channel.id)
progress.report(i, len(channels), "autotag")
removed = _cleanup_orphan_system_tags(db) removed = _cleanup_orphan_system_tags(db)
return {"channels_tagged": tagged, "orphan_tags_removed": removed} return {"channels_tagged": tagged, "orphan_tags_removed": removed}

View file

@ -12,7 +12,7 @@ from datetime import datetime, timezone
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app import progress, quota
from app.auth import has_read_scope, has_write_scope from app.auth import has_read_scope, has_write_scope
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
@ -382,8 +382,9 @@ def sync_all_playlists(db: Session) -> dict:
.all() .all()
) )
total = 0 total = 0
for user in users: for i, user in enumerate(users, 1):
if not has_read_scope(user): if not has_read_scope(user):
progress.report(i, len(users), "playlist_sync")
continue continue
try: try:
with quota.attribute(user.id, "playlist_sync"): with quota.attribute(user.id, "playlist_sync"):
@ -394,4 +395,5 @@ def sync_all_playlists(db: Session) -> dict:
except Exception: except Exception:
db.rollback() db.rollback()
log.exception("Playlist sync crashed for user %s", user.id) log.exception("Playlist sync crashed for user %s", user.id)
progress.report(i, len(users), "playlist_sync")
return {"users": len(users), "playlists_synced": total} return {"users": len(users), "playlists_synced": total}

View file

@ -5,6 +5,7 @@ user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configu
public reads. public reads.
""" """
import logging import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -16,15 +17,19 @@ log = logging.getLogger("subfeed.sync")
from app.models import Channel, OAuthToken, Subscription, User from app.models import Channel, OAuthToken, Subscription, User
from app.sync.subscriptions import import_subscriptions from app.sync.subscriptions import import_subscriptions
from app.sync.videos import ( from app.sync.videos import (
apply_rss_feed,
backfill_channel_deep, backfill_channel_deep,
backfill_channel_recent, backfill_channel_recent,
enrich_pending, enrich_pending,
poll_rss_channel,
reconcile_full_history, reconcile_full_history,
refresh_live, refresh_live,
run_shorts_classification, run_shorts_classification,
) )
from app.youtube.client import YouTubeClient from app.youtube.client import YouTubeClient
from app.youtube.rss import fetch_channel_feed
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
RSS_POLL_WORKERS = 16
def get_service_user(db: Session) -> User | None: def get_service_user(db: Session) -> User | None:
@ -43,13 +48,30 @@ def get_service_user(db: Session) -> User | None:
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int: def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
if channels is None: if channels is None:
channels = db.execute(select(Channel)).scalars().all() channels = db.execute(select(Channel)).scalars().all()
total = len(channels)
new = 0 new = 0
for channel in channels: done = 0
try: # Fetch feeds concurrently (network-bound, no DB), then apply inserts on this thread as
new += poll_rss_channel(db, channel) # each fetch returns — a SQLAlchemy session isn't thread-safe. Pass the channel id (a
except Exception: # plain str) to the workers; the ORM instance is touched only here on the main thread.
db.rollback() futures = {}
log.exception("RSS poll failed for channel %s", channel.id) with ThreadPoolExecutor(max_workers=RSS_POLL_WORKERS) as pool:
for channel in channels:
futures[pool.submit(fetch_channel_feed, channel.id)] = channel
for fut in as_completed(futures):
channel = futures[fut]
try:
entries = fut.result()
except Exception:
log.exception("RSS fetch failed for channel %s", channel.id)
entries = []
try:
new += apply_rss_feed(db, channel, entries)
except Exception:
db.rollback()
log.exception("RSS apply failed for channel %s", channel.id)
done += 1
progress.report(done, total, "rss_poll")
return new return new
@ -90,12 +112,13 @@ def run_subscription_resync(db: Session) -> dict:
.scalars() .scalars()
.all() .all()
) )
for user in users: for i, user in enumerate(users, 1):
try: try:
import_subscriptions(db, user) import_subscriptions(db, user)
except Exception: except Exception:
db.rollback() db.rollback()
log.exception("Subscription resync failed for user %s", user.id) log.exception("Subscription resync failed for user %s", user.id)
progress.report(i, len(users), "subscriptions")
return {"users": len(users)} return {"users": len(users)}

View file

@ -67,8 +67,10 @@ def _insert_stubs(db: Session, rows: list[dict]) -> int:
# --- RSS (free) --- # --- RSS (free) ---
def poll_rss_channel(db: Session, channel: Channel) -> int: def apply_rss_feed(db: Session, channel: Channel, entries: list[dict]) -> int:
entries = fetch_channel_feed(channel.id) """Insert new video stubs from an already-fetched feed and stamp the channel's last RSS
time. DB-only must run on the session's own thread; the network fetch
(fetch_channel_feed) is kept separate so callers like run_rss_poll can parallelise it."""
inserted = _insert_stubs(db, entries) inserted = _insert_stubs(db, entries)
channel.last_rss_at = _now() channel.last_rss_at = _now()
db.add(channel) db.add(channel)
@ -76,6 +78,10 @@ def poll_rss_channel(db: Session, channel: Channel) -> int:
return inserted return inserted
def poll_rss_channel(db: Session, channel: Channel) -> int:
return apply_rss_feed(db, channel, fetch_channel_feed(channel.id))
# --- Backfill (uploads playlist; costs quota) --- # --- Backfill (uploads playlist; costs quota) ---
def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None: def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
content = item.get("contentDetails", {}) content = item.get("contentDetails", {})

View file

@ -168,6 +168,15 @@ class YouTubeClient:
if not page_token: if not page_token:
break break
def get_my_channel_id(self) -> str | None:
"""The authenticated user's own channel id (channels.list?mine=true). OAuth only;
1 quota unit. Returns None if the account has no channel."""
data = self._get(
"channels", {"part": "id", "mine": "true", "maxResults": 1}, allow_key=False
)
items = data.get("items", [])
return items[0].get("id") if items else None
def get_channels(self, channel_ids: list[str]) -> list[dict]: def get_channels(self, channel_ids: list[str]) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
for batch in _chunks(channel_ids, 50): for batch in _chunks(channel_ids, 50):
@ -213,6 +222,23 @@ class YouTubeClient:
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}"
) )
def insert_subscription(self, channel_id: str) -> str:
"""Subscribe to a channel on YouTube. Requires the write scope and the user's
OAuth token (never the public API key). subscriptions.insert costs 50 quota
units. Returns the new subscription resource id store it as
`yt_subscription_id` so the channel can later be unsubscribed."""
data = self._write(
"POST",
"subscriptions",
params={"part": "snippet"},
json={
"snippet": {
"resourceId": {"kind": "youtube#channel", "channelId": channel_id}
}
},
)
return data.get("id", "")
def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]: def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]:
"""Yield {item_id, video_id} for each item of one of the user's playlists, in """Yield {item_id, video_id} for each item of one of the user's playlists, in
playlist order. `item_id` is the playlistItem resource id, needed to delete or playlist order. `item_id` is the playlistItem resource id, needed to delete or

View file

@ -26,7 +26,7 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; import Feed from "./components/Feed";
import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler"; import Scheduler from "./components/Scheduler";
@ -136,11 +136,27 @@ export default function App() {
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined); const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
// The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and
// persisted so navigation intents that target the subscriptions table — the header's
// "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open.
const CHANNELS_VIEW_KEY = "siftlode.channelsView";
const [channelsView, setChannelsViewState] = useState<ChannelsView>(() =>
localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed"
);
const setChannelsView = (v: ChannelsView) => {
setChannelsViewState(v);
localStorage.setItem(CHANNELS_VIEW_KEY, v);
};
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
// "Focus this channel in the manager": jump to the Channels page and seed its name filter // "Focus this channel in the manager": jump to the Channels page and seed its name filter
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null); const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const focusChannel = (name: string) => { const focusChannel = (name: string) => {
setFocusChannelName(name); setFocusChannelName(name);
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels"); setPage("channels");
}; };
useEffect(() => { useEffect(() => {
@ -409,6 +425,8 @@ export default function App() {
page={page} page={page}
onGoToFullHistory={() => { onGoToFullHistory={() => {
setChannelFilter("needs_full"); setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels"); setPage("channels");
}} }}
/> />
@ -433,6 +451,9 @@ export default function App() {
onFocusChannel={focusChannel} onFocusChannel={focusChannel}
statusFilter={channelFilter} statusFilter={channelFilter}
setStatusFilter={setChannelFilter} setStatusFilter={setChannelFilter}
view={channelsView}
setView={setChannelsView}
filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => { onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
@ -451,7 +472,7 @@ export default function App() {
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? ( ) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} /> <NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
) : page === "settings" ? ( ) : page === "settings" ? (
<SettingsPanel <SettingsPanel
me={meQuery.data!} me={meQuery.data!}

View file

@ -0,0 +1,157 @@
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus } from "lucide-react";
import { api, HttpError, type DiscoveredChannel } from "../lib/api";
import { formatViews } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink";
import DataTable, { type Column } from "./DataTable";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
export default function ChannelDiscovery({
canWrite,
onOpenWizard,
}: {
canWrite: boolean;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const query = useQuery({
queryKey: ["discovered-channels"],
queryFn: api.discoveredChannels,
});
const subscribe = useMutation({
mutationFn: (c: DiscoveredChannel) => api.subscribeChannel(c.id),
onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the
// Channel manager" / "open on YouTube" links — kept after reload, when live
// callbacks are gone (see ChannelSubscribedMeta).
notify({
level: "success",
title: t("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
});
},
onError: (err: unknown) => {
// A 403 means the user hasn't granted the write scope — offer to connect instead of
// a vague failure (mirrors the subscriptions tab).
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: t("channels.notify.needYouTube"),
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
});
} else {
notify({ level: "error", message: t("channels.discovery.subscribeFailed") });
}
},
});
const rows = query.data ?? [];
const columns: Column<DiscoveredChannel>[] = [
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
render: (c) => (
<ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} />
),
},
{
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span>
),
},
{
key: "inPlaylists",
header: t("channels.discovery.cols.inPlaylists"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.playlist_video_count,
render: (c) => (
<Tooltip
hint={t("channels.discovery.cols.inPlaylistsHint", {
videos: c.playlist_video_count,
playlists: c.playlist_count,
})}
>
<span className="text-muted tabular-nums cursor-help">
{c.playlist_video_count} / {c.playlist_count}
</span>
</Tooltip>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "right",
nowrap: true,
width: "120px",
cardLabel: false,
render: (c) => (
<Tooltip
hint={
canWrite
? t("channels.discovery.subscribeHint")
: t("channels.discovery.needWriteHint")
}
>
<button
onClick={() => subscribe.mutate(c)}
disabled={!canWrite || subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
</button>
</Tooltip>
),
},
];
return (
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
<p className="text-xs text-muted mb-4 leading-relaxed">
{t("channels.discovery.intro")}
</p>
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey="siftlode.channelDiscoveryTable"
controlsPosition="top"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
);
}

View file

@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import Avatar from "./Avatar";
import Tooltip from "./Tooltip";
import { channelYouTubeUrl } from "../lib/format";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
// is a button that opens the in-app channel view, with middle-click opening YouTube in a new
// tab; without it the name is plain text.
export default function ChannelLink({
id,
title,
handle,
thumbnailUrl,
onView,
}: {
id: string;
title: string | null;
handle?: string | null;
thumbnailUrl: string | null;
onView?: () => void;
}) {
const { t } = useTranslation();
const ytUrl = channelYouTubeUrl(id, handle);
const name = title ?? id;
return (
<div className="flex items-center gap-2 min-w-0">
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
{onView ? (
<button
onClick={onView}
onAuxClick={(e) => {
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
// opening the in-app channel detail.
if (e.button === 1) {
e.preventDefault();
window.open(ytUrl, "_blank", "noopener,noreferrer");
}
}}
onMouseDown={(e) => {
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
}}
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
>
{name}
</button>
) : (
<span className="text-sm font-medium truncate min-w-0">{name}</span>
)}
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
</div>
);
}

View file

@ -5,7 +5,6 @@ import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Check, Check,
ExternalLink,
Eye, Eye,
EyeOff, EyeOff,
History, History,
@ -18,11 +17,14 @@ import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta, formatViews, relativeTime } from "../lib/format"; import { formatEta, formatViews, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import ChannelLink from "./ChannelLink";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
export type ChannelsView = "subscribed" | "discovery";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge). // Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge).
@ -48,6 +50,9 @@ export default function Channels({
focusChannelName, focusChannelName,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
view,
setView,
filtersResetToken,
onOpenWizard, onOpenWizard,
}: { }: {
canWrite: boolean; canWrite: boolean;
@ -58,6 +63,12 @@ export default function Channels({
focusChannelName: string | null; focusChannelName: string | null;
statusFilter: ChannelStatusFilter; statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void; setStatusFilter: (f: ChannelStatusFilter) => void;
// The active tab lives in App so navigation intents that target the subscriptions table
// (the header's "without full history" link, focus-channel) can force it back here.
view: ChannelsView;
setView: (v: ChannelsView) => void;
// Bumped by the header's "without full history" intent to clear a stale column filter.
filtersResetToken: number;
onOpenWizard: () => void; onOpenWizard: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -228,7 +239,15 @@ export default function Channels({
sortValue: (c) => (c.title ?? c.id).toLowerCase(), sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true, cardPrimary: true,
render: (c) => <NameCell c={c} onView={() => onView(c)} />, render: (c) => (
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onView(c)}
/>
),
}, },
{ {
key: "stored", key: "stored",
@ -348,8 +367,39 @@ export default function Channels({
</div> </div>
); );
const tabs = (
<div className="px-4 pt-4 max-w-7xl mx-auto">
<div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery"] as ChannelsView[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`text-sm px-3 py-1.5 rounded-full border transition ${
view === v
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(`channels.tabs.${v}`)}
</button>
))}
</div>
</div>
);
if (view === "discovery") {
return (
<>
{tabs}
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} />
</>
);
}
return ( return (
<div className="p-4 max-w-7xl mx-auto"> <>
{tabs}
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering {/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
now lives in the table headers). */} now lives in the table headers). */}
<div className="flex items-start justify-between gap-4 flex-wrap mb-4"> <div className="flex items-start justify-between gap-4 flex-wrap mb-4">
@ -463,11 +513,13 @@ export default function Channels({
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null} externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
resetFiltersToken={filtersResetToken}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")} emptyText={t("channels.empty")}
/> />
)} )}
</div> </div>
</>
); );
} }
@ -521,45 +573,6 @@ function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta
); );
} }
function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) {
const { t } = useTranslation();
const ytUrl = c.handle
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
: `https://www.youtube.com/channel/${c.id}`;
return (
<div className="flex items-center gap-2 min-w-0">
<Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
<button
onClick={onView}
onAuxClick={(e) => {
// Middle-click opens the channel's YouTube page in a new tab; left-click
// keeps opening the in-app channel detail.
if (e.button === 1) {
e.preventDefault();
window.open(ytUrl, "_blank", "noopener,noreferrer");
}
}}
onMouseDown={(e) => {
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
}}
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
>
{c.title ?? c.id}
</button>
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
</div>
);
}
// Status only — the backfill *action* lives in the Actions column. When both recent and // Status only — the backfill *action* lives in the Actions column. When both recent and
// full history are in, collapse to a single "fully synced" chip; otherwise show what's // full history are in, collapse to a single "fully synced" chip; otherwise show what's

View file

@ -67,6 +67,7 @@ export default function DataTable<T>({
controlsPosition = "bottom", controlsPosition = "bottom",
controlsLeading, controlsLeading,
externalFilter, externalFilter,
resetFiltersToken,
}: { }: {
rows: T[]; rows: T[];
columns: Column<T>[]; columns: Column<T>[];
@ -81,6 +82,10 @@ export default function DataTable<T>({
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied // Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied
// whenever its value changes; the user can still clear it from the header afterwards. // whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null; externalFilter?: { key: string; value: string } | null;
// Bump this to clear all column filters — for navigation intents that mean "show me this
// set" (e.g. the header's "without full history" link), so a stale persisted filter can't
// hide the rows the user was just sent to see.
resetFiltersToken?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const initial = loadPersist(persistKey); const initial = loadPersist(persistKey);
@ -104,6 +109,18 @@ export default function DataTable<T>({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalFilter?.key, externalFilter?.value]); }, [externalFilter?.key, externalFilter?.value]);
// Clear column filters when the reset token advances. The ref starts at 0 (not at the prop)
// so a navigation that bumped the token still clears even when it remounts this table fresh
// (e.g. arriving from another page); a plain reload keeps the token at 0 and the effect is a
// no-op, so the user's persisted filters survive.
const resetRef = useRef(0);
useEffect(() => {
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
resetRef.current = resetFiltersToken;
setFilters({});
setPage(0);
}, [resetFiltersToken]);
useEffect(() => { useEffect(() => {
if (!openFilter) return; if (!openFilter) return;
function onDown(e: MouseEvent) { function onDown(e: MouseEvent) {

View file

@ -174,7 +174,13 @@ export default function Feed({
? i18n.t("feed.markedWatchedNamed", { title: v.title }) ? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"), : i18n.t("feed.markedWatched"),
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, meta: {
kind: "video-watched",
videoId: id,
title: v?.title ?? "this video",
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
}); });
} else if (status === "new") { } else if (status === "new") {
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve

View file

@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, CheckCheck, Eye, RotateCcw, Search, Trash2, X } from "lucide-react"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { import {
@ -14,9 +14,11 @@ import {
resolveVideo, resolveVideo,
subscribe, subscribe,
type Notification as ClientNotif, type Notification as ClientNotif,
type ChannelSubscribedMeta,
type VideoHiddenMeta, type VideoHiddenMeta,
type VideoWatchedMeta, type VideoWatchedMeta,
} from "../lib/notifications"; } from "../lib/notifications";
import { channelYouTubeUrl } from "../lib/format";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { LEVEL_STYLE } from "./Toaster"; import { LEVEL_STYLE } from "./Toaster";
@ -46,10 +48,12 @@ export default function NotificationsPanel({
filters, filters,
setFilters, setFilters,
setPage, setPage,
onFocusChannel,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
setPage: (p: Page) => void; setPage: (p: Page) => void;
onFocusChannel: (name: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
@ -91,11 +95,12 @@ export default function NotificationsPanel({
clearMut.mutate(); clearMut.mutate();
} }
// "Find in feed" / undo, ported from the old bell. // "Find in feed" / undo, ported from the old bell. Works for both hidden and watched
function locateHidden(meta: VideoHiddenMeta) { // notices — jump to the feed filtered to that state and the video's channel.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({ setFilters({
...filters, ...filters,
show: "hidden", show: meta.kind === "video-hidden" ? "hidden" : "watched",
channelId: meta.channelId || undefined, channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined, channelName: meta.channelName || undefined,
}); });
@ -165,6 +170,7 @@ export default function NotificationsPanel({
t={t} t={t}
onRead={() => readMut.mutate(n.id)} onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")}
/> />
))} ))}
</div> </div>
@ -181,8 +187,9 @@ export default function NotificationsPanel({
key={n.id} key={n.id}
n={n} n={n}
t={t} t={t}
onFind={locateHidden} onFind={locate}
onRevert={revertState} onRevert={revertState}
onFocusChannel={onFocusChannel}
onRemove={() => removeClient(n.id)} onRemove={() => removeClient(n.id)}
/> />
))} ))}
@ -200,11 +207,13 @@ function NotificationRow({
t, t,
onRead, onRead,
onDismiss, onDismiss,
onOpenScheduler,
}: { }: {
n: AppNotification; n: AppNotification;
t: (k: string, o?: any) => string; t: (k: string, o?: any) => string;
onRead: () => void; onRead: () => void;
onDismiss: () => void; onDismiss: () => void;
onOpenScheduler: () => void;
}) { }) {
// The maintenance producer ships the affected videos in `data.videos`; list a few titles. // The maintenance producer ships the affected videos in `data.videos`; list a few titles.
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
@ -257,6 +266,15 @@ function NotificationRow({
)} )}
</ul> </ul>
)} )}
{isScheduler && (
<button
onClick={onOpenScheduler}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
>
<Activity className="w-3.5 h-3.5" />
{t("inbox.openScheduler")}
</button>
)}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{!n.read && ( {!n.read && (
@ -287,18 +305,22 @@ function ClientActivityRow({
t, t,
onFind, onFind,
onRevert, onRevert,
onFocusChannel,
onRemove, onRemove,
}: { }: {
n: ClientNotif; n: ClientNotif;
t: (k: string, o?: any) => string; t: (k: string, o?: any) => string;
onFind: (meta: VideoHiddenMeta) => void; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { icon: Icon, color } = LEVEL_STYLE[n.level]; const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed; const needsAction = n.requiresInteraction && !n.dismissed;
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null; const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
const watched = n.meta?.kind === "video-watched" ? n.meta : null; const watched = n.meta?.kind === "video-watched" ? n.meta : null;
const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null;
return ( return (
<div <div
className={`glass rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`} className={`glass rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
@ -334,6 +356,13 @@ function ClientActivityRow({
</div> </div>
) : watched ? ( ) : watched ? (
<div className="flex items-center gap-3 mt-1"> <div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button <button
onClick={() => onRevert(watched)} onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline" className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
@ -342,6 +371,25 @@ function ClientActivityRow({
{t("notifications.unwatch")} {t("notifications.unwatch")}
</button> </button>
</div> </div>
) : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFocusChannel(subscribed.channelName)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Tv className="w-3.5 h-3.5" />
{t("notifications.openInManager")}
</button>
<a
href={channelYouTubeUrl(subscribed.channelId)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
{t("notifications.openOnYouTube")}
</a>
</div>
) : ( ) : (
n.action && n.action &&

View file

@ -7,7 +7,7 @@ import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, Sk
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
// Turn a description into clickable nodes: // Turn a description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player // - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
@ -572,7 +572,7 @@ export default function PlayerModal({
{navigated ? ( {navigated ? (
detail.data?.channel_id ? ( detail.data?.channel_id ? (
<a <a
href={`https://www.youtube.com/channel/${detail.data.channel_id}`} href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-medium hover:text-accent shrink-0" className="font-medium hover:text-accent shrink-0"

View file

@ -21,6 +21,8 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
const POLL_MS = 4000; const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
const FAST_POLL_MS = 1500;
// Seconds until an ISO instant (negative = past). // Seconds until an ISO instant (negative = past).
function secsUntil(iso: string | null): number | null { function secsUntil(iso: string | null): number | null {
@ -73,19 +75,25 @@ function StatusLegend() {
); );
} }
function JobProgress({ p }: { p: NonNullable<SchedulerJob["progress"]> }) { // Shown for ANY running job. With reported counts it's a determinate bar; for a job that
// runs but doesn't report progress (or hasn't yet) it falls back to a generic "working"
// label and an indeterminate sliver — so every active run is visible, not just the ones
// that report numbers.
function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
const { t } = useTranslation(); const { t } = useTranslation();
const phase = p.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : null; const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working");
const pct = const pct =
p.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null; p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
return ( return (
<div className="mt-1.5"> <div className="mt-1.5">
<div className="flex items-center justify-between text-[11px] text-muted mb-0.5"> <div className="flex items-center justify-between text-[11px] text-muted mb-0.5">
<span className="truncate">{phase}</span> <span className="truncate">{phase}</span>
<span className="tabular-nums shrink-0"> <span className="tabular-nums shrink-0">
{p.total != null {p?.total != null
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}` ? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
: p.current.toLocaleString()} : p
? p.current.toLocaleString()
: ""}
</span> </span>
</div> </div>
<div className="h-1.5 rounded-full bg-border overflow-hidden"> <div className="h-1.5 rounded-full bg-border overflow-hidden">
@ -184,7 +192,7 @@ function JobRow({
<span> · {job.last_result}</span> <span> · {job.last_result}</span>
) : null} ) : null}
</div> </div>
{job.running && job.progress && <JobProgress p={job.progress} />} {job.running && <JobProgress p={job.progress} />}
</div> </div>
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums"> <div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
{job.running ? ( {job.running ? (
@ -298,8 +306,10 @@ function Stat({
export default function Scheduler() { export default function Scheduler() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Poll faster while any job is running so live progress is smooth, then ease back when
// idle. Derived from the freshest data by react-query itself (see useLiveQuery).
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, { const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
intervalMs: POLL_MS, intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
}); });
const data = q.data; const data = q.data;

View file

@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Database, History, Loader2, Pause, Play } from "lucide-react"; import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api } from "../lib/api"; import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format"; import { formatViews } from "../lib/format";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -22,11 +22,19 @@ export default function SyncStatus({
queryFn: api.myStatus, queryFn: api.myStatus,
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and // Track the scheduler near-real-time: poll even when the tab is backgrounded, and
// refresh immediately on tab focus (the global default disables focus refetch), so the // refresh immediately on tab focus (the global default disables focus refetch), so the
// "N without full history" count keeps ticking down without a manual reload. // "N without full history" count keeps ticking down without a manual reload. Poll faster
refetchInterval: 30_000, // while a job is running or work is pending — so the live "syncing" state is actually
// caught — and ease back to a slow idle tick once everything's settled.
refetchInterval: (query) => {
const d = query.state.data as MyStatus | undefined;
const busy =
!!d &&
(d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
return busy ? 8_000 : 30_000;
},
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
staleTime: 25_000, staleTime: 5_000,
}); });
const toggle = useMutation({ const toggle = useMutation({
@ -38,6 +46,10 @@ export default function SyncStatus({
const syncing = data.channels_recent_pending; const syncing = data.channels_recent_pending;
const notFull = data.channels_deep_pending; // your channels without full history yet const notFull = data.channels_deep_pending; // your channels without full history yet
// Spin only while a sync job is actually running; otherwise pending work shows as a calm,
// static state (or, for deep history, just the "N without full history" link below).
const active = data.sync_active;
const showMain = data.paused || active || syncing > 0 || notFull === 0;
return ( return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted"> <div className="hidden lg:flex items-center gap-2 text-xs text-muted">
@ -54,23 +66,31 @@ export default function SyncStatus({
</span> </span>
</span> </span>
</Tooltip> </Tooltip>
<span className="opacity-40">·</span> {showMain && (
{data.paused ? ( <>
<span className="text-accent font-medium">{t("header.sync.paused")}</span> <span className="opacity-40">·</span>
) : syncing > 0 ? ( {data.paused ? (
<span className="flex items-center gap-1"> <span className="text-accent font-medium">{t("header.sync.paused")}</span>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> ) : active ? (
{t("header.sync.syncing", { count: syncing })} // A sync job is running right now — spin and name what it's doing.
</span> <span className="flex items-center gap-1">
) : notFull > 0 ? ( <Loader2 className="w-3.5 h-3.5 animate-spin" />
// Recent uploads are in, but the scheduler is still backfilling full history — {syncing > 0
// so "all synced" would be misleading. Reflect the deep work as active. ? t("header.sync.syncing", { count: syncing })
<span className="flex items-center gap-1"> : notFull > 0
<Loader2 className="w-3.5 h-3.5 animate-spin" /> ? t("header.sync.backfillingHistory")
{t("header.sync.backfillingHistory")} : t("header.sync.working")}
</span> </span>
) : ( ) : syncing > 0 ? (
<span>{t("header.sync.allSynced")}</span> // Recent uploads queued for the next run, but nothing running now — static.
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{t("header.sync.recentQueued", { count: syncing })}
</span>
) : (
<span>{t("header.sync.allSynced")}</span>
)}
</>
)} )}
{notFull > 0 && ( {notFull > 0 && (
<> <>

View file

@ -5,6 +5,25 @@
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.", "syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
"backfillEverything": "Alles nachladen", "backfillEverything": "Alles nachladen",
"backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.", "backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.",
"tabs": {
"subscribed": "Abonnements",
"discovery": "Aus Playlists entdecken"
},
"discovery": {
"intro": "Kanäle, die in deinen Playlists vorkommen, die du aber nicht abonniert hast. Abonniere sie, um ihnen zu folgen — ihre neuen Uploads erscheinen dann in deinem Feed (das Abonnieren kostet etwas YouTube-Kontingent; vorhandene Videos werden nicht erneut geladen).",
"loading": "Kanäle werden gesucht…",
"empty": "Keine neuen Kanäle — jeden Kanal in deinen Playlists abonnierst du bereits.",
"subscribe": "Abonnieren",
"subscribeHint": "Diesen Kanal auf YouTube abonnieren (ändert dein echtes Konto; verbraucht etwas API-Kontingent). Seine Videos kommen bei der nächsten Hintergrund-Synchronisierung.",
"needWriteHint": "Aktiviere das Bearbeiten von Playlists in den Einstellungen, um auf YouTube zu abonnieren.",
"subscribedTitle": "Auf YouTube abonniert",
"subscribedBody": "Du folgst jetzt {{name}} — neue Uploads erscheinen in deinem Feed.",
"subscribeFailed": "Abonnieren fehlgeschlagen",
"cols": {
"inPlaylists": "In Playlists",
"inPlaylistsHint": "{{videos}} Video(s) von diesem Kanal in {{playlists}} deiner Playlist(s)."
}
},
"filters": { "filters": {
"all": "Alle", "all": "Alle",
"needsFull": "Verlauf unvollständig", "needsFull": "Verlauf unvollständig",

View file

@ -31,6 +31,8 @@
"paused": "pausiert", "paused": "pausiert",
"syncing": "{{count}} werden synchronisiert", "syncing": "{{count}} werden synchronisiert",
"backfillingHistory": "Verlauf wird geladen", "backfillingHistory": "Verlauf wird geladen",
"working": "Synchronisierung…",
"recentQueued": "{{count}} in Warteschlange",
"allSynced": "alles synchronisiert", "allSynced": "alles synchronisiert",
"withoutFullHistory": "{{count}} ohne vollständigen Verlauf", "withoutFullHistory": "{{count}} ohne vollständigen Verlauf",
"fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.", "fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.",

View file

@ -10,6 +10,7 @@
"sectionSystem": "System", "sectionSystem": "System",
"sectionActivity": "Aktivität", "sectionActivity": "Aktivität",
"andMore": "und {{count}} weitere", "andMore": "und {{count}} weitere",
"openScheduler": "Zeitplaner öffnen",
"maintenance": { "maintenance": {
"title": "Videos entfernt", "title": "Videos entfernt",
"body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.", "body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.",

View file

@ -8,6 +8,8 @@
"findInFeed": "Im Feed finden", "findInFeed": "Im Feed finden",
"unhide": "Einblenden", "unhide": "Einblenden",
"unwatch": "Nicht angesehen", "unwatch": "Nicht angesehen",
"openInManager": "Kanalverwaltung",
"openOnYouTube": "Auf YouTube öffnen",
"unhidden": "Eingeblendet „{{title}}”", "unhidden": "Eingeblendet „{{title}}”",
"unwatched": "Als nicht angesehen markiert „{{title}}”", "unwatched": "Als nicht angesehen markiert „{{title}}”",
"time": { "time": {

View file

@ -38,7 +38,12 @@
"enrich": "Videos anreichern", "enrich": "Videos anreichern",
"backfill_recent": "Neue Uploads nachladen", "backfill_recent": "Neue Uploads nachladen",
"backfill_deep": "Ganze Historie nachladen", "backfill_deep": "Ganze Historie nachladen",
"probe": "Shorts klassifizieren" "probe": "Shorts klassifizieren",
"rss_poll": "Auf neue Uploads prüfen",
"subscriptions": "Abos synchronisieren",
"autotag": "Kanäle automatisch taggen",
"playlist_sync": "Playlists synchronisieren",
"working": "Arbeitet…"
}, },
"maintenance": { "maintenance": {
"title": "Wartung", "title": "Wartung",

View file

@ -5,6 +5,25 @@
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.", "syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
"backfillEverything": "Backfill everything", "backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.", "backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
"tabs": {
"subscribed": "Subscriptions",
"discovery": "Discover from playlists"
},
"discovery": {
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
"loading": "Finding channels…",
"empty": "No new channels — every channel in your playlists is one you already follow.",
"subscribe": "Subscribe",
"subscribeHint": "Subscribe to this channel on YouTube (changes your real account; uses a little API quota). Its videos arrive on the next background sync.",
"needWriteHint": "Enable playlist editing in Settings to subscribe on YouTube.",
"subscribedTitle": "Subscribed on YouTube",
"subscribedBody": "You're now following {{name}} — its new uploads will start arriving in your feed.",
"subscribeFailed": "Subscribe failed",
"cols": {
"inPlaylists": "In playlists",
"inPlaylistsHint": "{{videos}} video(s) from this channel across {{playlists}} of your playlist(s)."
}
},
"filters": { "filters": {
"all": "All", "all": "All",
"needsFull": "Needs full history", "needsFull": "Needs full history",

View file

@ -31,6 +31,8 @@
"paused": "paused", "paused": "paused",
"syncing": "{{count}} syncing", "syncing": "{{count}} syncing",
"backfillingHistory": "fetching history", "backfillingHistory": "fetching history",
"working": "syncing…",
"recentQueued": "{{count}} queued",
"allSynced": "all synced", "allSynced": "all synced",
"withoutFullHistory": "{{count}} without full history", "withoutFullHistory": "{{count}} without full history",
"fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.", "fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.",

View file

@ -10,6 +10,7 @@
"sectionSystem": "System", "sectionSystem": "System",
"sectionActivity": "Activity", "sectionActivity": "Activity",
"andMore": "and {{count}} more", "andMore": "and {{count}} more",
"openScheduler": "Open Scheduler",
"maintenance": { "maintenance": {
"title": "Videos removed", "title": "Videos removed",
"body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.", "body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.",

View file

@ -8,6 +8,8 @@
"findInFeed": "Find in feed", "findInFeed": "Find in feed",
"unhide": "Unhide", "unhide": "Unhide",
"unwatch": "Unwatch", "unwatch": "Unwatch",
"openInManager": "Channel manager",
"openOnYouTube": "Open on YouTube",
"unhidden": "Unhidden “{{title}}”", "unhidden": "Unhidden “{{title}}”",
"unwatched": "Unwatched “{{title}}”", "unwatched": "Unwatched “{{title}}”",
"time": { "time": {

View file

@ -38,7 +38,12 @@
"enrich": "Enriching videos", "enrich": "Enriching videos",
"backfill_recent": "Backfilling recent uploads", "backfill_recent": "Backfilling recent uploads",
"backfill_deep": "Backfilling full history", "backfill_deep": "Backfilling full history",
"probe": "Classifying Shorts" "probe": "Classifying Shorts",
"rss_poll": "Checking for new uploads",
"subscriptions": "Syncing subscriptions",
"autotag": "Auto-tagging channels",
"playlist_sync": "Syncing playlists",
"working": "Working…"
}, },
"maintenance": { "maintenance": {
"title": "Maintenance", "title": "Maintenance",

View file

@ -5,6 +5,25 @@
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.", "syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
"backfillEverything": "Minden letöltése", "backfillEverything": "Minden letöltése",
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.", "backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
"tabs": {
"subscribed": "Feliratkozások",
"discovery": "Felfedezés a lejátszási listákból"
},
"discovery": {
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
"loading": "Csatornák keresése…",
"empty": "Nincs új csatorna — a lejátszási listáidban minden csatornát már követsz.",
"subscribe": "Feliratkozás",
"subscribeHint": "Feliratkozás erre a csatornára a YouTube-on (a valódi fiókodat módosítja; kevés API-kvótát használ). A videói a következő háttér-szinkronnál érkeznek.",
"needWriteHint": "Engedélyezd a lejátszási listák szerkesztését a Beállításokban a feliratkozáshoz.",
"subscribedTitle": "Feliratkozva a YouTube-on",
"subscribedBody": "Mostantól követed: {{name}} — az új feltöltései megjelennek a hírfolyamodban.",
"subscribeFailed": "A feliratkozás sikertelen",
"cols": {
"inPlaylists": "Listákban",
"inPlaylistsHint": "{{videos}} videó ettől a csatornától, {{playlists}} lejátszási listádban."
}
},
"filters": { "filters": {
"all": "Mind", "all": "Mind",
"needsFull": "Hiányos előzmény", "needsFull": "Hiányos előzmény",

View file

@ -31,6 +31,8 @@
"paused": "szüneteltetve", "paused": "szüneteltetve",
"syncing": "{{count}} szinkronizálás alatt", "syncing": "{{count}} szinkronizálás alatt",
"backfillingHistory": "előzmény letöltése", "backfillingHistory": "előzmény letöltése",
"working": "szinkronizálás…",
"recentQueued": "{{count}} sorban",
"allSynced": "minden szinkronban", "allSynced": "minden szinkronban",
"withoutFullHistory": "{{count}} teljes előzmény nélkül", "withoutFullHistory": "{{count}} teljes előzmény nélkül",
"fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.", "fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.",

View file

@ -10,6 +10,7 @@
"sectionSystem": "Rendszer", "sectionSystem": "Rendszer",
"sectionActivity": "Tevékenység", "sectionActivity": "Tevékenység",
"andMore": "és még {{count}}", "andMore": "és még {{count}}",
"openScheduler": "Ütemező megnyitása",
"maintenance": { "maintenance": {
"title": "Videók eltávolítva", "title": "Videók eltávolítva",
"body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.", "body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.",

View file

@ -8,6 +8,8 @@
"findInFeed": "Keresés a hírfolyamban", "findInFeed": "Keresés a hírfolyamban",
"unhide": "Megjelenítés", "unhide": "Megjelenítés",
"unwatch": "Megtekintés visszavonása", "unwatch": "Megtekintés visszavonása",
"openInManager": "Csatornakezelő",
"openOnYouTube": "Megnyitás a YouTube-on",
"unhidden": "Megjelenítve: „{{title}}”", "unhidden": "Megjelenítve: „{{title}}”",
"unwatched": "Megtekintés visszavonva: „{{title}}”", "unwatched": "Megtekintés visszavonva: „{{title}}”",
"time": { "time": {

View file

@ -38,7 +38,12 @@
"enrich": "Videók dúsítása", "enrich": "Videók dúsítása",
"backfill_recent": "Friss feltöltések behúzása", "backfill_recent": "Friss feltöltések behúzása",
"backfill_deep": "Teljes előzmény behúzása", "backfill_deep": "Teljes előzmény behúzása",
"probe": "Shorts besorolás" "probe": "Shorts besorolás",
"rss_poll": "Új feltöltések keresése",
"subscriptions": "Feliratkozások szinkronizálása",
"autotag": "Csatornák automatikus címkézése",
"playlist_sync": "Lejátszási listák szinkronizálása",
"working": "Dolgozik…"
}, },
"maintenance": { "maintenance": {
"title": "Karbantartás", "title": "Karbantartás",

View file

@ -316,6 +316,7 @@ export interface MyStatus {
quota_used_today: number; quota_used_today: number;
quota_remaining_today: number; quota_remaining_today: number;
paused: boolean; paused: boolean;
sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
} }
export interface MyUsage { export interface MyUsage {
@ -362,6 +363,20 @@ export interface ManagedChannel {
backfill_done: boolean; backfill_done: boolean;
} }
// A channel that shows up in the user's playlists but that they don't subscribe to —
// surfaced in the Channel manager's Discovery tab so they can subscribe in one click.
export interface DiscoveredChannel {
id: string;
title: string | null;
handle: string | null;
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
playlist_video_count: number; // how many of the user's playlist videos are from here
playlist_count: number; // across how many of their playlists
details_synced: boolean;
}
export interface SchedulerJob { export interface SchedulerJob {
id: string; id: string;
interval_minutes: number; interval_minutes: number;
@ -485,6 +500,10 @@ export const api = {
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }), req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
unsubscribeChannel: (id: string) => unsubscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscription`, { method: "DELETE" }), req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
discoveredChannels: (): Promise<DiscoveredChannel[]> =>
req("/api/channels/discovery"),
subscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscribe`, { method: "POST" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- playlists --- // --- playlists ---

View file

@ -27,6 +27,14 @@ export function relativeTime(iso: string | null): string {
return i18n.t("time.monthsAgo", { count: months }); return i18n.t("time.monthsAgo", { count: months });
} }
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
// /channel/<id> form. One place so every channel link across the app stays consistent.
export function channelYouTubeUrl(id: string, handle?: string | null): string {
return handle
? `https://www.youtube.com/@${handle.replace(/^@/, "")}`
: `https://www.youtube.com/channel/${id}`;
}
export function formatDuration(sec: number | null): string { export function formatDuration(sec: number | null): string {
if (sec == null) return ""; if (sec == null) return "";
const h = Math.floor(sec / 3600); const h = Math.floor(sec / 3600);

View file

@ -23,8 +23,15 @@ export type VideoWatchedMeta = {
kind: "video-watched"; kind: "video-watched";
videoId: string; videoId: string;
title: string; title: string;
channelId: string;
channelName: string;
}; };
export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta; export type ChannelSubscribedMeta = {
kind: "channel-subscribed";
channelId: string;
channelName: string;
};
export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta | ChannelSubscribedMeta;
export interface Notification { export interface Notification {
id: number; id: number;
@ -239,7 +246,13 @@ export function remove(id: number): void {
* now-stale "Hidden/Watched X" notice disappears instead of lingering with a dead action. */ * now-stale "Hidden/Watched X" notice disappears instead of lingering with a dead action. */
export function resolveVideo(videoId: string): void { export function resolveVideo(videoId: string): void {
const before = items.length; const before = items.length;
items = items.filter((n) => n.meta?.videoId !== videoId); items = items.filter(
(n) =>
!(
(n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched") &&
n.meta.videoId === videoId
)
);
if (items.length !== before) emit(); if (items.length !== before) emit();
} }

View file

@ -14,6 +14,24 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.11.0",
date: "2026-06-19",
summary:
"Discover channels from your playlists, notifications that link to where they point, and a clearer, faster background sync.",
features: [
"Discover channels from your playlists: the Channel manager has a new “Discover from playlists” tab listing channels that appear in your playlists but that you don't follow yet. Each shows its subscriber count and avatar so you can size it up first, then follow it in one click — your own channel is left out.",
"Notifications now link to where they point, and the links keep working after a reload: subscribing names the channel with shortcuts to open it in the Channel manager or on YouTube; a “Marked watched” notice gains a “Find in feed” action (matching “Hidden”); and a finished background job links back to the Scheduler.",
],
fixes: [
"The header's sync indicator now spins only while a sync is actually running — instead of perpetually whenever some full-history backfill was still pending — and it refreshes more promptly while work is in progress.",
"“N without full history” in the header now reliably opens the Channel manager on the right tab and shows exactly those channels, even if you'd previously left a channel filter applied.",
"New uploads are picked up faster: the background feed check now reads many channels' feeds at once instead of one by one, cutting a full pass from minutes to seconds.",
],
chores: [
"Shared a single channel-link component and YouTube-URL helper across the app, and the admin Scheduler now shows live progress for every job type.",
],
},
{ {
version: "0.10.0", version: "0.10.0",
date: "2026-06-19", date: "2026-06-19",

View file

@ -8,14 +8,22 @@ import { useQuery, type QueryKey } from "@tanstack/react-query";
export function useLiveQuery<T>( export function useLiveQuery<T>(
key: QueryKey, key: QueryKey,
queryFn: () => Promise<T>, queryFn: () => Promise<T>,
opts: { intervalMs?: number; enabled?: boolean } = {} // intervalMs may be a function of the latest data, so the poll cadence can adapt to it
// (e.g. poll faster while a job is running). react-query re-evaluates it after each fetch
// against fresh data — the right place for this, vs. a React state toggle that can lag or
// stall the live updates.
opts: { intervalMs?: number | ((data: T | undefined) => number); enabled?: boolean } = {}
) { ) {
const { intervalMs = 4000, enabled = true } = opts; const { intervalMs = 4000, enabled = true } = opts;
return useQuery({ return useQuery({
queryKey: key, queryKey: key,
queryFn, queryFn,
enabled, enabled,
refetchInterval: enabled ? intervalMs : false, refetchInterval: !enabled
? false
: typeof intervalMs === "function"
? (query) => intervalMs(query.state.data as T | undefined)
: intervalMs,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
staleTime: 0, staleTime: 0,
}); });