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>
This commit is contained in:
2026-05-26 22:28:35 +02:00
parent d31fc1ef7f
commit 5b0cf27f07
7 changed files with 448 additions and 6 deletions

View File

@@ -155,3 +155,10 @@ export const deleteCollection = (id) => api.delete(`/collections/${id}`);
export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`);
export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId });
export const removeFromCollection = (collectionId, videoId) => api.delete(`/collections/${collectionId}/videos/${videoId}`);
// Playlists
export const getChannelPlaylists = (channelId) => api.get(`/playlists/channel/${channelId}`);
export const fetchChannelPlaylists = (channelId) => api.post(`/playlists/channel/${channelId}/fetch`);
export const getPlaylistVideos = (playlistId, offset = 0, limit = 60) =>
api.get(`/playlists/${playlistId}/videos`, { params: { offset, limit } });
export const indexPlaylist = (playlistId) => api.post(`/playlists/${playlistId}/index`);

View File

@@ -4,14 +4,16 @@ import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tansta
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: "videos", label: "Videos" },
{ value: "popular", label: "Popular" },
{ value: "playlists", label: "Playlists" },
];
const SORTS = [
@@ -38,6 +40,8 @@ export default function ChannelPage() {
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],
@@ -121,6 +125,28 @@ export default function ChannelPage() {
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),
@@ -310,8 +336,115 @@ export default function ChannelPage() {
</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 */}
{loadingVideos ? (
{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>
@@ -369,7 +502,7 @@ export default function ChannelPage() {
</button>
)}
</div>
)}
))}
</div>
);
}