Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
451 lines
21 KiB
TypeScript
451 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Edit, X, Save } from "lucide-react";
|
|
import { api } from "@/lib/api";
|
|
import { toast } from "sonner";
|
|
import { GradientSpinner } from "./ui/GradientSpinner";
|
|
|
|
interface MetadataEditorProps {
|
|
type: "artist" | "album" | "track";
|
|
id: string;
|
|
currentData: {
|
|
name?: string;
|
|
title?: string;
|
|
bio?: string;
|
|
genres?: string[];
|
|
year?: number;
|
|
mbid?: string;
|
|
rgMbid?: string;
|
|
coverUrl?: string;
|
|
heroUrl?: string;
|
|
// Original values for comparison (when user overrides exist)
|
|
_originalName?: string;
|
|
_originalBio?: string | null;
|
|
_originalGenres?: string[];
|
|
_originalHeroUrl?: string | null;
|
|
_originalTitle?: string;
|
|
_originalYear?: number | null;
|
|
_originalCoverUrl?: string | null;
|
|
_hasUserOverrides?: boolean;
|
|
};
|
|
onSave?: (updatedData: any) => void;
|
|
}
|
|
|
|
/**
|
|
* Metadata Editor Component
|
|
* Plex/Kavita-style metadata editor with pencil icon
|
|
* Opens a modal for editing artist/album/track metadata
|
|
*/
|
|
export function MetadataEditor({
|
|
type,
|
|
id,
|
|
currentData,
|
|
onSave,
|
|
}: MetadataEditorProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isResetting, setIsResetting] = useState(false);
|
|
const [formData, setFormData] = useState(currentData);
|
|
const hasOverrides = currentData._hasUserOverrides ?? false;
|
|
|
|
const handleOpen = () => {
|
|
setFormData(currentData);
|
|
setIsOpen(true);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setIsOpen(false);
|
|
setFormData(currentData);
|
|
};
|
|
|
|
const handleReset = async () => {
|
|
if (
|
|
!confirm(
|
|
"Reset all metadata to original values? This cannot be undone."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setIsResetting(true);
|
|
try {
|
|
if (type === "artist") {
|
|
await api.resetArtistMetadata(id);
|
|
} else if (type === "album") {
|
|
await api.resetAlbumMetadata(id);
|
|
} else {
|
|
await api.resetTrackMetadata(id);
|
|
}
|
|
|
|
toast.success("Metadata reset to original values");
|
|
onSave?.(null);
|
|
setIsOpen(false);
|
|
} catch (error: any) {
|
|
toast.error(error.message || "Failed to reset metadata");
|
|
} finally {
|
|
setIsResetting(false);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
// Call API to update metadata
|
|
let response;
|
|
if (type === "artist") {
|
|
response = await api.updateArtistMetadata(id, formData);
|
|
} else if (type === "album") {
|
|
response = await api.updateAlbumMetadata(id, formData);
|
|
} else {
|
|
response = await api.updateTrackMetadata(id, formData);
|
|
}
|
|
|
|
toast.success(
|
|
`${
|
|
type === "artist"
|
|
? "Artist"
|
|
: type === "album"
|
|
? "Album"
|
|
: "Track"
|
|
} metadata updated`
|
|
);
|
|
onSave?.(response);
|
|
setIsOpen(false);
|
|
} catch (error: any) {
|
|
console.error("Failed to update metadata:", error);
|
|
toast.error(error.message || "Failed to update metadata");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleChange = (field: string, value: any) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Pencil Icon Button */}
|
|
<button
|
|
onClick={handleOpen}
|
|
className="p-2 rounded-full bg-black/40 hover:bg-black/60 transition-all opacity-0 group-hover:opacity-100"
|
|
title={`Edit ${type} metadata`}
|
|
>
|
|
<Edit className="w-4 h-4 text-white" />
|
|
</button>
|
|
|
|
{/* Modal */}
|
|
{isOpen && (
|
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-[#121212] rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
|
<h2 className="text-2xl font-bold text-white">
|
|
Edit{" "}
|
|
{type === "artist"
|
|
? "Artist"
|
|
: type === "album"
|
|
? "Album"
|
|
: "Track"}{" "}
|
|
Metadata
|
|
</h2>
|
|
<button
|
|
onClick={handleClose}
|
|
className="p-2 hover:bg-white/10 rounded-full transition-all"
|
|
>
|
|
<X className="w-6 h-6 text-white" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
{/* Name/Title */}
|
|
<div>
|
|
<label className="block text-sm font-bold text-white mb-2">
|
|
{type === "artist"
|
|
? "Artist Name"
|
|
: type === "album"
|
|
? "Album Title"
|
|
: "Track Title"}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={
|
|
formData.name || formData.title || ""
|
|
}
|
|
onChange={(e) =>
|
|
handleChange(
|
|
type === "artist"
|
|
? "name"
|
|
: "title",
|
|
e.target.value
|
|
)
|
|
}
|
|
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
|
/>
|
|
{type === "artist" &&
|
|
currentData._originalName &&
|
|
currentData._originalName !==
|
|
(formData.name || "") && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Original:{" "}
|
|
{currentData._originalName}
|
|
</p>
|
|
)}
|
|
{type !== "artist" &&
|
|
currentData._originalTitle &&
|
|
currentData._originalTitle !==
|
|
(formData.title || "") && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Original:{" "}
|
|
{currentData._originalTitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bio (Artist only) */}
|
|
{type === "artist" && (
|
|
<div>
|
|
<label className="block text-sm font-bold text-white mb-2">
|
|
Biography
|
|
</label>
|
|
<textarea
|
|
value={formData.bio || ""}
|
|
onChange={(e) =>
|
|
handleChange("bio", e.target.value)
|
|
}
|
|
rows={6}
|
|
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none resize-none"
|
|
/>
|
|
{currentData._originalBio &&
|
|
currentData._originalBio !==
|
|
(formData.bio || "") && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Original:{" "}
|
|
{currentData._originalBio.substring(
|
|
0,
|
|
100
|
|
)}
|
|
...
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Year (Album only) */}
|
|
{type === "album" && (
|
|
<div>
|
|
<label className="block text-sm font-bold text-white mb-2">
|
|
Release Year
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.year || ""}
|
|
onChange={(e) =>
|
|
handleChange(
|
|
"year",
|
|
parseInt(e.target.value)
|
|
)
|
|
}
|
|
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
|
/>
|
|
{currentData._originalYear &&
|
|
currentData._originalYear !==
|
|
(formData.year || null) && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Original:{" "}
|
|
{currentData._originalYear}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Genres */}
|
|
<div>
|
|
<label className="block text-sm font-bold text-white mb-2">
|
|
Genres
|
|
<span className="text-xs text-gray-400 ml-2">
|
|
(comma-separated)
|
|
</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.genres?.join(", ") || ""}
|
|
onChange={(e) =>
|
|
handleChange(
|
|
"genres",
|
|
e.target.value
|
|
.split(",")
|
|
.map((g) => g.trim())
|
|
.filter(Boolean)
|
|
)
|
|
}
|
|
placeholder="Rock, Alternative, Indie"
|
|
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
|
/>
|
|
{currentData._originalGenres &&
|
|
currentData._originalGenres.length > 0 &&
|
|
JSON.stringify(
|
|
currentData._originalGenres.sort()
|
|
) !==
|
|
JSON.stringify(
|
|
(formData.genres || []).sort()
|
|
) && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Original:{" "}
|
|
{currentData._originalGenres.join(
|
|
", "
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* MusicBrainz ID */}
|
|
<div>
|
|
<label className="block text-sm font-bold text-white mb-2">
|
|
MusicBrainz ID
|
|
<span className="text-xs text-gray-400 ml-2">
|
|
(leave empty to auto-fetch)
|
|
</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={
|
|
type === "artist"
|
|
? formData.mbid || ""
|
|
: type === "album"
|
|
? formData.rgMbid || ""
|
|
: formData.mbid || ""
|
|
}
|
|
onChange={(e) =>
|
|
handleChange(
|
|
type === "artist"
|
|
? "mbid"
|
|
: type === "album"
|
|
? "rgMbid"
|
|
: "mbid",
|
|
e.target.value
|
|
)
|
|
}
|
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Image URL */}
|
|
<div>
|
|
<label className="block text-sm font-bold text-white mb-2">
|
|
{type === "artist"
|
|
? "Artist Image URL"
|
|
: "Cover Art URL"}
|
|
<span className="text-xs text-gray-400 ml-2">
|
|
(leave empty to auto-fetch)
|
|
</span>
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={
|
|
type === "artist"
|
|
? formData.heroUrl || ""
|
|
: formData.coverUrl || ""
|
|
}
|
|
onChange={(e) =>
|
|
handleChange(
|
|
type === "artist"
|
|
? "heroUrl"
|
|
: "coverUrl",
|
|
e.target.value
|
|
)
|
|
}
|
|
placeholder="https://..."
|
|
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none text-sm"
|
|
/>
|
|
{type === "artist" &&
|
|
currentData._originalHeroUrl &&
|
|
currentData._originalHeroUrl !==
|
|
(formData.heroUrl || "") && (
|
|
<p className="mt-1 text-xs text-gray-500 truncate">
|
|
Original:{" "}
|
|
{currentData._originalHeroUrl}
|
|
</p>
|
|
)}
|
|
{type === "album" &&
|
|
currentData._originalCoverUrl &&
|
|
currentData._originalCoverUrl !==
|
|
(formData.coverUrl || "") && (
|
|
<p className="mt-1 text-xs text-gray-500 truncate">
|
|
Original:{" "}
|
|
{currentData._originalCoverUrl}
|
|
</p>
|
|
)}
|
|
{/* Image Preview */}
|
|
{(formData.heroUrl || formData.coverUrl) && (
|
|
<div className="mt-2">
|
|
<img
|
|
src={
|
|
formData.heroUrl ||
|
|
formData.coverUrl
|
|
}
|
|
alt="Preview"
|
|
className="w-32 h-32 object-cover rounded"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Manual Override Warning */}
|
|
<div className="bg-yellow-600/10 border border-yellow-600/20 rounded p-4">
|
|
<p className="text-sm text-yellow-400">
|
|
<strong>Note:</strong> Manually edited
|
|
metadata will not be overwritten by
|
|
automatic enrichment.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-white/10">
|
|
{hasOverrides && (
|
|
<button
|
|
onClick={handleReset}
|
|
disabled={isSaving || isResetting}
|
|
className="px-6 py-2 rounded-full bg-red-500/20 hover:bg-red-500/30 text-red-400 font-bold transition-all border border-red-500/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isResetting
|
|
? "Resetting..."
|
|
: "Reset to Original"}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleClose}
|
|
className="px-6 py-2 rounded-full bg-white/10 hover:bg-white/20 text-white font-bold transition-all"
|
|
disabled={isSaving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
className="px-6 py-2 rounded-full bg-[#ecb200] hover:bg-[#d4a000] text-black font-bold transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<GradientSpinner size="sm" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-4 h-4" />
|
|
Save Changes
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|