diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 17e5644..2984545 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,6 +6,7 @@ on: tags: ["v*.*.*"] pull_request: branches: ["main"] + workflow_dispatch: env: REGISTRY: ghcr.io @@ -17,9 +18,9 @@ jobs: fail-fast: false matrix: include: - - image: lklynet/aurral-backend + - image: aurral-backend context: ./backend - - image: lklynet/aurral-frontend + - image: aurral-frontend context: ./frontend permissions: @@ -45,7 +46,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ matrix.image }} + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} tags: | type=ref,event=branch type=ref,event=pr diff --git a/backend/server.js b/backend/server.js index c506e40..4fdc6a4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -873,6 +873,7 @@ app.post("/api/lidarr/artists", async (req, res) => { monitored, searchForMissingAlbums, albumFolders, + selectedAlbums, // Array of MusicBrainz release group IDs to monitor } = req.body; if (!foreignArtistId || !artistName) { @@ -921,6 +922,11 @@ app.post("/api/lidarr/artists", async (req, res) => { metadataProfile = metadataProfiles[0].id; } + // If selectedAlbums is provided, use "none" as monitor option initially + // Then we'll update individual albums after + const hasGranularSelection = Array.isArray(selectedAlbums) && selectedAlbums.length > 0; + const monitorOption = hasGranularSelection ? "none" : (req.body.monitor || "all"); + const artistData = { foreignArtistId, artistName, @@ -930,13 +936,48 @@ app.post("/api/lidarr/artists", async (req, res) => { monitored: isMonitored, albumFolder: useAlbumFolders, addOptions: { - searchForMissingAlbums: searchMissing, - monitor: req.body.monitor || "all", + searchForMissingAlbums: hasGranularSelection ? false : searchMissing, + monitor: monitorOption, }, }; const result = await lidarrRequest("/artist", "POST", artistData); + // If granular album selection is requested, update individual albums + if (hasGranularSelection && result.id) { + // Wait for Lidarr to process the artist and create albums + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + // Get all albums for this artist from Lidarr + const albums = await lidarrRequest(`/album?artistId=${result.id}`); + const selectedSet = new Set(selectedAlbums); + const albumsToSearch = []; + + // Update each selected album to be monitored + for (const album of albums) { + if (selectedSet.has(album.foreignAlbumId)) { + await lidarrRequest(`/album/${album.id}`, "PUT", { + ...album, + monitored: true, + }); + albumsToSearch.push(album.id); + } + } + + // Trigger search for selected albums if requested + if (searchMissing && albumsToSearch.length > 0) { + await lidarrRequest("/command", "POST", { + name: "AlbumSearch", + albumIds: albumsToSearch, + }); + } + } catch (albumError) { + console.error("Error updating album monitoring:", albumError.message); + // Continue - artist was added successfully, album updates may need manual retry + } + } + const newRequest = { mbid: foreignArtistId, name: artistName, @@ -1130,6 +1171,58 @@ app.post("/api/lidarr/command/albumsearch", async (req, res) => { } }); +// Bulk update album monitoring status +app.post("/api/lidarr/albums/bulk-monitor", async (req, res) => { + try { + const { artistId, albumIds, monitored, searchAfter } = req.body; + + if (!artistId) { + return res.status(400).json({ error: "artistId is required" }); + } + if (!Array.isArray(albumIds)) { + return res.status(400).json({ error: "albumIds must be an array" }); + } + if (typeof monitored !== "boolean") { + return res.status(400).json({ error: "monitored must be a boolean" }); + } + + // Get all albums for this artist + const albums = await lidarrRequest(`/album?artistId=${artistId}`); + const albumIdSet = new Set(albumIds); + const updatedAlbums = []; + + // Update each specified album + for (const album of albums) { + if (albumIdSet.has(album.id)) { + const updatedAlbum = await lidarrRequest(`/album/${album.id}`, "PUT", { + ...album, + monitored, + }); + updatedAlbums.push(updatedAlbum); + } + } + + // Trigger search for newly monitored albums if requested + if (searchAfter && monitored && albumIds.length > 0) { + await lidarrRequest("/command", "POST", { + name: "AlbumSearch", + albumIds, + }); + } + + res.json({ + success: true, + updated: updatedAlbums.length, + albums: updatedAlbums + }); + } catch (error) { + res.status(500).json({ + error: "Failed to bulk update album monitoring", + message: error.message, + }); + } +}); + app.delete("/api/lidarr/artists/:id", async (req, res) => { try { const { id } = req.params; diff --git a/frontend/src/components/AddArtistModal.jsx b/frontend/src/components/AddArtistModal.jsx index fe50e1b..8b4109d 100644 --- a/frontend/src/components/AddArtistModal.jsx +++ b/frontend/src/components/AddArtistModal.jsx @@ -1,14 +1,31 @@ -import { useState, useEffect } from "react"; - -import { X, Loader, CheckCircle, AlertCircle, ChevronDown, ChevronUp } from "lucide-react"; +import { useState, useEffect, useMemo } from "react"; +import { + X, + Loader, + CheckCircle, + AlertCircle, + ChevronDown, + ChevronUp, + ChevronRight, + ChevronLeft, + Music, + Disc, + Filter, + CheckSquare, + Square, + MinusSquare, +} from "lucide-react"; import { getLidarrRootFolders, getLidarrQualityProfiles, getLidarrMetadataProfiles, addArtistToLidarr, getAppSettings, + getArtistDetails, } from "../utils/api"; +const ALBUM_TYPES = ["Album", "EP", "Single", "Broadcast", "Other"]; + function AddArtistModal({ artist, onClose, onSuccess }) { const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -25,6 +42,14 @@ function AddArtistModal({ artist, onClose, onSuccess }) { const [searchForMissingAlbums, setSearchForMissingAlbums] = useState(false); const [albumFolders, setAlbumFolders] = useState(true); + // Album selection state + const [step, setStep] = useState(1); // 1 = options, 2 = album selection + const [releaseGroups, setReleaseGroups] = useState([]); + const [selectedAlbums, setSelectedAlbums] = useState(new Set()); + const [loadingAlbums, setLoadingAlbums] = useState(false); + const [albumFilter, setAlbumFilter] = useState("all"); + const [showSecondaryTypes, setShowSecondaryTypes] = useState(true); + useEffect(() => { document.body.style.overflow = "hidden"; return () => { @@ -50,20 +75,20 @@ function AddArtistModal({ artist, onClose, onSuccess }) { setMetadataProfiles(metadata); setSelectedRootFolder( - savedSettings.rootFolderPath || (folders[0]?.path ?? ""), + savedSettings.rootFolderPath || (folders[0]?.path ?? "") ); setSelectedQualityProfile( - savedSettings.qualityProfileId || (quality[0]?.id ?? ""), + savedSettings.qualityProfileId || (quality[0]?.id ?? "") ); setSelectedMetadataProfile( - savedSettings.metadataProfileId || (metadata[0]?.id ?? ""), + savedSettings.metadataProfileId || (metadata[0]?.id ?? "") ); setMonitored(savedSettings.monitored ?? true); setSearchForMissingAlbums(savedSettings.searchForMissingAlbums ?? false); setAlbumFolders(savedSettings.albumFolders ?? true); } catch (err) { setError( - err.response?.data?.message || "Failed to load configuration options", + err.response?.data?.message || "Failed to load configuration options" ); } finally { setLoading(false); @@ -73,8 +98,72 @@ function AddArtistModal({ artist, onClose, onSuccess }) { fetchOptions(); }, []); + // Fetch release groups when switching to album selection step + useEffect(() => { + const fetchReleaseGroups = async () => { + if (step !== 2 || releaseGroups.length > 0) return; + + setLoadingAlbums(true); + try { + const artistData = await getArtistDetails(artist.id); + const groups = artistData["release-groups"] || []; + setReleaseGroups(groups); + + // Pre-select albums based on monitorOption from step 1 + const sorted = [...groups].sort((a, b) => { + const dateA = a["first-release-date"] || ""; + const dateB = b["first-release-date"] || ""; + return dateB.localeCompare(dateA); + }); + + const initialSelection = new Set(); + if (monitorOption === "all") { + groups.forEach((g) => initialSelection.add(g.id)); + } else if (monitorOption === "future") { + // Don't select any existing albums + } else if (monitorOption === "missing") { + groups.forEach((g) => initialSelection.add(g.id)); + } else if (monitorOption === "latest" && sorted.length > 0) { + initialSelection.add(sorted[0].id); + } else if (monitorOption === "first" && sorted.length > 0) { + initialSelection.add(sorted[sorted.length - 1].id); + } + setSelectedAlbums(initialSelection); + } catch (err) { + console.error("Failed to fetch release groups:", err); + setError("Failed to load album list"); + } finally { + setLoadingAlbums(false); + } + }; + + fetchReleaseGroups(); + }, [step, artist.id, monitorOption, releaseGroups.length]); + + const filteredReleaseGroups = useMemo(() => { + let filtered = releaseGroups; + + if (albumFilter !== "all") { + filtered = filtered.filter( + (rg) => rg["primary-type"] === albumFilter + ); + } + + if (!showSecondaryTypes) { + filtered = filtered.filter( + (rg) => !rg["secondary-types"] || rg["secondary-types"].length === 0 + ); + } + + return filtered.sort((a, b) => { + const dateA = a["first-release-date"] || ""; + const dateB = b["first-release-date"] || ""; + return dateB.localeCompare(dateA); + }); + }, [releaseGroups, albumFilter, showSecondaryTypes]); + const handleSubmit = async (e) => { - e.preventDefault(); + e?.preventDefault(); if ( !selectedRootFolder || @@ -89,6 +178,10 @@ function AddArtistModal({ artist, onClose, onSuccess }) { setError(null); try { + // If we're on step 2 (album selection), pass selected albums + const albumsToMonitor = + step === 2 ? Array.from(selectedAlbums) : undefined; + await addArtistToLidarr({ foreignArtistId: artist.id, artistName: artist.name, @@ -96,9 +189,10 @@ function AddArtistModal({ artist, onClose, onSuccess }) { metadataProfileId: parseInt(selectedMetadataProfile), rootFolderPath: selectedRootFolder, monitored, - monitor: monitorOption, + monitor: step === 2 ? "none" : monitorOption, searchForMissingAlbums, albumFolders, + selectedAlbums: albumsToMonitor, }); onSuccess(artist); @@ -109,6 +203,448 @@ function AddArtistModal({ artist, onClose, onSuccess }) { } }; + const handleSelectAll = () => { + const newSelection = new Set(selectedAlbums); + filteredReleaseGroups.forEach((rg) => newSelection.add(rg.id)); + setSelectedAlbums(newSelection); + }; + + const handleDeselectAll = () => { + const newSelection = new Set(selectedAlbums); + filteredReleaseGroups.forEach((rg) => newSelection.delete(rg.id)); + setSelectedAlbums(newSelection); + }; + + const handleToggleAlbum = (id) => { + const newSelection = new Set(selectedAlbums); + if (newSelection.has(id)) { + newSelection.delete(id); + } else { + newSelection.add(id); + } + setSelectedAlbums(newSelection); + }; + + const getSelectionState = () => { + const visibleIds = new Set(filteredReleaseGroups.map((rg) => rg.id)); + const selectedVisible = [...selectedAlbums].filter((id) => + visibleIds.has(id) + ).length; + + if (selectedVisible === 0) return "none"; + if (selectedVisible === filteredReleaseGroups.length) return "all"; + return "partial"; + }; + + const goToAlbumSelection = () => { + setStep(2); + }; + + const goBackToOptions = () => { + setStep(1); + setReleaseGroups([]); + setSelectedAlbums(new Set()); + }; + + const renderStep1 = () => ( +
+
+ + + +
+ + {showOptions && ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+

+ Options +

+ +
+
+ setMonitored(e.target.checked)} + className="w-4 h-4 text-primary-600 border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded focus:ring-primary-500" + disabled={submitting} + /> +
+
+ +

+ Lidarr will search for and download new releases +

+
+
+ + {monitored && ( +
+ + +
+ )} + +
+
+ setSearchForMissingAlbums(e.target.checked)} + className="w-4 h-4 text-primary-600 border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded focus:ring-primary-500" + disabled={submitting} + /> +
+
+ +
+
+ +
+
+ setAlbumFolders(e.target.checked)} + className="w-4 h-4 text-primary-600 border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded focus:ring-primary-500" + disabled={submitting} + /> +
+
+ +
+
+
+
+ )} + + {!showOptions && ( +
+ +
+ )} + + {showOptions && ( +
+ +
+ )} +
+ ); + + const renderStep2 = () => { + const selectionState = getSelectionState(); + + return ( +
+ {/* Filter controls */} +
+
+ + +
+ + + +
+ + +
+ + {/* Selection summary */} +
+ {selectedAlbums.size} of {releaseGroups.length} albums selected + {filteredReleaseGroups.length !== releaseGroups.length && ( + + (showing {filteredReleaseGroups.length} filtered) + + )} +
+ + {/* Album list */} + {loadingAlbums ? ( +
+ +

Loading albums...

+
+ ) : ( +
+ {filteredReleaseGroups.length === 0 ? ( +
+ +

No albums match the current filter

+
+ ) : ( + filteredReleaseGroups.map((releaseGroup) => { + const isSelected = selectedAlbums.has(releaseGroup.id); + return ( +
handleToggleAlbum(releaseGroup.id)} + className={`flex items-center p-3 rounded-lg cursor-pointer transition-colors ${ + isSelected + ? "bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-700" + : "bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 border border-transparent" + }`} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+
+

+ {releaseGroup.title} +

+
+ {releaseGroup["first-release-date"] && ( + + {releaseGroup["first-release-date"].split("-")[0]} + + )} + {releaseGroup["primary-type"] && ( + + {releaseGroup["primary-type"]} + + )} + {releaseGroup["secondary-types"]?.length > 0 && ( + + {releaseGroup["secondary-types"].join(", ")} + + )} +
+
+
+ ); + }) + )} +
+ )} + + {/* Action buttons */} +
+ + +
+
+ ); + }; + return (
@@ -116,7 +652,7 @@ function AddArtistModal({ artist, onClose, onSuccess }) {

- Add Artist to Lidarr + {step === 1 ? "Add Artist to Lidarr" : "Select Albums"}

{artist.name} @@ -149,221 +685,10 @@ function AddArtistModal({ artist, onClose, onSuccess }) {

{error}

+ ) : step === 1 ? ( + renderStep1() ) : ( -
-
- - -
- - {showOptions && ( -
-
- - -
- -
- - -
- -
- - -
- -
-

- Options -

- -
-
- setMonitored(e.target.checked)} - className="w-4 h-4 text-primary-600 border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded focus:ring-primary-500" - disabled={submitting} - /> -
-
- -

- Lidarr will search for and download new releases -

-
-
- - {monitored && ( -
- - -
- )} - -
-
- - setSearchForMissingAlbums(e.target.checked) - } - className="w-4 h-4 text-primary-600 border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded focus:ring-primary-500" - disabled={submitting} - /> -
-
- -
-
- -
-
- setAlbumFolders(e.target.checked)} - className="w-4 h-4 text-primary-600 border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded focus:ring-primary-500" - disabled={submitting} - /> -
-
- -
-
-
-
- )} - - {!showOptions && ( -
- -
- )} - - {showOptions && ( -
- -
- )} -
+ renderStep2() )}
@@ -372,4 +697,3 @@ function AddArtistModal({ artist, onClose, onSuccess }) { } export default AddArtistModal; - diff --git a/frontend/src/pages/ArtistDetailsPage.jsx b/frontend/src/pages/ArtistDetailsPage.jsx index f9ae042..674c36f 100644 --- a/frontend/src/pages/ArtistDetailsPage.jsx +++ b/frontend/src/pages/ArtistDetailsPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Loader, @@ -11,6 +11,14 @@ import { MapPin, Tag, Sparkles, + Filter, + CheckSquare, + Square, + MinusSquare, + Eye, + EyeOff, + Search, + X, } from "lucide-react"; import { getArtistDetails, @@ -21,11 +29,14 @@ import { searchLidarrAlbum, getSimilarArtistsForArtist, lookupArtistsInLidarrBatch, + bulkUpdateAlbumMonitoring, } from "../utils/api"; import { useToast } from "../contexts/ToastContext"; import AddArtistModal from "../components/AddArtistModal"; import ArtistImage from "../components/ArtistImage"; +const ALBUM_TYPES = ["Album", "EP", "Single", "Broadcast", "Other"]; + function ArtistDetailsPage() { const { mbid } = useParams(); const navigate = useNavigate(); @@ -43,6 +54,14 @@ function ArtistDetailsPage() { const [requestingAlbum, setRequestingAlbum] = useState(null); const { showSuccess, showError } = useToast(); + // Album management state + const [albumFilter, setAlbumFilter] = useState("all"); + const [showSecondaryTypes, setShowSecondaryTypes] = useState(true); + const [selectedAlbums, setSelectedAlbums] = useState(new Set()); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [bulkUpdating, setBulkUpdating] = useState(false); + const [showOnlyMonitored, setShowOnlyMonitored] = useState(false); + useEffect(() => { const fetchArtistData = async () => { setLoading(true); @@ -72,17 +91,18 @@ function ArtistDetailsPage() { } catch (err) { console.log("No cover art available"); } - try { - const lookup = await lookupArtistInLidarr(mbid); + + try { + const lookup = await lookupArtistInLidarr(mbid); setExistsInLidarr(lookup.exists); - if (lookup.exists && lookup.artist) { + if (lookup.exists && lookup.artist) { setLidarrArtist(lookup.artist); setTimeout(async () => { - try { + try { const albums = await getLidarrAlbums(lookup.artist.id); console.log("Lidarr Albums:", albums); setLidarrAlbums(albums); - } catch (err) { + } catch (err) { console.log("Retrying album fetch..."); setTimeout(async () => { try { @@ -90,7 +110,7 @@ function ArtistDetailsPage() { setLidarrAlbums(albums); } catch (e) {} }, 2000); - } + } }, 1000); } } catch (err) { @@ -98,7 +118,7 @@ function ArtistDetailsPage() { } } catch (err) { setError( - err.response?.data?.message || "Failed to fetch artist details", + err.response?.data?.message || "Failed to fetch artist details" ); } finally { setLoading(false); @@ -107,6 +127,7 @@ function ArtistDetailsPage() { fetchArtistData(); }, [mbid]); + const handleAddArtistClick = () => { setShowAddModal(true); }; @@ -119,22 +140,22 @@ function ArtistDetailsPage() { setShowAddModal(false); setArtistToAdd(null); showSuccess(`Successfully added ${addedArtist.name} to Lidarr!`); - + if (addedArtist.id) { setExistingSimilar((prev) => ({ ...prev, [addedArtist.id]: true })); } setTimeout(async () => { - try { + try { const lookup = await lookupArtistInLidarr(mbid); if (lookup.exists && lookup.artist) { setLidarrArtist(lookup.artist); const albums = await getLidarrAlbums(lookup.artist.id); setLidarrAlbums(albums); } - } catch (err) { + } catch (err) { console.error("Failed to refresh Lidarr data", err); - } + } }, 1500); }; @@ -142,8 +163,8 @@ function ArtistDetailsPage() { setRequestingAlbum(albumId); try { const lidarrAlbum = lidarrAlbums.find( - (a) => a.foreignAlbumId === albumId, - ); + (a) => a.foreignAlbumId === albumId + ); if (!lidarrAlbum) { throw new Error("Album not found in Lidarr"); @@ -158,9 +179,9 @@ function ArtistDetailsPage() { setLidarrAlbums((prev) => prev.map((a) => - a.id === lidarrAlbum.id ? { ...a, monitored: true } : a, - ), - ); + a.id === lidarrAlbum.id ? { ...a, monitored: true } : a + ) + ); showSuccess(`Requested album: ${title}`); } catch (err) { @@ -170,6 +191,82 @@ function ArtistDetailsPage() { } }; + const handleUnmonitorAlbum = async (albumId, title) => { + setRequestingAlbum(albumId); + try { + const lidarrAlbum = lidarrAlbums.find( + (a) => a.foreignAlbumId === albumId + ); + + if (!lidarrAlbum) { + throw new Error("Album not found in Lidarr"); + } + + await updateLidarrAlbum(lidarrAlbum.id, { + ...lidarrAlbum, + monitored: false, + }); + + setLidarrAlbums((prev) => + prev.map((a) => + a.id === lidarrAlbum.id ? { ...a, monitored: false } : a + ) + ); + + showSuccess(`Unmonitored album: ${title}`); + } catch (err) { + showError(`Failed to unmonitor album: ${err.message}`); + } finally { + setRequestingAlbum(null); + } + }; + + const handleBulkMonitor = async (shouldMonitor) => { + if (selectedAlbums.size === 0 || !lidarrArtist) return; + + setBulkUpdating(true); + try { + // Get Lidarr album IDs from selected MusicBrainz IDs + const lidarrAlbumIds = []; + for (const mbAlbumId of selectedAlbums) { + const lidarrAlbum = lidarrAlbums.find( + (a) => a.foreignAlbumId === mbAlbumId + ); + if (lidarrAlbum) { + lidarrAlbumIds.push(lidarrAlbum.id); + } + } + + if (lidarrAlbumIds.length === 0) { + throw new Error("No matching albums found in Lidarr"); + } + + await bulkUpdateAlbumMonitoring( + lidarrArtist.id, + lidarrAlbumIds, + shouldMonitor, + shouldMonitor // searchAfter only if monitoring + ); + + // Update local state + setLidarrAlbums((prev) => + prev.map((a) => + lidarrAlbumIds.includes(a.id) ? { ...a, monitored: shouldMonitor } : a + ) + ); + + showSuccess( + `${shouldMonitor ? "Monitored" : "Unmonitored"} ${lidarrAlbumIds.length} album${lidarrAlbumIds.length !== 1 ? "s" : ""}` + ); + setSelectedAlbums(new Set()); + setIsSelectionMode(false); + } catch (err) { + showError(`Failed to update albums: ${err.message}`); + } finally { + setBulkUpdating(false); + } + }; + const getAlbumStatus = (releaseGroupId) => { if (!existsInLidarr || lidarrAlbums.length === 0) return null; @@ -226,12 +323,91 @@ function ArtistDetailsPage() { return null; }; + const handleToggleAlbum = (id) => { + const newSelection = new Set(selectedAlbums); + if (newSelection.has(id)) { + newSelection.delete(id); + } else { + newSelection.add(id); + } + setSelectedAlbums(newSelection); + }; + + const filteredReleaseGroups = useMemo(() => { + if (!artist?.["release-groups"]) return []; + + let filtered = artist["release-groups"]; + + if (albumFilter !== "all") { + filtered = filtered.filter((rg) => rg["primary-type"] === albumFilter); + } + + if (!showSecondaryTypes) { + filtered = filtered.filter( + (rg) => !rg["secondary-types"] || rg["secondary-types"].length === 0 + ); + } + + if (showOnlyMonitored && existsInLidarr) { + filtered = filtered.filter((rg) => { + const status = getAlbumStatus(rg.id); + return status && status.status !== "unmonitored"; + }); + } + + return filtered.sort((a, b) => { + const dateA = a["first-release-date"] || ""; + const dateB = b["first-release-date"] || ""; + return dateB.localeCompare(dateA); + }); + }, [ + artist, + albumFilter, + showSecondaryTypes, + showOnlyMonitored, + existsInLidarr, + lidarrAlbums, + ]); + + const handleSelectAll = () => { + const newSelection = new Set(selectedAlbums); + filteredReleaseGroups.forEach((rg) => { + // Only select albums that are in Lidarr + const status = getAlbumStatus(rg.id); + if (status) { + newSelection.add(rg.id); + } + }); + setSelectedAlbums(newSelection); + }; + + const handleDeselectAll = () => { + const newSelection = new Set(selectedAlbums); + filteredReleaseGroups.forEach((rg) => newSelection.delete(rg.id)); + setSelectedAlbums(newSelection); + }; + + const getSelectionState = () => { + const visibleIds = new Set( + filteredReleaseGroups + .filter((rg) => getAlbumStatus(rg.id)) + .map((rg) => rg.id) + ); + const selectedVisible = [...selectedAlbums].filter((id) => + visibleIds.has(id) + ).length; + + if (selectedVisible === 0) return "none"; + if (selectedVisible === visibleIds.size) return "all"; + return "partial"; + }; + if (loading) { return (
-
- ); +
+ ); } if (error) { @@ -249,8 +425,8 @@ function ArtistDetailsPage() { > Back to Search + - ); } @@ -260,6 +436,7 @@ function ArtistDetailsPage() { const coverImage = getCoverImage(); const lifeSpan = formatLifeSpan(artist["life-span"]); + const selectionState = getSelectionState(); return (
@@ -400,23 +577,191 @@ function ArtistDetailsPage() { {artist["release-groups"] && artist["release-groups"].length > 0 && (
-

- Albums & Releases ({artist["release-groups"].length}) -

-
- {artist["release-groups"] - .sort((a, b) => { - const dateA = a["first-release-date"] || ""; - const dateB = b["first-release-date"] || ""; - return dateB.localeCompare(dateA); - }) - .map((releaseGroup) => { +
+

+ Albums & Releases ({artist["release-groups"].length}) +

+ + {existsInLidarr && ( + + )} +
+ + {/* Filter controls */} +
+
+ + +
+ + + + {existsInLidarr && ( + + )} + + {isSelectionMode && ( + <> +
+ + + )} +
+ + {/* Bulk action bar */} + {isSelectionMode && selectedAlbums.size > 0 && ( +
+ + {selectedAlbums.size} album + {selectedAlbums.size !== 1 ? "s" : ""} selected + +
+ + +
+ )} + + {/* Album list */} +
+ {filteredReleaseGroups.length === 0 ? ( +
+ +

No albums match the current filter

+
+ ) : ( + filteredReleaseGroups.map((releaseGroup) => { const status = getAlbumStatus(releaseGroup.id); + const isSelected = selectedAlbums.has(releaseGroup.id); + const canSelect = existsInLidarr && status; + return (
handleToggleAlbum(releaseGroup.id) + : undefined + } + className={`flex items-center justify-between p-4 rounded-lg transition-colors ${ + isSelectionMode && canSelect + ? "cursor-pointer" + : "cursor-default" + } ${ + isSelected + ? "bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-700" + : "bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 border border-transparent" + }`} > + {isSelectionMode && ( +
+ {canSelect ? ( + isSelected ? ( + + ) : ( + + ) + ) : ( + + )} +
+ )} +

{releaseGroup.title} @@ -441,58 +786,83 @@ function ArtistDetailsPage() {

-
- {status ? ( - status.status === "available" ? ( - - - Available - - ) : status.status === "processing" ? ( - - - Processing + {!isSelectionMode && ( +
+ {status ? ( + status.status === "available" ? ( + + + Available + + ) : status.status === "processing" ? ( +
+ + + Processing + + +
+ ) : ( + + ) + ) : existsInLidarr ? ( + + Not in Lidarr ) : ( - - ) - ) : existsInLidarr ? ( - - Not in Lidarr - - ) : ( - - Add Artist First - - )} - - - - -
+ + Add Artist First + + )} + + e.stopPropagation()} + > + + +
+ )}
); - })} + }) + )}
)} @@ -605,4 +975,3 @@ function ArtistDetailsPage() { } export default ArtistDetailsPage; - diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 3631880..3fff3fd 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -135,6 +135,16 @@ export const searchLidarrAlbum = async (albumIds) => { return response.data; }; +export const bulkUpdateAlbumMonitoring = async (artistId, albumIds, monitored, searchAfter = false) => { + const response = await api.post("/lidarr/albums/bulk-monitor", { + artistId, + albumIds, + monitored, + searchAfter, + }); + return response.data; +}; + export const getRequests = async () => { const response = await api.get("/requests"); return response.data;