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:
@@ -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`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user