From 8a8a22d2ebe0ee785a485c595240a45a5716604b Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 22 Aug 2025 00:16:41 +0200 Subject: [PATCH 01/33] only show watch button when enabled in settings; better ux when clicking download button --- spotizerr-ui/src/components/AlbumCard.tsx | 38 +++++++- .../src/components/SearchResultCard.tsx | 38 +++++++- spotizerr-ui/src/routes/album.tsx | 36 ++++++- spotizerr-ui/src/routes/artist.tsx | 93 +++++++++++++------ spotizerr-ui/src/routes/playlist.tsx | 91 +++++++++++++----- spotizerr-ui/src/routes/track.tsx | 27 +++++- 6 files changed, 260 insertions(+), 63 deletions(-) diff --git a/spotizerr-ui/src/components/AlbumCard.tsx b/spotizerr-ui/src/components/AlbumCard.tsx index b144983..00026e9 100644 --- a/spotizerr-ui/src/components/AlbumCard.tsx +++ b/spotizerr-ui/src/components/AlbumCard.tsx @@ -1,4 +1,7 @@ import { Link } from "@tanstack/react-router"; +import { useContext, useEffect } from "react"; +import { toast } from "sonner"; +import { QueueContext, getStatus } from "../contexts/queue-context"; import type { AlbumType } from "../types/spotify"; interface AlbumCardProps { @@ -7,6 +10,19 @@ interface AlbumCardProps { } export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => { + const context = useContext(QueueContext); + if (!context) throw new Error("useQueue must be used within a QueueProvider"); + const { items } = context; + const queueItem = items.find(item => item.downloadType === "album" && item.spotifyId === album.id); + const status = queueItem ? getStatus(queueItem) : null; + + useEffect(() => { + if (status === "queued") { + toast.success(`${album.name} queued.`); + } else if (status === "error") { + toast.error(`Failed to queue ${album.name}`); + } + }, [status, album.name]); const imageUrl = album.images && album.images.length > 0 ? album.images[0].url : "/placeholder.jpg"; const subtitle = album.artists.map((artist) => artist.name).join(", "); @@ -21,10 +37,26 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => { e.preventDefault(); onDownload(); }} - className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300" - title="Download album" + disabled={!!status && status !== "error"} + className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 disabled:opacity-50 disabled:cursor-not-allowed" + title={ + status + ? status === "queued" + ? "Album queued" + : status === "error" + ? "Download album" + : "Downloading..." + : "Download album" + } > - Download + {status + ? status === "queued" + ? "Queued." + : status === "error" + ? Download + : "Downloading..." + : Download + } )} diff --git a/spotizerr-ui/src/components/SearchResultCard.tsx b/spotizerr-ui/src/components/SearchResultCard.tsx index 317d3d6..7522bcd 100644 --- a/spotizerr-ui/src/components/SearchResultCard.tsx +++ b/spotizerr-ui/src/components/SearchResultCard.tsx @@ -1,4 +1,7 @@ import { Link } from "@tanstack/react-router"; +import { useContext, useEffect } from "react"; +import { toast } from "sonner"; +import { QueueContext, getStatus } from "../contexts/queue-context"; interface SearchResultCardProps { id: string; @@ -10,6 +13,19 @@ interface SearchResultCardProps { } export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownload }: SearchResultCardProps) => { + const context = useContext(QueueContext); + if (!context) throw new Error("useQueue must be used within a QueueProvider"); + const { items } = context; + const queueItem = items.find(item => item.downloadType === type && item.spotifyId === id); + const status = queueItem ? getStatus(queueItem) : null; + + useEffect(() => { + if (status === "queued") { + toast.success(`${name} queued.`); + } else if (status === "error") { + toast.error(`Failed to queue ${name}`); + } + }, [status]); const getLinkPath = () => { switch (type) { case "track": @@ -32,10 +48,26 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa {onDownload && ( )} diff --git a/spotizerr-ui/src/routes/album.tsx b/spotizerr-ui/src/routes/album.tsx index 502001c..992e844 100644 --- a/spotizerr-ui/src/routes/album.tsx +++ b/spotizerr-ui/src/routes/album.tsx @@ -1,7 +1,7 @@ import { Link, useParams } from "@tanstack/react-router"; import { useEffect, useState, useContext, useRef, useCallback } from "react"; import apiClient from "../lib/api-client"; -import { QueueContext } from "../contexts/queue-context"; +import { QueueContext, getStatus } from "../contexts/queue-context"; import { useSettings } from "../contexts/settings-context"; import type { AlbumType, TrackType } from "../types/spotify"; import { toast } from "sonner"; @@ -24,7 +24,19 @@ export const Album = () => { if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } - const { addItem } = context; + const { addItem, items } = context; + + // Queue status for this album + const albumQueueItem = items.find(item => item.downloadType === "album" && item.spotifyId === album?.id); + const albumStatus = albumQueueItem ? getStatus(albumQueueItem) : null; + + useEffect(() => { + if (albumStatus === "queued") { + toast.success(`${album?.name} queued.`); + } else if (albumStatus === "error") { + toast.error(`Failed to queue ${album?.name}`); + } + }, [albumStatus]); const totalTracks = album?.total_tracks ?? 0; const hasMore = tracks.length < totalTracks; @@ -174,13 +186,27 @@ export const Album = () => {
diff --git a/spotizerr-ui/src/routes/artist.tsx b/spotizerr-ui/src/routes/artist.tsx index 7a59725..9f74b6c 100644 --- a/spotizerr-ui/src/routes/artist.tsx +++ b/spotizerr-ui/src/routes/artist.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react"; import { toast } from "sonner"; import apiClient from "../lib/api-client"; import type { AlbumType, ArtistType, TrackType } from "../types/spotify"; -import { QueueContext } from "../contexts/queue-context"; +import { QueueContext, getStatus } from "../contexts/queue-context"; import { useSettings } from "../contexts/settings-context"; import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa"; import { AlbumCard } from "../components/AlbumCard"; @@ -14,6 +14,7 @@ export const Artist = () => { const [albums, setAlbums] = useState([]); const [topTracks, setTopTracks] = useState([]); const [isWatched, setIsWatched] = useState(false); + const [artistStatus, setArtistStatus] = useState(null); const [error, setError] = useState(null); const context = useContext(QueueContext); const { settings } = useSettings(); @@ -30,7 +31,14 @@ export const Artist = () => { if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } - const { addItem } = context; + const { addItem, items } = context; + + // Track queue status mapping + const trackStatuses = topTracks.reduce((acc, t) => { + const qi = items.find(item => item.downloadType === "track" && item.spotifyId === t.id); + acc[t.id] = qi ? getStatus(qi) : null; + return acc; + }, {} as Record); const applyFilters = useCallback( (items: AlbumType[]) => { @@ -194,6 +202,7 @@ export const Artist = () => { }; const handleDownloadArtist = async () => { + setArtistStatus("downloading"); if (!artistId || !artist) return; try { @@ -203,13 +212,16 @@ export const Artist = () => { const response = await apiClient.get(`/artist/download/${artistId}`); if (response.data.queued_albums?.length > 0) { + setArtistStatus("queued"); toast.success(`${artist.name} discography queued successfully!`, { description: `${response.data.queued_albums.length} albums added to queue.`, }); } else { + setArtistStatus(null); toast.info("No new albums to download for this artist."); } } catch (error: any) { + setArtistStatus("error"); console.error("Artist download failed:", error); toast.error("Failed to download artist", { description: error.response?.data?.error || "An unexpected error occurred.", @@ -273,30 +285,52 @@ export const Artist = () => {
- + {settings?.watch?.enabled && ( + + )}
@@ -318,9 +352,16 @@ export const Artist = () => { ))} diff --git a/spotizerr-ui/src/routes/playlist.tsx b/spotizerr-ui/src/routes/playlist.tsx index 342a65a..93096b5 100644 --- a/spotizerr-ui/src/routes/playlist.tsx +++ b/spotizerr-ui/src/routes/playlist.tsx @@ -4,7 +4,7 @@ import apiClient from "../lib/api-client"; import { useSettings } from "../contexts/settings-context"; import { toast } from "sonner"; import type { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify"; -import { QueueContext } from "../contexts/queue-context"; +import { QueueContext, getStatus } from "../contexts/queue-context"; import { FaArrowLeft } from "react-icons/fa"; @@ -28,7 +28,21 @@ export const Playlist = () => { if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } - const { addItem } = context; + const { addItem, items } = context; + + // Playlist queue status + const playlistQueueItem = playlistMetadata + ? items.find(item => item.downloadType === "playlist" && item.spotifyId === playlistMetadata.id) + : undefined; + const playlistStatus = playlistQueueItem ? getStatus(playlistQueueItem) : null; + + useEffect(() => { + if (playlistStatus === "queued") { + toast.success(`${playlistMetadata?.name} queued.`); + } else if (playlistStatus === "error") { + toast.error(`Failed to queue ${playlistMetadata?.name}`); + } + }, [playlistStatus]); // Load playlist metadata first useEffect(() => { @@ -167,6 +181,14 @@ export const Playlist = () => { return
Loading playlist...
; } + // Map track download statuses + const trackStatuses = tracks.reduce((acc, { track }) => { + if (!track) return acc; + const qi = items.find(item => item.downloadType === "track" && item.spotifyId === track.id); + acc[track.id] = qi ? getStatus(qi) : null; + return acc; + }, {} as Record); + const filteredTracks = tracks.filter(({ track }) => { if (!track) return false; if (settings?.explicitFilter && track.explicit) return false; @@ -209,25 +231,34 @@ export const Playlist = () => {
- + {settings?.watch?.enabled && ( + + )}
@@ -287,10 +318,26 @@ export const Playlist = () => { diff --git a/spotizerr-ui/src/routes/track.tsx b/spotizerr-ui/src/routes/track.tsx index ad85aab..387f7e4 100644 --- a/spotizerr-ui/src/routes/track.tsx +++ b/spotizerr-ui/src/routes/track.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useContext } from "react"; import apiClient from "../lib/api-client"; import type { TrackType } from "../types/spotify"; import { toast } from "sonner"; -import { QueueContext } from "../contexts/queue-context"; +import { QueueContext, getStatus } from "../contexts/queue-context"; import { FaSpotify, FaArrowLeft } from "react-icons/fa"; // Helper to format milliseconds to mm:ss @@ -22,7 +22,19 @@ export const Track = () => { if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } - const { addItem } = context; + const { addItem, items } = context; + + // Track queue status + const trackQueueItem = track ? items.find(item => item.downloadType === "track" && item.spotifyId === track.id) : undefined; + const trackStatus = trackQueueItem ? getStatus(trackQueueItem) : null; + + useEffect(() => { + if (trackStatus === "queued") { + toast.success(`${track?.name} queued.`); + } else if (trackStatus === "error") { + toast.error(`Failed to queue ${track?.name}`); + } + }, [trackStatus]); useEffect(() => { const fetchTrack = async () => { @@ -173,9 +185,16 @@ export const Track = () => {
Date: Thu, 21 Aug 2025 22:16:39 -0700 Subject: [PATCH 02/33] feat(toaster): Implement Toaster and remove inline save messages - #300, #227 Remove save messages --- .../src/components/config/DownloadsTab.tsx | 28 ++----- .../src/components/config/FormattingTab.tsx | 14 +--- .../src/components/config/GeneralTab.tsx | 35 ++++---- .../src/components/config/ServerTab.tsx | 26 +----- .../src/components/config/WatchTab.tsx | 60 +++++++------- spotizerr-ui/src/components/ui/Toaster.tsx | 47 +++++++++++ spotizerr-ui/src/lib/theme.ts | 83 ++++++++++--------- spotizerr-ui/src/main.tsx | 2 + 8 files changed, 155 insertions(+), 140 deletions(-) create mode 100644 spotizerr-ui/src/components/ui/Toaster.tsx diff --git a/spotizerr-ui/src/components/config/DownloadsTab.tsx b/spotizerr-ui/src/components/config/DownloadsTab.tsx index 81eec47..ffabfd0 100644 --- a/spotizerr-ui/src/components/config/DownloadsTab.tsx +++ b/spotizerr-ui/src/components/config/DownloadsTab.tsx @@ -53,7 +53,7 @@ const CONVERSION_FORMATS: Record = { // --- API Functions --- const saveDownloadConfig = async (data: Partial) => { - const payload: any = { ...data }; + const payload: Partial = { ...data }; const { data: response } = await authApiClient.client.post("/config", payload); return response; }; @@ -72,7 +72,6 @@ const fetchCredentials = async (service: "spotify" | "deezer"): Promise(""); - const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); // Fetch watch config const { data: watchConfig } = useQuery({ @@ -89,7 +88,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { }); const { data: deezerCredentials } = useQuery({ - queryKey: ["credentials", "deezer"], + queryKey: ["credentials", "deezer"], queryFn: () => fetchCredentials("deezer"), staleTime: 30000, }); @@ -98,14 +97,11 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { mutationFn: saveDownloadConfig, onSuccess: () => { toast.success("Download settings saved successfully!"); - setSaveStatus("success"); - setTimeout(() => setSaveStatus("idle"), 3000); queryClient.invalidateQueries({ queryKey: ["config"] }); }, onError: (error) => { + console.error("Failed to save settings", error.message); toast.error(`Failed to save settings: ${error.message}`); - setSaveStatus("error"); - setTimeout(() => setSaveStatus("idle"), 3000); }, }); @@ -126,12 +122,12 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { // Validation effect for watch + download method requirement useEffect(() => { let error = ""; - + // Check watch requirements if (watchConfig?.enabled && !realTime && !fallback) { error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled."; } - + // Check fallback account requirements if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) { const missingServices: string[] = []; @@ -139,7 +135,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { if (!deezerCredentials?.length) missingServices.push("Deezer"); error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`; } - + setValidationError(error); }, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]); @@ -180,12 +176,6 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
- {saveStatus === "success" && ( - Saved - )} - {saveStatus === "error" && ( - Save failed - )}
@@ -118,7 +111,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro

Spotify Settings

- + {globalSettings?.explicitFilter ? "Enabled" : "Disabled"} - ENV + + ENV +

diff --git a/spotizerr-ui/src/components/config/ServerTab.tsx b/spotizerr-ui/src/components/config/ServerTab.tsx index e0b288c..26c787a 100644 --- a/spotizerr-ui/src/components/config/ServerTab.tsx +++ b/spotizerr-ui/src/components/config/ServerTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useForm, Controller } from "react-hook-form"; import { authApiClient } from "../../lib/api-client"; import { toast } from "sonner"; @@ -46,20 +46,16 @@ function SpotifyApiForm() { const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig }); const { register, handleSubmit, reset } = useForm(); - const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); const mutation = useMutation({ mutationFn: saveSpotifyApiConfig, onSuccess: () => { toast.success("Spotify API settings saved!"); - setSaveStatus("success"); - setTimeout(() => setSaveStatus("idle"), 3000); queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] }); }, onError: (e) => { + console.error("Failed to save Spotify API settings:", e.message); toast.error(`Failed to save: ${e.message}`); - setSaveStatus("error"); - setTimeout(() => setSaveStatus("idle"), 3000); }, }); @@ -75,12 +71,6 @@ function SpotifyApiForm() {

- {saveStatus === "success" && ( - Saved - )} - {saveStatus === "error" && ( - Save failed - )}
- + {/* Download requirements info */} {downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && (
-

- Download methods required -

+

Download methods required

To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab.

)} - + {/* Fallback account requirements info */} {downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
-

- Fallback accounts required -

+

Fallback accounts required

Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab.

)} - + {/* Validation error display */} {validationError && (

{validationError}

)} - +
- + -

