Merge feature/s5-channel-discovery: discover & subscribe to channels from playlists

This commit is contained in:
npeter83 2026-06-19 02:45:36 +02:00
commit d86aee262d
16 changed files with 552 additions and 6 deletions

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

@ -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

@ -451,7 +451,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,179 @@
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, 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 Avatar from "./Avatar";
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) => <DiscoveryNameCell c={c} />,
},
{
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>
);
}
function DiscoveryNameCell({ c }: { c: DiscoveredChannel }) {
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" />
<span className="text-sm font-medium truncate min-w-0">{c.title ?? c.id}</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

@ -20,9 +20,12 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
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).
@ -64,6 +67,17 @@ export default function Channels({
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// Which tab is showing: the user's subscriptions, or channels discovered from their
// playlists. Persisted so a reload keeps the active tab (see other siftlode.* keys).
const [view, setView] = useState<ChannelsView>(() =>
localStorage.getItem("siftlode.channelsView") === "discovery"
? "discovery"
: "subscribed"
);
useEffect(() => {
localStorage.setItem("siftlode.channelsView", view);
}, [view]);
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the needed scope — surface that with a "Connect" action instead of a vague fail. // granted the needed scope — surface that with a "Connect" action instead of a vague fail.
const notifyActionError = (err: unknown, fallbackKey: string) => { const notifyActionError = (err: unknown, fallbackKey: string) => {
@ -348,8 +362,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 ( return (
<div className="p-4 max-w-7xl mx-auto"> <>
{tabs}
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} />
</>
);
}
return (
<>
{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">
@ -468,6 +513,7 @@ export default function Channels({
/> />
)} )}
</div> </div>
</>
); );
} }

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 { 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,6 +14,7 @@ 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";
@ -46,10 +47,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();
@ -183,6 +186,7 @@ export default function NotificationsPanel({
t={t} t={t}
onFind={locateHidden} onFind={locateHidden}
onRevert={revertState} onRevert={revertState}
onFocusChannel={onFocusChannel}
onRemove={() => removeClient(n.id)} onRemove={() => removeClient(n.id)}
/> />
))} ))}
@ -287,18 +291,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) => 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" : ""}`}
@ -342,6 +350,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={`https://www.youtube.com/channel/${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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -362,6 +362,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 +499,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

@ -24,7 +24,12 @@ export type VideoWatchedMeta = {
videoId: string; videoId: string;
title: string; title: 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 +244,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();
} }