feat: add bulk add mode for download and watch

This commit is contained in:
Phlogi
2025-08-23 21:35:29 +02:00
parent a4bc9780e0
commit 2f11233ea1
5 changed files with 351 additions and 31 deletions

View File

@@ -89,6 +89,7 @@ export function WatchTab() {
onSuccess: () => {
toast.success("Watch settings saved successfully!");
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate main config to refresh watch.enabled in SettingsProvider
},
onError: (error: any) => {
const message = error?.response?.data?.error || error?.message || "Unknown error";

View File

@@ -0,0 +1,15 @@
export interface ParsedSpotifyUrl {
type: "track" | "album" | "playlist" | "artist" | "unknown";
id: string;
}
export const parseSpotifyUrl = (url: string): ParsedSpotifyUrl => {
const match = url.match(/https:\/\/open\.spotify\.com(?:\/intl-[a-z]{2})?\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)(?:\?.*)?/);
if (match) {
return {
type: match[1] as ParsedSpotifyUrl["type"],
id: match[2],
};
}
return { type: "unknown", id: "" };
};

View File

@@ -3,9 +3,13 @@ import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router";
import { useDebounce } from "use-debounce";
import { toast } from "sonner";
import type { TrackType, AlbumType, SearchResult } from "@/types/spotify";
import { parseSpotifyUrl} from "@/lib/spotify-utils";
import { QueueContext } from "@/contexts/queue-context";
import { SearchResultCard } from "@/components/SearchResultCard";
import { indexRoute } from "@/router";
import { authApiClient } from "@/lib/api-client";
import { useSettings } from "@/contexts/settings-context";
import { FaEye } from "react-icons/fa";
// Utility function to safely get properties from search results
const safelyGetProperty = <T,>(obj: any, path: string[], fallback: T): T => {
@@ -30,10 +34,15 @@ export const Home = () => {
const { q, type } = useSearch({ from: "/" });
const { items: allResults } = indexRoute.useLoaderData();
const isLoading = useRouterState({ select: (s) => s.status === "pending" });
const { settings } = useSettings();
const [query, setQuery] = useState(q || "");
const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">(type || "track");
const [debouncedQuery] = useDebounce(query, 500);
const [activeTab, setActiveTab] = useState<"search" | "bulkAdd">("search");
const [linksInput, setLinksInput] = useState("");
const [isBulkAdding, setIsBulkAdding] = useState(false);
const [isBulkWatching, setIsBulkWatching] = useState(false);
const [displayedResults, setDisplayedResults] = useState<SearchResult[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -55,6 +64,121 @@ export const Home = () => {
}
const { addItem } = context;
const handleAddBulkLinks = useCallback(async () => {
const allLinks = linksInput.split("\n").map((link) => link.trim()).filter(Boolean);
if (allLinks.length === 0) {
toast.info("No links provided to add.");
return;
}
const supportedLinks: string[] = [];
const unsupportedLinks: string[] = [];
allLinks.forEach((link) => {
const parsed = parseSpotifyUrl(link);
if (parsed.type !== "unknown") {
supportedLinks.push(link);
} else {
unsupportedLinks.push(link);
}
});
if (unsupportedLinks.length > 0) {
toast.warning("Some links are not supported and will be skipped.", {
description: `Unsupported: ${unsupportedLinks.join(", ")}`,
});
}
if (supportedLinks.length === 0) {
toast.info("No supported links to add.");
return;
}
setIsBulkAdding(true);
try {
const response = await authApiClient.client.post("/bulk/bulk-add-spotify-links", { links: supportedLinks });
const { message, count, failed_links } = response.data;
if (failed_links && failed_links.length > 0) {
toast.warning("Bulk Add Completed with Warnings", {
description: `${count} links added. Failed to add ${failed_links.length} links: ${failed_links.join(", ")}`,
});
} else {
toast.success("Bulk Add Successful", {
description: `${count} links added to queue.`,
});
}
setLinksInput(""); // Clear input after successful add
} catch (error: any) {
const errorMessage = error.response?.data?.detail?.message || error.message;
const failedLinks = error.response?.data?.detail?.failed_links || [];
let description = errorMessage;
if (failedLinks.length > 0) {
description += ` Failed links: ${failedLinks.join(", ")}`;
}
toast.error("Bulk Add Failed", {
description: description,
});
if (failedLinks.length > 0) {
console.error("Failed links:", failedLinks);
}
} finally {
setIsBulkAdding(false);
}
}, [linksInput]);
const handleWatchBulkLinks = useCallback(async () => {
const links = linksInput.split("\n").map((link) => link.trim()).filter(Boolean);
if (links.length === 0) {
toast.info("No links provided to watch.");
return;
}
const supportedLinks: { type: "artist" | "playlist"; id: string }[] = [];
const unsupportedLinks: string[] = [];
links.forEach((link) => {
const parsed = parseSpotifyUrl(link);
if (parsed.type === "artist" || parsed.type === "playlist") {
supportedLinks.push({ type: parsed.type, id: parsed.id });
} else {
unsupportedLinks.push(link);
}
});
if (unsupportedLinks.length > 0) {
toast.warning("Some links are not supported for watching.", {
description: `Unsupported: ${unsupportedLinks.join(", ")}`,
});
}
if (supportedLinks.length === 0) {
toast.info("No supported links to watch.");
return;
}
setIsBulkWatching(true);
try {
const watchPromises = supportedLinks.map((item) =>
authApiClient.client.put(`/${item.type}/watch/${item.id}`)
);
await Promise.all(watchPromises);
toast.success("Bulk Watch Successful", {
description: `${supportedLinks.length} supported links added to watchlist.`,
});
setLinksInput(""); // Clear input after successful add
} catch (error: any) {
const errorMessage = error.response?.data?.detail?.message || error.message;
toast.error("Bulk Watch Failed", {
description: errorMessage,
});
} finally {
setIsBulkWatching(false);
}
}, [linksInput]);
const loadMore = useCallback(() => {
setIsLoadingMore(true);
setTimeout(() => {
@@ -159,39 +283,109 @@ export const Home = () => {
<div className="text-center mb-4 md:mb-8 px-4 md:px-0">
<h1 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">Spotizerr</h1>
</div>
<div className="flex flex-col sm:flex-row gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a track, album, or artist"
className="flex-1 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")}
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
<div className="flex justify-center mb-4 md:mb-6 px-4 md:px-0 border-b border-gray-300 dark:border-gray-700">
<button
className={`flex-1 py-2 text-center transition-colors duration-200 ${
activeTab === "search"
? "border-b-2 border-green-500 text-green-500"
: "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
}`}
onClick={() => setActiveTab("search")}
>
<option value="track">Track</option>
<option value="album">Album</option>
<option value="artist">Artist</option>
<option value="playlist">Playlist</option>
</select>
</div>
<div className={`flex-1 px-4 md:px-0 pb-4 ${
// Only restrict overflow on mobile when there are results, otherwise allow normal behavior
displayedResults.length > 0 ? 'overflow-y-auto md:overflow-visible' : ''
}`}>
{isLoading ? (
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p>
) : (
<>
{resultComponent}
<div ref={loaderRef} />
{isLoadingMore && <p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>}
</>
)}
Search
</button>
<button
className={`flex-1 py-2 text-center transition-colors duration-200 ${
activeTab === "bulkAdd"
? "border-b-2 border-green-500 text-green-500"
: "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
}`}
onClick={() => setActiveTab("bulkAdd")}
>
Bulk Add
</button>
</div>
{activeTab === "search" && (
<>
<div className="flex flex-col gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
<div className="flex flex-col sm:flex-row gap-3">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a track, album, or artist"
className="flex-1 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")}
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
>
<option value="track">Track</option>
<option value="album">Album</option>
<option value="artist">Artist</option>
<option value="playlist">Playlist</option>
</select>
</div>
</div>
<div className={`flex-1 px-4 md:px-0 pb-4 ${
// Only restrict overflow on mobile when there are results, otherwise allow normal behavior
displayedResults.length > 0 ? 'overflow-y-auto md:overflow-visible' : ''
}`}>
{isLoading ? (
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p>
) : (
<>
{resultComponent}
<div ref={loaderRef} />
{isLoadingMore && <p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>}
</>
)}
</div>
</>
)}
{activeTab === "bulkAdd" && (
<div className="flex flex-col gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
<textarea
className="w-full h-60 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md mb-4 focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="Paste Spotify links here, one per line..."
value={linksInput}
onChange={(e) => setLinksInput(e.target.value)}
></textarea>
<div className="flex justify-end gap-3">
<button
onClick={() => setLinksInput("")} // Clear input
className="px-4 py-2 bg-gray-300 dark:bg-gray-700 text-content-primary dark:text-content-primary-dark rounded-md hover:bg-gray-400 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Clear
</button>
<button
onClick={handleAddBulkLinks}
disabled={isBulkAdding}
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isBulkAdding ? "Adding..." : "Download"}
</button>
{settings?.watch?.enabled && (
<button
onClick={handleWatchBulkLinks}
disabled={isBulkWatching}
className="px-4 py-2 bg-error hover:bg-error-hover text-button-primary-text rounded-md flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="Only Spotify Artist and Playlist links are supported for watching."
>
{isBulkWatching ? "Watching..." : (
<>
<FaEye className="icon-inverse" /> Watch
</>
)}
</button>
)}
</div>
</div>
)}
</div>
);
};