Files
youclonedl/frontend/src/pages/Channel.jsx
Mattias Thall 5b0cf27f07 Add playlists support and fix explore older videos
- New playlists router: fetch channel playlists from YouTube, index
  playlist videos, browse by playlist with pagination
- Playlist model gets video_ids column to store ordered video list
- Register playlists router in main.py with DB migration
- Add Playlists tab to Channel page: grid of playlist cards, click to
  browse videos, index/re-index per playlist
- Fix explore older videos skipping all entries without published_at;
  flat-playlist entries for older videos rarely include timestamp data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:28:35 +02:00

509 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
import {
getChannel, getChannelVideos, searchChannelYoutube,
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
} from "../api";
import VideoCard from "../components/VideoCard";
const LIMIT = 60;
const TABS = [
{ value: "videos", label: "Videos" },
{ value: "popular", label: "Popular" },
{ value: "playlists", label: "Playlists" },
];
const SORTS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "title", label: "AZ" },
{ value: "unwatched", label: "Unwatched" },
];
function formatSubs(n) {
if (!n) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
}
export default function ChannelPage() {
const { id } = useParams();
const qc = useQueryClient();
const [tab, setTab] = useState("videos");
const [sort, setSort] = useState("newest");
const [search, setSearch] = useState("");
const [activeQ, setActiveQ] = useState("");
const [indexing, setIndexing] = useState(false);
const [explorePage, setExplorePage] = useState(2);
const searchInputRef = useRef(null);
const [openPlaylistId, setOpenPlaylistId] = useState(null);
const [playlistOffset, setPlaylistOffset] = useState(0);
const { data: channel, isLoading: loadingChannel } = useQuery({
queryKey: ["channel", id],
queryFn: () => getChannel(id).then((r) => r.data),
});
const effectiveSort = tab === "popular" ? "popular" : sort;
const {
data: videosData,
isLoading: loadingVideos,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["channel-videos", id, effectiveSort, activeQ],
queryFn: ({ pageParam = 0 }) =>
getChannelVideos(id, effectiveSort, pageParam, LIMIT, activeQ).then((r) => r.data),
getNextPageParam: (lastPage, pages) =>
lastPage.length === LIMIT ? pages.length * LIMIT : undefined,
enabled: !!id,
});
const videos = videosData?.pages.flat() ?? [];
// Refetch after background re-index
const refetchedRef = useRef(false);
useEffect(() => {
if (!id || refetchedRef.current) return;
refetchedRef.current = true;
const t = setTimeout(() => {
qc.invalidateQueries({ queryKey: ["channel", id] });
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
}, 8000);
return () => clearTimeout(t);
}, [id, qc]);
const followMut = useMutation({
mutationFn: () =>
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", id] });
qc.invalidateQueries({ queryKey: ["channels"] });
},
});
const scheduleRefetch = (delayMs) => {
setIndexing(true);
setTimeout(() => {
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
qc.invalidateQueries({ queryKey: ["channel", id] });
setIndexing(false);
}, delayMs);
};
const indexMut = useMutation({
mutationFn: () => indexChannel(id),
onSuccess: () => scheduleRefetch(6000),
});
const fullIndexMut = useMutation({
mutationFn: () => indexChannelFull(id),
onSuccess: () => scheduleRefetch(45000),
});
const exploreMut = useMutation({
mutationFn: () => exploreChannelOlder(id, explorePage),
onSuccess: () => {
setExplorePage(p => p + 1);
scheduleRefetch(20000);
},
});
const popularMut = useMutation({
mutationFn: () => fetchPopularVideos(id),
onSuccess: () => scheduleRefetch(20000),
});
const deepSearchMut = useMutation({
mutationFn: () => searchChannelYoutube(id, activeQ || search),
onSuccess: () => scheduleRefetch(20000),
});
const { data: playlists = [], refetch: refetchPlaylists } = useQuery({
queryKey: ["channel-playlists", id],
queryFn: () => getChannelPlaylists(id).then((r) => r.data),
enabled: !!id && tab === "playlists",
});
const fetchPlaylistsMut = useMutation({
mutationFn: () => fetchChannelPlaylists(id),
onSuccess: () => setTimeout(() => refetchPlaylists(), 8000),
});
const { data: playlistVideos, isLoading: loadingPlaylistVideos, refetch: refetchPlaylistVideos } = useQuery({
queryKey: ["playlist-videos", openPlaylistId, playlistOffset],
queryFn: () => getPlaylistVideos(openPlaylistId, playlistOffset, 60).then((r) => r.data),
enabled: !!openPlaylistId,
});
const indexPlaylistMut = useMutation({
mutationFn: (plId) => indexPlaylist(plId),
onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000),
});
const [dlResult, setDlResult] = useState(null);
const dlMut = useMutation({
mutationFn: () => downloadChannel(id),
onSuccess: (res) => {
setDlResult(res.data.queued);
qc.invalidateQueries({ queryKey: ["downloads"] });
},
});
const handleSearch = (e) => {
e.preventDefault();
setActiveQ(search.trim());
};
const clearSearch = () => {
setSearch("");
setActiveQ("");
searchInputRef.current?.focus();
};
if (loadingChannel) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!channel) return <p className="text-zinc-500">Channel not found.</p>;
const isFollowed = channel.status === "followed";
const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || popularMut.isPending || indexing;
return (
<div className="flex flex-col gap-5">
{/* Banner */}
<div className={`-mx-4 -mt-6 relative ${channel.banner_url ? "" : "bg-zinc-900"} rounded-b-2xl overflow-hidden`}>
{channel.banner_url && (
<img src={channel.banner_url} alt="" className="w-full h-28 sm:h-48 object-cover" />
)}
<div className={channel.banner_url ? "absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" : ""} />
<div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-3`}>
{channel.thumbnail_url ? (
<img src={channel.thumbnail_url} alt={channel.name}
className="w-14 h-14 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40" />
) : (
<div className="w-14 h-14 sm:w-20 sm:h-20 rounded-full bg-zinc-800 flex items-center justify-center text-2xl font-display font-bold text-zinc-400 shrink-0">
{channel.name?.[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<h1 className="font-display font-bold text-lg sm:text-2xl text-white drop-shadow leading-tight">{channel.name}</h1>
<p className="text-xs text-zinc-400 mt-0.5 drop-shadow">
{[
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subs`,
channel.video_count && `${channel.video_count} indexed`,
].filter(Boolean).join(" · ")}
</p>
</div>
<div className="hidden sm:flex items-center gap-2 shrink-0">
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}>
{isFollowed ? "Following" : "Follow"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
</div>
</div>
</div>
{/* Mobile actions */}
<div className="sm:hidden flex items-center gap-2 -mt-1">
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors">
{isFollowed ? "Following ✓" : "Follow"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
</div>
{dlResult != null && (
<p className="text-xs text-zinc-400 font-mono -mt-2">
{dlResult === 0 ? "Already up to date" : `${dlResult} downloads queued`}
</p>
)}
{channel.description && (
<p className="text-xs text-zinc-500 line-clamp-2 -mt-1">{channel.description}</p>
)}
{/* Search bar */}
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1">
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search videos…"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600 transition-colors"
/>
{search && (
<button type="button" onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<button type="submit"
className="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors shrink-0">
Filter
</button>
{activeQ && (
<button
type="button"
onClick={() => deepSearchMut.mutate()}
disabled={deepSearchMut.isPending || indexing}
className="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 transition-colors shrink-0 disabled:opacity-40"
>
Search YouTube
</button>
)}
</form>
{/* Tabs + controls */}
<div className="flex items-center justify-between gap-3 -mt-1 border-b border-zinc-800/60 pb-3">
<div className="flex items-center gap-0.5">
{TABS.map(t => (
<button key={t.value} onClick={() => setTab(t.value)}
className={`text-sm px-3 py-1 rounded-md transition-colors font-medium ${
tab === t.value ? "text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
}`}>
{t.label}
</button>
))}
</div>
<div className="flex items-center gap-3">
{isPending && (
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Fetching
</span>
)}
{tab === "popular" ? (
<button onClick={() => popularMut.mutate()} disabled={isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
Fetch popular
</button>
) : (
<>
<button onClick={() => indexMut.mutate()} disabled={isPending}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
Fetch recent
</button>
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
Fetch all
</button>
</>
)}
</div>
</div>
{/* Sort bar — videos tab only */}
{tab === "videos" && (
<div className="flex items-center gap-0.5 -mt-1">
{SORTS.map(s => (
<button key={s.value} onClick={() => setSort(s.value)}
className={`text-xs px-2.5 py-1 rounded-md transition-colors ${
sort === s.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
}`}>
{s.label}
</button>
))}
</div>
)}
{/* Playlist browser */}
{tab === "playlists" && (
openPlaylistId ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<button onClick={() => { setOpenPlaylistId(null); setPlaylistOffset(0); }}
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors">
Back to playlists
</button>
{(() => {
const pl = playlists.find(p => p.id === openPlaylistId);
return pl ? <span className="text-sm font-medium text-zinc-200 truncate">{pl.title}</span> : null;
})()}
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
disabled={indexPlaylistMut.isPending}
className="ml-auto text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
{indexPlaylistMut.isPending ? "Indexing…" : "Re-index"}
</button>
</div>
{loadingPlaylistVideos ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : playlistVideos?.length ? (
<>
<div className="flex flex-col gap-1">
{playlistVideos.map((v) => (
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
))}
</div>
<div className="flex items-center justify-center gap-4 mt-2">
{playlistOffset > 0 && (
<button onClick={() => setPlaylistOffset(o => Math.max(0, o - 60))}
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
Previous
</button>
)}
{playlistVideos.length === 60 && (
<button onClick={() => setPlaylistOffset(o => o + 60)}
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
Next
</button>
)}
</div>
</>
) : (
<div className="py-8 flex flex-col items-center gap-3">
<p className="text-zinc-500 text-sm">No videos indexed for this playlist yet.</p>
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
disabled={indexPlaylistMut.isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
{indexPlaylistMut.isPending ? "Indexing…" : "Index playlist"}
</button>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500">{playlists.length} playlists</span>
<button onClick={() => fetchPlaylistsMut.mutate()}
disabled={fetchPlaylistsMut.isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
{fetchPlaylistsMut.isPending ? "Fetching…" : "Fetch playlists"}
</button>
</div>
{playlists.length === 0 ? (
<div className="py-8 flex flex-col items-center gap-3">
<p className="text-zinc-500 text-sm">No playlists fetched yet.</p>
<button onClick={() => fetchPlaylistsMut.mutate()}
disabled={fetchPlaylistsMut.isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Fetch playlists from YouTube
</button>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{playlists.map(pl => (
<button key={pl.id} onClick={() => { setOpenPlaylistId(pl.id); setPlaylistOffset(0); }}
className="text-left flex flex-col gap-1.5 rounded-lg overflow-hidden hover:bg-zinc-800/50 transition-colors p-1.5">
<div className="relative aspect-video w-full rounded overflow-hidden bg-zinc-800">
{pl.thumbnail_url ? (
<img src={pl.thumbnail_url} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-zinc-600">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 6h16M4 10h16M4 14h8M4 18h8" />
</svg>
</div>
)}
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{pl.video_count}
</div>
</div>
<p className="text-xs font-medium text-zinc-200 line-clamp-2 leading-snug px-0.5">{pl.title}</p>
{pl.indexed_at && (
<p className="text-xs text-zinc-600 px-0.5">Indexed</p>
)}
</button>
))}
</div>
)}
</div>
)
)}
{/* Video list */}
{tab !== "playlists" && (loadingVideos ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : videos.length ? (
<div className="flex flex-col gap-1">
{videos.map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
))}
{hasNextPage ? (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}
className="mt-4 self-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 py-2 px-4">
{isFetchingNextPage ? "Loading…" : "Load more"}
</button>
) : !activeQ && tab === "videos" && (
<div className="mt-4 flex flex-col items-center gap-3 py-4 border-t border-zinc-800/50">
<p className="text-xs text-zinc-600">{videos.length} videos indexed</p>
<div className="flex items-center gap-4">
<button onClick={() => exploreMut.mutate()} disabled={isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
Explore older videos
</button>
<span className="text-zinc-800 text-xs">·</span>
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
Fetch entire history
</button>
</div>
</div>
)}
</div>
) : (
<div className="py-8 flex flex-col items-center gap-3">
<p className="text-zinc-500 text-sm">
{activeQ
? `No indexed videos match "${activeQ}"`
: tab === "popular"
? "No popular videos fetched yet."
: "No videos indexed yet."}
</p>
{activeQ ? (
<button onClick={() => deepSearchMut.mutate()} disabled={isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Search YouTube for "{activeQ}"
</button>
) : tab === "popular" ? (
<button onClick={() => popularMut.mutate()} disabled={isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Fetch popular videos from YouTube
</button>
) : (
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Fetch all videos
</button>
)}
</div>
))}
</div>
);
}