Merge feature/s5-channel-discovery: discover & subscribe to channels from playlists
This commit is contained in:
commit
724f9e6a67
16 changed files with 552 additions and 6 deletions
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))
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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!}
|
||||||
|
|
|
||||||
179
frontend/src/components/ChannelDiscovery.tsx
Normal file
179
frontend/src/components/ChannelDiscovery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
{tabs}
|
||||||
|
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-7xl mx-auto">
|
<>
|
||||||
|
{tabs}
|
||||||
|
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
|
||||||
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
|
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
|
||||||
now lives in the table headers). */}
|
now lives in the table headers). */}
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
|
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
|
||||||
|
|
@ -468,6 +513,7 @@ export default function Channels({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 &&
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue