From 2f11233ea1443c77f343f320c449fbec636b74a8 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Sat, 23 Aug 2025 21:35:29 +0200 Subject: [PATCH 1/4] feat: add bulk add mode for download and watch --- app.py | 2 + routes/content/bulk_add.py | 108 ++++++++ .../src/components/config/WatchTab.tsx | 1 + spotizerr-ui/src/lib/spotify-utils.ts | 15 + spotizerr-ui/src/routes/home.tsx | 256 +++++++++++++++--- 5 files changed, 351 insertions(+), 31 deletions(-) create mode 100644 routes/content/bulk_add.py create mode 100644 spotizerr-ui/src/lib/spotify-utils.ts diff --git a/app.py b/app.py index c2449b4..4d182ab 100755 --- a/app.py +++ b/app.py @@ -241,6 +241,7 @@ def create_app(): from routes.content.album import router as album_router from routes.content.track import router as track_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.system.progress import router as prgs_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(track_router, prefix="/api/track", tags=["track"]) 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(prgs_router, prefix="/api/prgs", tags=["progress"]) app.include_router(history_router, prefix="/api/history", tags=["history"]) diff --git a/routes/content/bulk_add.py b/routes/content/bulk_add.py new file mode 100644 index 0000000..f0480e6 --- /dev/null +++ b/routes/content/bulk_add.py @@ -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, + } \ No newline at end of file diff --git a/spotizerr-ui/src/components/config/WatchTab.tsx b/spotizerr-ui/src/components/config/WatchTab.tsx index f75d9d8..2722293 100644 --- a/spotizerr-ui/src/components/config/WatchTab.tsx +++ b/spotizerr-ui/src/components/config/WatchTab.tsx @@ -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"; diff --git a/spotizerr-ui/src/lib/spotify-utils.ts b/spotizerr-ui/src/lib/spotify-utils.ts new file mode 100644 index 0000000..96f0f03 --- /dev/null +++ b/spotizerr-ui/src/lib/spotify-utils.ts @@ -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: "" }; +}; \ No newline at end of file diff --git a/spotizerr-ui/src/routes/home.tsx b/spotizerr-ui/src/routes/home.tsx index f969a5e..23064c7 100644 --- a/spotizerr-ui/src/routes/home.tsx +++ b/spotizerr-ui/src/routes/home.tsx @@ -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 = (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([]); 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 = () => {

Spotizerr

-
- 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" - /> - -
-
0 ? 'overflow-y-auto md:overflow-visible' : '' - }`}> - {isLoading ? ( -

Loading results...

- ) : ( - <> - {resultComponent} -
- {isLoadingMore &&

Loading more results...

} - - )} + Search + +
+ + {activeTab === "search" && ( + <> +
+
+ 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" + /> + +
+
+
0 ? 'overflow-y-auto md:overflow-visible' : '' + }`}> + {isLoading ? ( +

Loading results...

+ ) : ( + <> + {resultComponent} +
+ {isLoadingMore &&

Loading more results...

} + + )} +
+ + )} + + {activeTab === "bulkAdd" && ( +
+ +
+ + + {settings?.watch?.enabled && ( + + )} +
+
+ )}
); }; From 4049bea29e4afed63b61bd094e35eb2fce8d34be Mon Sep 17 00:00:00 2001 From: Phlogi Date: Sat, 23 Aug 2025 21:48:10 +0200 Subject: [PATCH 2/4] match the more flexible regexp allowing more URLs --- routes/content/bulk_add.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/content/bulk_add.py b/routes/content/bulk_add.py index f0480e6..b5471ea 100644 --- a/routes/content/bulk_add.py +++ b/routes/content/bulk_add.py @@ -24,7 +24,7 @@ async def bulk_add_spotify_links(request: BulkAddLinksRequest): # 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) + 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) From 965362cddcb1548dc7d13794b63a937a98c79251 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Sat, 23 Aug 2025 21:54:43 +0200 Subject: [PATCH 3/4] add download icon --- spotizerr-ui/src/routes/home.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/spotizerr-ui/src/routes/home.tsx b/spotizerr-ui/src/routes/home.tsx index 23064c7..1f0cf81 100644 --- a/spotizerr-ui/src/routes/home.tsx +++ b/spotizerr-ui/src/routes/home.tsx @@ -9,7 +9,7 @@ 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"; +import { FaEye, FaDownload } from "react-icons/fa"; // Utility function to safely get properties from search results const safelyGetProperty = (obj: any, path: string[], fallback: T): T => { @@ -365,9 +365,13 @@ export const Home = () => { {settings?.watch?.enabled && (