diff --git a/backend/alembic/versions/0055_channel_keywords.py b/backend/alembic/versions/0055_channel_keywords.py new file mode 100644 index 0000000..0a2877c --- /dev/null +++ b/backend/alembic/versions/0055_channel_keywords.py @@ -0,0 +1,23 @@ +"""channels.keywords: creator keyword tags from brandingSettings (About tab) + +Revision ID: 0055_channel_keywords +Revises: 0054_audit_log +Create Date: 2026-07-12 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0055_channel_keywords" +down_revision: Union[str, None] = "0054_audit_log" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("channels", sa.Column("keywords", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("channels", "keywords") diff --git a/backend/app/models.py b/backend/app/models.py index 6e40d5b..d31a9f8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -180,6 +180,9 @@ class Channel(Base, TimestampMixin): topic_categories: Mapped[list | None] = mapped_column(JSON) default_language: Mapped[str | None] = mapped_column(String(16)) country: Mapped[str | None] = mapped_column(String(8)) + # Creator keyword tags (brandingSettings.channel.keywords) — a single space-separated string, + # multi-word tags quoted; the client parses it into chips for the About tab. + keywords: Mapped[str | None] = mapped_column(Text) # Sync bookkeeping. details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 38b5dff..e74589f 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -233,6 +233,9 @@ def _channel_detail_dict( "published_at": channel.published_at.isoformat() if channel.published_at else None, "external_links": channel.external_links or [], "country": channel.country, + "default_language": channel.default_language, + "topic_categories": channel.topic_categories or [], + "keywords": channel.keywords, "subscribed": subscribed, "explored": explored, "blocked": blocked, diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index a007296..e8b98cc 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -69,6 +69,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None: channel.banner_url = branding.get("image", {}).get("bannerExternalUrl") channel.external_links = _external_links(branding) channel.topic_categories = topics.get("topicCategories") + channel.keywords = (branding.get("channel") or {}).get("keywords") channel.details_synced_at = now diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 17e98d1..0353e5a 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -18,9 +18,11 @@ import { useConfirm } from "./ConfirmProvider"; export default function ChannelDiscovery({ canWrite, onOpenWizard, + onViewChannel, }: { canWrite: boolean; onOpenWizard: () => void; + onViewChannel: (id: string, name: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -76,7 +78,13 @@ export default function ChannelDiscovery({ filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, render: (c) => ( - + onViewChannel(c.id, c.title ?? c.id)} + /> ), }, subsColumn(t), diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 08e259b..703c287 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -10,6 +10,40 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { api, type FeedFilters, type Me } from "../lib/api"; import { channelYouTubeUrl, formatViews } from "../lib/format"; +// --- About-tab metadata helpers --- +// ISO 3166-1 alpha-2 code → 🇺🇸 regional-indicator flag emoji. +function countryFlag(code: string): string { + if (!/^[A-Za-z]{2}$/.test(code)) return ""; + return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)); +} +function displayName(code: string, lang: string, type: "region" | "language"): string { + try { + return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code; + } catch { + return code; + } +} +// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play". +function parseKeywords(s: string): string[] { + return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean); +} +// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated. +function topicLabels(urls: string[]): string[] { + const seen = new Set(); + for (const url of urls) { + const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url; + let label = seg; + try { + label = decodeURIComponent(seg); + } catch { + /* keep raw */ + } + label = label.replace(/_/g, " ").trim(); + if (label) seen.add(label); + } + return [...seen]; +} + // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it // auto-ingests recent uploads in the background ("explore") so they're browsable immediately, @@ -34,12 +68,20 @@ export default function ChannelPage({ const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = useState<"videos" | "about">("videos"); + // The banner URL is valid, but googleusercontent intermittently throttles it when the channel + // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the + // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would + // succeed. So on error we retry a few times with a cache-buster (forcing a fresh request), and + // only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare). + const MAX_BANNER_RETRIES = 3; + const [bannerAttempt, setBannerAttempt] = useState(0); const detail = useQuery({ queryKey: ["channel", channelId], queryFn: () => api.channelDetail(channelId), }); const ch = detail.data; + useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again // Background auto-ingest ("explore") of an un-subscribed channel's uploads. const [nextToken, setNextToken] = useState(undefined); @@ -147,16 +189,23 @@ export default function ChannelPage({ ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null, joined ? t("channel.joined", { date: joined }) : null, ].filter(Boolean) as string[]; + const topics = ch?.topic_categories?.length ? topicLabels(ch.topic_categories) : []; + const keywords = ch?.keywords ? parseKeywords(ch.keywords) : []; + const hasAboutDetails = + !!ch?.country || !!ch?.default_language || topics.length > 0 || keywords.length > 0; const tabClass = (active: boolean) => active ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg" : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent"; return ( -
+ // scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the + // tall Videos tab (scrollbar) and the short About tab (no scrollbar) no longer shifts the + // header/logo/buttons horizontally as the scrollbar appears/disappears. +
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
- {ch?.banner_url ? ( + {ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? ( // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a // low default size, so request a crisp wide version (=w1707) and object-cover the // desktop "safe area" — the centre 2560×423 (~6:1) band. @@ -165,13 +214,27 @@ export default function ChannelPage({ style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }} > per attempt; the ?r= cache-buster on a retry bypasses the + // browser's negative cache (googleusercontent accepts an extra query param). + key={bannerAttempt} + src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`} alt="" className="w-full h-full object-cover object-center" + onError={() => { + if (bannerAttempt <= MAX_BANNER_RETRIES) { + window.setTimeout(() => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1)); + } + }} />
) : ( -
+ // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the + // overlapping avatar + the Back button have the same room and don't collide (a short bar + // let the -mt-8 avatar ride up into the Back button). +
)}
)}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index b4e2043..d41af77 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -399,7 +399,7 @@ export default function Channels({ return ( <> {tabs} - + ); } diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 37e0423..a2a76e5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -552,25 +552,33 @@ export default function Feed({ ); })} -