Merge: promote dev to prod
This commit is contained in:
commit
022bab3518
41 changed files with 860 additions and 105 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.10.0
|
||||
0.11.0
|
||||
|
|
|
|||
28
backend/alembic/versions/0019_user_yt_channel_id.py
Normal file
28
backend/alembic/versions/0019_user_yt_channel_id.py
Normal 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")
|
||||
|
|
@ -28,6 +28,9 @@ class User(Base):
|
|||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||
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
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
"""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
|
||||
(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 sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -8,13 +11,25 @@ from sqlalchemy.orm import Session
|
|||
from app import quota
|
||||
from app.auth import current_user, has_write_scope
|
||||
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.sync.runner import run_recent_backfill
|
||||
from app.sync.subscriptions import apply_channel_details
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||||
|
||||
log = logging.getLogger("subfeed.api")
|
||||
|
||||
|
||||
@router.get("")
|
||||
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:
|
||||
sub = db.execute(
|
||||
select(Subscription).where(
|
||||
|
|
@ -181,6 +279,53 @@ def reset_backfill(
|
|||
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")
|
||||
def unsubscribe(
|
||||
channel_id: str,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from app.sync.runner import (
|
|||
run_rss_poll,
|
||||
)
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
|
|
@ -170,6 +171,9 @@ def my_status(
|
|||
"quota_used_today": quota.units_used_today(db),
|
||||
"quota_remaining_today": quota.remaining_today(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()),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -272,6 +272,13 @@ def _is_running(job_id: str) -> bool:
|
|||
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:
|
||||
"""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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
log = logging.getLogger("subfeed.autotag")
|
||||
|
||||
from app import progress
|
||||
from app.config import settings
|
||||
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()
|
||||
tagged = 0
|
||||
for channel in channels:
|
||||
for i, channel in enumerate(channels, 1):
|
||||
try:
|
||||
apply_channel_autotags(db, channel)
|
||||
tagged += 1
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("Auto-tagging failed for channel %s", channel.id)
|
||||
progress.report(i, len(channels), "autotag")
|
||||
removed = _cleanup_orphan_system_tags(db)
|
||||
return {"channels_tagged": tagged, "orphan_tags_removed": removed}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
|||
from sqlalchemy import delete, select
|
||||
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.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
|
||||
from app.sync.videos import apply_video_details, parse_dt
|
||||
|
|
@ -382,8 +382,9 @@ def sync_all_playlists(db: Session) -> dict:
|
|||
.all()
|
||||
)
|
||||
total = 0
|
||||
for user in users:
|
||||
for i, user in enumerate(users, 1):
|
||||
if not has_read_scope(user):
|
||||
progress.report(i, len(users), "playlist_sync")
|
||||
continue
|
||||
try:
|
||||
with quota.attribute(user.id, "playlist_sync"):
|
||||
|
|
@ -394,4 +395,5 @@ def sync_all_playlists(db: Session) -> dict:
|
|||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("Playlist sync crashed for user %s", user.id)
|
||||
progress.report(i, len(users), "playlist_sync")
|
||||
return {"users": len(users), "playlists_synced": total}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configu
|
|||
public reads.
|
||||
"""
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -16,15 +17,19 @@ log = logging.getLogger("subfeed.sync")
|
|||
from app.models import Channel, OAuthToken, Subscription, User
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.sync.videos import (
|
||||
apply_rss_feed,
|
||||
backfill_channel_deep,
|
||||
backfill_channel_recent,
|
||||
enrich_pending,
|
||||
poll_rss_channel,
|
||||
reconcile_full_history,
|
||||
refresh_live,
|
||||
run_shorts_classification,
|
||||
)
|
||||
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:
|
||||
|
|
@ -43,13 +48,30 @@ def get_service_user(db: Session) -> User | None:
|
|||
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
|
||||
if channels is None:
|
||||
channels = db.execute(select(Channel)).scalars().all()
|
||||
total = len(channels)
|
||||
new = 0
|
||||
for channel in channels:
|
||||
try:
|
||||
new += poll_rss_channel(db, channel)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("RSS poll failed for channel %s", channel.id)
|
||||
done = 0
|
||||
# Fetch feeds concurrently (network-bound, no DB), then apply inserts on this thread as
|
||||
# each fetch returns — a SQLAlchemy session isn't thread-safe. Pass the channel id (a
|
||||
# plain str) to the workers; the ORM instance is touched only here on the main thread.
|
||||
futures = {}
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -90,12 +112,13 @@ def run_subscription_resync(db: Session) -> dict:
|
|||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for user in users:
|
||||
for i, user in enumerate(users, 1):
|
||||
try:
|
||||
import_subscriptions(db, user)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("Subscription resync failed for user %s", user.id)
|
||||
progress.report(i, len(users), "subscriptions")
|
||||
return {"users": len(users)}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -67,8 +67,10 @@ def _insert_stubs(db: Session, rows: list[dict]) -> int:
|
|||
|
||||
|
||||
# --- RSS (free) ---
|
||||
def poll_rss_channel(db: Session, channel: Channel) -> int:
|
||||
entries = fetch_channel_feed(channel.id)
|
||||
def apply_rss_feed(db: Session, channel: Channel, entries: list[dict]) -> int:
|
||||
"""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)
|
||||
channel.last_rss_at = _now()
|
||||
db.add(channel)
|
||||
|
|
@ -76,6 +78,10 @@ def poll_rss_channel(db: Session, channel: Channel) -> int:
|
|||
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) ---
|
||||
def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
|
||||
content = item.get("contentDetails", {})
|
||||
|
|
|
|||
|
|
@ -168,6 +168,15 @@ class YouTubeClient:
|
|||
if not page_token:
|
||||
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]:
|
||||
items: list[dict] = []
|
||||
for batch in _chunks(channel_ids, 50):
|
||||
|
|
@ -213,6 +222,23 @@ class YouTubeClient:
|
|||
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]:
|
||||
"""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
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import Header from "./components/Header";
|
|||
import NavSidebar from "./components/NavSidebar";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
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 Stats from "./components/Stats";
|
||||
import Scheduler from "./components/Scheduler";
|
||||
|
|
@ -136,11 +136,27 @@ export default function App() {
|
|||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
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
|
||||
// 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 focusChannel = (name: string) => {
|
||||
setFocusChannelName(name);
|
||||
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
|
||||
setPage("channels");
|
||||
};
|
||||
useEffect(() => {
|
||||
|
|
@ -409,6 +425,8 @@ export default function App() {
|
|||
page={page}
|
||||
onGoToFullHistory={() => {
|
||||
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");
|
||||
}}
|
||||
/>
|
||||
|
|
@ -433,6 +451,9 @@ export default function App() {
|
|||
onFocusChannel={focusChannel}
|
||||
statusFilter={channelFilter}
|
||||
setStatusFilter={setChannelFilter}
|
||||
view={channelsView}
|
||||
setView={setChannelsView}
|
||||
filtersResetToken={channelsFilterReset}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
onViewChannel={(id, name) => {
|
||||
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
||||
|
|
@ -451,7 +472,7 @@ export default function App() {
|
|||
) : page === "playlists" ? (
|
||||
<Playlists canWrite={meQuery.data!.can_write} />
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} />
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
||||
) : page === "settings" ? (
|
||||
<SettingsPanel
|
||||
me={meQuery.data!}
|
||||
|
|
|
|||
157
frontend/src/components/ChannelDiscovery.tsx
Normal file
157
frontend/src/components/ChannelDiscovery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/ChannelLink.tsx
Normal file
64
frontend/src/components/ChannelLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import {
|
|||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
History,
|
||||
|
|
@ -18,11 +17,14 @@ import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
|||
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Avatar from "./Avatar";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery";
|
||||
|
||||
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).
|
||||
|
|
@ -48,6 +50,9 @@ export default function Channels({
|
|||
focusChannelName,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
view,
|
||||
setView,
|
||||
filtersResetToken,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
|
|
@ -58,6 +63,12 @@ export default function Channels({
|
|||
focusChannelName: string | null;
|
||||
statusFilter: ChannelStatusFilter;
|
||||
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;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -228,7 +239,15 @@ export default function Channels({
|
|||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||
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",
|
||||
|
|
@ -348,8 +367,39 @@ export default function Channels({
|
|||
</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 (
|
||||
<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
|
||||
now lives in the table headers). */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
|
||||
|
|
@ -463,11 +513,13 @@ export default function Channels({
|
|||
controlsPosition="top"
|
||||
controlsLeading={statusChips}
|
||||
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
|
||||
resetFiltersToken={filtersResetToken}
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export default function DataTable<T>({
|
|||
controlsPosition = "bottom",
|
||||
controlsLeading,
|
||||
externalFilter,
|
||||
resetFiltersToken,
|
||||
}: {
|
||||
rows: 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
|
||||
// whenever its value changes; the user can still clear it from the header afterwards.
|
||||
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 initial = loadPersist(persistKey);
|
||||
|
|
@ -104,6 +109,18 @@ export default function DataTable<T>({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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(() => {
|
||||
if (!openFilter) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
|
|
|
|||
|
|
@ -174,7 +174,13 @@ export default function Feed({
|
|||
? i18n.t("feed.markedWatchedNamed", { title: v.title })
|
||||
: i18n.t("feed.markedWatched"),
|
||||
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") {
|
||||
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import {
|
||||
|
|
@ -14,9 +14,11 @@ import {
|
|||
resolveVideo,
|
||||
subscribe,
|
||||
type Notification as ClientNotif,
|
||||
type ChannelSubscribedMeta,
|
||||
type VideoHiddenMeta,
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { LEVEL_STYLE } from "./Toaster";
|
||||
|
||||
|
|
@ -46,10 +48,12 @@ export default function NotificationsPanel({
|
|||
filters,
|
||||
setFilters,
|
||||
setPage,
|
||||
onFocusChannel,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
setPage: (p: Page) => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
|
|
@ -91,11 +95,12 @@ export default function NotificationsPanel({
|
|||
clearMut.mutate();
|
||||
}
|
||||
|
||||
// "Find in feed" / undo, ported from the old bell.
|
||||
function locateHidden(meta: VideoHiddenMeta) {
|
||||
// "Find in feed" / undo, ported from the old bell. Works for both hidden and watched
|
||||
// notices — jump to the feed filtered to that state and the video's channel.
|
||||
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
|
||||
setFilters({
|
||||
...filters,
|
||||
show: "hidden",
|
||||
show: meta.kind === "video-hidden" ? "hidden" : "watched",
|
||||
channelId: meta.channelId || undefined,
|
||||
channelName: meta.channelName || undefined,
|
||||
});
|
||||
|
|
@ -165,6 +170,7 @@ export default function NotificationsPanel({
|
|||
t={t}
|
||||
onRead={() => readMut.mutate(n.id)}
|
||||
onDismiss={() => dismissMut.mutate(n.id)}
|
||||
onOpenScheduler={() => setPage("scheduler")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -181,8 +187,9 @@ export default function NotificationsPanel({
|
|||
key={n.id}
|
||||
n={n}
|
||||
t={t}
|
||||
onFind={locateHidden}
|
||||
onFind={locate}
|
||||
onRevert={revertState}
|
||||
onFocusChannel={onFocusChannel}
|
||||
onRemove={() => removeClient(n.id)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -200,11 +207,13 @@ function NotificationRow({
|
|||
t,
|
||||
onRead,
|
||||
onDismiss,
|
||||
onOpenScheduler,
|
||||
}: {
|
||||
n: AppNotification;
|
||||
t: (k: string, o?: any) => string;
|
||||
onRead: () => void;
|
||||
onDismiss: () => void;
|
||||
onOpenScheduler: () => void;
|
||||
}) {
|
||||
// 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)
|
||||
|
|
@ -257,6 +266,15 @@ function NotificationRow({
|
|||
)}
|
||||
</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 className="flex items-center gap-1 shrink-0">
|
||||
{!n.read && (
|
||||
|
|
@ -287,18 +305,22 @@ function ClientActivityRow({
|
|||
t,
|
||||
onFind,
|
||||
onRevert,
|
||||
onFocusChannel,
|
||||
onRemove,
|
||||
}: {
|
||||
n: ClientNotif;
|
||||
t: (k: string, o?: any) => string;
|
||||
onFind: (meta: VideoHiddenMeta) => void;
|
||||
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
|
||||
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
||||
const needsAction = n.requiresInteraction && !n.dismissed;
|
||||
const hidden = n.meta?.kind === "video-hidden" ? 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 (
|
||||
<div
|
||||
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>
|
||||
) : watched ? (
|
||||
<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
|
||||
onClick={() => onRevert(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
|
|
@ -342,6 +371,25 @@ function ClientActivityRow({
|
|||
{t("notifications.unwatch")}
|
||||
</button>
|
||||
</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 &&
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, Sk
|
|||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
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:
|
||||
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
||||
|
|
@ -572,7 +572,7 @@ export default function PlayerModal({
|
|||
{navigated ? (
|
||||
detail.data?.channel_id ? (
|
||||
<a
|
||||
href={`https://www.youtube.com/channel/${detail.data.channel_id}`}
|
||||
href={channelYouTubeUrl(detail.data.channel_id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium hover:text-accent shrink-0"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { notify } from "../lib/notifications";
|
|||
import Tooltip from "./Tooltip";
|
||||
|
||||
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).
|
||||
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 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 =
|
||||
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 (
|
||||
<div className="mt-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] text-muted mb-0.5">
|
||||
<span className="truncate">{phase}</span>
|
||||
<span className="tabular-nums shrink-0">
|
||||
{p.total != null
|
||||
{p?.total != null
|
||||
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
|
||||
: p.current.toLocaleString()}
|
||||
: p
|
||||
? p.current.toLocaleString()
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-border overflow-hidden">
|
||||
|
|
@ -184,7 +192,7 @@ function JobRow({
|
|||
<span> · {job.last_result}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{job.running && job.progress && <JobProgress p={job.progress} />}
|
||||
{job.running && <JobProgress p={job.progress} />}
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
|
||||
{job.running ? (
|
||||
|
|
@ -298,8 +306,10 @@ function Stat({
|
|||
export default function Scheduler() {
|
||||
const { t } = useTranslation();
|
||||
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, {
|
||||
intervalMs: POLL_MS,
|
||||
intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
|
||||
});
|
||||
const data = q.data;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../lib/api";
|
||||
import { formatViews } from "../lib/format";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
|
|
@ -22,11 +22,19 @@ export default function SyncStatus({
|
|||
queryFn: api.myStatus,
|
||||
// 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
|
||||
// "N without full history" count keeps ticking down without a manual reload.
|
||||
refetchInterval: 30_000,
|
||||
// "N without full history" count keeps ticking down without a manual reload. Poll faster
|
||||
// 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,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 25_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
|
|
@ -38,6 +46,10 @@ export default function SyncStatus({
|
|||
|
||||
const syncing = data.channels_recent_pending;
|
||||
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 (
|
||||
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
|
||||
|
|
@ -54,23 +66,31 @@ export default function SyncStatus({
|
|||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="opacity-40">·</span>
|
||||
{data.paused ? (
|
||||
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
||||
) : syncing > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("header.sync.syncing", { count: syncing })}
|
||||
</span>
|
||||
) : notFull > 0 ? (
|
||||
// Recent uploads are in, but the scheduler is still backfilling full history —
|
||||
// so "all synced" would be misleading. Reflect the deep work as active.
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("header.sync.backfillingHistory")}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("header.sync.allSynced")}</span>
|
||||
{showMain && (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
{data.paused ? (
|
||||
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
||||
) : active ? (
|
||||
// A sync job is running right now — spin and name what it's doing.
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{syncing > 0
|
||||
? t("header.sync.syncing", { count: syncing })
|
||||
: notFull > 0
|
||||
? t("header.sync.backfillingHistory")
|
||||
: t("header.sync.working")}
|
||||
</span>
|
||||
) : syncing > 0 ? (
|
||||
// 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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"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.",
|
||||
"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": {
|
||||
"all": "Alle",
|
||||
"needsFull": "Verlauf unvollständig",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
"paused": "pausiert",
|
||||
"syncing": "{{count}} werden synchronisiert",
|
||||
"backfillingHistory": "Verlauf wird geladen",
|
||||
"working": "Synchronisierung…",
|
||||
"recentQueued": "{{count}} in Warteschlange",
|
||||
"allSynced": "alles synchronisiert",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"sectionSystem": "System",
|
||||
"sectionActivity": "Aktivität",
|
||||
"andMore": "und {{count}} weitere",
|
||||
"openScheduler": "Zeitplaner öffnen",
|
||||
"maintenance": {
|
||||
"title": "Videos entfernt",
|
||||
"body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
"findInFeed": "Im Feed finden",
|
||||
"unhide": "Einblenden",
|
||||
"unwatch": "Nicht angesehen",
|
||||
"openInManager": "Kanalverwaltung",
|
||||
"openOnYouTube": "Auf YouTube öffnen",
|
||||
"unhidden": "Eingeblendet „{{title}}”",
|
||||
"unwatched": "Als nicht angesehen markiert „{{title}}”",
|
||||
"time": {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@
|
|||
"enrich": "Videos anreichern",
|
||||
"backfill_recent": "Neue Uploads 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": {
|
||||
"title": "Wartung",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"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.",
|
||||
"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": {
|
||||
"all": "All",
|
||||
"needsFull": "Needs full history",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
"paused": "paused",
|
||||
"syncing": "{{count}} syncing",
|
||||
"backfillingHistory": "fetching history",
|
||||
"working": "syncing…",
|
||||
"recentQueued": "{{count}} queued",
|
||||
"allSynced": "all synced",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"sectionSystem": "System",
|
||||
"sectionActivity": "Activity",
|
||||
"andMore": "and {{count}} more",
|
||||
"openScheduler": "Open Scheduler",
|
||||
"maintenance": {
|
||||
"title": "Videos removed",
|
||||
"body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
"findInFeed": "Find in feed",
|
||||
"unhide": "Unhide",
|
||||
"unwatch": "Unwatch",
|
||||
"openInManager": "Channel manager",
|
||||
"openOnYouTube": "Open on YouTube",
|
||||
"unhidden": "Unhidden “{{title}}”",
|
||||
"unwatched": "Unwatched “{{title}}”",
|
||||
"time": {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@
|
|||
"enrich": "Enriching videos",
|
||||
"backfill_recent": "Backfilling recent uploads",
|
||||
"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": {
|
||||
"title": "Maintenance",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"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.",
|
||||
"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": {
|
||||
"all": "Mind",
|
||||
"needsFull": "Hiányos előzmény",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
"paused": "szüneteltetve",
|
||||
"syncing": "{{count}} szinkronizálás alatt",
|
||||
"backfillingHistory": "előzmény letöltése",
|
||||
"working": "szinkronizálás…",
|
||||
"recentQueued": "{{count}} sorban",
|
||||
"allSynced": "minden szinkronban",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"sectionSystem": "Rendszer",
|
||||
"sectionActivity": "Tevékenység",
|
||||
"andMore": "és még {{count}}",
|
||||
"openScheduler": "Ütemező megnyitása",
|
||||
"maintenance": {
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
"findInFeed": "Keresés a hírfolyamban",
|
||||
"unhide": "Megjelenítés",
|
||||
"unwatch": "Megtekintés visszavonása",
|
||||
"openInManager": "Csatornakezelő",
|
||||
"openOnYouTube": "Megnyitás a YouTube-on",
|
||||
"unhidden": "Megjelenítve: „{{title}}”",
|
||||
"unwatched": "Megtekintés visszavonva: „{{title}}”",
|
||||
"time": {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@
|
|||
"enrich": "Videók dúsítása",
|
||||
"backfill_recent": "Friss feltöltések 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": {
|
||||
"title": "Karbantartás",
|
||||
|
|
|
|||
|
|
@ -316,6 +316,7 @@ export interface MyStatus {
|
|||
quota_used_today: number;
|
||||
quota_remaining_today: number;
|
||||
paused: boolean;
|
||||
sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
|
||||
}
|
||||
|
||||
export interface MyUsage {
|
||||
|
|
@ -362,6 +363,20 @@ export interface ManagedChannel {
|
|||
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 {
|
||||
id: string;
|
||||
interval_minutes: number;
|
||||
|
|
@ -485,6 +500,10 @@ export const api = {
|
|||
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
|
||||
unsubscribeChannel: (id: string) =>
|
||||
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" }),
|
||||
|
||||
// --- playlists ---
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ export function relativeTime(iso: string | null): string {
|
|||
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 {
|
||||
if (sec == null) return "";
|
||||
const h = Math.floor(sec / 3600);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,15 @@ export type VideoWatchedMeta = {
|
|||
kind: "video-watched";
|
||||
videoId: 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 {
|
||||
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. */
|
||||
export function resolveVideo(videoId: string): void {
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,24 @@ export interface 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",
|
||||
date: "2026-06-19",
|
||||
|
|
|
|||
|
|
@ -8,14 +8,22 @@ import { useQuery, type QueryKey } from "@tanstack/react-query";
|
|||
export function useLiveQuery<T>(
|
||||
key: QueryKey,
|
||||
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;
|
||||
return useQuery({
|
||||
queryKey: key,
|
||||
queryFn,
|
||||
enabled,
|
||||
refetchInterval: enabled ? intervalMs : false,
|
||||
refetchInterval: !enabled
|
||||
? false
|
||||
: typeof intervalMs === "function"
|
||||
? (query) => intervalMs(query.state.data as T | undefined)
|
||||
: intervalMs,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue