Files
siftlode/frontend/src/lib/api/channels.ts
T
peter 00527d0cf3 feat(channels): leading '+ Tag' pill + detail-page Unhide affordance (S6)
Item 6 — the tag add-trigger now LEADS the chip row (a stable left anchor)
instead of trailing it, so on the detail page (bounded=false) adding chips
grows the row to its right and the '+'/its editor popover no longer drift.
Compact icon in the width-bounded manager cell; a labelled '+ Tag' pill
where there's free width (the detail header).

Item 7 — surface the per-subscription 'hidden' flag on the detail GET
(channel_detail was omitting it) and add an Eye/EyeOff hide/unhide toggle
next to Block on the channel page, mirroring the manager. FE ChannelDetail
gains 'hidden'; new channel.* i18n keys (HU+EN).

Tests: +3 backend (channel_detail surfaces hidden, both states + unsubscribed
default). Gate green: backend pytest 471, FE vitest 498.
2026-08-27 04:02:25 +02:00

142 lines
5.1 KiB
TypeScript

// Channel manager (subscribed + discovery), the channel About/detail page, ephemeral exploration
// of un-subscribed channels, and the per-user channel blocklist. Only touches core's req.
import { req } from "./core";
interface ChannelLink {
title: string | null;
url: string;
}
export interface ChannelDetail {
id: string;
title: string | null;
handle: string | null;
description: string | null;
thumbnail_url: string | null;
banner_url: string | null;
subscriber_count: number | null;
video_count: number | null;
total_view_count: number | null;
published_at: string | null;
external_links: ChannelLink[];
country: string | null;
default_language: string | null;
topic_categories: string[];
keywords: string | null;
subscribed: boolean;
explored: boolean;
blocked: boolean;
stored_videos: number;
count_normal: number;
count_short: number;
count_live: number;
deep_requested: boolean;
priority: number;
hidden: boolean;
backfill_done: boolean;
from_explore: boolean;
details_synced: boolean;
tag_ids: number[];
}
export interface ExploreResult {
channel_id: string;
ingested: number;
next_page_token: string | null;
}
export interface MyStatus {
channels_total: number;
channels_details_synced: number;
channels_recent_synced: number;
channels_deep_done: number;
channels_recent_pending: number;
channels_deep_pending: number;
channels_deep_requested: number;
deep_pending_count: number;
deep_eta_seconds: number;
my_videos: number;
total_videos: number;
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 ManagedChannel {
id: string;
title: string | null;
handle: string | null;
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
description: string | null;
stored_videos: number;
last_video_at: string | null;
total_duration_seconds: number;
priority: number;
hidden: boolean;
deep_requested: boolean;
deep_in_queue: boolean;
tag_ids: number[];
details_synced: boolean;
recent_synced: 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;
description: string | 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 const channelsApi = {
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
updateChannel: (
id: string,
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean },
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
resetChannelBackfill: (id: string) =>
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
deepAll: (on = true): Promise<{ updated: number; deep_requested: boolean }> =>
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
attachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
detachChannelTag: (id: string, tagId: number) =>
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" }),
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
// marked via_explore) + record the exploration. `pageToken` continues into older uploads
// ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page.
exploreChannel: (id: string, pageToken?: string | null): Promise<ExploreResult> => {
const p = new URLSearchParams();
if (pageToken) p.set("page_token", pageToken);
const qs = p.toString();
return req(
`/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
{ method: "POST" },
{ quiet: true },
);
},
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
blockedChannels: (): Promise<
{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]
> => req("/api/channels/blocked/list"),
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
};