How often to check for new items in watchlist.

+

+ How often to check for new items in watchlist. +

- + -

Batch size per watch cycle (1–50).

+

+ Batch size per watch cycle (1–50). +

-

Artist Album Groups

-

Select which album groups to monitor for watched artists.

+

+ Artist Album Groups +

+

+ Select which album groups to monitor for watched artists. +

{ALBUM_GROUPS.map((group) => ( . Sonner auto-detects, but we can also +// explicitly set className variants for better contrast. (as needed/commented out below) +export const Toaster: React.FC = () => { + const [theme, setTheme] = useState<"light" | "dark" | "system">(getEffectiveTheme()); + + useEffect(() => { + const update = () => setTheme(getEffectiveTheme()); + window.addEventListener("app-theme-changed", update); + window.addEventListener("storage", (e) => { + if (e.key === "theme") update(); + }); + return () => { + window.removeEventListener("app-theme-changed", update); + }; + }, []); + + return ( + + ); +}; + +export default Toaster; diff --git a/spotizerr-ui/src/lib/theme.ts b/spotizerr-ui/src/lib/theme.ts index c41e4bd..aff69de 100644 --- a/spotizerr-ui/src/lib/theme.ts +++ b/spotizerr-ui/src/lib/theme.ts @@ -1,71 +1,80 @@ // Theme management functions -export function getTheme(): 'light' | 'dark' | 'system' { - return (localStorage.getItem('theme') as 'light' | 'dark' | 'system') || 'system'; +export function getTheme(): "light" | "dark" | "system" { + return (localStorage.getItem("theme") as "light" | "dark" | "system") || "system"; } -export function setTheme(theme: 'light' | 'dark' | 'system') { - localStorage.setItem('theme', theme); +export function setTheme(theme: "light" | "dark" | "system") { + localStorage.setItem("theme", theme); applyTheme(theme); + dispatchThemeChange(); } export function toggleTheme() { const currentTheme = getTheme(); - let nextTheme: 'light' | 'dark' | 'system'; - + let nextTheme: "light" | "dark" | "system"; + switch (currentTheme) { - case 'light': - nextTheme = 'dark'; + case "light": + nextTheme = "dark"; break; - case 'dark': - nextTheme = 'system'; + case "dark": + nextTheme = "system"; break; default: - nextTheme = 'light'; + nextTheme = "light"; break; } - + setTheme(nextTheme); return nextTheme; } -function applyTheme(theme: 'light' | 'dark' | 'system') { +function applyTheme(theme: "light" | "dark" | "system") { const root = document.documentElement; - - if (theme === 'system') { + + if (theme === "system") { // Use system preference - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - if (prefersDark) { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - } else if (theme === 'dark') { - root.classList.add('dark'); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (prefersDark) root.classList.add("dark"); + else root.classList.remove("dark"); + } else if (theme === "dark") { + root.classList.add("dark"); } else { - root.classList.remove('dark'); + root.classList.remove("dark"); } } +function dispatchThemeChange() { + window.dispatchEvent(new CustomEvent("app-theme-changed")); +} + +export function getEffectiveTheme(): "light" | "dark" { + const stored = getTheme(); + if (stored === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + return stored; +} + // Dark mode detection and setup export function setupDarkMode() { // First, ensure we start with a clean slate - document.documentElement.classList.remove('dark'); - + document.documentElement.classList.remove("dark"); + const savedTheme = getTheme(); applyTheme(savedTheme); - + dispatchThemeChange(); + // Listen for system theme changes (only when using system theme) - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleSystemThemeChange = (e: MediaQueryListEvent) => { // Only respond to system changes when we're in system mode - if (getTheme() === 'system') { - if (e.matches) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } + if (getTheme() === "system") { + if (e.matches) document.documentElement.classList.add("dark"); + else document.documentElement.classList.remove("dark"); + dispatchThemeChange(); } }; - - mediaQuery.addEventListener('change', handleSystemThemeChange); -} \ No newline at end of file + + mediaQuery.addEventListener("change", handleSystemThemeChange); +} diff --git a/spotizerr-ui/src/main.tsx b/spotizerr-ui/src/main.tsx index e669b74..fb5258d 100644 --- a/spotizerr-ui/src/main.tsx +++ b/spotizerr-ui/src/main.tsx @@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { router } from "./router"; import { AuthProvider } from "./contexts/AuthProvider"; +import { Toaster } from "./components/ui/Toaster"; import { setupDarkMode } from "./lib/theme"; import "./index.css"; @@ -23,6 +24,7 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById("root")!).render( + From 9dc0deafa49a54bf3b7539b32c37b26ce7bf0484 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 22 Aug 2025 20:20:51 +0200 Subject: [PATCH 03/33] fixup: NoneType Float Issue --- routes/system/progress.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/system/progress.py b/routes/system/progress.py index 465407d..c6cbc58 100755 --- a/routes/system/progress.py +++ b/routes/system/progress.py @@ -4,7 +4,7 @@ import logging import time import json import asyncio -from typing import Dict, Set +from typing import Dict, Set, Optional from routes.utils.celery_tasks import ( get_task_info, @@ -141,7 +141,7 @@ def start_sse_redis_subscriber(): thread.start() logger.info("SSE Redis Subscriber: Background thread started") -async def transform_callback_to_task_format(task_id: str, event_data: dict) -> dict: +async def transform_callback_to_task_format(task_id: str, event_data: dict) -> Optional[dict]: """Transform callback event data into the task format expected by frontend""" try: # Import here to avoid circular imports @@ -646,7 +646,7 @@ async def list_tasks(request: Request, current_user: User = Depends(require_auth other_tasks.append(task_response) # Sort other tasks by creation time (newest first) - other_tasks.sort(key=lambda x: x.get("created_at", 0), reverse=True) + other_tasks.sort(key=lambda x: x.get("created_at") or 0.0, reverse=True) if active_only: # Return only active tasks without pagination @@ -876,7 +876,7 @@ async def cancel_task_endpoint(task_id: str, current_user: User = Depends(requir try: # Push an immediate SSE update so clients reflect cancellation and partial summary await trigger_sse_update(task_id, "cancelled") - result["sse_notified"] = True + result["sse_notified"] = "true" except Exception as e: logger.error(f"SSE notify after cancel failed for {task_id}: {e}") return result From 3e95518cea67f49187bd3bb3e5d3af83ee863edf Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 22 Aug 2025 21:51:25 +0200 Subject: [PATCH 04/33] feature: add link to item in history --- spotizerr-ui/src/routes/history.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/spotizerr-ui/src/routes/history.tsx b/spotizerr-ui/src/routes/history.tsx index 7effd19..70a69a0 100644 --- a/spotizerr-ui/src/routes/history.tsx +++ b/spotizerr-ui/src/routes/history.tsx @@ -166,18 +166,32 @@ export const History = () => { cell: (info) => { const entry = info.row.original; const isChild = "album_title" in entry; - return isChild ? ( + const historyEntry = entry as HistoryEntry; + const spotifyId = historyEntry.external_ids?.spotify; + const downloadType = historyEntry.download_type; + + const titleContent = isChild ? ( └─ {entry.title} ) : (
{entry.title} - {(entry as HistoryEntry).children_table && ( + {historyEntry.children_table && ( - {(entry as HistoryEntry).total_tracks || "?"} tracks + {historyEntry.total_tracks || "?"} tracks )}
); + + if (!isChild && spotifyId && downloadType) { + return ( +
+ {titleContent} + + ); + } + + return titleContent; }, }), columnHelper.accessor("artists", { From f3af3fb2cc15cc528a35aea8b760af2bcc1306d4 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 22 Aug 2025 21:53:40 +0200 Subject: [PATCH 05/33] Revert "fixup: NoneType Float Issue" This reverts commit 9dc0deafa49a54bf3b7539b32c37b26ce7bf0484. --- routes/system/progress.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/system/progress.py b/routes/system/progress.py index c6cbc58..465407d 100755 --- a/routes/system/progress.py +++ b/routes/system/progress.py @@ -4,7 +4,7 @@ import logging import time import json import asyncio -from typing import Dict, Set, Optional +from typing import Dict, Set from routes.utils.celery_tasks import ( get_task_info, @@ -141,7 +141,7 @@ def start_sse_redis_subscriber(): thread.start() logger.info("SSE Redis Subscriber: Background thread started") -async def transform_callback_to_task_format(task_id: str, event_data: dict) -> Optional[dict]: +async def transform_callback_to_task_format(task_id: str, event_data: dict) -> dict: """Transform callback event data into the task format expected by frontend""" try: # Import here to avoid circular imports @@ -646,7 +646,7 @@ async def list_tasks(request: Request, current_user: User = Depends(require_auth other_tasks.append(task_response) # Sort other tasks by creation time (newest first) - other_tasks.sort(key=lambda x: x.get("created_at") or 0.0, reverse=True) + other_tasks.sort(key=lambda x: x.get("created_at", 0), reverse=True) if active_only: # Return only active tasks without pagination @@ -876,7 +876,7 @@ async def cancel_task_endpoint(task_id: str, current_user: User = Depends(requir try: # Push an immediate SSE update so clients reflect cancellation and partial summary await trigger_sse_update(task_id, "cancelled") - result["sse_notified"] = "true" + result["sse_notified"] = True except Exception as e: logger.error(f"SSE notify after cancel failed for {task_id}: {e}") return result From ef944e4ffebfd0d13653655734a9316d1ecd658a Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 22 Aug 2025 22:06:42 +0200 Subject: [PATCH 06/33] Revert "fixup: NoneType Float Issue" This reverts commit 9dc0deafa49a54bf3b7539b32c37b26ce7bf0484. --- routes/system/progress.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/system/progress.py b/routes/system/progress.py index c6cbc58..465407d 100755 --- a/routes/system/progress.py +++ b/routes/system/progress.py @@ -4,7 +4,7 @@ import logging import time import json import asyncio -from typing import Dict, Set, Optional +from typing import Dict, Set from routes.utils.celery_tasks import ( get_task_info, @@ -141,7 +141,7 @@ def start_sse_redis_subscriber(): thread.start() logger.info("SSE Redis Subscriber: Background thread started") -async def transform_callback_to_task_format(task_id: str, event_data: dict) -> Optional[dict]: +async def transform_callback_to_task_format(task_id: str, event_data: dict) -> dict: """Transform callback event data into the task format expected by frontend""" try: # Import here to avoid circular imports @@ -646,7 +646,7 @@ async def list_tasks(request: Request, current_user: User = Depends(require_auth other_tasks.append(task_response) # Sort other tasks by creation time (newest first) - other_tasks.sort(key=lambda x: x.get("created_at") or 0.0, reverse=True) + other_tasks.sort(key=lambda x: x.get("created_at", 0), reverse=True) if active_only: # Return only active tasks without pagination @@ -876,7 +876,7 @@ async def cancel_task_endpoint(task_id: str, current_user: User = Depends(requir try: # Push an immediate SSE update so clients reflect cancellation and partial summary await trigger_sse_update(task_id, "cancelled") - result["sse_notified"] = "true" + result["sse_notified"] = True except Exception as e: logger.error(f"SSE notify after cancel failed for {task_id}: {e}") return result From 99eb84372faf5f7fec031308b7487142b04673ab Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 22 Aug 2025 22:09:58 +0200 Subject: [PATCH 07/33] small comment update --- spotizerr-ui/src/components/config/AccountsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spotizerr-ui/src/components/config/AccountsTab.tsx b/spotizerr-ui/src/components/config/AccountsTab.tsx index f2d52c8..62feec6 100644 --- a/spotizerr-ui/src/components/config/AccountsTab.tsx +++ b/spotizerr-ui/src/components/config/AccountsTab.tsx @@ -85,7 +85,7 @@ export function AccountsTab() { onSuccess: () => { toast.success("Account added successfully!"); queryClient.invalidateQueries({ queryKey: ["credentials", activeService] }); - queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify account in UI + queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify/Deezer account in UI setIsAdding(false); setSubmitError(null); reset(); From afb7caa2b4411bc35eddde7e51e3abdbfd43db2b Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 22 Aug 2025 22:10:14 +0200 Subject: [PATCH 08/33] helper function --- routes/auth/credentials.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/routes/auth/credentials.py b/routes/auth/credentials.py index 8b8496f..6427933 100755 --- a/routes/auth/credentials.py +++ b/routes/auth/credentials.py @@ -23,6 +23,22 @@ router = APIRouter() init_credentials_db() +def _set_active_account_if_empty(service: str, name: str): + """ + Sets the newly created account as the active account in the main config + if no active account is currently set for the given service. + """ + try: + from routes.utils.celery_config import get_config_params as get_main_config_params + from routes.system.config import save_config + config = get_main_config_params() + if not config.get(service): + config[service] = name + save_config(config) + except Exception as e: + logger.warning(f"Could not set new {service.capitalize()} account '{name}' as active: {e}") + + @router.get("/spotify_api_config") @router.put("/spotify_api_config") async def handle_spotify_api_config(request: Request, current_user: User = Depends(require_admin_from_state)): @@ -130,18 +146,7 @@ async def handle_create_credential(service: str, name: str, request: Request, cu # Validation is handled within create_credential utility function result = create_credential(service, name, data) - # set as active Spotify account if none is set - if service == "spotify": - try: - from routes.utils.celery_config import get_config_params as get_main_config_params - from routes.system.config import save_config - config = get_main_config_params() - # The field is likely "spotify" (as used in frontend) - if not config.get("spotify"): - config["spotify"] = name - save_config(config) - except Exception as e: - logger.warning(f"Could not set new Spotify account '{name}' as active: {e}") + _set_active_account_if_empty(service, name) return { "message": f"Credential for '{name}' ({service}) created successfully.", From 2c07fdeccc758cebed30159586dc3e7b2cfc7fbe Mon Sep 17 00:00:00 2001 From: Xoconoch Date: Sat, 23 Aug 2025 08:28:10 -0600 Subject: [PATCH 09/33] chore(docs): implement documentation --- .readthedocs.yaml | 11 ++ README.md | 151 ------------------ docs/user/albums.md | 17 ++ docs/user/artists.md | 27 ++++ docs/user/configuration.md | 42 +++++ docs/user/getting-started.md | 93 +++++++++++ docs/user/history.md | 16 ++ docs/user/multi-user.md | 20 +++ docs/user/playlists.md | 26 +++ docs/user/tracks.md | 16 ++ docs/user/watchlist.md | 17 ++ mkdocs.yml | 29 ++++ requirements.txt | 2 +- spotizerr-ui/package.json | 2 +- .../src/components/SearchResultCard.tsx | 2 +- 15 files changed, 317 insertions(+), 154 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 docs/user/albums.md create mode 100644 docs/user/artists.md create mode 100644 docs/user/configuration.md create mode 100644 docs/user/getting-started.md create mode 100644 docs/user/history.md create mode 100644 docs/user/multi-user.md create mode 100644 docs/user/playlists.md create mode 100644 docs/user/tracks.md create mode 100644 docs/user/watchlist.md create mode 100644 mkdocs.yml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..c85cdff --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,11 @@ +version: 2 +mkdocs: + configuration: mkdocs.yml +build: + os: ubuntu-22.04 + tools: + python: "3.11" + jobs: + post_install: + - pip install --upgrade pip + - pip install mkdocs mkdocs-material \ No newline at end of file diff --git a/README.md b/README.md index 5c6ecc6..8ff2833 100644 --- a/README.md +++ b/README.md @@ -27,157 +27,6 @@ If you self-host a music server with other users than yourself, you almost certa image -## ✨ Key Features - -### 🎵 **Granular download support** -- **Individual Tracks** - Download any single track -- **Complete Albums** - Download entire albums with proper metadata -- **Full Playlists** - Download complete playlists (even massive ones with 1000+ tracks) -- **Artist Discographies** - Download an artist's complete catalog with filtering options -- **Spotify URL Support** - Paste any Spotify URL directly to queue downloads - -### 📱 **Modern Web Interface** -- **Progressive Web App (PWA)** - Install as a native client on mobile/desktop (installation process may vary depending on the browser/device) -- **Multiple Themes** - Light, dark, and system themes -- **Touch-friendly** - Swipe gestures and mobile-optimized controls - -### 🤖 **Intelligent Monitoring** -- **Playlist Watching** - Automatically download new tracks added to Spotify playlists -- **Artist Watching** - Monitor artists for new releases and download them automatically -- **Configurable Intervals** - Set how often to check for updates -- **Manual Triggers** - Force immediate checks when needed - -### ⚡ **Advanced Queue Management** -- **Concurrent Downloads** - Configure multiple simultaneous downloads -- **Real-time Updates** - Live progress updates via Server-Sent Events -- **Duplicate Prevention** - Automatically prevents duplicate downloads -- **Queue Persistence** - Downloads continue even after browser restart -- **Cancellation Support** - Cancel individual downloads or clear entire queue - -### 🔧 **Extensive Configuration** -- **Quality Control** - Configure audio quality per service (limitations per account tier apply) -- **Format Options** - Convert to MP3, FLAC, AAC, OGG, OPUS, WAV, ALAC in various bitrates -- **Custom Naming** - Flexible file and folder naming patterns -- **Content Filtering** - Hide explicit content if desired - -### 📊 **Comprehensive History** -- **Download Tracking** - Complete history of all downloads with metadata -- **Success Analytics** - Track success rates, failures, and skipped items -- **Search & Filter** - Find past downloads by title, artist, or status -- **Detailed Logs** - View individual track status for album/playlist downloads -- **Export Data** - Access complete metadata and external service IDs - -### 👥 **Multi-User Support** -- **User Authentication** - Secure login system with JWT tokens -- **SSO Integration** - Single Sign-On with Google and GitHub -- **Admin Panel** - User management and system configuration - -## 🚀 Quick Start - -### Prerequisites -- Docker and Docker Compose -- Spotify account(s) -- Deezer account(s) (optional, but recommended) -- Spotify API credentials (Client ID & Secret from [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)) - -### Installation - -1. **Create project directory** - ```bash - mkdir spotizerr && cd spotizerr - ``` - -2. **Setup environment file** - ```bash - # Download .env.example from the repository and create .env - # Update all variables (e.g. Redis credentials, PUID/PGID, UMASK) - ``` - -3. **Copy docker-compose.yaml** - ```bash - # Download docker-compose.yaml from the repository - ``` - -4. **Start the application** - ```bash - docker compose up -d - ``` - -5. **Next steps** - - Before doing anything, it is recommended to go straight to [Configuration](#-configuration) - -## 🔧 Configuration - -### Service Accounts Setup - -1. **Spotify setup** - - Spotify is very restrictive, so use the [spotizerr-auth](https://github.com/Xoconoch/spotizerr-auth) tool on a computer with the spotify client installed to simplify this part of the setup. - -2. **Deezer setup (Optional but recommended for better stability, even if it's a free account)** - - Get your Deezer ARL token: - - **Chrome/Edge**: Open [Deezer](https://www.deezer.com/), press F12 → Application → Cookies → "https://www.deezer.com" → Copy "arl" value - - **Firefox**: Open [Deezer](https://www.deezer.com/), press F12 → Storage → Cookies → "https://www.deezer.com" → Copy "arl" value - - Add the ARL token in Settings → Accounts - -3. **Configure Download Settings** - - Set audio quality preferences - - Configure output format and naming - - Adjust concurrent download limits - -### Watch System Setup - -1. **Enable Monitoring** - - Go to Settings → Watch - - Enable the watch system - - Set check intervals - -2. **Add Items to Watch** - - Search for playlists or artists - - Click the "Watch" button - - New content will be automatically downloaded - -## 📋 Usage Examples - -### Download a Playlist -1. Search for the playlist or paste its Spotify URL -2. Click the download button -3. Monitor progress in the real-time queue - -### Monitor an Artist -1. Search for the artist -2. Click "Add to Watchlist" -3. Configure which release types to monitor (albums, singles, etc.) -4. New releases will be automatically downloaded - -### Bulk Download an Artist's Discography -1. Go to the artist page -2. Select release types (albums, singles, compilations) -3. Click "Download Discography" -4. All albums will be queued automatically - -## 🔍 Advanced Features - -### Custom File Naming -Configure how files and folders are named: -- `%artist%/%album%/%tracknum%. %title%` -- `%ar_album%/%album% (%year%)/%title%` -- Support for track numbers, artists, albums, years, and more - -### Quality Settings -- **Spotify**: OGG 96k, 160k, and 320k (320k requires Premium) -- **Deezer**: MP3 128k, MP3 320k (sometimes requires Premium), and FLAC (Premium only) -- **Conversion**: Convert to any supported format with custom bitrate - -### Fallback System -- Configure primary and fallback services -- Automatically switches if primary service fails -- Useful for geographic restrictions or account limits - -### Real-time Mode -- **Spotify only**: Matches track length with download time for optimal timing - -## 🆘 Support & Troubleshooting - ### Common Issues **Downloads not starting?** diff --git a/docs/user/albums.md b/docs/user/albums.md new file mode 100644 index 0000000..838d7bf --- /dev/null +++ b/docs/user/albums.md @@ -0,0 +1,17 @@ +## Albums + +Open an album from search or artist page. + +- Download + - Download full album + - Download individual tracks from the tracklist +- Tracklist + - Shows order, artists, and duration + - Respects explicit filter (hidden if enabled) +- Large albums + - Tracks load progressively as you scroll + +Backend endpoints used: +- GET `/api/album/info?id=...&limit=50&offset=...` (metadata + paged tracks) +- GET `/api/album/download/{album_id}` (queue download) +- GET `/api/progress/stream` (live queue updates) \ No newline at end of file diff --git a/docs/user/artists.md b/docs/user/artists.md new file mode 100644 index 0000000..34dd131 --- /dev/null +++ b/docs/user/artists.md @@ -0,0 +1,27 @@ +## Artists + +Open an artist from search. + +- Discography + - Albums, Singles, Compilations, Appears On sections + - Infinite loading as you scroll +- Download + - Download all (queues albums respecting filters) + - Download any album individually +- Watch + - Add/remove artist to Watchlist (auto-download new releases when enabled) + +How-to: monitor an artist +1. Search for the artist and open their page +2. Click Watch +3. Configure release types and intervals in Configuration → Watch + +How-to: download discography +1. Open the artist page +2. Choose release types (e.g., Albums, Singles, Compilations) +3. Click Download All; track progress in Queue and History + +Backend endpoints used: +- GET `/api/artist/info?id=...&limit=20&offset=...` (metadata + paged albums) +- GET `/api/artist/download/{artist_id}?album_type=album,single,compilation` (queue discography) +- PUT `/api/artist/watch/{artist_id}` / DELETE `/api/artist/watch/{artist_id}` / GET `/api/artist/watch/{artist_id}/status` \ No newline at end of file diff --git a/docs/user/configuration.md b/docs/user/configuration.md new file mode 100644 index 0000000..d1871f1 --- /dev/null +++ b/docs/user/configuration.md @@ -0,0 +1,42 @@ +## Configuration + +Open Configuration in the web UI. Tabs: + +- General (admin) + - App version, basic info +- Downloads (admin) + - Concurrent downloads, retry behavior + - Quality/format defaults and conversion + - Real-time mode (Spotify only): aligns download time with track length +- Formatting (admin) + - File/folder naming patterns (examples) + - `%artist%/%album%/%tracknum%. %title%` + - `%ar_album%/%album% (%year%)/%title%` +- Accounts (admin) + - Spotify: use `spotizerr-auth` to add credentials + - Deezer ARL (optional): + - Chrome/Edge: devtools → Application → Cookies → https://www.deezer.com → copy `arl` + - Firefox: devtools → Storage → Cookies → https://www.deezer.com → copy `arl` + - Paste ARL in Accounts + - Select main account when multiple exist +- Watch (admin) + - Enable/disable watch system + - Set check intervals + - Manually trigger checks (artists/playlists) +- Server (admin) + - System info and advanced settings +- Profile (all users when auth is enabled) + - Change password, view role and email + +Quality formats (reference): +- Spotify: OGG 96k/160k/320k (320k requires Premium) +- Deezer: MP3 128k/320k (320k may require Premium), FLAC (Premium) +- Conversion: MP3/FLAC/AAC/OGG/OPUS/WAV/ALAC with custom bitrate + +Fallback system: +- Configure primary and fallback services +- Automatically switches if primary fails (useful for geo/account limits) + +Notes: +- Explicit content filter applies in pages (e.g., hides explicit tracks on album/playlist views) +- Watch system must be enabled before adding items \ No newline at end of file diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md new file mode 100644 index 0000000..a6c2291 --- /dev/null +++ b/docs/user/getting-started.md @@ -0,0 +1,93 @@ +## Getting started + +### Prerequisites +- Docker and Docker Compose +- Spotify account(s) +- Deezer account (optional, recommended for FLAC) +- Spotify API `client_id` and `client_secret` (from Spotify Developer Dashboard) + +Quick start (Docker Compose): + +```bash +# 1) Create a project directory +mkdir spotizerr && cd spotizerr + +# 2) Add .env +# Download .env.example from the repo and create .env with your values + +# 3) Add docker-compose.yaml +# Download docker-compose.yaml from the repo to this folder + +# 4) Start +docker compose up -d +``` + +### Initial setup +- Open the web UI (default: `http://localhost:7171`) +- Go to Configuration → Accounts +- Use `spotizerr-auth` to register Spotify credentials quickly + +Spotify account setup with spotizerr-auth: + +```bash +docker run --network=host --rm -it cooldockerizer93/spotizerr-auth +``` +or, if docker doesn't work: + +#### Alternative installers + +
+Linux / macOS + +```bash +python3 -m venv .venv && source .venv/bin/activate && pip install spotizerr-auth +```` + +
+ +
+Windows (PowerShell) + +```powershell +python -m venv .venv; .venv\Scripts\Activate.ps1; pip install spotizerr-auth +``` + +
+ +
+Windows (cmd.exe) + +```cmd +python -m venv .venv && .venv\Scripts\activate && pip install spotizerr-auth +``` + +
+ +Then run `spotizerr-auth`. + +_Note: You will have to enable the virtual environment everytime you want to register a new account._ + +### Registering account +- Ensure Spotify client is opened before starting +- Enter Spotizerr URL (e.g., http://localhost:7171) +- Enter Spotify API `client_id` and `client_secret` if prompted (one-time) +- Name the account + region code (e.g., US) +- Transfer playback to the temporary device when asked +- Credentials are posted to Spotizerr automatically + +**Next steps:** +- Add Deezer ARL in Configuration → Accounts (optional, allows for FLAC availability if premium) +- Adjust Download and Formatting options +- Enable Watch system if you want automatic downloads + +**Troubleshooting (quick):** +- Downloads not starting: verify service credentials and API keys +- Watch not working: enable in Configuration → Watch and set intervals +- Auth issues: ensure JWT secret and SSO creds (if used); try clearing browser cache +- Queue stalling: force-refresh the page (ctrl+F5) + +**Logs:** +```bash +docker logs spotizerr +``` +- Enable Watch system if you want auto-downloads \ No newline at end of file diff --git a/docs/user/history.md b/docs/user/history.md new file mode 100644 index 0000000..d1f0969 --- /dev/null +++ b/docs/user/history.md @@ -0,0 +1,16 @@ +## History + +See all downloads and their outcomes. + +- Filters + - By type (track/album/playlist) and status (completed/failed/skipped/in_progress) + - Pagination for large histories +- Drill-down + - Open an entry to view child tracks for albums/playlists + - Re-queue failures from the UI + +Backend endpoints used: +- GET `/api/history?download_type=&status=&limit=&offset=` +- GET `/api/history/{task_id}` (entry) +- GET `/api/history/{task_id}/children` (child tracks) +- GET `/api/history/stats`, `/api/history/recent`, `/api/history/failed` (summaries) \ No newline at end of file diff --git a/docs/user/multi-user.md b/docs/user/multi-user.md new file mode 100644 index 0000000..0030da8 --- /dev/null +++ b/docs/user/multi-user.md @@ -0,0 +1,20 @@ +## Multi-user + +Authentication is optional. When enabled: + +- Login/Register + - Local accounts with username/password + - First registered user becomes admin + - Public registration can be disabled +- SSO (optional) + - Google and GitHub when configured +- Roles + - User: can search/download, manage their profile + - Admin: access to all Configuration tabs and user management +- Admin actions + - Create/delete users, change roles + - Reset user passwords + +Where to find it in the UI: +- User menu (top-right) → Profile settings +- Configuration → User Management (admin) \ No newline at end of file diff --git a/docs/user/playlists.md b/docs/user/playlists.md new file mode 100644 index 0000000..adeab3b --- /dev/null +++ b/docs/user/playlists.md @@ -0,0 +1,26 @@ +## Playlists + +Open a playlist from search. + +- Download + - Download entire playlist + - Download individual tracks +- Metadata and tracks + - Loads metadata first (fast, avoids rate limits) + - Tracks load in pages as you scroll +- Watch + - Add/remove playlist to Watchlist (auto-download new additions when enabled) + +How-to: download a playlist +1. Search for the playlist or paste its Spotify URL +2. Click Download +3. Monitor progress in the Queue; results appear in History + +Backend endpoints used: +- GET `/api/playlist/metadata?id=...` (metadata only) +- GET `/api/playlist/tracks?id=...&limit=50&offset=...` (paged tracks) +- GET `/api/playlist/info?id=...&include_tracks=true` (full info when needed) +- GET `/api/playlist/download/{playlist_id}` (queue download) +- PUT `/api/playlist/watch/{playlist_id}` (watch) +- DELETE `/api/playlist/watch/{playlist_id}` (unwatch) +- GET `/api/playlist/watch/{playlist_id}/status` (status) \ No newline at end of file diff --git a/docs/user/tracks.md b/docs/user/tracks.md new file mode 100644 index 0000000..e51cf33 --- /dev/null +++ b/docs/user/tracks.md @@ -0,0 +1,16 @@ +## Tracks + +Find a track via search or open a track page. + +- Download + - Click Download on result card or track page + - Progress visible in the Queue drawer +- Open on Spotify + - From track page, open the Spotify link +- Details shown + - Artists, album, duration, popularity + +Backend endpoints used: +- GET `/api/track/info?id=...` (metadata) +- GET `/api/track/download/{track_id}` (queue download) +- GET `/api/progress/stream` (live queue updates) \ No newline at end of file diff --git a/docs/user/watchlist.md b/docs/user/watchlist.md new file mode 100644 index 0000000..7e21baa --- /dev/null +++ b/docs/user/watchlist.md @@ -0,0 +1,17 @@ +## Watchlist + +Enable the watch system in Configuration → Watch first. + +- Add items + - From Artist or Playlist pages, click Watch +- What it does + - Periodically checks watched items + - Queues new releases (artists) and/or newly added tracks (playlists) +- Setup + - Enable watch system and set intervals in Configuration → Watch + - Trigger a manual check if you want immediate processing + +Backend endpoints used: +- Artists: PUT/DELETE/GET status under `/api/artist/watch/*` +- Playlists: PUT/DELETE/GET status under `/api/playlist/watch/*` +- Manual triggers: POST `/api/artist/watch/trigger_check` and `/api/playlist/watch/trigger_check` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..0b324b4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,29 @@ +site_name: Spotizerr Documentation +site_description: Straight-to-the-point docs for Spotizerr +site_dir: site +use_directory_urls: true +theme: + name: material + features: + - navigation.instant + - navigation.tracking + - content.action.edit + - search.suggest + - search.highlight +nav: + - Getting started: docs/user/getting-started.md + - Configuration: docs/user/configuration.md + - Tracks: docs/user/tracks.md + - Albums: docs/user/albums.md + - Playlists: docs/user/playlists.md + - Artists: docs/user/artists.md + - Watchlist: docs/user/watchlist.md + - History: docs/user/history.md + - Multi-user: docs/user/multi-user.md + - API: docs/API_DOCUMENTATION.md +markdown_extensions: + - toc: + permalink: true + - admonition + - tables + - fenced_code \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index eb1861e..0e42ced 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.116.1 uvicorn[standard]==0.35.0 celery==5.5.3 -deezspot-spotizerr==2.7.3 +deezspot-spotizerr==2.7.4 httpx==0.28.1 bcrypt==4.2.1 PyJWT==2.10.1 diff --git a/spotizerr-ui/package.json b/spotizerr-ui/package.json index 1473397..59c1f3a 100644 --- a/spotizerr-ui/package.json +++ b/spotizerr-ui/package.json @@ -1,7 +1,7 @@ { "name": "spotizerr-ui", "private": true, - "version": "3.2.1", + "version": "3.2.2", "type": "module", "scripts": { "dev": "vite", diff --git a/spotizerr-ui/src/components/SearchResultCard.tsx b/spotizerr-ui/src/components/SearchResultCard.tsx index 7522bcd..9add427 100644 --- a/spotizerr-ui/src/components/SearchResultCard.tsx +++ b/spotizerr-ui/src/components/SearchResultCard.tsx @@ -49,7 +49,7 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa