mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
Feat: Edit Artists
This commit is contained in:
parent
e36eb48036
commit
c641707be9
15 changed files with 678 additions and 283 deletions
69
client/app/components/ComboBox.tsx
Normal file
69
client/app/components/ComboBox.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useDeferredValue, useEffect, useState } from "react";
|
||||||
|
import { search, type SearchResponse } from "api/api";
|
||||||
|
import { useCombobox } from "downshift";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelection: (selection: any) => void;
|
||||||
|
filterFunction: (r: SearchResponse) => SearchResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComboBox({ onSelection, filterFunction }: Props) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const deferredQuery = useDeferredValue(query);
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deferredQuery) {
|
||||||
|
search(deferredQuery).then((r) => {
|
||||||
|
const filtered = filterFunction(r);
|
||||||
|
setData([...filtered.artists, ...filtered.albums, ...filtered.tracks]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [deferredQuery]);
|
||||||
|
|
||||||
|
const { isOpen, getMenuProps, getInputProps, getItemProps, selectedItem } =
|
||||||
|
useCombobox({
|
||||||
|
items: data,
|
||||||
|
itemToString(item) {
|
||||||
|
return item ? item.title || item.name : "";
|
||||||
|
},
|
||||||
|
onInputValueChange: ({ inputValue }) => {
|
||||||
|
setQuery(inputValue);
|
||||||
|
},
|
||||||
|
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||||
|
if (newSelectedItem) {
|
||||||
|
setQuery(newSelectedItem.name);
|
||||||
|
onSelection(selectedItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-grow">
|
||||||
|
<input
|
||||||
|
{...getInputProps()}
|
||||||
|
value={query}
|
||||||
|
placeholder="Add an artist"
|
||||||
|
className="mx-auto fg bg rounded-md p-3 w-full"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
{...getMenuProps()}
|
||||||
|
className={`bg rounded-b-md p-3 absolute ${
|
||||||
|
!(isOpen && data.length) && "hidden"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isOpen &&
|
||||||
|
data &&
|
||||||
|
data.map((item, index) => (
|
||||||
|
<li
|
||||||
|
className="fg py-2 px-3 rounded-md shadow-sm cursor-pointer hover:bg-(--color-bg-tertiary)"
|
||||||
|
key={item.id}
|
||||||
|
{...getItemProps({ item, index })}
|
||||||
|
>
|
||||||
|
{item.title || item.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
client/app/components/modals/EditModal/AliasManager.tsx
Normal file
139
client/app/components/modals/EditModal/AliasManager.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createAlias,
|
||||||
|
deleteAlias,
|
||||||
|
getAliases,
|
||||||
|
setPrimaryAlias,
|
||||||
|
type Alias,
|
||||||
|
} from "api/api";
|
||||||
|
import { AsyncButton } from "../../AsyncButton";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trash } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AliasManager({ type, id }: Props) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setError] = useState<string>();
|
||||||
|
const [displayData, setDisplayData] = useState<Alias[]>([]);
|
||||||
|
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"aliases",
|
||||||
|
{
|
||||||
|
type: type,
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => {
|
||||||
|
const params = queryKey[1] as { type: string; id: number };
|
||||||
|
return getAliases(params.type, params.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setDisplayData(data);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="error">Error: {error.message}</p>;
|
||||||
|
}
|
||||||
|
if (isPending) {
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetPrimary = (alias: string) => {
|
||||||
|
setError(undefined);
|
||||||
|
setLoading(true);
|
||||||
|
setPrimaryAlias(type, id, alias).then((r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewAlias = () => {
|
||||||
|
setError(undefined);
|
||||||
|
if (input === "") {
|
||||||
|
setError("no input");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
createAlias(type, id, input).then((r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
setDisplayData([
|
||||||
|
...displayData,
|
||||||
|
{ alias: input, source: "Manual", is_primary: false, id: id },
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAlias = (alias: string) => {
|
||||||
|
setError(undefined);
|
||||||
|
setLoading(true);
|
||||||
|
deleteAlias(type, id, alias).then((r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
setDisplayData(displayData.filter((v) => v.alias != alias));
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<h3>Alias Manager</h3>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{displayData.map((v) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>
|
||||||
|
{v.alias} (source: {v.source})
|
||||||
|
</div>
|
||||||
|
<AsyncButton
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => handleSetPrimary(v.alias)}
|
||||||
|
disabled={v.is_primary}
|
||||||
|
>
|
||||||
|
Set Primary
|
||||||
|
</AsyncButton>
|
||||||
|
<AsyncButton
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => handleDeleteAlias(v.alias)}
|
||||||
|
confirm
|
||||||
|
disabled={v.is_primary}
|
||||||
|
>
|
||||||
|
<Trash size={16} />
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2 w-3/5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a new alias"
|
||||||
|
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={loading} onClick={handleNewAlias}>
|
||||||
|
Submit
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
{err && <p className="error">{err}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
client/app/components/modals/EditModal/ArtistManager.tsx
Normal file
167
client/app/components/modals/EditModal/ArtistManager.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { type Artist, type SearchResponse } from "api/api";
|
||||||
|
import { Trash } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { AsyncButton } from "../../AsyncButton";
|
||||||
|
import ComboBox from "~/components/ComboBox";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArtistManager({ type, id }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setError] = useState<string>();
|
||||||
|
const [displayData, setDisplayData] = useState<Artist[]>([]);
|
||||||
|
const [addArtistTarget, setAddArtistTarget] = useState<Artist>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||||
|
queryFn: () => {
|
||||||
|
return fetch(
|
||||||
|
"/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
|
||||||
|
).then((r) => r.json()) as Promise<Artist[]>;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectArtist = useCallback(
|
||||||
|
(artist: Artist) => {
|
||||||
|
setAddArtistTarget(artist);
|
||||||
|
},
|
||||||
|
[type, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setDisplayData(data);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="error">Error: {error.message}</p>;
|
||||||
|
}
|
||||||
|
if (isPending) {
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetPrimary = (artist: Artist, val: boolean) => {
|
||||||
|
setError(undefined);
|
||||||
|
setLoading(true);
|
||||||
|
fetch(
|
||||||
|
`/apis/web/v1/artists/primary?artist_id=${
|
||||||
|
artist.id
|
||||||
|
}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).then(async (r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddArtist = () => {
|
||||||
|
setError(undefined);
|
||||||
|
if (!addArtistTarget) {
|
||||||
|
setError("no artist selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append("add_artist", String(addArtistTarget.id));
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/apis/web/v1/${type}?id=${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: form,
|
||||||
|
}).then(async (r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteArtist = (artist: number) => {
|
||||||
|
setError(undefined);
|
||||||
|
setLoading(true);
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append("remove_artist", String(artist));
|
||||||
|
fetch(`/apis/web/v1/${type}?id=${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: form,
|
||||||
|
}).then(async (r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<h3>Artist Manager</h3>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{displayData.map((v) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg p-3 rounded-md flex-grow" key={v.name}>
|
||||||
|
{v.name}
|
||||||
|
</div>
|
||||||
|
<AsyncButton
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => handleSetPrimary(v, true)}
|
||||||
|
disabled={v.is_primary}
|
||||||
|
>
|
||||||
|
Set Primary
|
||||||
|
</AsyncButton>
|
||||||
|
{type == "track" && (
|
||||||
|
<AsyncButton
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => handleDeleteArtist(v.id)}
|
||||||
|
confirm
|
||||||
|
disabled={v.is_primary}
|
||||||
|
>
|
||||||
|
<Trash size={16} />
|
||||||
|
</AsyncButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{type == "track" && (
|
||||||
|
<div className="flex gap-2 w-3/5">
|
||||||
|
<ComboBox
|
||||||
|
onSelection={handleSelectArtist}
|
||||||
|
filterFunction={(r: SearchResponse) => {
|
||||||
|
r.albums = [];
|
||||||
|
r.tracks = [];
|
||||||
|
const ids = displayData.map((d) => d.id);
|
||||||
|
r.artists = r.artists.filter((a) => !ids.includes(a.id));
|
||||||
|
return r;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={loading} onClick={handleAddArtist}>
|
||||||
|
Submit
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <p className="error">{err}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,8 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
createAlias,
|
|
||||||
deleteAlias,
|
|
||||||
getAliases,
|
|
||||||
setPrimaryAlias,
|
|
||||||
updateMbzId,
|
|
||||||
type Alias,
|
|
||||||
} from "api/api";
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import { AsyncButton } from "../../AsyncButton";
|
import AliasManager from "./AliasManager";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Trash } from "lucide-react";
|
|
||||||
import SetVariousArtists from "./SetVariousArtist";
|
import SetVariousArtists from "./SetVariousArtist";
|
||||||
import SetPrimaryArtist from "./SetPrimaryArtist";
|
|
||||||
import UpdateMbzID from "./UpdateMbzID";
|
import UpdateMbzID from "./UpdateMbzID";
|
||||||
|
import ArtistManager from "./ArtistManager";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -23,141 +12,22 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditModal({ open, setOpen, type, id }: Props) {
|
export default function EditModal({ open, setOpen, type, id }: Props) {
|
||||||
const [input, setInput] = useState("");
|
type = type.toLowerCase();
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [err, setError] = useState<string>();
|
|
||||||
const [displayData, setDisplayData] = useState<Alias[]>([]);
|
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"aliases",
|
|
||||||
{
|
|
||||||
type: type,
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
queryFn: ({ queryKey }) => {
|
|
||||||
const params = queryKey[1] as { type: string; id: number };
|
|
||||||
return getAliases(params.type, params.id);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
setDisplayData(data);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error: {error.message}</p>;
|
|
||||||
}
|
|
||||||
if (isPending) {
|
|
||||||
return <p>Loading...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSetPrimary = (alias: string) => {
|
|
||||||
setError(undefined);
|
|
||||||
setLoading(true);
|
|
||||||
setPrimaryAlias(type, id, alias).then((r) => {
|
|
||||||
if (r.ok) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
r.json().then((r) => setError(r.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewAlias = () => {
|
|
||||||
setError(undefined);
|
|
||||||
if (input === "") {
|
|
||||||
setError("no input");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
createAlias(type, id, input).then((r) => {
|
|
||||||
if (r.ok) {
|
|
||||||
setDisplayData([
|
|
||||||
...displayData,
|
|
||||||
{ alias: input, source: "Manual", is_primary: false, id: id },
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
r.json().then((r) => setError(r.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAlias = (alias: string) => {
|
|
||||||
setError(undefined);
|
|
||||||
setLoading(true);
|
|
||||||
deleteAlias(type, id, alias).then((r) => {
|
|
||||||
if (r.ok) {
|
|
||||||
setDisplayData(displayData.filter((v) => v.alias != alias));
|
|
||||||
} else {
|
|
||||||
r.json().then((r) => setError(r.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setInput("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal maxW={1000} isOpen={open} onClose={handleClose}>
|
<Modal maxW={1000} isOpen={open} onClose={handleClose}>
|
||||||
<div className="flex flex-col items-start gap-6 w-full">
|
<div className="flex flex-col items-start gap-6 w-full">
|
||||||
<div className="w-full">
|
<AliasManager id={id} type={type} />
|
||||||
<h3>Alias Manager</h3>
|
{type === "album" && (
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{displayData.map((v) => (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>
|
|
||||||
{v.alias} (source: {v.source})
|
|
||||||
</div>
|
|
||||||
<AsyncButton
|
|
||||||
loading={loading}
|
|
||||||
onClick={() => handleSetPrimary(v.alias)}
|
|
||||||
disabled={v.is_primary}
|
|
||||||
>
|
|
||||||
Set Primary
|
|
||||||
</AsyncButton>
|
|
||||||
<AsyncButton
|
|
||||||
loading={loading}
|
|
||||||
onClick={() => handleDeleteAlias(v.alias)}
|
|
||||||
confirm
|
|
||||||
disabled={v.is_primary}
|
|
||||||
>
|
|
||||||
<Trash size={16} />
|
|
||||||
</AsyncButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="flex gap-2 w-3/5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Add a new alias"
|
|
||||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
/>
|
|
||||||
<AsyncButton loading={loading} onClick={handleNewAlias}>
|
|
||||||
Submit
|
|
||||||
</AsyncButton>
|
|
||||||
</div>
|
|
||||||
{err && <p className="error">{err}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{type.toLowerCase() === "album" && (
|
|
||||||
<>
|
<>
|
||||||
<SetVariousArtists id={id} />
|
<SetVariousArtists id={id} />
|
||||||
<SetPrimaryArtist id={id} type="album" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{type.toLowerCase() === "track" && (
|
{type !== "artist" && <ArtistManager id={id} type={type} />}
|
||||||
<SetPrimaryArtist id={id} type="track" />
|
|
||||||
)}
|
|
||||||
<UpdateMbzID type={type} id={id} />
|
<UpdateMbzID type={type} id={id} />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getAlbum, type Artist } from "api/api";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SetPrimaryArtist({ id, type }: Props) {
|
|
||||||
const [err, setErr] = useState("");
|
|
||||||
const [primary, setPrimary] = useState<Artist>();
|
|
||||||
const [success, setSuccess] = useState("");
|
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"get-artists-" + type.toLowerCase(),
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
queryFn: () => {
|
|
||||||
return fetch(
|
|
||||||
"/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
|
|
||||||
).then((r) => r.json()) as Promise<Artist[]>;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
for (let a of data) {
|
|
||||||
if (a.is_primary) {
|
|
||||||
setPrimary(a);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error: {error.message}</p>;
|
|
||||||
}
|
|
||||||
if (isPending) {
|
|
||||||
return <p>Loading...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePrimary = (artist: number, val: boolean) => {
|
|
||||||
setErr("");
|
|
||||||
setSuccess("");
|
|
||||||
fetch(
|
|
||||||
`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
).then((r) => {
|
|
||||||
if (r.ok) {
|
|
||||||
setSuccess("successfully updated primary artists");
|
|
||||||
} else {
|
|
||||||
r.json().then((r) => setErr(r.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<h3>Set Primary Artist</h3>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<select
|
|
||||||
name="mark-various-artists"
|
|
||||||
id="mark-various-artists"
|
|
||||||
className="w-60 px-3 py-2 rounded-md"
|
|
||||||
value={primary?.name || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
for (let a of data) {
|
|
||||||
if (a.name === e.target.value) {
|
|
||||||
setPrimary(a);
|
|
||||||
updatePrimary(a.id, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select an artist
|
|
||||||
</option>
|
|
||||||
{data.map((a) => (
|
|
||||||
<option key={a.id} value={a.name}>
|
|
||||||
{a.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{err && <p className="error">{err}</p>}
|
|
||||||
{success && <p className="success">{success}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -23,7 +23,8 @@
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-is": "^19.2.3",
|
"react-is": "^19.2.3",
|
||||||
"react-router": "^7.5.3",
|
"react-router": "^7.5.3",
|
||||||
"recharts": "^3.6.0"
|
"recharts": "^3.6.0",
|
||||||
|
"downshift": "^9.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.5.3",
|
"@react-router/dev": "^7.5.3",
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
|
||||||
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
|
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
|
||||||
|
|
||||||
|
"@babel/runtime@^7.28.6":
|
||||||
|
version "7.28.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b"
|
||||||
|
integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==
|
||||||
|
|
||||||
"@babel/template@^7.27.2":
|
"@babel/template@^7.27.2":
|
||||||
version "7.27.2"
|
version "7.27.2"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
|
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
|
||||||
|
|
@ -1288,6 +1293,11 @@ compression@^1.7.4:
|
||||||
safe-buffer "5.2.1"
|
safe-buffer "5.2.1"
|
||||||
vary "~1.1.2"
|
vary "~1.1.2"
|
||||||
|
|
||||||
|
compute-scroll-into-view@^3.1.1:
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz#02c3386ec531fb6a9881967388e53e8564f3e9aa"
|
||||||
|
integrity sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==
|
||||||
|
|
||||||
confbox@^0.1.8:
|
confbox@^0.1.8:
|
||||||
version "0.1.8"
|
version "0.1.8"
|
||||||
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
|
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
|
||||||
|
|
@ -1469,6 +1479,17 @@ detect-libc@^2.0.3, detect-libc@^2.0.4:
|
||||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8"
|
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8"
|
||||||
integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==
|
integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==
|
||||||
|
|
||||||
|
downshift@^9.3.1:
|
||||||
|
version "9.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/downshift/-/downshift-9.3.1.tgz#f663f060e514c8ec08b856ccd453685d5bf68cec"
|
||||||
|
integrity sha512-d/Bt/c74+TvG2MJW/xnoN8+zNTc2cYjbZ8yqlMPxmKbvsncJR0sXd4U1eu+JAbeKuwE8AppYRgmjkh4X0Us1hQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.28.6"
|
||||||
|
compute-scroll-into-view "^3.1.1"
|
||||||
|
prop-types "^15.8.1"
|
||||||
|
react-is "^18.2.0"
|
||||||
|
tslib "^2.8.1"
|
||||||
|
|
||||||
dunder-proto@^1.0.1:
|
dunder-proto@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
||||||
|
|
@ -1886,7 +1907,7 @@ jiti@^2.4.2:
|
||||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
|
||||||
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
|
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
|
||||||
|
|
||||||
js-tokens@^4.0.0:
|
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||||
|
|
@ -2000,6 +2021,13 @@ lodash@^4.17.21:
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
|
|
||||||
|
loose-envify@^1.4.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||||
|
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||||
|
dependencies:
|
||||||
|
js-tokens "^3.0.0 || ^4.0.0"
|
||||||
|
|
||||||
lru-cache@^10.2.0, lru-cache@^10.4.3:
|
lru-cache@^10.2.0, lru-cache@^10.4.3:
|
||||||
version "10.4.3"
|
version "10.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
||||||
|
|
@ -2200,6 +2228,11 @@ npm-pick-manifest@^8.0.0:
|
||||||
npm-package-arg "^10.0.0"
|
npm-package-arg "^10.0.0"
|
||||||
semver "^7.3.5"
|
semver "^7.3.5"
|
||||||
|
|
||||||
|
object-assign@^4.1.1:
|
||||||
|
version "4.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||||
|
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||||
|
|
||||||
object-inspect@^1.13.3:
|
object-inspect@^1.13.3:
|
||||||
version "1.13.4"
|
version "1.13.4"
|
||||||
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
|
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
|
||||||
|
|
@ -2332,6 +2365,15 @@ promise-retry@^2.0.1:
|
||||||
err-code "^2.0.2"
|
err-code "^2.0.2"
|
||||||
retry "^0.12.0"
|
retry "^0.12.0"
|
||||||
|
|
||||||
|
prop-types@^15.8.1:
|
||||||
|
version "15.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.4.0"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
react-is "^16.13.1"
|
||||||
|
|
||||||
proxy-addr@~2.0.7:
|
proxy-addr@~2.0.7:
|
||||||
version "2.0.7"
|
version "2.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
|
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
|
||||||
|
|
@ -2369,6 +2411,16 @@ react-dom@^19.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
scheduler "^0.26.0"
|
scheduler "^0.26.0"
|
||||||
|
|
||||||
|
react-is@^16.13.1:
|
||||||
|
version "16.13.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||||
|
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||||
|
|
||||||
|
react-is@^18.2.0:
|
||||||
|
version "18.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||||
|
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||||
|
|
||||||
react-is@^19.2.3:
|
react-is@^19.2.3:
|
||||||
version "19.2.3"
|
version "19.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
|
||||||
|
|
@ -2749,7 +2801,7 @@ tsconfck@^3.0.3:
|
||||||
resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.6.tgz#da1f0b10d82237ac23422374b3fce1edb23c3ead"
|
resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.6.tgz#da1f0b10d82237ac23422374b3fce1edb23c3ead"
|
||||||
integrity sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==
|
integrity sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==
|
||||||
|
|
||||||
tslib@^2.4.0, tslib@^2.8.0:
|
tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1:
|
||||||
version "2.8.1"
|
version "2.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ INSERT INTO artist_tracks (artist_id, track_id, is_primary)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- name: UnssociateArtistFromTrack :exec
|
||||||
|
DELETE FROM artist_tracks
|
||||||
|
WHERE artist_id = $1
|
||||||
|
AND track_id = $2
|
||||||
|
AND is_primary = false;
|
||||||
|
|
||||||
-- name: GetTrack :one
|
-- name: GetTrack :one
|
||||||
SELECT
|
SELECT
|
||||||
t.*,
|
t.*,
|
||||||
|
|
|
||||||
|
|
@ -131,46 +131,3 @@ func MergeArtistsHandler(store db.DB) http.HandlerFunc {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateAlbumHandler(store db.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
l := logger.FromContext(ctx)
|
|
||||||
|
|
||||||
l.Debug().Msg("UpdateAlbumHandler: Received request")
|
|
||||||
|
|
||||||
idStr := r.URL.Query().Get("id")
|
|
||||||
id, err := strconv.Atoi(idStr)
|
|
||||||
|
|
||||||
valStr := r.URL.Query().Get("is_various_artists")
|
|
||||||
var variousArists bool
|
|
||||||
var updateVariousArtists = false
|
|
||||||
if strings.ToLower(valStr) == "true" {
|
|
||||||
variousArists = true
|
|
||||||
updateVariousArtists = true
|
|
||||||
} else if strings.ToLower(valStr) == "false" {
|
|
||||||
variousArists = false
|
|
||||||
updateVariousArtists = true
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
l.Debug().AnErr("error", err).Msg("UpdateAlbumHandler: Invalid id parameter")
|
|
||||||
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = store.UpdateAlbum(ctx, db.UpdateAlbumOpts{
|
|
||||||
ID: int32(id),
|
|
||||||
VariousArtistsUpdate: updateVariousArtists,
|
|
||||||
VariousArtistsValue: variousArists,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
l.Debug().AnErr("error", err).Msg("UpdateAlbumHandler: Failed to update album")
|
|
||||||
utils.WriteError(w, "failed to update album", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Debug().Msg("UpdateAlbumHandler: Successfully updated album")
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
124
engine/handlers/update.go
Normal file
124
engine/handlers/update.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateTrackHandler(store db.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
l.Debug().Msg("UpdateTrackHandler: Received request")
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateTrackHandler: Failed to parse form")
|
||||||
|
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := r.Form.Get("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateTrackHandler: Invalid id parameter")
|
||||||
|
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateOpts = db.UpdateTrackOpts{
|
||||||
|
ID: int32(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
if formVal, ok := r.Form["add_artist"]; ok {
|
||||||
|
var artists []int32
|
||||||
|
for _, val := range formVal {
|
||||||
|
if id, err := strconv.Atoi(val); err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateTrackHandler: ID of artist to add is invalid")
|
||||||
|
utils.WriteError(w, "ID of artist to add is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
artists = append(artists, int32(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateOpts.AddArtists = artists
|
||||||
|
}
|
||||||
|
|
||||||
|
if formVal, ok := r.Form["remove_artist"]; ok {
|
||||||
|
var artists []int32
|
||||||
|
for _, val := range formVal {
|
||||||
|
if id, err := strconv.Atoi(val); err != nil {
|
||||||
|
l.Debug().Msg("UpdateTrackHandler: ID of artist to remove is invalid")
|
||||||
|
utils.WriteError(w, "ID of artist to remove is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
artists = append(artists, int32(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateOpts.RemoveArtists = artists
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = store.UpdateTrack(ctx, updateOpts); err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateTrackHandler: Failed to update track")
|
||||||
|
utils.WriteError(w, "failed to update track", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debug().Msg("UpdateTrackHandler: Successfully updated track")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateAlbumHandler(store db.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
l.Debug().Msg("UpdateAlbumHandler: Received request")
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateAlbumHandler: Failed to parse form")
|
||||||
|
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := r.Form.Get("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateAlbumHandler: Invalid id parameter")
|
||||||
|
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateOpts = db.UpdateAlbumOpts{
|
||||||
|
ID: int32(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Form.Has("is_various_artists") {
|
||||||
|
valStr := r.Form.Get("is_various_artists")
|
||||||
|
VariousArtistsValue, err := strconv.ParseBool(valStr)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateAlbumHandler: Various artists setting is invalid")
|
||||||
|
utils.WriteError(w, "Various artists setting is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateOpts.VariousArtistsUpdate = true
|
||||||
|
updateOpts.VariousArtistsValue = VariousArtistsValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = store.UpdateAlbum(ctx, updateOpts); err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("UpdateAlbumHandler: Failed to update album")
|
||||||
|
utils.WriteError(w, "failed to update album", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debug().Msg("UpdateAlbumHandler: Successfully updated album")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,7 @@ func bindRoutes(
|
||||||
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
|
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
|
||||||
r.Delete("/album", handlers.DeleteAlbumHandler(db))
|
r.Delete("/album", handlers.DeleteAlbumHandler(db))
|
||||||
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
||||||
|
r.Patch("/track", handlers.UpdateTrackHandler(db))
|
||||||
r.Post("/listen", handlers.SubmitListenWithIDHandler(db))
|
r.Post("/listen", handlers.SubmitListenWithIDHandler(db))
|
||||||
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
||||||
r.Post("/aliases", handlers.CreateAliasHandler(db))
|
r.Post("/aliases", handlers.CreateAliasHandler(db))
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ type UpdateTrackOpts struct {
|
||||||
ID int32
|
ID int32
|
||||||
MusicBrainzID uuid.UUID
|
MusicBrainzID uuid.UUID
|
||||||
Duration int32
|
Duration int32
|
||||||
|
AddArtists []int32
|
||||||
|
RemoveArtists []int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateArtistOpts struct {
|
type UpdateArtistOpts struct {
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,44 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
|
||||||
return fmt.Errorf("UpdateTrack: UpdateTrackDuration: %w", err)
|
return fmt.Errorf("UpdateTrack: UpdateTrackDuration: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(opts.AddArtists) > 0 {
|
||||||
|
var releaseID int32
|
||||||
|
if t, err := d.q.GetTrack(ctx, opts.ID); err != nil {
|
||||||
|
return fmt.Errorf("UpdateTrack: GetTrack By ID: %w", err)
|
||||||
|
} else {
|
||||||
|
releaseID = t.ReleaseID
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, aid := range opts.AddArtists {
|
||||||
|
if err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{
|
||||||
|
ArtistID: aid,
|
||||||
|
TrackID: opts.ID,
|
||||||
|
IsPrimary: false,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("UpdateTrack: AssociateArtistToTrack: %w", err)
|
||||||
|
}
|
||||||
|
if err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{
|
||||||
|
ArtistID: aid,
|
||||||
|
ReleaseID: releaseID,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("UpdateTrack: AssociateArtistToRelease: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.RemoveArtists) > 0 {
|
||||||
|
for _, aid := range opts.RemoveArtists {
|
||||||
|
if err = qtx.UnssociateArtistFromTrack(ctx, repository.UnssociateArtistFromTrackParams{
|
||||||
|
ArtistID: aid,
|
||||||
|
TrackID: opts.ID,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("UpdateTrack: UnssociateArtistFromTrack: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = qtx.CleanOrphanedEntries(ctx); err != nil {
|
||||||
|
return fmt.Errorf("UpdateTrack: CleanOrphanedEntries: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ func testDataForTracks(t *testing.T) {
|
||||||
('00000000-0000-0000-0000-000000000022')`)
|
('00000000-0000-0000-0000-000000000022')`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Associate releases with artists
|
||||||
|
err = store.Exec(context.Background(),
|
||||||
|
`INSERT INTO artist_releases (artist_id, release_id, is_primary)
|
||||||
|
VALUES (1, 1, true),
|
||||||
|
(2, 2, true)`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Insert release aliases
|
// Insert release aliases
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
|
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
|
||||||
|
|
@ -58,8 +65,8 @@ func testDataForTracks(t *testing.T) {
|
||||||
|
|
||||||
// Associate tracks with artists
|
// Associate tracks with artists
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO artist_tracks (artist_id, track_id)
|
`INSERT INTO artist_tracks (artist_id, track_id, is_primary)
|
||||||
VALUES (1, 1), (2, 2)`)
|
VALUES (1, 1, true), (2, 2, true)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Insert listens
|
// Insert listens
|
||||||
|
|
@ -187,6 +194,50 @@ func TestUpdateTrack(t *testing.T) {
|
||||||
Duration: int32(newDuration),
|
Duration: int32(newDuration),
|
||||||
})
|
})
|
||||||
assert.NoError(t, err) // No update should occur
|
assert.NoError(t, err) // No update should occur
|
||||||
|
|
||||||
|
// Test adding artist to track
|
||||||
|
err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
|
||||||
|
ID: 1,
|
||||||
|
AddArtists: []int32{2},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
exists, err := store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM artist_tracks
|
||||||
|
WHERE artist_id = $1 AND track_id = $2
|
||||||
|
)`, 2, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "expected artist to be associated with track")
|
||||||
|
|
||||||
|
// Test removing artist from track
|
||||||
|
err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
|
||||||
|
ID: 1,
|
||||||
|
RemoveArtists: []int32{2},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
exists, err = store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM artist_tracks
|
||||||
|
WHERE artist_id = $1 AND track_id = $2
|
||||||
|
)`, 2, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists, "expected artist to be unassociated with track")
|
||||||
|
|
||||||
|
// Test that the primary artist cannot be removed
|
||||||
|
err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
|
||||||
|
ID: 1,
|
||||||
|
RemoveArtists: []int32{int32(1)},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
exists, err = store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM artist_tracks
|
||||||
|
WHERE artist_id = $1 AND track_id = $2
|
||||||
|
)`, 1, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "expected primary artist to not be removed from track")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackAliases(t *testing.T) {
|
func TestTrackAliases(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,23 @@ func (q *Queries) InsertTrack(ctx context.Context, arg InsertTrackParams) (Track
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unssociateArtistFromTrack = `-- name: UnssociateArtistFromTrack :exec
|
||||||
|
DELETE FROM artist_tracks
|
||||||
|
WHERE artist_id = $1
|
||||||
|
AND track_id = $2
|
||||||
|
AND is_primary = false
|
||||||
|
`
|
||||||
|
|
||||||
|
type UnssociateArtistFromTrackParams struct {
|
||||||
|
ArtistID int32
|
||||||
|
TrackID int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UnssociateArtistFromTrack(ctx context.Context, arg UnssociateArtistFromTrackParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, unssociateArtistFromTrack, arg.ArtistID, arg.TrackID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const updateReleaseForAll = `-- name: UpdateReleaseForAll :exec
|
const updateReleaseForAll = `-- name: UpdateReleaseForAll :exec
|
||||||
UPDATE tracks SET release_id = $2
|
UPDATE tracks SET release_id = $2
|
||||||
WHERE release_id = $1
|
WHERE release_id = $1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue