feat: add bulk add mode for download and watch
This commit is contained in:
2
app.py
2
app.py
@@ -241,6 +241,7 @@ def create_app():
|
|||||||
from routes.content.album import router as album_router
|
from routes.content.album import router as album_router
|
||||||
from routes.content.track import router as track_router
|
from routes.content.track import router as track_router
|
||||||
from routes.content.playlist import router as playlist_router
|
from routes.content.playlist import router as playlist_router
|
||||||
|
from routes.content.bulk_add import router as bulk_add_router
|
||||||
from routes.content.artist import router as artist_router
|
from routes.content.artist import router as artist_router
|
||||||
from routes.system.progress import router as prgs_router
|
from routes.system.progress import router as prgs_router
|
||||||
from routes.core.history import router as history_router
|
from routes.core.history import router as history_router
|
||||||
@@ -263,6 +264,7 @@ def create_app():
|
|||||||
app.include_router(album_router, prefix="/api/album", tags=["album"])
|
app.include_router(album_router, prefix="/api/album", tags=["album"])
|
||||||
app.include_router(track_router, prefix="/api/track", tags=["track"])
|
app.include_router(track_router, prefix="/api/track", tags=["track"])
|
||||||
app.include_router(playlist_router, prefix="/api/playlist", tags=["playlist"])
|
app.include_router(playlist_router, prefix="/api/playlist", tags=["playlist"])
|
||||||
|
app.include_router(bulk_add_router, prefix="/api/bulk", tags=["bulk"])
|
||||||
app.include_router(artist_router, prefix="/api/artist", tags=["artist"])
|
app.include_router(artist_router, prefix="/api/artist", tags=["artist"])
|
||||||
app.include_router(prgs_router, prefix="/api/prgs", tags=["progress"])
|
app.include_router(prgs_router, prefix="/api/prgs", tags=["progress"])
|
||||||
app.include_router(history_router, prefix="/api/history", tags=["history"])
|
app.include_router(history_router, prefix="/api/history", tags=["history"])
|
||||||
|
|||||||
108
routes/content/bulk_add.py
Normal file
108
routes/content/bulk_add.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import re
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Assuming these imports are available for queue management and Spotify info
|
||||||
|
from routes.utils.get_info import get_spotify_info
|
||||||
|
from routes.utils.celery_tasks import download_track, download_album, download_playlist
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class BulkAddLinksRequest(BaseModel):
|
||||||
|
links: List[str]
|
||||||
|
|
||||||
|
@router.post("/bulk-add-spotify-links")
|
||||||
|
async def bulk_add_spotify_links(request: BulkAddLinksRequest):
|
||||||
|
added_count = 0
|
||||||
|
failed_links = []
|
||||||
|
total_links = len(request.links)
|
||||||
|
|
||||||
|
for link in request.links:
|
||||||
|
# Assuming links are pre-filtered by the frontend,
|
||||||
|
# but still handle potential errors during info retrieval or unsupported types
|
||||||
|
# Extract type and ID from the link directly using regex
|
||||||
|
match = re.match(r"https://open\.spotify\.com(?:/intl-[a-z]{2})?/(track|album|playlist|artist)/([a-zA-Z0-9]+)", link)
|
||||||
|
if not match:
|
||||||
|
logger.warning(f"Could not parse Spotify link (unexpected format after frontend filter): {link}")
|
||||||
|
failed_links.append(link)
|
||||||
|
continue
|
||||||
|
|
||||||
|
spotify_type = match.group(1)
|
||||||
|
spotify_id = match.group(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get basic info to confirm existence and get name/artist
|
||||||
|
# For playlists, we might want to get full info later when adding to queue
|
||||||
|
if spotify_type == "playlist":
|
||||||
|
item_info = get_spotify_info(spotify_id, "playlist_metadata")
|
||||||
|
else:
|
||||||
|
item_info = get_spotify_info(spotify_id, spotify_type)
|
||||||
|
|
||||||
|
item_name = item_info.get("name", "Unknown Name")
|
||||||
|
artist_name = ""
|
||||||
|
if spotify_type in ["track", "album"]:
|
||||||
|
artists = item_info.get("artists", [])
|
||||||
|
if artists:
|
||||||
|
artist_name = ", ".join([a.get("name", "Unknown Artist") for a in artists])
|
||||||
|
elif spotify_type == "playlist":
|
||||||
|
owner = item_info.get("owner", {})
|
||||||
|
artist_name = owner.get("display_name", "Unknown Owner")
|
||||||
|
|
||||||
|
# Construct URL for the download task
|
||||||
|
spotify_url = f"https://open.spotify.com/{spotify_type}/{spotify_id}"
|
||||||
|
|
||||||
|
# Add to Celery queue based on type
|
||||||
|
if spotify_type == "track":
|
||||||
|
download_track.delay(
|
||||||
|
url=spotify_url,
|
||||||
|
spotify_id=spotify_id,
|
||||||
|
type=spotify_type,
|
||||||
|
name=item_name,
|
||||||
|
artist=artist_name,
|
||||||
|
download_type="track",
|
||||||
|
)
|
||||||
|
elif spotify_type == "album":
|
||||||
|
download_album.delay(
|
||||||
|
url=spotify_url,
|
||||||
|
spotify_id=spotify_id,
|
||||||
|
type=spotify_type,
|
||||||
|
name=item_name,
|
||||||
|
artist=artist_name,
|
||||||
|
download_type="album",
|
||||||
|
)
|
||||||
|
elif spotify_type == "playlist":
|
||||||
|
download_playlist.delay(
|
||||||
|
url=spotify_url,
|
||||||
|
spotify_id=spotify_id,
|
||||||
|
type=spotify_type,
|
||||||
|
name=item_name,
|
||||||
|
artist=artist_name,
|
||||||
|
download_type="playlist",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unsupported Spotify type for download: {spotify_type} for link: {link}")
|
||||||
|
failed_links.append(link)
|
||||||
|
continue
|
||||||
|
|
||||||
|
added_count += 1
|
||||||
|
logger.debug(f"Added {added_count+1}/{total_links} {spotify_type} '{item_name}' ({spotify_id}) to queue.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Spotify link {link}: {e}", exc_info=True)
|
||||||
|
failed_links.append(link)
|
||||||
|
|
||||||
|
message = f"Successfully added {added_count}/{total_links} links to queue."
|
||||||
|
if failed_links:
|
||||||
|
message += f" Failed to add {len(failed_links)} links."
|
||||||
|
logger.warning(f"Bulk add completed with {len(failed_links)} failures.")
|
||||||
|
else:
|
||||||
|
logger.info(f"Bulk add completed successfully. Added {added_count} links.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": message,
|
||||||
|
"count": added_count,
|
||||||
|
"failed_links": failed_links,
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ export function WatchTab() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Watch settings saved successfully!");
|
toast.success("Watch settings saved successfully!");
|
||||||
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate main config to refresh watch.enabled in SettingsProvider
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
const message = error?.response?.data?.error || error?.message || "Unknown error";
|
const message = error?.response?.data?.error || error?.message || "Unknown error";
|
||||||
|
|||||||
15
spotizerr-ui/src/lib/spotify-utils.ts
Normal file
15
spotizerr-ui/src/lib/spotify-utils.ts
Normal 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: "" };
|
||||||
|
};
|
||||||
@@ -3,9 +3,13 @@ import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router";
|
|||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { TrackType, AlbumType, SearchResult } from "@/types/spotify";
|
import type { TrackType, AlbumType, SearchResult } from "@/types/spotify";
|
||||||
|
import { parseSpotifyUrl} from "@/lib/spotify-utils";
|
||||||
import { QueueContext } from "@/contexts/queue-context";
|
import { QueueContext } from "@/contexts/queue-context";
|
||||||
import { SearchResultCard } from "@/components/SearchResultCard";
|
import { SearchResultCard } from "@/components/SearchResultCard";
|
||||||
import { indexRoute } from "@/router";
|
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
|
// Utility function to safely get properties from search results
|
||||||
const safelyGetProperty = <T,>(obj: any, path: string[], fallback: T): T => {
|
const safelyGetProperty = <T,>(obj: any, path: string[], fallback: T): T => {
|
||||||
@@ -30,10 +34,15 @@ export const Home = () => {
|
|||||||
const { q, type } = useSearch({ from: "/" });
|
const { q, type } = useSearch({ from: "/" });
|
||||||
const { items: allResults } = indexRoute.useLoaderData();
|
const { items: allResults } = indexRoute.useLoaderData();
|
||||||
const isLoading = useRouterState({ select: (s) => s.status === "pending" });
|
const isLoading = useRouterState({ select: (s) => s.status === "pending" });
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const [query, setQuery] = useState(q || "");
|
const [query, setQuery] = useState(q || "");
|
||||||
const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">(type || "track");
|
const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">(type || "track");
|
||||||
const [debouncedQuery] = useDebounce(query, 500);
|
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 [displayedResults, setDisplayedResults] = useState<SearchResult[]>([]);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
@@ -55,6 +64,121 @@ export const Home = () => {
|
|||||||
}
|
}
|
||||||
const { addItem } = context;
|
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(() => {
|
const loadMore = useCallback(() => {
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -159,39 +283,109 @@ export const Home = () => {
|
|||||||
<div className="text-center mb-4 md:mb-8 px-4 md:px-0">
|
<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>
|
<h1 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">Spotizerr</h1>
|
||||||
</div>
|
</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
|
<div className="flex justify-center mb-4 md:mb-6 px-4 md:px-0 border-b border-gray-300 dark:border-gray-700">
|
||||||
type="text"
|
<button
|
||||||
value={query}
|
className={`flex-1 py-2 text-center transition-colors duration-200 ${
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
activeTab === "search"
|
||||||
placeholder="Search for a track, album, or artist"
|
? "border-b-2 border-green-500 text-green-500"
|
||||||
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"
|
: "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
|
||||||
/>
|
}`}
|
||||||
<select
|
onClick={() => setActiveTab("search")}
|
||||||
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>
|
Search
|
||||||
<option value="album">Album</option>
|
</button>
|
||||||
<option value="artist">Artist</option>
|
<button
|
||||||
<option value="playlist">Playlist</option>
|
className={`flex-1 py-2 text-center transition-colors duration-200 ${
|
||||||
</select>
|
activeTab === "bulkAdd"
|
||||||
</div>
|
? "border-b-2 border-green-500 text-green-500"
|
||||||
<div className={`flex-1 px-4 md:px-0 pb-4 ${
|
: "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
|
||||||
// Only restrict overflow on mobile when there are results, otherwise allow normal behavior
|
}`}
|
||||||
displayedResults.length > 0 ? 'overflow-y-auto md:overflow-visible' : ''
|
onClick={() => setActiveTab("bulkAdd")}
|
||||||
}`}>
|
>
|
||||||
{isLoading ? (
|
Bulk Add
|
||||||
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p>
|
</button>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{resultComponent}
|
|
||||||
<div ref={loaderRef} />
|
|
||||||
{isLoadingMore && <p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user