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 = () => (
+
+ );
+
+ 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 ? (
+
+ ) : (
+
+ {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()
) : (
-
+ 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;