From 3250a4ec3f9e82bd673af7207ff628b38760a20a Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 00:12:21 -0400 Subject: [PATCH 01/91] feat: v0.0.3 --- CHANGELOG.md | 14 +-- client/README.md | 87 -------------- client/api/api.ts | 9 ++ client/app/components/ActivityGrid.tsx | 18 +-- client/app/components/LastPlays.tsx | 89 ++++++++++---- client/app/components/PeriodSelector.tsx | 2 +- client/app/components/Popup.tsx | 82 +++++++------ client/app/components/TopItemList.tsx | 6 +- client/app/components/sidebar/Sidebar.tsx | 65 +++++++---- client/app/root.tsx | 2 +- client/app/routes/Charts/AlbumChart.tsx | 2 +- client/app/routes/Charts/ArtistChart.tsx | 2 +- client/app/routes/Charts/ChartLayout.tsx | 68 +++++------ client/app/routes/Charts/Listens.tsx | 136 ++++++++++++++-------- client/app/routes/Charts/TrackChart.tsx | 2 +- client/app/routes/Home.tsx | 4 +- client/app/routes/Root.tsx | 43 ------- docs/README.md | 54 --------- engine/engine.go | 4 + internal/cfg/cfg.go | 5 + internal/db/psql/search.go | 2 +- 21 files changed, 322 insertions(+), 374 deletions(-) delete mode 100644 client/README.md delete mode 100644 client/app/routes/Root.tsx delete mode 100644 docs/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eee375..1a3357e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,14 @@ -# v0.0.2 +# v0.0.3 ## Features -- Configurable CORS policy via KOITO_CORS_ALLOWED_ORIGINS -- A baseline mobile UI +- Delete listens from the UI ## Enhancements -- The import source is now saved as the client for the imported listen. +- Better behaved mobile UI +- Search now returns 8 items per category instead of 5 ## Fixes -- Account update form now works on enter key +- Many mobile UI fixes ## Updates -- Non-sensitive query parameters are logged with requests -- Koito version number is embedded through tags \ No newline at end of file +- Refuses a config that changes the MusicBrainz rate limit while using the official MusicBrainz URL +- Warns when enabling ListenBrainz relay with missing configuration \ No newline at end of file diff --git a/client/README.md b/client/README.md deleted file mode 100644 index 5c4780a..0000000 --- a/client/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Welcome to React Router! - -A modern, production-ready template for building full-stack React applications using React Router. - -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) - -## Features - -- 🚀 Server-side rendering -- ⚡️ Hot Module Replacement (HMR) -- 📦 Asset bundling and optimization -- 🔄 Data loading and mutations -- 🔒 TypeScript by default -- 🎉 TailwindCSS for styling -- 📖 [React Router docs](https://reactrouter.com/) - -## Getting Started - -### Installation - -Install the dependencies: - -```bash -npm install -``` - -### Development - -Start the development server with HMR: - -```bash -npm run dev -``` - -Your application will be available at `http://localhost:5173`. - -## Building for Production - -Create a production build: - -```bash -npm run build -``` - -## Deployment - -### Docker Deployment - -To build and run using Docker: - -```bash -docker build -t my-app . - -# Run the container -docker run -p 3000:3000 my-app -``` - -The containerized application can be deployed to any platform that supports Docker, including: - -- AWS ECS -- Google Cloud Run -- Azure Container Apps -- Digital Ocean App Platform -- Fly.io -- Railway - -### DIY Deployment - -If you're familiar with deploying Node applications, the built-in app server is production-ready. - -Make sure to deploy the output of `npm run build` - -``` -├── package.json -├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) -├── build/ -│ ├── client/ # Static assets -│ └── server/ # Server-side code -``` - -## Styling - -This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. - ---- - -Built with ❤️ using React Router. diff --git a/client/api/api.ts b/client/api/api.ts index 40f4d6f..ec79aa0 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -157,6 +157,14 @@ function setPrimaryAlias(type: string, id: number, alias: string): Promise { + const ms = new Date(listen.time).getTime() + const unix= Math.floor(ms / 1000); + return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { + method: "DELETE" + }) +} + export { getLastListens, getTopTracks, @@ -182,6 +190,7 @@ export { createApiKey, deleteApiKey, updateApiKeyLabel, + deleteListen, } type Track = { id: number diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index a410c39..818d6e3 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -142,14 +142,6 @@ export default function ActivityGrid({ } } - - const mobileDotSize = 10 - const normalDotSize = 12 - - let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) - - let dotSize = vw > 768 ? normalDotSize : mobileDotSize - return (

Activity

{configurable ? ( @@ -162,29 +154,27 @@ export default function ActivityGrid({ ) : ( '' )} -
+
{data.map((item) => (
0 ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100)) : 'var(--color-bg-secondary)', }} - className={`rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`} + className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`} >
diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index b1eda5e..2bc1cc3 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -1,7 +1,8 @@ +import { useState } from "react" import { useQuery } from "@tanstack/react-query" import { timeSince } from "~/utils/utils" import ArtistLinks from "./ArtistLinks" -import { getLastListens, type getItemsArgs } from "api/api" +import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api" import { Link } from "react-router" interface Props { @@ -11,47 +12,95 @@ interface Props { trackId?: number hideArtists?: boolean } - -export default function LastPlays(props: Props) { - const { isPending, isError, data, error } = useQuery({ - queryKey: ['last-listens', {limit: props.limit, period: 'all_time', artist_id: props.artistId, album_id: props.albumId, track_id: props.trackId}], +export default function LastPlays(props: Props) { + const { isPending, isError, data, error } = useQuery({ + queryKey: ['last-listens', { + limit: props.limit, + period: 'all_time', + artist_id: props.artistId, + album_id: props.albumId, + track_id: props.trackId + }], queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), }) + const [items, setItems] = useState(null) + + const handleDelete = async (listen: Listen) => { + if (!data) return + try { + const res = await deleteListen(listen) + if (res.ok || (res.status >= 200 && res.status < 300)) { + setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time)) + } else { + console.error("Failed to delete listen:", res.status) + } + } catch (err) { + console.error("Error deleting listen:", err) + } + } + if (isPending) { return ( -
+

Last Played

Loading...

) } if (isError) { - return

Error:{error.message}

+ return

Error: {error.message}

} + const listens = items ?? data.items + let params = '' params += props.artistId ? `&artist_id=${props.artistId}` : '' params += props.albumId ? `&album_id=${props.albumId}` : '' params += props.trackId ? `&track_id=${props.trackId}` : '' return ( -
-

Last Played

- +
+

+ Last Played +

+
- {data.items.map((item) => ( - - - - - ))} + {listens.map((item) => ( + + + + + + ))}
{timeSince(new Date(item.time))} - {props.hideArtists ? <> : <> - } - {item.track.title} -
+ + + {timeSince(new Date(item.time))} + + {props.hideArtists ? null : ( + <> + –{' '} + + )} + + {item.track.title} + +
) -} \ No newline at end of file +} diff --git a/client/app/components/PeriodSelector.tsx b/client/app/components/PeriodSelector.tsx index 91bad9a..3393dc7 100644 --- a/client/app/components/PeriodSelector.tsx +++ b/client/app/components/PeriodSelector.tsx @@ -31,7 +31,7 @@ export default function PeriodSelector({ setter, current, disableCache = false } }, []); return ( -
+

Showing stats for:

{periods.map((p, i) => (
diff --git a/client/app/components/Popup.tsx b/client/app/components/Popup.tsx index 3c73cb5..a032e4e 100644 --- a/client/app/components/Popup.tsx +++ b/client/app/components/Popup.tsx @@ -1,48 +1,64 @@ -import React, { type PropsWithChildren, useState } from 'react'; +import React, { type PropsWithChildren, useEffect, useState } from 'react'; interface Props { - inner: React.ReactNode - position: string - space: number - extraClasses?: string - hint?: string + inner: React.ReactNode + position: string + space: number + extraClasses?: string + hint?: string } export default function Popup({ inner, position, space, extraClasses, children }: PropsWithChildren) { const [isVisible, setIsVisible] = useState(false); + const [showPopup, setShowPopup] = useState(true); - let positionClasses - let spaceCSS = {} - if (position == "top") { - positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2` - } else if (position == "right") { - positionClasses = `bottom-1 -translate-x-1/2` - spaceCSS = {left: 70 + space} + useEffect(() => { + const mediaQuery = window.matchMedia('(min-width: 640px)'); + + const handleChange = (e: MediaQueryListEvent) => { + setShowPopup(e.matches); + }; + + setShowPopup(mediaQuery.matches); + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + let positionClasses = ''; + let spaceCSS: React.CSSProperties = {}; + if (position === 'top') { + positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2`; + } else if (position === 'right') { + positionClasses = `bottom-1 -translate-x-1/2`; + spaceCSS = { left: 70 + space }; } return (
setIsVisible(true)} - onMouseLeave={() => setIsVisible(false)} + className="relative" + onMouseEnter={() => setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} > - {children} -
- {inner} -
+ {children} + {showPopup && ( +
+ {inner} +
+ )}
); } diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 7f68c9e..22d307c 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -7,14 +7,14 @@ type Item = Album | Track | Artist; interface Props { data: PaginatedResponse separators?: ConstrainBoolean - width?: number type: "album" | "track" | "artist"; + className?: string, } -export default function TopItemList({ data, separators, type, width }: Props) { +export default function TopItemList({ data, separators, type, className }: Props) { return ( -
+
{data.items.map((item, index) => { const key = `${type}-${item.id}`; return ( diff --git a/client/app/components/sidebar/Sidebar.tsx b/client/app/components/sidebar/Sidebar.tsx index 11ff824..1a42e67 100644 --- a/client/app/components/sidebar/Sidebar.tsx +++ b/client/app/components/sidebar/Sidebar.tsx @@ -7,29 +7,48 @@ export default function Sidebar() { const iconSize = 20; return ( -
-
-
- {}} modal={<>}> - - - -
-
- } - space={22} - externalLink - to="https://koito.io" - name="About" - onClick={() => {}} - modal={<>} - > - - - -
+
+
+ {}} modal={<>}> + + + +
+
+ } + space={22} + externalLink + to="https://koito.io" + name="About" + onClick={() => {}} + modal={<>} + > + + +
); diff --git a/client/app/root.tsx b/client/app/root.tsx index e7e2415..b210088 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -67,7 +67,7 @@ export default function App() {
-
+
diff --git a/client/app/routes/Charts/AlbumChart.tsx b/client/app/routes/Charts/AlbumChart.tsx index 8e68186..ba323bf 100644 --- a/client/app/routes/Charts/AlbumChart.tsx +++ b/client/app/routes/Charts/AlbumChart.tsx @@ -40,7 +40,7 @@ export default function AlbumChart() {
diff --git a/client/app/routes/Charts/ArtistChart.tsx b/client/app/routes/Charts/ArtistChart.tsx index bc3be16..ec3dfd8 100644 --- a/client/app/routes/Charts/ArtistChart.tsx +++ b/client/app/routes/Charts/ArtistChart.tsx @@ -40,7 +40,7 @@ export default function Artist() {
diff --git a/client/app/routes/Charts/ChartLayout.tsx b/client/app/routes/Charts/ChartLayout.tsx index 6690cd3..ee5ef59 100644 --- a/client/app/routes/Charts/ChartLayout.tsx +++ b/client/app/routes/Charts/ChartLayout.tsx @@ -212,43 +212,45 @@ export default function ChartLayout({ {pgTitle} -
+

{title}

-
+
- - - +
+ + + +

{getDateRange()}

-
+
{render({ data, page: currentPage, diff --git a/client/app/routes/Charts/Listens.tsx b/client/app/routes/Charts/Listens.tsx index 6f5efdb..979e1c1 100644 --- a/client/app/routes/Charts/Listens.tsx +++ b/client/app/routes/Charts/Listens.tsx @@ -1,66 +1,104 @@ import ChartLayout from "./ChartLayout"; import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router"; -import { type Album, type Listen, type PaginatedResponse } from "api/api"; +import { deleteListen, type Listen, type PaginatedResponse } from "api/api"; import { timeSince } from "~/utils/utils"; import ArtistLinks from "~/components/ArtistLinks"; +import { useState } from "react"; export async function clientLoader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url); - const page = url.searchParams.get("page") || "0"; - url.searchParams.set('page', page) + const url = new URL(request.url); + const page = url.searchParams.get("page") || "0"; + url.searchParams.set('page', page) - const res = await fetch( - `/apis/web/v1/listens?${url.searchParams.toString()}` - ); - if (!res.ok) { - throw new Response("Failed to load top tracks", { status: 500 }); - } + const res = await fetch( + `/apis/web/v1/listens?${url.searchParams.toString()}` + ); + if (!res.ok) { + throw new Response("Failed to load top tracks", { status: 500 }); + } - const listens: PaginatedResponse = await res.json(); - return { listens }; + const listens: PaginatedResponse = await res.json(); + return { listens }; } export default function Listens() { - const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse }>(); + const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse }>(); + const [items, setItems] = useState(null) + + const handleDelete = async (listen: Listen) => { + if (!initialData) return + try { + const res = await deleteListen(listen) + if (res.ok || (res.status >= 200 && res.status < 300)) { + setItems((prev) => (prev ?? initialData.items).filter((i) => i.time !== listen.time)) + } else { + console.error("Failed to delete listen:", res.status) + } + } catch (err) { + console.error("Error deleting listen:", err) + } + } + + const listens = items ?? initialData.items + return ( - ( -
-
- - -
- - - {data.items.map((item) => ( - - - - - ))} - -
{timeSince(new Date(item.time))} - {' - '} - {item.track.title} -
-
- -
-
- )} - /> - ); +
+ + + {listens.map((item) => ( + + + + + + ))} + +
+ + + {timeSince(new Date(item.time))} + + –{' '} + + {item.track.title} + +
+
+ + +
+
+ )} + /> + ); } diff --git a/client/app/routes/Charts/TrackChart.tsx b/client/app/routes/Charts/TrackChart.tsx index 23c1531..eeeb145 100644 --- a/client/app/routes/Charts/TrackChart.tsx +++ b/client/app/routes/Charts/TrackChart.tsx @@ -40,7 +40,7 @@ export default function TrackChart() {
diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx index 04359a2..8af882b 100644 --- a/client/app/routes/Home.tsx +++ b/client/app/routes/Home.tsx @@ -24,12 +24,12 @@ export default function Home() { return (
-
+
-
+
diff --git a/client/app/routes/Root.tsx b/client/app/routes/Root.tsx deleted file mode 100644 index 9672dd8..0000000 --- a/client/app/routes/Root.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { isRouteErrorResponse, Outlet } from "react-router"; -import Footer from "~/components/Footer"; -import type { Route } from "../+types/root"; - -export default function Root() { - - return ( -
- -
-
- ) -} - -export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = "Oops!"; - let details = "An unexpected error occurred."; - let stack: string | undefined; - - if (isRouteErrorResponse(error)) { - message = error.status === 404 ? "404" : "Error"; - details = - error.status === 404 - ? "The requested page could not be found." - : error.statusText || details; - } else if (import.meta.env.DEV && error && error instanceof Error) { - details = error.message; - stack = error.stack; - } - - return ( -
-

{message}

-

{details}

- {stack && ( -
-            {stack}
-          
- )} -
- ); - } - \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 824799f..0000000 --- a/docs/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Starlight Starter Kit: Basics - -[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) - -``` -yarn create astro@latest -- --template starlight -``` - -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) -[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) -[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) - -> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! - -## 🚀 Project Structure - -Inside of your Astro + Starlight project, you'll see the following folders and files: - -``` -. -├── public/ -├── src/ -│ ├── assets/ -│ ├── content/ -│ │ ├── docs/ -│ └── content.config.ts -├── astro.config.mjs -├── package.json -└── tsconfig.json -``` - -Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. - -Images can be added to `src/assets/` and embedded in Markdown with a relative link. - -Static assets, like favicons, can be placed in the `public/` directory. - -## 🧞 Commands - -All commands are run from the root of the project, from a terminal: - -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `yarn install` | Installs dependencies | -| `yarn dev` | Starts local dev server at `localhost:4321` | -| `yarn build` | Build your production site to `./dist/` | -| `yarn preview` | Preview your build locally, before deploying | -| `yarn astro ...` | Run CLI commands like `astro add`, `astro check` | -| `yarn astro -- --help` | Get help using the Astro CLI | - -## 👀 Want to learn more? - -Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). diff --git a/engine/engine.go b/engine/engine.go index fdaab5c..2cbcb28 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -156,6 +156,10 @@ func Run( l.Info().Msgf("Engine: CORS policy: Allowing origins: %v", cfg.AllowedOrigins()) } + if cfg.LbzRelayEnabled() && (cfg.LbzRelayUrl() == "" || cfg.LbzRelayToken() == "") { + l.Warn().Msg("You have enabled ListenBrainz relay, but either the URL or token is missing. Double check your configuration to make sure it is correct!") + } + l.Debug().Msg("Engine: Setting up HTTP server") var ready atomic.Bool mux := chi.NewRouter() diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 0d65f7a..ad15869 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -110,6 +110,11 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { if cfg.musicBrainzUrl == "" { cfg.musicBrainzUrl = defaultMusicBrainzUrl } + + if cfg.musicBrainzUrl == defaultMusicBrainzUrl && cfg.musicBrainzRateLimit != 1 { + return nil, fmt.Errorf("loadConfig: invalid configuration: %s cannot be altered when %s is default", MUSICBRAINZ_RATE_LIMIT_ENV, MUSICBRAINZ_URL_ENV) + } + if parseBool(getenv(ENABLE_LBZ_RELAY_ENV)) { cfg.lbzRelayEnabled = true cfg.lbzRelayToken = getenv(LBZ_RELAY_TOKEN_ENV) diff --git a/internal/db/psql/search.go b/internal/db/psql/search.go index 69250fb..675134b 100644 --- a/internal/db/psql/search.go +++ b/internal/db/psql/search.go @@ -9,7 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const searchItemLimit = 5 +const searchItemLimit = 8 const substringSearchLength = 6 func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, error) { From aeb79adefb6f67d68b8d7e8ef287e819bcb722d4 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 00:14:55 -0400 Subject: [PATCH 02/91] fix: don't run docker workflow on branch push --- .github/workflows/docker.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0df4d53..cd7ecb0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,7 +11,6 @@ name: Publish Docker image on: push: - branches: [main] tags: - 'v*' From 3af969b25ccd0ba137abc999aba97e232e658efd Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 00:43:44 -0400 Subject: [PATCH 03/91] docs: only run workflow when docs are updated --- .github/workflows/astro.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/astro.yml b/.github/workflows/astro.yml index 71ee903..2da5fc4 100644 --- a/.github/workflows/astro.yml +++ b/.github/workflows/astro.yml @@ -1,14 +1,12 @@ name: Deploy to GitHub Pages on: - # Trigger the workflow every time you push to the `main` branch - # Using a different branch name? Replace `main` with your branch’s name push: branches: [main] - # Allows you to run this workflow manually from the Actions tab on GitHub. - workflow_dispatch: + paths: + - 'docs/**' + - '.github/workflows/**' -# Allow this job to clone the repo and create a page deployment permissions: contents: read pages: write From 4c4ebc593dc131359849e9e1db32aad113d40603 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 01:02:59 -0400 Subject: [PATCH 04/91] feat: re-download missing images on request --- CHANGELOG.md | 15 ++------------ engine/handlers/image_handler.go | 34 ++++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3357e..9eb9f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,3 @@ -# v0.0.3 -## Features -- Delete listens from the UI - +# v0.0.4 ## Enhancements -- Better behaved mobile UI -- Search now returns 8 items per category instead of 5 - -## Fixes -- Many mobile UI fixes - -## Updates -- Refuses a config that changes the MusicBrainz rate limit while using the official MusicBrainz URL -- Warns when enabling ListenBrainz relay with missing configuration \ No newline at end of file +- Re-download images missing from cache on request diff --git a/engine/handlers/image_handler.go b/engine/handlers/image_handler.go index 84d8681..8ad1c54 100644 --- a/engine/handlers/image_handler.go +++ b/engine/handlers/image_handler.go @@ -2,6 +2,8 @@ package handlers import ( "bytes" + "context" + "fmt" "net/http" "os" "path" @@ -47,18 +49,23 @@ func ImageHandler(store db.DB) http.HandlerFunc { fullSizePath := filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, string(catalog.ImageSizeFull), filepath.Clean(filename)) largeSizePath := filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, string(catalog.ImageSizeLarge), filepath.Clean(filename)) + // this if statement flow is terrible but whatever var sourcePath string if _, err = os.Stat(fullSizePath); os.IsNotExist(err) { if _, err = os.Stat(largeSizePath); os.IsNotExist(err) { - l.Warn().Msgf("ImageHandler: Could not find requested image %s. Serving default image", imgid.String()) - serveDefaultImage(w, r, imageSize) - return + l.Warn().Msgf("ImageHandler: Could not find requested image %s. Attempting to download from source", imgid.String()) + sourcePath, err = downloadMissingImage(r.Context(), store, imgid) + if err != nil { + l.Err(err).Msg("ImageHandler: Failed to redownload missing image") + w.WriteHeader(http.StatusInternalServerError) + } } else if err != nil { l.Err(err).Msg("ImageHandler: Failed to access source image file at large size") w.WriteHeader(http.StatusInternalServerError) return + } else { + sourcePath = largeSizePath } - sourcePath = largeSizePath } else if err != nil { l.Err(err).Msg("ImageHandler: Failed to access source image file at full size") w.WriteHeader(http.StatusInternalServerError) @@ -139,3 +146,22 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag l.Debug().Msgf("serveDefaultImage: Successfully serving default image at size '%s'", size) http.ServeFile(w, r, path.Join(cfg.ConfigDir(), catalog.ImageCacheDir, string(size), "default_img")) } + +// finds the item associated with the image id, downloads it, and saves it in the source path, returning the path to the image +func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (string, error) { + src, err := store.GetImageSource(ctx, id) + if err != nil { + return "", fmt.Errorf("downloadMissingImage: store.GetImageSource: %w", err) + } + var size catalog.ImageSize + if cfg.FullImageCacheEnabled() { + size = catalog.ImageSizeFull + } else { + size = catalog.ImageSizeLarge + } + err = catalog.DownloadAndCacheImage(ctx, id, src, size) + if err != nil { + return "", fmt.Errorf("downloadMissingImage: catalog.DownloadAndCacheImage: %w", err) + } + return path.Join(catalog.SourceImageDir(), id.String()), nil +} From 242a82ad8ca2e56a1e99909305ea210197c8477a Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 19:09:44 -0400 Subject: [PATCH 05/91] feat: v0.0.5 --- CHANGELOG.md | 15 ++- client/api/api.ts | 11 +- client/app/components/TopItemList.tsx | 2 +- client/app/components/modals/MergeModal.tsx | 10 +- client/app/routes/MediaItems/Album.tsx | 6 +- client/app/routes/MediaItems/Artist.tsx | 6 +- client/app/routes/MediaItems/MediaLayout.tsx | 2 +- client/app/routes/MediaItems/Track.tsx | 4 +- client/app/utils/utils.ts | 14 ++- docs/src/content/docs/guides/importing.md | 3 +- engine/handlers/lbz_submit_listen.go | 67 +++++++++--- engine/handlers/merge.go | 19 +++- engine/import_test.go | 72 ++++++++++++ engine/middleware/validate.go | 12 +- go.mod | 2 +- internal/catalog/associate_artists.go | 109 +++++++++++++++++-- internal/catalog/catalog.go | 43 +++++--- internal/catalog/images.go | 9 ++ internal/catalog/submit_listen_test.go | 61 +++++++++++ internal/db/db.go | 5 +- internal/db/opts.go | 7 ++ internal/db/psql/album.go | 9 ++ internal/db/psql/album_test.go | 17 +-- internal/db/psql/artist.go | 61 ++++++++--- internal/db/psql/artist_test.go | 27 +++-- internal/db/psql/counts.go | 39 +++++++ internal/db/psql/counts_test.go | 30 +++++ internal/db/psql/merge.go | 53 +++++++-- internal/db/psql/merge_test.go | 28 +++-- internal/db/psql/track.go | 11 +- internal/db/psql/track_test.go | 15 ++- internal/importer/lastfm.go | 28 +++-- internal/importer/listenbrainz.go | 40 ++++--- internal/models/album.go | 1 + internal/models/artist.go | 13 ++- internal/models/track.go | 17 +-- 36 files changed, 694 insertions(+), 174 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb9f33..7592076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ -# v0.0.4 +# v0.0.5 +## Features +- Artist MusicBrainz IDs will now be mapped during ListenBrainz and LastFM imports, even when MusicBrainz is disabled +- Merges now support replacing images for artists and albums +- Time listened per item is now displayed on the item page, below the total play count + ## Enhancements -- Re-download images missing from cache on request +- More reliable artist MusicBrainz ID mapping when scrobbling + +## Fixes +- Token validation now correctly validates case-insensitive authorization scheme + +## Docs +- Removed the portion that mentions not being able to map MusicBrainz IDs when it is disabled, as that is no longer true \ No newline at end of file diff --git a/client/api/api.ts b/client/api/api.ts index ec79aa0..150be81 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -74,13 +74,13 @@ function mergeTracks(from: number, to: number): Promise { method: "POST", }) } -function mergeAlbums(from: number, to: number): Promise { - return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, { +function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise { + return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { method: "POST", }) } -function mergeArtists(from: number, to: number): Promise { - return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, { +function mergeArtists(from: number, to: number, replaceImage: boolean): Promise { + return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { method: "POST", }) } @@ -200,6 +200,7 @@ type Track = { image: string album_id: number musicbrainz_id: string + time_listened: number } type Artist = { id: number @@ -208,6 +209,7 @@ type Artist = { aliases: string[] listen_count: number musicbrainz_id: string + time_listened: number } type Album = { id: number, @@ -217,6 +219,7 @@ type Album = { is_various_artists: boolean artists: SimpleArtists[] musicbrainz_id: string + time_listened: number } type Alias = { id: number diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 22d307c..5884e63 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -82,7 +82,7 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis Various Artists :
- +
}
{album.listen_count} plays
diff --git a/client/app/components/modals/MergeModal.tsx b/client/app/components/modals/MergeModal.tsx index ff1079b..9f3fdcc 100644 --- a/client/app/components/modals/MergeModal.tsx +++ b/client/app/components/modals/MergeModal.tsx @@ -21,6 +21,7 @@ export default function MergeModal(props: Props) { const [debouncedQuery, setDebouncedQuery] = useState(query); const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0}) const [mergeOrderReversed, setMergeOrderReversed] = useState(false) + const [replaceImage, setReplaceImage] = useState(false) const navigate = useNavigate() @@ -53,7 +54,7 @@ export default function MergeModal(props: Props) { from = {id: props.currentId, title: props.currentTitle} to = mergeTarget } - props.mergeFunc(from.id, to.id) + props.mergeFunc(from.id, to.id, replaceImage) .then(r => { if (r.ok) { if (mergeOrderReversed) { @@ -117,6 +118,13 @@ export default function MergeModal(props: Props) { setMergeOrderReversed(!mergeOrderReversed)} />
+ { + (props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") && +
+ setReplaceImage(!replaceImage)} /> + +
+ } : ''}
diff --git a/client/app/routes/MediaItems/Album.tsx b/client/app/routes/MediaItems/Album.tsx index 654fc9e..9751a87 100644 --- a/client/app/routes/MediaItems/Album.tsx +++ b/client/app/routes/MediaItems/Album.tsx @@ -6,6 +6,7 @@ import LastPlays from "~/components/LastPlays"; import PeriodSelector from "~/components/PeriodSelector"; import MediaLayout from "./MediaLayout"; import ActivityGrid from "~/components/ActivityGrid"; +import { timeListenedString } from "~/utils/utils"; export async function clientLoader({ params }: LoaderFunctionArgs) { const res = await fetch(`/apis/web/v1/album?id=${params.id}`); @@ -40,9 +41,10 @@ export default function Album() { } return r }} - subContent={<> + subContent={
{album.listen_count &&

{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}

} - } + {

{timeListenedString(album.time_listened)}

} +
} >
diff --git a/client/app/routes/MediaItems/Artist.tsx b/client/app/routes/MediaItems/Artist.tsx index b742f56..272d5fb 100644 --- a/client/app/routes/MediaItems/Artist.tsx +++ b/client/app/routes/MediaItems/Artist.tsx @@ -7,6 +7,7 @@ import PeriodSelector from "~/components/PeriodSelector"; import MediaLayout from "./MediaLayout"; import ArtistAlbums from "~/components/ArtistAlbums"; import ActivityGrid from "~/components/ActivityGrid"; +import { timeListenedString } from "~/utils/utils"; export async function clientLoader({ params }: LoaderFunctionArgs) { const res = await fetch(`/apis/web/v1/artist?id=${params.id}`); @@ -46,9 +47,10 @@ export default function Artist() { } return r }} - subContent={<> + subContent={
{artist.listen_count &&

{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}

} - } + {

{timeListenedString(artist.time_listened)}

} +
} >
diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx index 18a8b78..2503d4b 100644 --- a/client/app/routes/MediaItems/MediaLayout.tsx +++ b/client/app/routes/MediaItems/MediaLayout.tsx @@ -9,7 +9,7 @@ import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; import DeleteModal from "~/components/modals/DeleteModal"; import RenameModal from "~/components/modals/RenameModal"; -export type MergeFunc = (from: number, to: number) => Promise +export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse interface Props { diff --git a/client/app/routes/MediaItems/Track.tsx b/client/app/routes/MediaItems/Track.tsx index bd08a8f..039d951 100644 --- a/client/app/routes/MediaItems/Track.tsx +++ b/client/app/routes/MediaItems/Track.tsx @@ -5,6 +5,7 @@ import LastPlays from "~/components/LastPlays"; import PeriodSelector from "~/components/PeriodSelector"; import MediaLayout from "./MediaLayout"; import ActivityGrid from "~/components/ActivityGrid"; +import { timeListenedString } from "~/utils/utils"; export async function clientLoader({ params }: LoaderFunctionArgs) { let res = await fetch(`/apis/web/v1/track?id=${params.id}`); @@ -42,9 +43,10 @@ export default function Track() { } return r }} - subContent={
+ subContent={
appears on {album.title} {track.listen_count &&

{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}

} + {

{timeListenedString(track.time_listened)}

}
} >
diff --git a/client/app/utils/utils.ts b/client/app/utils/utils.ts index 0cf0b33..fb3fc4f 100644 --- a/client/app/utils/utils.ts +++ b/client/app/utils/utils.ts @@ -86,5 +86,17 @@ const hexToHSL = (hex: string): hsl => { }; }; -export {hexToHSL} +const timeListenedString = (seconds: number) => { + if (!seconds) return "" + + if (seconds > (120 * 60) - 1) { + let hours = Math.floor(seconds / 60 / 60) + return `${hours} hours listened` + } else { + let minutes = Math.floor(seconds / 60) + return `${minutes} minutes listened` + } + } + +export {hexToHSL, timeListenedString} export type {hsl} \ No newline at end of file diff --git a/docs/src/content/docs/guides/importing.md b/docs/src/content/docs/guides/importing.md index c7c1845..cba8a4f 100644 --- a/docs/src/content/docs/guides/importing.md +++ b/docs/src/content/docs/guides/importing.md @@ -12,8 +12,7 @@ Koito currently supports the following sources to import data from: :::note ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this -means that artist aliases will not be automatically fetched for imported artists. This also means that artists will not be associated with their MusicBrainz IDs internally, -which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use +means that artist aliases will not be automatically fetched for imported artists. You can also use [your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and [disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster. ::: diff --git a/engine/handlers/lbz_submit_listen.go b/engine/handlers/lbz_submit_listen.go index 6a0dad1..34004db 100644 --- a/engine/handlers/lbz_submit_listen.go +++ b/engine/handlers/lbz_submit_listen.go @@ -42,8 +42,19 @@ type LbzTrackMeta struct { ArtistName string `json:"artist_name"` // required TrackName string `json:"track_name"` // required ReleaseName string `json:"release_name,omitempty"` + MBIDMapping LbzMBIDMapping `json:"mbid_mapping"` AdditionalInfo LbzAdditionalInfo `json:"additional_info,omitempty"` } +type LbzArtist struct { + ArtistMBID string `json:"artist_mbid"` + ArtistName string `json:"artist_credit_name"` +} +type LbzMBIDMapping struct { + ReleaseMBID string `json:"release_mbid"` + RecordingMBID string `json:"recording_mbid"` + ArtistMBIDs []string `json:"artist_mbids"` + Artists []LbzArtist `json:"artists"` +} type LbzAdditionalInfo struct { MediaPlayer string `json:"media_player,omitempty"` @@ -128,17 +139,30 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http if err != nil { l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") } + if len(artistMbzIDs) < 1 { + l.Debug().Err(err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping") + utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs) + if err != nil { + l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") + } + } rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID) if err != nil { rgMbzID = uuid.Nil } releaseMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseMBID) if err != nil { - releaseMbzID = uuid.Nil + releaseMbzID, err = uuid.Parse(payload.TrackMeta.MBIDMapping.ReleaseMBID) + if err != nil { + releaseMbzID = uuid.Nil + } } recordingMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.RecordingMBID) if err != nil { - recordingMbzID = uuid.Nil + recordingMbzID, err = uuid.Parse(payload.TrackMeta.MBIDMapping.RecordingMBID) + if err != nil { + recordingMbzID = uuid.Nil + } } var client string @@ -160,20 +184,33 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http listenedAt = time.Unix(payload.ListenedAt, 0) } + var artistMbidMap []catalog.ArtistMbidMap + for _, a := range payload.TrackMeta.MBIDMapping.Artists { + if a.ArtistMBID == "" || a.ArtistName == "" { + continue + } + mbid, err := uuid.Parse(a.ArtistMBID) + if err != nil { + l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName) + } + artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid}) + } + opts := catalog.SubmitListenOpts{ - MbzCaller: mbzc, - ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames, - Artist: payload.TrackMeta.ArtistName, - ArtistMbzIDs: artistMbzIDs, - TrackTitle: payload.TrackMeta.TrackName, - RecordingMbzID: recordingMbzID, - ReleaseTitle: payload.TrackMeta.ReleaseName, - ReleaseMbzID: releaseMbzID, - ReleaseGroupMbzID: rgMbzID, - Duration: duration, - Time: listenedAt, - UserID: u.ID, - Client: client, + MbzCaller: mbzc, + ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames, + Artist: payload.TrackMeta.ArtistName, + ArtistMbzIDs: artistMbzIDs, + TrackTitle: payload.TrackMeta.TrackName, + RecordingMbzID: recordingMbzID, + ReleaseTitle: payload.TrackMeta.ReleaseName, + ReleaseMbzID: releaseMbzID, + ReleaseGroupMbzID: rgMbzID, + ArtistMbidMappings: artistMbidMap, + Duration: duration, + Time: listenedAt, + UserID: u.ID, + Client: client, } if req.ListenType == ListenTypePlayingNow { diff --git a/engine/handlers/merge.go b/engine/handlers/merge.go index 03e83b8..41d38cc 100644 --- a/engine/handlers/merge.go +++ b/engine/handlers/merge.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" "strconv" + "strings" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" @@ -67,9 +68,16 @@ func MergeReleaseGroupsHandler(store db.DB) http.HandlerFunc { return } + var replaceImage bool + replaceImgStr := r.URL.Query().Get("replace_image") + if strings.ToLower(replaceImgStr) == "true" { + l.Debug().Msg("MergeReleaseGroupsHandler: Merge will replace image") + replaceImage = true + } + l.Debug().Msgf("MergeReleaseGroupsHandler: Merging release groups from ID %d to ID %d", fromId, toId) - err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId)) + err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId), replaceImage) if err != nil { l.Err(err).Msg("MergeReleaseGroupsHandler: Failed to merge release groups") utils.WriteError(w, "Failed to merge release groups: "+err.Error(), http.StatusInternalServerError) @@ -103,9 +111,16 @@ func MergeArtistsHandler(store db.DB) http.HandlerFunc { return } + var replaceImage bool + replaceImgStr := r.URL.Query().Get("replace_image") + if strings.ToLower(replaceImgStr) == "true" { + l.Debug().Msg("MergeReleaseGroupsHandler: Merge will replace image") + replaceImage = true + } + l.Debug().Msgf("MergeArtistsHandler: Merging artists from ID %d to ID %d", fromId, toId) - err = store.MergeArtists(r.Context(), int32(fromId), int32(toId)) + err = store.MergeArtists(r.Context(), int32(fromId), int32(toId), replaceImage) if err != nil { l.Err(err).Msg("MergeArtistsHandler: Failed to merge artists") utils.WriteError(w, "Failed to merge artists: "+err.Error(), http.StatusInternalServerError) diff --git a/engine/import_test.go b/engine/import_test.go index b128232..4289706 100644 --- a/engine/import_test.go +++ b/engine/import_test.go @@ -119,6 +119,40 @@ func TestImportLastFM(t *testing.T) { truncateTestData(t) } +func TestImportLastFM_MbzDisabled(t *testing.T) { + + src := path.Join("..", "test_assets", "recenttracks-shoko2-1749776100.json") + destDir := filepath.Join(cfg.ConfigDir(), "import") + dest := filepath.Join(destDir, "recenttracks-shoko2-1749776100.json") + + // not going to make the dest dir because engine should make it already + + input, err := os.ReadFile(src) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(dest, input, os.ModePerm)) + + engine.RunImporter(logger.Get(), store, &mbz.MbzErrorCaller{}) + + album, err := store.GetAlbum(context.Background(), db.GetAlbumOpts{MusicBrainzID: uuid.MustParse("e9e78802-0bf8-4ca3-9655-1d943d2d2fa0")}) + require.NoError(t, err) + assert.Equal(t, "ZOO!!", album.Title) + artist, err := store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("4b00640f-3be6-43f8-9b34-ff81bd89320a")}) + require.NoError(t, err) + assert.Equal(t, "OurR", artist.Name) + artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{Name: "CHUU"}) + require.NoError(t, err) + track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "because I'm stupid?", ArtistIDs: []int32{artist.ID}}) + require.NoError(t, err) + t.Log(track) + listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Period: db.PeriodAllTime}) + require.NoError(t, err) + require.Len(t, listens.Items, 1) + assert.WithinDuration(t, time.Unix(1749776100, 0), listens.Items[0].Time, 1*time.Second) + + truncateTestData(t) +} + func TestImportListenBrainz(t *testing.T) { src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip") @@ -188,3 +222,41 @@ func TestImportListenBrainz(t *testing.T) { truncateTestData(t) } + +func TestImportListenBrainz_MbzDisabled(t *testing.T) { + + src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip") + destDir := filepath.Join(cfg.ConfigDir(), "import") + dest := filepath.Join(destDir, "listenbrainz_shoko1_1749780844.zip") + + // not going to make the dest dir because engine should make it already + + input, err := os.ReadFile(src) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(dest, input, os.ModePerm)) + + engine.RunImporter(logger.Get(), store, &mbz.MbzErrorCaller{}) + + album, err := store.GetAlbum(context.Background(), db.GetAlbumOpts{MusicBrainzID: uuid.MustParse("ce330d67-9c46-4a3b-9d62-08406370f234")}) + require.NoError(t, err) + assert.Equal(t, "酸欠少女", album.Title) + artist, err := store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("4b00640f-3be6-43f8-9b34-ff81bd89320a")}) + require.NoError(t, err) + assert.Equal(t, "OurR", artist.Name) + artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("09887aa7-226e-4ecc-9a0c-02d2ae5777e1")}) + require.NoError(t, err) + assert.Equal(t, "Carly Rae Jepsen", artist.Name) + artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("78e46ae5-9bfd-433b-be3f-19e993d67ecc")}) + require.NoError(t, err) + assert.Equal(t, "Rufus Wainwright", artist.Name) + track, err := store.GetTrack(context.Background(), db.GetTrackOpts{MusicBrainzID: uuid.MustParse("08e8f55b-f1a4-46b8-b2d1-fab4c592165c")}) + require.NoError(t, err) + assert.Equal(t, "Desert", track.Title) + listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Period: db.PeriodAllTime}) + require.NoError(t, err) + assert.Len(t, listens.Items, 1) + assert.WithinDuration(t, time.Unix(1749780612, 0), listens.Items[0].Time, 1*time.Second) + + truncateTestData(t) +} diff --git a/engine/middleware/validate.go b/engine/middleware/validate.go index fc08a4f..b3e1369 100644 --- a/engine/middleware/validate.go +++ b/engine/middleware/validate.go @@ -87,16 +87,16 @@ func ValidateApiKey(store db.DB) func(next http.Handler) http.Handler { } authh := r.Header.Get("Authorization") - s := strings.Split(authh, "Token ") - if len(s) < 2 { - l.Debug().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'") + var token string + if strings.HasPrefix(strings.ToLower(authh), "token ") { + token = strings.TrimSpace(authh[6:]) // strip "Token " + } else { + l.Error().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } - key := s[1] - - u, err := store.GetUserByApiKey(ctx, key) + u, err := store.GetUserByApiKey(ctx, token) if err != nil { l.Err(err).Msg("Failed to get user from database using api key") utils.WriteError(w, "internal server error", http.StatusInternalServerError) diff --git a/go.mod b/go.mod index 874f117..b09cccb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gabehf/koito -go 1.23.7 +go 1.24.2 require ( github.com/go-chi/chi/v5 v5.2.1 diff --git a/internal/catalog/associate_artists.go b/internal/catalog/associate_artists.go index 0014b3e..3e0adf3 100644 --- a/internal/catalog/associate_artists.go +++ b/internal/catalog/associate_artists.go @@ -3,6 +3,7 @@ package catalog import ( "context" "errors" + "fmt" "slices" "strings" @@ -17,11 +18,12 @@ import ( ) type AssociateArtistsOpts struct { - ArtistMbzIDs []uuid.UUID - ArtistNames []string - ArtistName string - TrackTitle string - Mbzc mbz.MusicBrainzCaller + ArtistMbzIDs []uuid.UUID + ArtistNames []string + ArtistMbidMap []ArtistMbidMap + ArtistName string + TrackTitle string + Mbzc mbz.MusicBrainzCaller } func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { @@ -29,9 +31,19 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ( var result []*models.Artist - if len(opts.ArtistMbzIDs) > 0 { - l.Debug().Msg("Associating artists by MusicBrainz ID(s)") - mbzMatches, err := matchArtistsByMBID(ctx, d, opts) + // use mbid map first, as it is the most reliable way to get mbid for artists + if len(opts.ArtistMbidMap) > 0 { + l.Debug().Msg("Associating artists by MusicBrainz ID(s) mappings") + mbzMatches, err := matchArtistsByMBIDMappings(ctx, d, opts) + if err != nil { + return nil, err + } + result = append(result, mbzMatches...) + } + + if len(opts.ArtistMbzIDs) > len(result) { + l.Debug().Msg("Associating artists by list of MusicBrainz ID(s)") + mbzMatches, err := matchArtistsByMBID(ctx, d, opts, result) if err != nil { return nil, err } @@ -60,11 +72,82 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ( return result, nil } -func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { +func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { + l := logger.FromContext(ctx) + var result []*models.Artist + + for _, a := range opts.ArtistMbidMap { + // first, try to get by mbid + artist, err := d.GetArtist(ctx, db.GetArtistOpts{ + MusicBrainzID: a.Mbid, + }) + if err == nil { + l.Debug().Msgf("Artist '%s' found by MusicBrainz ID", artist.Name) + result = append(result, artist) + continue + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err) + } + // then, try to get by mbz name + artist, err = d.GetArtist(ctx, db.GetArtistOpts{ + Name: a.Artist, + }) + if err == nil { + l.Debug().Msgf("Artist '%s' found by Name", a.Artist) + // ...associate with mbzid if found + err = d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: artist.ID, MusicBrainzID: a.Mbid}) + if err != nil { + l.Err(fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)).Msgf("Failed to associate artist '%s' with MusicBrainz ID", artist.Name) + } else { + artist.MbzID = &a.Mbid + } + result = append(result, artist) + continue + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err) + } + + // then, try to get by aliases, or create + artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts.Mbzc) + if err != nil { + // if mbz unreachable, just create a new artist with provided name and mbid + l.Warn().Msg("MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping") + var imgid uuid.UUID + imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{ + Aliases: []string{a.Artist}, + }) + if err == nil { + imgid = uuid.New() + err = DownloadAndCacheImage(ctx, imgid, imgUrl, ImageSourceSize()) + if err != nil { + l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to download artist image for artist '%s'", a.Artist) + imgid = uuid.Nil + } + } else { + l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to get artist image for artist '%s'", a.Artist) + } + artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: a.Artist, MusicBrainzID: a.Mbid, Image: imgid, ImageSrc: imgUrl}) + if err != nil { + l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to create artist '%s' in database", a.Artist) + return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err) + } + } + result = append(result, artist) + } + return result, nil +} + +func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts, existing []*models.Artist) ([]*models.Artist, error) { l := logger.FromContext(ctx) var result []*models.Artist for _, id := range opts.ArtistMbzIDs { + if artistExistsByMbzID(id, existing) || artistExistsByMbzID(id, result) { + l.Debug().Msgf("Artist with MusicBrainz ID %s already found, skipping...", id) + continue + } if id == uuid.Nil { l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID") return matchArtistsByNames(ctx, opts.ArtistNames, result, d) @@ -229,3 +312,11 @@ func artistExists(name string, artists []*models.Artist) bool { } return false } +func artistExistsByMbzID(id uuid.UUID, artists []*models.Artist) bool { + for _, a := range artists { + if a.MbzID != nil && *a.MbzID == id { + return true + } + } + return false +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index e7d3641..26b3a09 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -29,24 +29,30 @@ type SaveListenOpts struct { Time time.Time } +type ArtistMbidMap struct { + Artist string + Mbid uuid.UUID +} + type SubmitListenOpts struct { // When true, skips registering the listen and only associates or creates the // artist, release, release group, and track in DB SkipSaveListen bool - MbzCaller mbz.MusicBrainzCaller - ArtistNames []string - Artist string - ArtistMbzIDs []uuid.UUID - TrackTitle string - RecordingMbzID uuid.UUID - Duration int32 // in seconds - ReleaseTitle string - ReleaseMbzID uuid.UUID - ReleaseGroupMbzID uuid.UUID - Time time.Time - UserID int32 - Client string + MbzCaller mbz.MusicBrainzCaller + ArtistNames []string + Artist string + ArtistMbzIDs []uuid.UUID + ArtistMbidMappings []ArtistMbidMap + TrackTitle string + RecordingMbzID uuid.UUID + Duration int32 // in seconds + ReleaseTitle string + ReleaseMbzID uuid.UUID + ReleaseGroupMbzID uuid.UUID + Time time.Time + UserID int32 + Client string } const ( @@ -64,11 +70,12 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error ctx, store, AssociateArtistsOpts{ - ArtistMbzIDs: opts.ArtistMbzIDs, - ArtistNames: opts.ArtistNames, - ArtistName: opts.Artist, - Mbzc: opts.MbzCaller, - TrackTitle: opts.TrackTitle, + ArtistMbzIDs: opts.ArtistMbzIDs, + ArtistNames: opts.ArtistNames, + ArtistName: opts.Artist, + ArtistMbidMap: opts.ArtistMbidMappings, + Mbzc: opts.MbzCaller, + TrackTitle: opts.TrackTitle, }) if err != nil { l.Error().Err(err).Msg("Failed to associate artists to listen") diff --git a/internal/catalog/images.go b/internal/catalog/images.go index 1d6f421..ecce26c 100644 --- a/internal/catalog/images.go +++ b/internal/catalog/images.go @@ -30,6 +30,15 @@ const ( ImageCacheDir = "image_cache" ) +func ImageSourceSize() (size ImageSize) { + if cfg.FullImageCacheEnabled() { + size = ImageSizeFull + } else { + size = ImageSizeLarge + } + return +} + func ParseImageSize(size string) (ImageSize, error) { switch strings.ToLower(size) { case "small": diff --git a/internal/catalog/submit_listen_test.go b/internal/catalog/submit_listen_test.go index 5fcea61..35cb0c1 100644 --- a/internal/catalog/submit_listen_test.go +++ b/internal/catalog/submit_listen_test.go @@ -856,3 +856,64 @@ func TestSubmitListen_MusicBrainzUnreachable(t *testing.T) { require.NoError(t, err) assert.True(t, exists, "expected listen row to exist") } + +func TestSubmitListen_MusicBrainzUnreachableMBIDMappings(t *testing.T) { + truncateTestData(t) + + // correctly associate MBID when musicbrainz unreachable, but map provided + + ctx := context.Background() + mbzc := &mbz.MbzErrorCaller{} + artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + artist2MbzID := uuid.MustParse("00000000-0000-0000-0000-000000000002") + releaseGroupMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000011") + releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101") + trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001") + artistMbzIdMap := []catalog.ArtistMbidMap{{Artist: "ATARASHII GAKKO!", Mbid: artistMbzID}, {Artist: "Featured Artist", Mbid: artist2MbzID}} + opts := catalog.SubmitListenOpts{ + MbzCaller: mbzc, + ArtistNames: []string{"ATARASHII GAKKO!", "Featured Artist"}, + Artist: "ATARASHII GAKKO! feat. Featured Artist", + ArtistMbzIDs: []uuid.UUID{ + artistMbzID, + }, + TrackTitle: "Tokyo Calling", + RecordingMbzID: trackMbzID, + ReleaseTitle: "AG! Calling", + ReleaseMbzID: releaseMbzID, + ReleaseGroupMbzID: releaseGroupMbzID, + ArtistMbidMappings: artistMbzIdMap, + Time: time.Now(), + UserID: 1, + } + + err := catalog.SubmitListen(ctx, store, opts) + require.NoError(t, err) + + // Verify that the listen was saved + exists, err := store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM listens + WHERE track_id = $1 + )`, 1) + require.NoError(t, err) + assert.True(t, exists, "expected listen row to exist") + + // Verify that the artist has the mbid saved + exists, err = store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artists + WHERE musicbrainz_id = $1 + )`, artistMbzID) + require.NoError(t, err) + assert.True(t, exists, "expected artist to have correct musicbrainz id") + + // Verify that the artist has the mbid saved + exists, err = store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artists + WHERE musicbrainz_id = $1 + )`, artist2MbzID) + require.NoError(t, err) + assert.True(t, exists, "expected artist to have correct musicbrainz id") +} diff --git a/internal/db/db.go b/internal/db/db.go index 637a51f..16cecd1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -64,6 +64,7 @@ type DB interface { CountAlbums(ctx context.Context, period Period) (int64, error) CountArtists(ctx context.Context, period Period) (int64, error) CountTimeListened(ctx context.Context, period Period) (int64, error) + CountTimeListenedToItem(ctx context.Context, opts TimeListenedOpts) (int64, error) CountUsers(ctx context.Context) (int64, error) // Search SearchArtists(ctx context.Context, q string) ([]*models.Artist, error) @@ -71,8 +72,8 @@ type DB interface { SearchTracks(ctx context.Context, q string) ([]*models.Track, error) // Merge MergeTracks(ctx context.Context, fromId, toId int32) error - MergeAlbums(ctx context.Context, fromId, toId int32) error - MergeArtists(ctx context.Context, fromId, toId int32) error + MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error + MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error // Etc ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) GetImageSource(ctx context.Context, image uuid.UUID) (string, error) diff --git a/internal/db/opts.go b/internal/db/opts.go index 481ccc3..0ecd03f 100644 --- a/internal/db/opts.go +++ b/internal/db/opts.go @@ -138,3 +138,10 @@ type ListenActivityOpts struct { ArtistID int32 TrackID int32 } + +type TimeListenedOpts struct { + Period Period + AlbumID int32 + ArtistID int32 + TrackID int32 +} diff --git a/internal/db/psql/album.go b/internal/db/psql/album.go index 0444b45..94c5782 100644 --- a/internal/db/psql/album.go +++ b/internal/db/psql/album.go @@ -57,6 +57,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu return nil, err } + seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ + Period: db.PeriodAllTime, + AlbumID: row.ID, + }) + if err != nil { + return nil, err + } + return &models.Album{ ID: row.ID, MbzID: row.MusicBrainzID, @@ -64,6 +72,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu Image: row.Image, VariousArtists: row.VariousArtists, ListenCount: count, + TimeListened: seconds, }, nil } diff --git a/internal/db/psql/album_test.go b/internal/db/psql/album_test.go index 373abdb..d0848cc 100644 --- a/internal/db/psql/album_test.go +++ b/internal/db/psql/album_test.go @@ -47,21 +47,16 @@ func testDataForRelease(t *testing.T) { } func TestGetAlbum(t *testing.T) { - testDataForRelease(t) + testDataForTopItems(t) ctx := context.Background() - // Insert test data - rg, err := store.SaveAlbum(ctx, db.SaveAlbumOpts{ - Title: "Test Release Group", - ArtistIDs: []int32{1}, - }) - require.NoError(t, err) - // Test GetAlbum by ID - result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: rg.ID}) + result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: 1}) require.NoError(t, err) - assert.Equal(t, rg.ID, result.ID) - assert.Equal(t, "Test Release Group", result.Title) + assert.EqualValues(t, 1, result.ID) + assert.Equal(t, "Release One", result.Title) + assert.EqualValues(t, 4, result.ListenCount) + assert.EqualValues(t, 400, result.TimeListened) // Test GetAlbum with insufficient information _, err = store.GetAlbum(ctx, db.GetAlbumOpts{}) diff --git a/internal/db/psql/artist.go b/internal/db/psql/artist.go index 0368fc6..0d9b702 100644 --- a/internal/db/psql/artist.go +++ b/internal/db/psql/artist.go @@ -16,6 +16,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +// this function sucks because sqlc keeps making new types for rows that are the same func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error) { l := logger.FromContext(ctx) if opts.ID != 0 { @@ -32,13 +33,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar if err != nil { return nil, err } + seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ + Period: db.PeriodAllTime, + ArtistID: row.ID, + }) + if err != nil { + return nil, err + } return &models.Artist{ - ID: row.ID, - MbzID: row.MusicBrainzID, - Name: row.Name, - Aliases: row.Aliases, - Image: row.Image, - ListenCount: count, + ID: row.ID, + MbzID: row.MusicBrainzID, + Name: row.Name, + Aliases: row.Aliases, + Image: row.Image, + ListenCount: count, + TimeListened: seconds, }, nil } else if opts.MusicBrainzID != uuid.Nil { l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID) @@ -54,13 +63,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar if err != nil { return nil, err } + seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ + Period: db.PeriodAllTime, + ArtistID: row.ID, + }) + if err != nil { + return nil, err + } return &models.Artist{ - ID: row.ID, - MbzID: row.MusicBrainzID, - Name: row.Name, - Aliases: row.Aliases, - Image: row.Image, - ListenCount: count, + ID: row.ID, + MbzID: row.MusicBrainzID, + Name: row.Name, + Aliases: row.Aliases, + Image: row.Image, + TimeListened: seconds, + ListenCount: count, }, nil } else if opts.Name != "" { l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name) @@ -76,13 +93,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar if err != nil { return nil, err } + seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ + Period: db.PeriodAllTime, + ArtistID: row.ID, + }) + if err != nil { + return nil, err + } return &models.Artist{ - ID: row.ID, - MbzID: row.MusicBrainzID, - Name: row.Name, - Aliases: row.Aliases, - Image: row.Image, - ListenCount: count, + ID: row.ID, + MbzID: row.MusicBrainzID, + Name: row.Name, + Aliases: row.Aliases, + Image: row.Image, + ListenCount: count, + TimeListened: seconds, }, nil } else { return nil, errors.New("insufficient information to get artist") diff --git a/internal/db/psql/artist_test.go b/internal/db/psql/artist_test.go index 4928988..85ee9ed 100644 --- a/internal/db/psql/artist_test.go +++ b/internal/db/psql/artist_test.go @@ -13,30 +13,33 @@ import ( ) func TestGetArtist(t *testing.T) { + testDataForTopItems(t) ctx := context.Background() mbzId := uuid.MustParse("00000000-0000-0000-0000-000000000001") - // Insert test data - artist, err := store.SaveArtist(ctx, db.SaveArtistOpts{ - Name: "Test Artist", - MusicBrainzID: mbzId, - }) - require.NoError(t, err) // Test GetArtist by ID - result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: artist.ID}) + result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: 1}) require.NoError(t, err) - assert.Equal(t, artist.ID, result.ID) - assert.Equal(t, "Test Artist", result.Name) + assert.EqualValues(t, 1, result.ID) + assert.Equal(t, "Artist One", result.Name) + assert.EqualValues(t, 4, result.ListenCount) + assert.EqualValues(t, 400, result.TimeListened) // Test GetArtist by Name - result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: artist.Name}) + result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "Artist One"}) require.NoError(t, err) - assert.Equal(t, artist.ID, result.ID) + assert.EqualValues(t, 1, result.ID) + assert.Equal(t, "Artist One", result.Name) + assert.EqualValues(t, 4, result.ListenCount) + assert.EqualValues(t, 400, result.TimeListened) // Test GetArtist by MusicBrainzID result, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: mbzId}) require.NoError(t, err) - assert.Equal(t, artist.ID, result.ID) + assert.EqualValues(t, 1, result.ID) + assert.Equal(t, "Artist One", result.Name) + assert.EqualValues(t, 4, result.ListenCount) + assert.EqualValues(t, 400, result.TimeListened) // Test GetArtist with insufficient information _, err = store.GetArtist(ctx, db.GetArtistOpts{}) diff --git a/internal/db/psql/counts.go b/internal/db/psql/counts.go index 5523c92..c7ab3bb 100644 --- a/internal/db/psql/counts.go +++ b/internal/db/psql/counts.go @@ -2,6 +2,7 @@ package psql import ( "context" + "errors" "time" "github.com/gabehf/koito/internal/db" @@ -68,3 +69,41 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, } return count, nil } +func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) { + t2 := time.Now() + t1 := db.StartTimeFromPeriod(opts.Period) + + if opts.ArtistID > 0 { + count, err := p.q.CountTimeListenedToArtist(ctx, repository.CountTimeListenedToArtistParams{ + ListenedAt: t1, + ListenedAt_2: t2, + ArtistID: opts.ArtistID, + }) + if err != nil { + return 0, err + } + return count, nil + } else if opts.AlbumID > 0 { + count, err := p.q.CountTimeListenedToRelease(ctx, repository.CountTimeListenedToReleaseParams{ + ListenedAt: t1, + ListenedAt_2: t2, + ReleaseID: opts.AlbumID, + }) + if err != nil { + return 0, err + } + return count, nil + + } else if opts.TrackID > 0 { + count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{ + ListenedAt: t1, + ListenedAt_2: t2, + ID: opts.TrackID, + }) + if err != nil { + return 0, err + } + return count, nil + } + return 0, errors.New("an id must be provided") +} diff --git a/internal/db/psql/counts_test.go b/internal/db/psql/counts_test.go index b6ddd18..414ebbc 100644 --- a/internal/db/psql/counts_test.go +++ b/internal/db/psql/counts_test.go @@ -74,3 +74,33 @@ func TestCountTimeListened(t *testing.T) { truncateTestData(t) } + +func TestCountTimeListenedToArtist(t *testing.T) { + ctx := context.Background() + testDataForTopItems(t) + period := db.PeriodAllTime + count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, ArtistID: 1}) + require.NoError(t, err) + assert.EqualValues(t, 400, count) + truncateTestData(t) +} + +func TestCountTimeListenedToAlbum(t *testing.T) { + ctx := context.Background() + testDataForTopItems(t) + period := db.PeriodAllTime + count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, AlbumID: 2}) + require.NoError(t, err) + assert.EqualValues(t, 300, count) + truncateTestData(t) +} + +func TestCountTimeListenedToTrack(t *testing.T) { + ctx := context.Background() + testDataForTopItems(t) + period := db.PeriodAllTime + count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, TrackID: 3}) + require.NoError(t, err) + assert.EqualValues(t, 200, count) + truncateTestData(t) +} diff --git a/internal/db/psql/merge.go b/internal/db/psql/merge.go index 91bce1a..0b4a24b 100644 --- a/internal/db/psql/merge.go +++ b/internal/db/psql/merge.go @@ -2,6 +2,7 @@ package psql import ( "context" + "fmt" "github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/repository" @@ -14,7 +15,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error { tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("MergeTracks: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -23,7 +24,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error { TrackID_2: toId, }) if err != nil { - return err + return fmt.Errorf("MergeTracks: %w", err) } err = qtx.CleanOrphanedEntries(ctx) if err != nil { @@ -33,13 +34,13 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error { return tx.Commit(ctx) } -func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error { +func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error { l := logger.FromContext(ctx) l.Info().Msgf("Merging album %d into album %d", fromId, toId) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("MergeAlbums: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -48,7 +49,21 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error { ReleaseID_2: toId, }) if err != nil { - return err + return fmt.Errorf("MergeAlbums: %w", err) + } + if replaceImage { + old, err := qtx.GetRelease(ctx, fromId) + if err != nil { + return fmt.Errorf("MergeAlbums: %w", err) + } + err = qtx.UpdateReleaseImage(ctx, repository.UpdateReleaseImageParams{ + ID: toId, + Image: old.Image, + ImageSource: old.ImageSource, + }) + if err != nil { + return fmt.Errorf("MergeAlbums: %w", err) + } } err = qtx.CleanOrphanedEntries(ctx) if err != nil { @@ -58,13 +73,13 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error { return tx.Commit(ctx) } -func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error { +func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error { l := logger.FromContext(ctx) l.Info().Msgf("Merging artist %d into artist %d", fromId, toId) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("MergeArtists: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -74,7 +89,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error { }) if err != nil { l.Err(err).Msg("Failed to delete conflicting artist tracks") - return err + return fmt.Errorf("MergeArtists: %w", err) } err = qtx.DeleteConflictingArtistReleases(ctx, repository.DeleteConflictingArtistReleasesParams{ ArtistID: fromId, @@ -82,7 +97,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error { }) if err != nil { l.Err(err).Msg("Failed to delete conflicting artist releases") - return err + return fmt.Errorf("MergeArtists: %w", err) } err = qtx.UpdateArtistTracks(ctx, repository.UpdateArtistTracksParams{ ArtistID: fromId, @@ -90,7 +105,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error { }) if err != nil { l.Err(err).Msg("Failed to update artist tracks") - return err + return fmt.Errorf("MergeArtists: %w", err) } err = qtx.UpdateArtistReleases(ctx, repository.UpdateArtistReleasesParams{ ArtistID: fromId, @@ -98,12 +113,26 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error { }) if err != nil { l.Err(err).Msg("Failed to update artist releases") - return err + return fmt.Errorf("MergeArtists: %w", err) + } + if replaceImage { + old, err := qtx.GetArtist(ctx, fromId) + if err != nil { + return fmt.Errorf("MergeAlbums: %w", err) + } + err = qtx.UpdateArtistImage(ctx, repository.UpdateArtistImageParams{ + ID: toId, + Image: old.Image, + ImageSource: old.ImageSource, + }) + if err != nil { + return fmt.Errorf("MergeAlbums: %w", err) + } } err = qtx.CleanOrphanedEntries(ctx) if err != nil { l.Err(err).Msg("Failed to clean orphaned entries") - return err + return fmt.Errorf("MergeArtists: %w", err) } return tx.Commit(ctx) } diff --git a/internal/db/psql/merge_test.go b/internal/db/psql/merge_test.go index ceb612e..f71fccf 100644 --- a/internal/db/psql/merge_test.go +++ b/internal/db/psql/merge_test.go @@ -12,9 +12,9 @@ func setupTestDataForMerge(t *testing.T) { truncateTestData(t) // Insert artists err := store.Exec(context.Background(), - `INSERT INTO artists (musicbrainz_id) - VALUES ('00000000-0000-0000-0000-000000000001'), - ('00000000-0000-0000-0000-000000000002')`) + `INSERT INTO artists (musicbrainz_id, image, image_source) + VALUES ('00000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000000', 'source.com'), + ('00000000-0000-0000-0000-000000000002', NULL, NULL)`) require.NoError(t, err) err = store.Exec(context.Background(), @@ -25,9 +25,9 @@ func setupTestDataForMerge(t *testing.T) { // Insert albums err = store.Exec(context.Background(), - `INSERT INTO releases (musicbrainz_id) - VALUES ('11111111-1111-1111-1111-111111111111'), - ('22222222-2222-2222-2222-222222222222')`) + `INSERT INTO releases (musicbrainz_id, image, image_source) + VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com'), + ('22222222-2222-2222-2222-222222222222', NULL, NULL)`) require.NoError(t, err) err = store.Exec(context.Background(), @@ -90,11 +90,15 @@ func TestMergeAlbums(t *testing.T) { setupTestDataForMerge(t) // Merge Album 1 into Album 2 - err := store.MergeAlbums(ctx, 1, 2) + err := store.MergeAlbums(ctx, 1, 2, true) require.NoError(t, err) + // Verify image was replaced + count, err := store.Count(ctx, `SELECT COUNT(*) FROM releases WHERE image = '20000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`) + require.NoError(t, err) + assert.Equal(t, 1, count, "expected merged release to contain image information") + // Verify tracks are updated - var count int count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`) require.NoError(t, err) assert.Equal(t, 2, count, "expected all tracks to be merged into Album 2") @@ -107,11 +111,15 @@ func TestMergeArtists(t *testing.T) { setupTestDataForMerge(t) // Merge Artist 1 into Artist 2 - err := store.MergeArtists(ctx, 1, 2) + err := store.MergeArtists(ctx, 1, 2, true) require.NoError(t, err) + // Verify image was replaced + count, err := store.Count(ctx, `SELECT COUNT(*) FROM artists WHERE image = '10000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`) + require.NoError(t, err) + assert.Equal(t, 1, count, "expected merged artist to contain image information") + // Verify artist associations are updated - var count int count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`) require.NoError(t, err) assert.Equal(t, 2, count, "expected all tracks to be associated with Artist 2") diff --git a/internal/db/psql/track.go b/internal/db/psql/track.go index 0c3c2a4..5d3961d 100644 --- a/internal/db/psql/track.go +++ b/internal/db/psql/track.go @@ -72,10 +72,19 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac TrackID: track.ID, }) if err != nil { - l.Err(err).Msgf("Failed to get listen count for track with id %d", track.ID) + return nil, err + } + + seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ + Period: db.PeriodAllTime, + TrackID: track.ID, + }) + if err != nil { + return nil, err } track.ListenCount = count + track.TimeListened = seconds return &track, nil } diff --git a/internal/db/psql/track_test.go b/internal/db/psql/track_test.go index ac79423..777b22c 100644 --- a/internal/db/psql/track_test.go +++ b/internal/db/psql/track_test.go @@ -44,9 +44,9 @@ func testDataForTracks(t *testing.T) { // Insert tracks err = store.Exec(context.Background(), - `INSERT INTO tracks (musicbrainz_id, release_id) - VALUES ('11111111-1111-1111-1111-111111111111', 1), - ('22222222-2222-2222-2222-222222222222', 2)`) + `INSERT INTO tracks (musicbrainz_id, release_id, duration) + VALUES ('11111111-1111-1111-1111-111111111111', 1, 100), + ('22222222-2222-2222-2222-222222222222', 2, 100)`) require.NoError(t, err) // Insert track aliases @@ -61,6 +61,12 @@ func testDataForTracks(t *testing.T) { `INSERT INTO artist_tracks (artist_id, track_id) VALUES (1, 1), (2, 2)`) require.NoError(t, err) + + // Associate tracks with artists + err = store.Exec(context.Background(), + `INSERT INTO listens (user_id, track_id, listened_at) + VALUES (1, 1, NOW()), (1, 2, NOW())`) + require.NoError(t, err) } func TestGetTrack(t *testing.T) { @@ -73,12 +79,14 @@ func TestGetTrack(t *testing.T) { assert.Equal(t, int32(1), track.ID) assert.Equal(t, "Track One", track.Title) assert.Equal(t, uuid.MustParse("11111111-1111-1111-1111-111111111111"), *track.MbzID) + assert.EqualValues(t, 100, track.TimeListened) // Test GetTrack by MusicBrainzID track, err = store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: uuid.MustParse("22222222-2222-2222-2222-222222222222")}) require.NoError(t, err) assert.Equal(t, int32(2), track.ID) assert.Equal(t, "Track Two", track.Title) + assert.EqualValues(t, 100, track.TimeListened) // Test GetTrack by Title and ArtistIDs track, err = store.GetTrack(ctx, db.GetTrackOpts{ @@ -88,6 +96,7 @@ func TestGetTrack(t *testing.T) { require.NoError(t, err) assert.Equal(t, int32(1), track.ID) assert.Equal(t, "Track One", track.Title) + assert.EqualValues(t, 100, track.TimeListened) // Test GetTrack with insufficient information _, err = store.GetTrack(ctx, db.GetTrackOpts{}) diff --git a/internal/importer/lastfm.go b/internal/importer/lastfm.go index f01e4b1..d3e0028 100644 --- a/internal/importer/lastfm.go +++ b/internal/importer/lastfm.go @@ -97,17 +97,25 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall l.Debug().Msgf("Skipping import due to import time rules") continue } + + var artistMbidMap []catalog.ArtistMbidMap + if artistMbzID != uuid.Nil { + artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: track.Artist.Text, Mbid: artistMbzID}) + } + opts := catalog.SubmitListenOpts{ - MbzCaller: mbzc, - Artist: track.Artist.Text, - ArtistMbzIDs: []uuid.UUID{artistMbzID}, - TrackTitle: track.Name, - RecordingMbzID: trackMbzID, - ReleaseTitle: album, - ReleaseMbzID: albumMbzID, - Client: "lastfm", - Time: ts, - UserID: 1, + MbzCaller: mbzc, + Artist: track.Artist.Text, + ArtistNames: []string{track.Artist.Text}, + ArtistMbzIDs: []uuid.UUID{artistMbzID}, + TrackTitle: track.Name, + RecordingMbzID: trackMbzID, + ReleaseTitle: album, + ReleaseMbzID: albumMbzID, + ArtistMbidMappings: artistMbidMap, + Client: "lastfm", + Time: ts, + UserID: 1, } err = catalog.SubmitListen(ctx, store, opts) if err != nil { diff --git a/internal/importer/listenbrainz.go b/internal/importer/listenbrainz.go index c9b7355..f8a8218 100644 --- a/internal/importer/listenbrainz.go +++ b/internal/importer/listenbrainz.go @@ -113,20 +113,34 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai } else if payload.TrackMeta.AdditionalInfo.DurationMs != 0 { duration = payload.TrackMeta.AdditionalInfo.DurationMs / 1000 } + + var artistMbidMap []catalog.ArtistMbidMap + for _, a := range payload.TrackMeta.MBIDMapping.Artists { + if a.ArtistMBID == "" || a.ArtistName == "" { + continue + } + mbid, err := uuid.Parse(a.ArtistMBID) + if err != nil { + l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName) + } + artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid}) + } + opts := catalog.SubmitListenOpts{ - MbzCaller: mbzc, - ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames, - Artist: payload.TrackMeta.ArtistName, - ArtistMbzIDs: artistMbzIDs, - TrackTitle: payload.TrackMeta.TrackName, - RecordingMbzID: recordingMbzID, - ReleaseTitle: payload.TrackMeta.ReleaseName, - ReleaseMbzID: releaseMbzID, - ReleaseGroupMbzID: rgMbzID, - Duration: duration, - Time: ts, - UserID: 1, - Client: client, + MbzCaller: mbzc, + ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames, + Artist: payload.TrackMeta.ArtistName, + ArtistMbzIDs: artistMbzIDs, + TrackTitle: payload.TrackMeta.TrackName, + RecordingMbzID: recordingMbzID, + ReleaseTitle: payload.TrackMeta.ReleaseName, + ReleaseMbzID: releaseMbzID, + ReleaseGroupMbzID: rgMbzID, + ArtistMbidMappings: artistMbidMap, + Duration: duration, + Time: ts, + UserID: 1, + Client: client, } err = catalog.SubmitListen(ctx, store, opts) if err != nil { diff --git a/internal/models/album.go b/internal/models/album.go index 90b8cdd..a92a3aa 100644 --- a/internal/models/album.go +++ b/internal/models/album.go @@ -10,6 +10,7 @@ type Album struct { Artists []SimpleArtist `json:"artists"` VariousArtists bool `json:"is_various_artists"` ListenCount int64 `json:"listen_count"` + TimeListened int64 `json:"time_listened"` } // type SimpleAlbum struct { diff --git a/internal/models/artist.go b/internal/models/artist.go index b240370..b515414 100644 --- a/internal/models/artist.go +++ b/internal/models/artist.go @@ -3,12 +3,13 @@ package models import "github.com/google/uuid" type Artist struct { - ID int32 `json:"id"` - MbzID *uuid.UUID `json:"musicbrainz_id"` - Name string `json:"name"` - Aliases []string `json:"aliases"` - Image *uuid.UUID `json:"image"` - ListenCount int64 `json:"listen_count"` + ID int32 `json:"id"` + MbzID *uuid.UUID `json:"musicbrainz_id"` + Name string `json:"name"` + Aliases []string `json:"aliases"` + Image *uuid.UUID `json:"image"` + ListenCount int64 `json:"listen_count"` + TimeListened int64 `json:"time_listened"` } type SimpleArtist struct { diff --git a/internal/models/track.go b/internal/models/track.go index 386a2fc..086813f 100644 --- a/internal/models/track.go +++ b/internal/models/track.go @@ -3,12 +3,13 @@ package models import "github.com/google/uuid" type Track struct { - ID int32 `json:"id"` - Title string `json:"title"` - Artists []SimpleArtist `json:"artists"` - MbzID *uuid.UUID `json:"musicbrainz_id"` - ListenCount int64 `json:"listen_count"` - Duration int32 `json:"duration"` - Image *uuid.UUID `json:"image"` - AlbumID int32 `json:"album_id"` + ID int32 `json:"id"` + Title string `json:"title"` + Artists []SimpleArtist `json:"artists"` + MbzID *uuid.UUID `json:"musicbrainz_id"` + ListenCount int64 `json:"listen_count"` + Duration int32 `json:"duration"` + Image *uuid.UUID `json:"image"` + AlbumID int32 `json:"album_id"` + TimeListened int64 `json:"time_listened"` } From bf9b84a171badff6c53aaab86e19e4655ef9bad5 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 19:19:46 -0400 Subject: [PATCH 06/91] fix: bump dockerfile go version --- .github/workflows/docker.yml | 2 ++ Dockerfile | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index cd7ecb0..b6a4d95 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,6 +14,8 @@ on: tags: - 'v*' + workflow_dispatch: + jobs: test: name: Go Test diff --git a/Dockerfile b/Dockerfile index 3c95c9d..72fd522 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY ./client . RUN yarn run build -FROM golang:1.23 AS backend +FROM golang:1.24 AS backend ARG KOITO_VERSION ENV CGO_ENABLED=1 From dc5dcbd474b76d566bf28981bdbfa2f32a14e2f0 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 22:25:55 -0400 Subject: [PATCH 07/91] fix: associate artists with merged items --- internal/db/opts.go | 10 +- internal/db/psql/album.go | 10 ++ internal/db/psql/album_test.go | 11 +- internal/db/psql/merge.go | 45 +++++++- internal/db/psql/merge_test.go | 161 +++++++++++++++++++++++++++-- internal/repository/release.sql.go | 15 +++ 6 files changed, 232 insertions(+), 20 deletions(-) diff --git a/internal/db/opts.go b/internal/db/opts.go index 0ecd03f..018f23d 100644 --- a/internal/db/opts.go +++ b/internal/db/opts.go @@ -96,10 +96,12 @@ type UpdateArtistOpts struct { } type UpdateAlbumOpts struct { - ID int32 - MusicBrainzID uuid.UUID - Image uuid.UUID - ImageSrc string + ID int32 + MusicBrainzID uuid.UUID + Image uuid.UUID + ImageSrc string + VariousArtistsUpdate bool + VariousArtistsValue bool } type UpdateUserOpts struct { diff --git a/internal/db/psql/album.go b/internal/db/psql/album.go index 94c5782..111b4cc 100644 --- a/internal/db/psql/album.go +++ b/internal/db/psql/album.go @@ -200,6 +200,16 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error { return err } } + if opts.VariousArtistsUpdate { + l.Debug().Msgf("Updating release with ID %d with image %s", opts.ID, opts.Image) + err := qtx.UpdateReleaseVariousArtists(ctx, repository.UpdateReleaseVariousArtistsParams{ + ID: opts.ID, + VariousArtists: opts.VariousArtistsValue, + }) + if err != nil { + return err + } + } return tx.Commit(ctx) } diff --git a/internal/db/psql/album_test.go b/internal/db/psql/album_test.go index d0848cc..49ebfbb 100644 --- a/internal/db/psql/album_test.go +++ b/internal/db/psql/album_test.go @@ -116,10 +116,12 @@ func TestUpdateAlbum(t *testing.T) { newMbzID := uuid.New() imgid := uuid.New() err = store.UpdateAlbum(ctx, db.UpdateAlbumOpts{ - ID: rg.ID, - MusicBrainzID: newMbzID, - Image: imgid, - ImageSrc: catalog.ImageSourceUserUpload, + ID: rg.ID, + MusicBrainzID: newMbzID, + Image: imgid, + ImageSrc: catalog.ImageSourceUserUpload, + VariousArtistsUpdate: true, + VariousArtistsValue: true, }) require.NoError(t, err) @@ -127,6 +129,7 @@ func TestUpdateAlbum(t *testing.T) { require.NoError(t, err) assert.Equal(t, newMbzID, *result.MbzID) assert.Equal(t, imgid, *result.Image) + assert.True(t, result.VariousArtists) truncateTestData(t) } diff --git a/internal/db/psql/merge.go b/internal/db/psql/merge.go index 0b4a24b..c7c46fe 100644 --- a/internal/db/psql/merge.go +++ b/internal/db/psql/merge.go @@ -19,12 +19,36 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error { } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) + from, err := qtx.GetTrack(ctx, fromId) + if err != nil { + return fmt.Errorf("MergeTracks: GetTrack: %w", err) + } + to, err := qtx.GetTrack(ctx, toId) + if err != nil { + return fmt.Errorf("MergeTracks: GetTrack: %w", err) + } err = qtx.UpdateTrackIdForListens(ctx, repository.UpdateTrackIdForListensParams{ TrackID: fromId, TrackID_2: toId, }) if err != nil { - return fmt.Errorf("MergeTracks: %w", err) + return fmt.Errorf("MergeTracks: UpdateTrackIdForListens: %w", err) + } + if from.ReleaseID != to.ReleaseID { + // tracks are from different releases, track artist should be associated with to.release + artists, err := qtx.GetTrackArtists(ctx, fromId) + if err != nil { + return fmt.Errorf("MergeTracks: GetTrackArtists: %w", err) + } + for _, artist := range artists { + err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{ + ArtistID: artist.ID, + ReleaseID: to.ReleaseID, + }) + if err != nil { + return fmt.Errorf("MergeTracks: AssociateArtistToRelease: %w", err) + } + } } err = qtx.CleanOrphanedEntries(ctx) if err != nil { @@ -44,6 +68,12 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) + + fromArtists, err := qtx.GetReleaseArtists(ctx, fromId) + if err != nil { + return fmt.Errorf("MergeTracks: GetReleaseArtists: %w", err) + } + err = qtx.UpdateReleaseForAll(ctx, repository.UpdateReleaseForAllParams{ ReleaseID: fromId, ReleaseID_2: toId, @@ -65,10 +95,21 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage return fmt.Errorf("MergeAlbums: %w", err) } } + + for _, artist := range fromArtists { + err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{ + ArtistID: artist.ID, + ReleaseID: toId, + }) + if err != nil { + return fmt.Errorf("MergeAlbums: AssociateArtistToRelease: %w", err) + } + } + err = qtx.CleanOrphanedEntries(ctx) if err != nil { l.Err(err).Msg("Failed to clean orphaned entries") - return err + return fmt.Errorf("MergeAlbums: CleanOrphanedEntries: %w", err) } return tx.Commit(ctx) } diff --git a/internal/db/psql/merge_test.go b/internal/db/psql/merge_test.go index f71fccf..7977282 100644 --- a/internal/db/psql/merge_test.go +++ b/internal/db/psql/merge_test.go @@ -27,44 +27,52 @@ func setupTestDataForMerge(t *testing.T) { err = store.Exec(context.Background(), `INSERT INTO releases (musicbrainz_id, image, image_source) VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com'), - ('22222222-2222-2222-2222-222222222222', NULL, NULL)`) + ('22222222-2222-2222-2222-222222222222', NULL, NULL), + (NULL, NULL, NULL)`) require.NoError(t, err) err = store.Exec(context.Background(), `INSERT INTO release_aliases (release_id, alias, source, is_primary) VALUES (1, 'Album One', 'Testing', true), - (2, 'Album Two', 'Testing', true)`) + (2, 'Album Two', 'Testing', true), + (3, 'Album Three', 'Testing', true)`) require.NoError(t, err) // Insert tracks err = store.Exec(context.Background(), `INSERT INTO tracks (musicbrainz_id, release_id) VALUES ('33333333-3333-3333-3333-333333333333', 1), - ('44444444-4444-4444-4444-444444444444', 2)`) + ('44444444-4444-4444-4444-444444444444', 2), + ('55555555-5555-5555-5555-555555555555', 1), + (NULL, 3)`) require.NoError(t, err) err = store.Exec(context.Background(), `INSERT INTO track_aliases (track_id, alias, source, is_primary) VALUES (1, 'Track One', 'Testing', true), - (2, 'Track Two', 'Testing', true)`) + (2, 'Track Two', 'Testing', true), + (3, 'Track Three', 'Testing', true), + (4, 'Track Four', 'Testing', true)`) require.NoError(t, err) // Associate artists with albums and tracks err = store.Exec(context.Background(), `INSERT INTO artist_releases (artist_id, release_id) - VALUES (1, 1), (2, 2)`) + VALUES (1, 1), (2, 2), (1, 3)`) require.NoError(t, err) err = store.Exec(context.Background(), `INSERT INTO artist_tracks (artist_id, track_id) - VALUES (1, 1), (2, 2)`) + VALUES (1, 1), (2, 2), (1, 3), (1, 4)`) require.NoError(t, err) // Insert listens err = store.Exec(context.Background(), `INSERT INTO listens (user_id, track_id, listened_at) VALUES (1, 1, NOW() - INTERVAL '1 day'), - (1, 2, NOW() - INTERVAL '2 days')`) + (1, 2, NOW() - INTERVAL '2 days'), + (1, 3, NOW() - INTERVAL '3 days'), + (1, 4, NOW() - INTERVAL '3 days')`) require.NoError(t, err) } @@ -82,6 +90,32 @@ func TestMergeTracks(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, count, "expected all listens to be merged into Track 2") + // Verify artist is associated with album + exists, err := store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artist_releases + WHERE release_id = $1 AND artist_id = $2 + )`, 2, 1) + require.NoError(t, err) + assert.True(t, exists, "expected old artist to be associated with album") + + truncateTestData(t) +} + +func TestMergeTracks_SameRelease(t *testing.T) { + ctx := context.Background() + setupTestDataForMerge(t) + + // Merge Track 1 into Track 2 + err := store.MergeTracks(ctx, 1, 3) + require.NoError(t, err) + + // Verify listens are updated + var count int + count, err = store.Count(ctx, `SELECT COUNT(*) FROM listens WHERE track_id = 3`) + require.NoError(t, err) + assert.Equal(t, 2, count, "expected all listens to be merged into Track 3") + truncateTestData(t) } @@ -101,7 +135,32 @@ func TestMergeAlbums(t *testing.T) { // Verify tracks are updated count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`) require.NoError(t, err) - assert.Equal(t, 2, count, "expected all tracks to be merged into Album 2") + assert.Equal(t, 3, count, "expected all tracks to be merged into Album 2") + + // Verify artist is associated with primary album + exists, err := store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artist_releases + WHERE release_id = $1 AND artist_id = $2 + )`, 2, 1) + require.NoError(t, err) + assert.True(t, exists, "expected old album artist to be associated with new album") + + truncateTestData(t) +} + +func TestMergeAlbums_SameArtists(t *testing.T) { + ctx := context.Background() + setupTestDataForMerge(t) + + // Merge Album 1 into Album 3 + err := store.MergeAlbums(ctx, 1, 3, false) + require.NoError(t, err) + + // Verify tracks are updated + count, err := store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 3`) + require.NoError(t, err) + assert.Equal(t, 3, count, "expected all tracks to be merged into Album 3") truncateTestData(t) } @@ -122,11 +181,93 @@ func TestMergeArtists(t *testing.T) { // Verify artist associations are updated count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`) require.NoError(t, err) - assert.Equal(t, 2, count, "expected all tracks to be associated with Artist 2") + assert.Equal(t, 4, count, "expected all tracks to be associated with Artist 2") count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_releases WHERE artist_id = 2`) require.NoError(t, err) - assert.Equal(t, 2, count, "expected all releases to be associated with Artist 2") + assert.Equal(t, 3, count, "expected all releases to be associated with Artist 2") truncateTestData(t) } + +func TestMergeTracks_EnsureParentOfReleaseIsKept(t *testing.T) { + truncateTestData(t) + + // Prepare test data with only album assigned to artist 1, + // and two tracks, both under album one, but under two different artists + + // Insert artists + err := store.Exec(context.Background(), + `INSERT INTO artists (musicbrainz_id, image, image_source) + VALUES ('00000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000000', 'source.com'), + ('00000000-0000-0000-0000-000000000002', NULL, NULL)`) + require.NoError(t, err) + + err = store.Exec(context.Background(), + `INSERT INTO artist_aliases (artist_id, alias, source, is_primary) + VALUES (1, 'Artist One', 'Testing', true), + (2, 'Artist Two', 'Testing', true)`) + require.NoError(t, err) + + // Insert albums + err = store.Exec(context.Background(), + `INSERT INTO releases (musicbrainz_id, image, image_source) + VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com')`) + require.NoError(t, err) + + err = store.Exec(context.Background(), + `INSERT INTO release_aliases (release_id, alias, source, is_primary) + VALUES (1, 'Album One', 'Testing', true)`) + require.NoError(t, err) + + // Insert tracks + err = store.Exec(context.Background(), + `INSERT INTO tracks (musicbrainz_id, release_id) + VALUES ('33333333-3333-3333-3333-333333333333', 1), + ('44444444-4444-4444-4444-444444444444', 1)`) + require.NoError(t, err) + + err = store.Exec(context.Background(), + `INSERT INTO track_aliases (track_id, alias, source, is_primary) + VALUES (1, 'Track One', 'Testing', true), + (2, 'Track Two', 'Testing', true)`) + require.NoError(t, err) + + // Associate artists with albums and tracks + err = store.Exec(context.Background(), + `INSERT INTO artist_releases (artist_id, release_id) + VALUES (1, 1)`) + require.NoError(t, err) + + err = store.Exec(context.Background(), + `INSERT INTO artist_tracks (artist_id, track_id) + VALUES (1, 1), (2, 2)`) + require.NoError(t, err) + + // Insert listens + err = store.Exec(context.Background(), + `INSERT INTO listens (user_id, track_id, listened_at) + VALUES (1, 1, NOW() - INTERVAL '1 day'), + (1, 2, NOW() - INTERVAL '2 days')`) + require.NoError(t, err) + + ctx := context.Background() + err = store.MergeTracks(ctx, 1, 2) + require.NoError(t, err) + + exists, err := store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artists + WHERE id = $1 + )`, 1) + require.NoError(t, err) + assert.True(t, exists, "expected artist associated with release to still exist") + + exists, err = store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM releases + WHERE id = $1 + )`, 1) + require.NoError(t, err) + assert.True(t, exists, "expected release to still exist") +} diff --git a/internal/repository/release.sql.go b/internal/repository/release.sql.go index 06a936e..6d5cc68 100644 --- a/internal/repository/release.sql.go +++ b/internal/repository/release.sql.go @@ -460,3 +460,18 @@ func (q *Queries) UpdateReleaseMbzID(ctx context.Context, arg UpdateReleaseMbzID _, err := q.db.Exec(ctx, updateReleaseMbzID, arg.ID, arg.MusicBrainzID) return err } + +const updateReleaseVariousArtists = `-- name: UpdateReleaseVariousArtists :exec +UPDATE releases SET various_artists = $2 +WHERE id = $1 +` + +type UpdateReleaseVariousArtistsParams struct { + ID int32 + VariousArtists bool +} + +func (q *Queries) UpdateReleaseVariousArtists(ctx context.Context, arg UpdateReleaseVariousArtistsParams) error { + _, err := q.db.Exec(ctx, updateReleaseVariousArtists, arg.ID, arg.VariousArtists) + return err +} From 57cc60534d50035e718a96dcc8a2f12189c7f736 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 22:26:17 -0400 Subject: [PATCH 08/91] feat: mark album as various artists --- Makefile | 3 + client/api/api.ts | 4 + .../modals/{RenameModal.tsx => EditModal.tsx} | 51 +++++++----- .../components/modals/SetVariousArtist.tsx | 79 +++++++++++++++++++ client/app/routes/MediaItems/MediaLayout.tsx | 7 +- db/queries/release.sql | 4 + engine/handlers/merge.go | 43 ++++++++++ engine/routes.go | 1 + 8 files changed, 168 insertions(+), 24 deletions(-) rename client/app/components/modals/{RenameModal.tsx => EditModal.tsx} (58%) create mode 100644 client/app/components/modals/SetVariousArtist.tsx diff --git a/Makefile b/Makefile index 78c1fb0..5167863 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ postgres.start: postgres.stop: docker stop koito-db +postgres.remove: + docker stop koito-db && docker rm koito-db + api.debug: KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go diff --git a/client/api/api.ts b/client/api/api.ts index 150be81..5a3807f 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -156,6 +156,9 @@ function setPrimaryAlias(type: string, id: number, alias: string): Promise { + return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise) +} function deleteListen(listen: Listen): Promise { const ms = new Date(listen.time).getTime() @@ -191,6 +194,7 @@ export { deleteApiKey, updateApiKeyLabel, deleteListen, + getAlbum, } type Track = { id: number diff --git a/client/app/components/modals/RenameModal.tsx b/client/app/components/modals/EditModal.tsx similarity index 58% rename from client/app/components/modals/RenameModal.tsx rename to client/app/components/modals/EditModal.tsx index 4a53ae6..539bb9a 100644 --- a/client/app/components/modals/RenameModal.tsx +++ b/client/app/components/modals/EditModal.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api"; +import { createAlias, deleteAlias, getAliases, getAlbum, setPrimaryAlias, type Album, type Alias } from "api/api"; import { Modal } from "./Modal"; import { AsyncButton } from "../AsyncButton"; import { useEffect, useState } from "react"; import { Trash } from "lucide-react"; +import SetVariousArtists from "./SetVariousArtist"; interface Props { type: string @@ -12,11 +13,12 @@ interface Props { setOpen: Function } -export default function RenameModal({ open, setOpen, type, id }: Props) { +export default function EditModal({ open, setOpen, type, id }: Props) { const [input, setInput] = useState('') const [loading, setLoading ] = useState(false) const [err, setError ] = useState() const [displayData, setDisplayData] = useState([]) + const [variousArtists, setVariousArtists] = useState(false) const { isPending, isError, data, error } = useQuery({ queryKey: [ @@ -38,7 +40,6 @@ export default function RenameModal({ open, setOpen, type, id }: Props) { } }, [data]) - if (isError) { return (

Error: {error.message}

@@ -49,6 +50,7 @@ export default function RenameModal({ open, setOpen, type, id }: Props) {

Loading...

) } + const handleSetPrimary = (alias: string) => { setError(undefined) setLoading(true) @@ -98,26 +100,33 @@ export default function RenameModal({ open, setOpen, type, id }: Props) { return ( setOpen(false)}> -

Alias Manager

-
- {displayData.map((v) => ( -
-
{v.alias} (source: {v.source})
- handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary - handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}> +
+
+

Alias Manager

+
+ {displayData.map((v) => ( +
+
{v.alias} (source: {v.source})
+ handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary + handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}> +
+ ))} +
+ setInput(e.target.value)} + /> + Submit +
+ {err &&

{err}

}
- ))} -
- setInput(e.target.value)} - /> - Submit
- {err &&

{err}

} + { type.toLowerCase() === "album" && + + }
) diff --git a/client/app/components/modals/SetVariousArtist.tsx b/client/app/components/modals/SetVariousArtist.tsx new file mode 100644 index 0000000..8761b9b --- /dev/null +++ b/client/app/components/modals/SetVariousArtist.tsx @@ -0,0 +1,79 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAlbum } from "api/api"; +import { useEffect, useState } from "react" + +interface Props { + id: number +} + +export default function SetVariousArtists({ id }: Props) { + const [err, setErr] = useState('') + const [va, setVA] = useState(false) + const [success, setSuccess] = useState('') + + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + 'get-album', + { + id: id + }, + ], + queryFn: ({ queryKey }) => { + const params = queryKey[1] as { id: number }; + return getAlbum(params.id); + }, + }); + + useEffect(() => { + if (data) { + setVA(data.is_various_artists) + } + }, [data]) + + if (isError) { + return ( +

Error: {error.message}

+ ) + } + if (isPending) { + return ( +

Loading...

+ ) + } + + const updateVA = (val: boolean) => { + setErr(''); + setSuccess(''); + fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, { method: 'PATCH' }) + .then(r => { + if (r.ok) { + setSuccess('Successfully updated album'); + } else { + r.json().then(r => setErr(r.error)); + } + }); + } + + return ( +
+

Mark as Various Artists

+
+ + {err &&

{err}

} +
+
+ ) +} \ No newline at end of file diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx index 2503d4b..2dcff0b 100644 --- a/client/app/routes/MediaItems/MediaLayout.tsx +++ b/client/app/routes/MediaItems/MediaLayout.tsx @@ -7,7 +7,8 @@ import { useAppContext } from "~/providers/AppProvider"; import MergeModal from "~/components/modals/MergeModal"; import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; import DeleteModal from "~/components/modals/DeleteModal"; -import RenameModal from "~/components/modals/RenameModal"; +import RenameModal from "~/components/modals/EditModal"; +import EditModal from "~/components/modals/EditModal"; export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse @@ -79,11 +80,11 @@ export default function MediaLayout(props: Props) {
{ user &&
- + - + diff --git a/db/queries/release.sql b/db/queries/release.sql index e90d95e..74c5c0a 100644 --- a/db/queries/release.sql +++ b/db/queries/release.sql @@ -104,6 +104,10 @@ LIMIT $1; UPDATE releases SET musicbrainz_id = $2 WHERE id = $1; +-- name: UpdateReleaseVariousArtists :exec +UPDATE releases SET various_artists = $2 +WHERE id = $1; + -- name: UpdateReleaseImage :exec UPDATE releases SET image = $2, image_source = $3 WHERE id = $1; diff --git a/engine/handlers/merge.go b/engine/handlers/merge.go index 41d38cc..26da665 100644 --- a/engine/handlers/merge.go +++ b/engine/handlers/merge.go @@ -131,3 +131,46 @@ func MergeArtistsHandler(store db.DB) http.HandlerFunc { 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) + } +} diff --git a/engine/routes.go b/engine/routes.go index 4b7d302..18fc164 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -70,6 +70,7 @@ func bindRoutes( r.Group(func(r chi.Router) { r.Use(middleware.ValidateSession(db)) r.Post("/replace-image", handlers.ReplaceImageHandler(db)) + r.Patch("/album", handlers.UpdateAlbumHandler(db)) r.Post("/merge/tracks", handlers.MergeTracksHandler(db)) r.Post("/merge/albums", handlers.MergeReleaseGroupsHandler(db)) r.Post("/merge/artists", handlers.MergeArtistsHandler(db)) From 2981ec4e8a3f7faf0120b30d11213020f8a6def4 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 22:27:10 -0400 Subject: [PATCH 09/91] chore: update changelog --- CHANGELOG.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7592076..77cebe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,6 @@ -# v0.0.5 +# v0.0.6 ## Features -- Artist MusicBrainz IDs will now be mapped during ListenBrainz and LastFM imports, even when MusicBrainz is disabled -- Merges now support replacing images for artists and albums -- Time listened per item is now displayed on the item page, below the total play count - -## Enhancements -- More reliable artist MusicBrainz ID mapping when scrobbling +- Albums can now be marked as Various Artists ## Fixes -- Token validation now correctly validates case-insensitive authorization scheme - -## Docs -- Removed the portion that mentions not being able to map MusicBrainz IDs when it is disabled, as that is no longer true \ No newline at end of file +- Artists will now be correctly associated with merged items \ No newline at end of file From b1bac4feb520995b064517e021f00e8982b9f7a8 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 22:28:43 -0400 Subject: [PATCH 10/91] fix: remove old test --- internal/db/psql/merge_test.go | 82 ---------------------------------- 1 file changed, 82 deletions(-) diff --git a/internal/db/psql/merge_test.go b/internal/db/psql/merge_test.go index 7977282..08169fb 100644 --- a/internal/db/psql/merge_test.go +++ b/internal/db/psql/merge_test.go @@ -189,85 +189,3 @@ func TestMergeArtists(t *testing.T) { truncateTestData(t) } - -func TestMergeTracks_EnsureParentOfReleaseIsKept(t *testing.T) { - truncateTestData(t) - - // Prepare test data with only album assigned to artist 1, - // and two tracks, both under album one, but under two different artists - - // Insert artists - err := store.Exec(context.Background(), - `INSERT INTO artists (musicbrainz_id, image, image_source) - VALUES ('00000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000000', 'source.com'), - ('00000000-0000-0000-0000-000000000002', NULL, NULL)`) - require.NoError(t, err) - - err = store.Exec(context.Background(), - `INSERT INTO artist_aliases (artist_id, alias, source, is_primary) - VALUES (1, 'Artist One', 'Testing', true), - (2, 'Artist Two', 'Testing', true)`) - require.NoError(t, err) - - // Insert albums - err = store.Exec(context.Background(), - `INSERT INTO releases (musicbrainz_id, image, image_source) - VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com')`) - require.NoError(t, err) - - err = store.Exec(context.Background(), - `INSERT INTO release_aliases (release_id, alias, source, is_primary) - VALUES (1, 'Album One', 'Testing', true)`) - require.NoError(t, err) - - // Insert tracks - err = store.Exec(context.Background(), - `INSERT INTO tracks (musicbrainz_id, release_id) - VALUES ('33333333-3333-3333-3333-333333333333', 1), - ('44444444-4444-4444-4444-444444444444', 1)`) - require.NoError(t, err) - - err = store.Exec(context.Background(), - `INSERT INTO track_aliases (track_id, alias, source, is_primary) - VALUES (1, 'Track One', 'Testing', true), - (2, 'Track Two', 'Testing', true)`) - require.NoError(t, err) - - // Associate artists with albums and tracks - err = store.Exec(context.Background(), - `INSERT INTO artist_releases (artist_id, release_id) - VALUES (1, 1)`) - require.NoError(t, err) - - err = store.Exec(context.Background(), - `INSERT INTO artist_tracks (artist_id, track_id) - VALUES (1, 1), (2, 2)`) - require.NoError(t, err) - - // Insert listens - err = store.Exec(context.Background(), - `INSERT INTO listens (user_id, track_id, listened_at) - VALUES (1, 1, NOW() - INTERVAL '1 day'), - (1, 2, NOW() - INTERVAL '2 days')`) - require.NoError(t, err) - - ctx := context.Background() - err = store.MergeTracks(ctx, 1, 2) - require.NoError(t, err) - - exists, err := store.RowExists(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM artists - WHERE id = $1 - )`, 1) - require.NoError(t, err) - assert.True(t, exists, "expected artist associated with release to still exist") - - exists, err = store.RowExists(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM releases - WHERE id = $1 - )`, 1) - require.NoError(t, err) - assert.True(t, exists, "expected release to still exist") -} From ef064cd9bdbaa453be0a5ae2252c651d579097ce Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Mon, 16 Jun 2025 11:14:11 -0400 Subject: [PATCH 11/91] fix: use correct form body for login and user update --- client/api/api.ts | 22 +++++++++++++++++----- engine/handlers/auth.go | 21 ++++++++++++++++++--- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/client/api/api.ts b/client/api/api.ts index 5a3807f..fe9f204 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -85,8 +85,13 @@ function mergeArtists(from: number, to: number, replaceImage: boolean): Promise< }) } function login(username: string, password: string, remember: boolean): Promise { - return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, { + const form = new URLSearchParams + form.append('username', username) + form.append('password', password) + form.append('remember_me', String(remember)) + return fetch(`/apis/web/v1/login`, { method: "POST", + body: form, }) } function logout(): Promise { @@ -99,8 +104,11 @@ function getApiKeys(): Promise { return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise) } const createApiKey = async (label: string): Promise => { - const r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, { - method: "POST" + const form = new URLSearchParams + form.append('label', label) + const r = await fetch(`/apis/web/v1/user/apikeys`, { + method: "POST", + body: form, }); if (!r.ok) { let errorMessage = `error: ${r.status}`; @@ -134,8 +142,12 @@ function deleteItem(itemType: string, id: number): Promise { }) } function updateUser(username: string, password: string) { - return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, { - method: "PATCH" + const form = new URLSearchParams + form.append('username', username) + form.append('password', password) + return fetch(`/apis/web/v1/user`, { + method: "PATCH", + body: form, }) } function getAliases(type: string, id: number): Promise { diff --git a/engine/handlers/auth.go b/engine/handlers/auth.go index c8edce6..1b0fa53 100644 --- a/engine/handlers/auth.go +++ b/engine/handlers/auth.go @@ -20,7 +20,12 @@ func LoginHandler(store db.DB) http.HandlerFunc { l.Debug().Msg("LoginHandler: Received login request") - r.ParseForm() + err := r.ParseForm() + if err != nil { + l.Debug().Msg("LoginHandler: Failed to parse request form") + utils.WriteError(w, "failed to parse request", http.StatusInternalServerError) + return + } username := r.FormValue("username") password := r.FormValue("password") if username == "" || password == "" { @@ -149,12 +154,22 @@ func UpdateUserHandler(store db.DB) http.HandlerFunc { return } - r.ParseForm() + err := r.ParseForm() + if err != nil { + l.Err(err).Msg("UpdateUserHandler: Failed to parse request form") + utils.WriteError(w, "failed to parse request", http.StatusInternalServerError) + return + } username := r.FormValue("username") password := r.FormValue("password") + if username == "" && password == "" { + l.Debug().Msg("UpdateUserHandler: No parameters were recieved") + utils.WriteError(w, "all parameters missing", http.StatusBadRequest) + return + } l.Debug().Msgf("UpdateUserHandler: Updating user with ID %d", u.ID) - err := store.UpdateUser(ctx, db.UpdateUserOpts{ + err = store.UpdateUser(ctx, db.UpdateUserOpts{ ID: u.ID, Username: username, Password: password, From 5a8b999f73060b202eb80e895cdd18492320a92f Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Mon, 16 Jun 2025 11:22:53 -0400 Subject: [PATCH 12/91] fix: hide delete listen button when not logged in --- client/app/components/LastPlays.tsx | 6 +++++- client/app/routes/Charts/Listens.tsx | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index 2bc1cc3..c1e1add 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -4,6 +4,7 @@ import { timeSince } from "~/utils/utils" import ArtistLinks from "./ArtistLinks" import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api" import { Link } from "react-router" +import { useAppContext } from "~/providers/AppProvider" interface Props { limit: number @@ -14,6 +15,8 @@ interface Props { } export default function LastPlays(props: Props) { + const { user } = useAppContext() + console.log(user) const { isPending, isError, data, error } = useQuery({ queryKey: ['last-listens', { limit: props.limit, @@ -69,11 +72,12 @@ export default function LastPlays(props: Props) { {listens.map((item) => ( - + diff --git a/client/app/routes/Charts/Listens.tsx b/client/app/routes/Charts/Listens.tsx index 979e1c1..2dff3f2 100644 --- a/client/app/routes/Charts/Listens.tsx +++ b/client/app/routes/Charts/Listens.tsx @@ -4,6 +4,7 @@ import { deleteListen, type Listen, type PaginatedResponse } from "api/api"; import { timeSince } from "~/utils/utils"; import ArtistLinks from "~/components/ArtistLinks"; import { useState } from "react"; +import { useAppContext } from "~/providers/AppProvider"; export async function clientLoader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); @@ -25,6 +26,7 @@ export default function Listens() { const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse }>(); const [items, setItems] = useState(null) + const { user } = useAppContext() const handleDelete = async (listen: Listen) => { if (!initialData) return @@ -61,11 +63,12 @@ export default function Listens() { {listens.map((item) => ( - + From 00e7782be20e1a2413f563c84bc9b648bdbafbba Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Mon, 16 Jun 2025 11:28:26 -0400 Subject: [PATCH 13/91] chore: update changelog --- CHANGELOG.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77cebe1..b58d284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ -# v0.0.6 -## Features -- Albums can now be marked as Various Artists - +# v0.0.7 ## Fixes -- Artists will now be correctly associated with merged items \ No newline at end of file +- Login form now correctly handles special characters +- Update User form now correctly handles special characters +- Delete Listen button is now hidden when not logged in \ No newline at end of file From 80b6f4deaace6ae25a0d778d37f071f332179c4e Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Mon, 16 Jun 2025 21:55:39 -0400 Subject: [PATCH 14/91] feat: v0.0.8 --- CHANGELOG.md | 21 ++- client/api/api.ts | 1 + client/app/components/SearchResults.tsx | 2 +- client/app/components/TopItemList.tsx | 100 ++++------- .../modals/{ => EditModal}/EditModal.tsx | 11 +- .../modals/EditModal/SetPrimaryArtist.tsx | 99 +++++++++++ .../{ => EditModal}/SetVariousArtist.tsx | 1 + client/app/components/modals/MergeModal.tsx | 8 +- client/app/routes/Home.tsx | 2 +- client/app/routes/MediaItems/MediaLayout.tsx | 8 +- db/migrations/000003_add_primary_artist.sql | 48 ++++++ db/queries/artist.sql | 10 +- db/queries/listen.sql | 28 +-- db/queries/release.sql | 25 +-- db/queries/search.sql | 28 +-- db/queries/track.sql | 25 +-- .../content/docs/reference/configuration.md | 3 + engine/handlers/alias.go | 124 ++++++++------ engine/handlers/apikeys.go | 79 ++++----- engine/handlers/artists.go | 156 +++++++++++++++++ engine/handlers/auth.go | 147 ++++++++-------- engine/handlers/delete.go | 4 - engine/handlers/image_handler.go | 11 +- engine/handlers/lbz_submit_listen.go | 8 +- engine/long_test.go | 160 +++++++++++++++++- engine/routes.go | 2 + internal/catalog/associate_album.go | 96 +++++++---- internal/catalog/associate_artists.go | 143 +++++++++------- internal/catalog/associate_track.go | 17 +- internal/catalog/catalog.go | 31 ++-- internal/catalog/images.go | 56 +++--- internal/cfg/cfg.go | 118 +++++++------ internal/db/db.go | 4 + internal/db/psql/album.go | 107 +++++++++--- internal/db/psql/artist.go | 132 ++++++++++----- internal/db/psql/counts.go | 25 +-- internal/db/psql/images.go | 26 +-- internal/db/psql/listen.go | 27 +-- internal/db/psql/listen_activity.go | 9 +- internal/db/psql/merge.go | 2 +- internal/db/psql/psql.go | 12 +- internal/db/psql/search.go | 21 +-- internal/db/psql/sessions.go | 5 +- internal/db/psql/top_albums.go | 15 +- internal/db/psql/top_artists.go | 7 +- internal/db/psql/top_tracks.go | 19 ++- internal/db/psql/track.go | 109 +++++++++--- internal/db/psql/user.go | 29 ++-- internal/images/deezer.go | 24 +-- internal/images/imagesrc.go | 4 +- internal/importer/lastfm.go | 11 +- internal/importer/listenbrainz.go | 3 +- internal/importer/maloja.go | 24 +-- internal/importer/spotify.go | 24 +-- internal/mbz/artist.go | 7 +- internal/mbz/mbz.go | 6 +- internal/mbz/release.go | 9 +- internal/mbz/track.go | 3 +- internal/models/artist.go | 1 + internal/repository/artist.sql.go | 42 +++-- internal/repository/listen.sql.go | 28 +-- internal/repository/models.go | 6 +- internal/repository/release.sql.go | 37 ++-- internal/repository/search.sql.go | 28 +-- internal/repository/track.sql.go | 37 ++-- internal/utils/utils.go | 60 ++++--- 66 files changed, 1559 insertions(+), 916 deletions(-) rename client/app/components/modals/{ => EditModal}/EditModal.tsx (92%) create mode 100644 client/app/components/modals/EditModal/SetPrimaryArtist.tsx rename client/app/components/modals/{ => EditModal}/SetVariousArtist.tsx (97%) create mode 100644 db/migrations/000003_add_primary_artist.sql create mode 100644 engine/handlers/artists.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b58d284..c9f5535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ -# v0.0.7 +# v0.0.8 +## Features +- An album artist can now be set as primary so that they are shown as the album artist in the top albums list + +## Enhancements +- Show a few more items under "Last Played" on the home page +- Importing is now 4-5x faster + ## Fixes -- Login form now correctly handles special characters -- Update User form now correctly handles special characters -- Delete Listen button is now hidden when not logged in \ No newline at end of file +- Merge selections now function correctly when selecting an item while another is selected +- Use anchor tags for top tracks and top albums +- UI fixes + +## Updates +- Improved logging and error traces in logs + +## Docs +- Add KOITO_FETCH_IMAGES_DURING_IMPORT to config reference \ No newline at end of file diff --git a/client/api/api.ts b/client/api/api.ts index fe9f204..ca2cf91 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -226,6 +226,7 @@ type Artist = { listen_count: number musicbrainz_id: string time_listened: number + is_primary: boolean } type Album = { id: number, diff --git a/client/app/components/SearchResults.tsx b/client/app/components/SearchResults.tsx index c0269e8..b2a4566 100644 --- a/client/app/components/SearchResults.tsx +++ b/client/app/components/SearchResults.tsx @@ -16,7 +16,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) { const selectItem = (title: string, id: number) => { if (selected === id) { setSelected(0) - onSelect({id: id, title: title}) + onSelect({id: 0, title: ''}) } else { setSelected(id) onSelect({id: id, title: title}) diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 5884e63..491625e 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -35,92 +35,52 @@ export default function TopItemList({ data, separators, type, cl function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) { - const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)` - - const navigate = useNavigate(); - - const handleItemClick = (type: string, id: number) => { - navigate(`/${type.toLowerCase()}/${id}`); - }; - - const handleArtistClick = (event: React.MouseEvent) => { - // Stop the click from navigating to the album page - event.stopPropagation(); - }; - - // Also stop keyboard events on the inner links from bubbling up - const handleArtistKeyDown = (event: React.KeyboardEvent) => { - event.stopPropagation(); - } + const itemClasses = `flex items-center gap-2` switch (type) { case "album": { const album = item as Album; - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleItemClick("album", album.id); - } - }; - return ( -
-
handleItemClick("album", album.id)} - onKeyDown={handleKeyDown} - role="link" - tabIndex={0} - aria-label={`View album: ${album.title}`} - style={{ cursor: 'pointer' }} - > - {album.title} -
+
+ + {album.title} + +
+ {album.title} -
- {album.is_various_artists ? - Various Artists - : -
- -
- } -
{album.listen_count} plays
+ +
+ {album.is_various_artists ? + Various Artists + : +
+
+ } +
{album.listen_count} plays
); } case "track": { const track = item as Track; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleItemClick("track", track.id); - } - }; return ( -
-
handleItemClick("track", track.id)} - onKeyDown={handleKeyDown} - role="link" - tabIndex={0} - aria-label={`View track: ${track.title}`} - style={{ cursor: 'pointer' }} - > - {track.title} +
+ + {track.title} +
- {track.title} + + {track.title} +
-
+
{track.listen_count} plays
-
); } @@ -128,12 +88,12 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis const artist = item as Artist; return (
- - {artist.name} -
- {artist.name} -
{artist.listen_count} plays
-
+ + {artist.name} +
+ {artist.name} +
{artist.listen_count} plays
+
); diff --git a/client/app/components/modals/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx similarity index 92% rename from client/app/components/modals/EditModal.tsx rename to client/app/components/modals/EditModal/EditModal.tsx index 539bb9a..78ce169 100644 --- a/client/app/components/modals/EditModal.tsx +++ b/client/app/components/modals/EditModal/EditModal.tsx @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { createAlias, deleteAlias, getAliases, getAlbum, setPrimaryAlias, type Album, type Alias } from "api/api"; -import { Modal } from "./Modal"; -import { AsyncButton } from "../AsyncButton"; +import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api"; +import { Modal } from "../Modal"; +import { AsyncButton } from "../../AsyncButton"; import { useEffect, useState } from "react"; import { Trash } from "lucide-react"; import SetVariousArtists from "./SetVariousArtist"; +import SetPrimaryArtist from "./SetPrimaryArtist"; interface Props { type: string @@ -18,7 +19,6 @@ export default function EditModal({ open, setOpen, type, id }: Props) { const [loading, setLoading ] = useState(false) const [err, setError ] = useState() const [displayData, setDisplayData] = useState([]) - const [variousArtists, setVariousArtists] = useState(false) const { isPending, isError, data, error } = useQuery({ queryKey: [ @@ -125,7 +125,10 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
{ type.toLowerCase() === "album" && + <> + + }
diff --git a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx new file mode 100644 index 0000000..b96536f --- /dev/null +++ b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx @@ -0,0 +1,99 @@ +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() + 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; + }, + }); + + useEffect(() => { + if (data) { + for (let a of data) { + if (a.is_primary) { + setPrimary(a) + break + } + } + } + }, [data]) + + if (isError) { + return ( +

Error: {error.message}

+ ) + } + if (isPending) { + return ( +

Loading...

+ ) + } + + 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 ( +
+

Set Primary Artist

+
+ + {err &&

{err}

} + {success &&

{success}

} +
+
+ ); +} \ No newline at end of file diff --git a/client/app/components/modals/SetVariousArtist.tsx b/client/app/components/modals/EditModal/SetVariousArtist.tsx similarity index 97% rename from client/app/components/modals/SetVariousArtist.tsx rename to client/app/components/modals/EditModal/SetVariousArtist.tsx index 8761b9b..c35f332 100644 --- a/client/app/components/modals/SetVariousArtist.tsx +++ b/client/app/components/modals/EditModal/SetVariousArtist.tsx @@ -73,6 +73,7 @@ export default function SetVariousArtists({ id }: Props) { {err &&

{err}

} + {success &&

{success}

}
) diff --git a/client/app/components/modals/MergeModal.tsx b/client/app/components/modals/MergeModal.tsx index 9f3fdcc..d4bec44 100644 --- a/client/app/components/modals/MergeModal.tsx +++ b/client/app/components/modals/MergeModal.tsx @@ -34,15 +34,11 @@ export default function MergeModal(props: Props) { } const toggleSelect = ({title, id}: {title: string, id: number}) => { - if (mergeTarget.id === 0) { - setMergeTarget({title: title, id: id}) - } else { - setMergeTarget({title:"", id: 0}) - } + setMergeTarget({title: title, id: id}) } useEffect(() => { - console.log(mergeTarget) + console.log("mergeTarget",mergeTarget) }, [mergeTarget]) const doMerge = () => { diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx index 8af882b..52dc9be 100644 --- a/client/app/routes/Home.tsx +++ b/client/app/routes/Home.tsx @@ -33,7 +33,7 @@ export default function Home() { - +
diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx index 2dcff0b..968dbe2 100644 --- a/client/app/routes/MediaItems/MediaLayout.tsx +++ b/client/app/routes/MediaItems/MediaLayout.tsx @@ -7,8 +7,8 @@ import { useAppContext } from "~/providers/AppProvider"; import MergeModal from "~/components/modals/MergeModal"; import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; import DeleteModal from "~/components/modals/DeleteModal"; -import RenameModal from "~/components/modals/EditModal"; -import EditModal from "~/components/modals/EditModal"; +import RenameModal from "~/components/modals/EditModal/EditModal"; +import EditModal from "~/components/modals/EditModal/EditModal"; export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse @@ -69,9 +69,9 @@ export default function MediaLayout(props: Props) { content={title} />
-
+
- {props.title} + {props.title}

{props.type}

diff --git a/db/migrations/000003_add_primary_artist.sql b/db/migrations/000003_add_primary_artist.sql new file mode 100644 index 0000000..ca6758f --- /dev/null +++ b/db/migrations/000003_add_primary_artist.sql @@ -0,0 +1,48 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; +-- +goose StatementEnd +ALTER TABLE artist_tracks +ADD COLUMN is_primary boolean NOT NULL DEFAULT false; + +ALTER TABLE artist_releases +ADD COLUMN is_primary boolean NOT NULL DEFAULT false; + +-- +goose StatementBegin +CREATE FUNCTION get_artists_for_release(release_id INTEGER) +RETURNS JSONB AS $$ + SELECT json_agg( + jsonb_build_object('id', a.id, 'name', a.name) + ORDER BY ar.is_primary DESC, a.name + ) + FROM artist_releases ar + JOIN artists_with_name a ON a.id = ar.artist_id + WHERE ar.release_id = $1; +$$ LANGUAGE sql STABLE; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE FUNCTION get_artists_for_track(track_id INTEGER) +RETURNS JSONB AS $$ + SELECT json_agg( + jsonb_build_object('id', a.id, 'name', a.name) + ORDER BY at.is_primary DESC, a.name + ) + FROM artist_tracks at + JOIN artists_with_name a ON a.id = at.artist_id + WHERE at.track_id = $1; +$$ LANGUAGE sql STABLE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +-- +goose StatementEnd +ALTER TABLE artist_tracks +DROP COLUMN is_primary; + +ALTER TABLE artist_releases +DROP COLUMN is_primary; + +DROP FUNCTION IF EXISTS get_artists_for_release(INTEGER); +DROP FUNCTION IF EXISTS get_artists_for_track(INTEGER); \ No newline at end of file diff --git a/db/queries/artist.sql b/db/queries/artist.sql index 89eef45..2825092 100644 --- a/db/queries/artist.sql +++ b/db/queries/artist.sql @@ -14,22 +14,24 @@ GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; -- name: GetTrackArtists :many SELECT - a.* + a.*, + at.is_primary as is_primary FROM artists_with_name a LEFT JOIN artist_tracks at ON a.id = at.artist_id WHERE at.track_id = $1 -GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; +GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary; -- name: GetArtistByImage :one SELECT * FROM artists WHERE image = $1 LIMIT 1; -- name: GetReleaseArtists :many SELECT - a.* + a.*, + ar.is_primary as is_primary FROM artists_with_name a LEFT JOIN artist_releases ar ON a.id = ar.artist_id WHERE ar.release_id = $1 -GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; +GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary; -- name: GetArtistByName :one WITH artist_with_aliases AS ( diff --git a/db/queries/listen.sql b/db/queries/listen.sql index 9049c4e..5252380 100644 --- a/db/queries/listen.sql +++ b/db/queries/listen.sql @@ -8,12 +8,7 @@ SELECT l.*, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id WHERE l.listened_at BETWEEN $1 AND $2 @@ -25,12 +20,7 @@ SELECT l.*, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN artist_tracks at ON t.id = at.track_id @@ -44,12 +34,7 @@ SELECT l.*, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id WHERE l.listened_at BETWEEN $1 AND $2 @@ -62,12 +47,7 @@ SELECT l.*, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id WHERE l.listened_at BETWEEN $1 AND $2 diff --git a/db/queries/release.sql b/db/queries/release.sql index 74c5c0a..5a888f1 100644 --- a/db/queries/release.sql +++ b/db/queries/release.sql @@ -33,12 +33,7 @@ LIMIT 1; SELECT r.*, COUNT(*) AS listen_count, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = r.id - ) AS artists + get_artists_for_release(r.id) AS artists FROM listens l JOIN tracks t ON l.track_id = t.id JOIN releases_with_title r ON t.release_id = r.id @@ -53,12 +48,7 @@ LIMIT $3 OFFSET $4; SELECT r.*, COUNT(*) AS listen_count, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = r.id - ) AS artists + get_artists_for_release(r.id) AS artists FROM listens l JOIN tracks t ON l.track_id = t.id JOIN releases_with_title r ON t.release_id = r.id @@ -88,12 +78,7 @@ ON CONFLICT DO NOTHING; -- name: GetReleasesWithoutImages :many SELECT r.*, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON a.id = ar.artist_id - WHERE ar.release_id = r.id - ) AS artists + get_artists_for_release(r.id) AS artists FROM releases_with_title r WHERE r.image IS NULL AND r.id > $2 @@ -108,6 +93,10 @@ WHERE id = $1; UPDATE releases SET various_artists = $2 WHERE id = $1; +-- name: UpdateReleasePrimaryArtist :exec +UPDATE artist_releases SET is_primary = $3 +WHERE artist_id = $1 AND release_id = $2; + -- name: UpdateReleaseImage :exec UPDATE releases SET image = $2, image_source = $3 WHERE id = $1; diff --git a/db/queries/search.sql b/db/queries/search.sql index 979d004..b957a27 100644 --- a/db/queries/search.sql +++ b/db/queries/search.sql @@ -42,12 +42,7 @@ SELECT ranked.release_id, ranked.image, ranked.score, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = ranked.id - ) AS artists + get_artists_for_track(ranked.id) AS artists FROM ( SELECT t.id, @@ -74,12 +69,7 @@ SELECT ranked.release_id, ranked.image, ranked.score, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = ranked.id - ) AS artists + get_artists_for_track(ranked.id) AS artists FROM ( SELECT t.id, @@ -106,12 +96,7 @@ SELECT ranked.image, ranked.various_artists, ranked.score, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = ranked.id - ) AS artists + get_artists_for_release(ranked.id) AS artists FROM ( SELECT r.id, @@ -137,12 +122,7 @@ SELECT ranked.image, ranked.various_artists, ranked.score, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = ranked.id - ) AS artists + get_artists_for_release(ranked.id) AS artists FROM ( SELECT r.id, diff --git a/db/queries/track.sql b/db/queries/track.sql index 73fce83..97092f6 100644 --- a/db/queries/track.sql +++ b/db/queries/track.sql @@ -43,12 +43,7 @@ SELECT t.release_id, r.image, COUNT(*) AS listen_count, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN releases r ON t.release_id = r.id @@ -65,12 +60,7 @@ SELECT t.release_id, r.image, COUNT(*) AS listen_count, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at2 - JOIN artists_with_name a ON a.id = at2.artist_id - WHERE at2.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN releases r ON t.release_id = r.id @@ -89,12 +79,7 @@ SELECT t.release_id, r.image, COUNT(*) AS listen_count, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at2 - JOIN artists_with_name a ON a.id = at2.artist_id - WHERE at2.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN releases r ON t.release_id = r.id @@ -135,5 +120,9 @@ WHERE id = $1; UPDATE tracks SET release_id = $2 WHERE release_id = $1; +-- name: UpdateTrackPrimaryArtist :exec +UPDATE artist_tracks SET is_primary = $3 +WHERE artist_id = $1 AND track_id = $2; + -- name: DeleteTrack :exec DELETE FROM tracks WHERE id = $1; \ No newline at end of file diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 6524e43..6976bee 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -70,6 +70,9 @@ Koito is configured using **environment variables**. This is the full list of co - Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded. ##### KOITO_IMPORT_AFTER_UNIX - Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded. +##### KOITO_FETCH_IMAGES_DURING_IMPORT +- Default: `false` +- Description: When true, images will be downloaded and cached during imports. ##### KOITO_CORS_ALLOWED_ORIGINS - Default: No CORS policy - Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins. \ No newline at end of file diff --git a/engine/handlers/alias.go b/engine/handlers/alias.go index add1b09..79a7598 100644 --- a/engine/handlers/alias.go +++ b/engine/handlers/alias.go @@ -1,7 +1,6 @@ package handlers import ( - "fmt" "net/http" "strconv" @@ -40,44 +39,43 @@ func GetAliasesHandler(store db.DB) http.HandlerFunc { if artistIDStr != "" { artistID, err := strconv.Atoi(artistIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid artist id") + l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid artist id") utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) return } aliases, err = store.GetAllArtistAliases(ctx, int32(artistID)) if err != nil { - l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get artist aliases") + l.Err(err).Msg("GetAliasesHandler: Failed to get artist aliases") utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError) return } } else if albumIDStr != "" { albumID, err := strconv.Atoi(albumIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid album id") + l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid album id") utils.WriteError(w, "invalid album_id", http.StatusBadRequest) return } aliases, err = store.GetAllAlbumAliases(ctx, int32(albumID)) if err != nil { - l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get album aliases") + l.Err(err).Msg("GetAliasesHandler: Failed to get album aliases") utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError) return } } else if trackIDStr != "" { trackID, err := strconv.Atoi(trackIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid track id") + l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid track id") utils.WriteError(w, "invalid track_id", http.StatusBadRequest) return } aliases, err = store.GetAllTrackAliases(ctx, int32(trackID)) if err != nil { - l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get track aliases") + l.Err(err).Msg("GetAliasesHandler: Failed to get track aliases") utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError) return } } - utils.WriteJSON(w, http.StatusOK, aliases) } } @@ -88,7 +86,7 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msgf("DeleteAliasHandler: Got request with params: '%s'", r.URL.Query().Encode()) + l.Debug().Msg("DeleteAliasHandler: Got request") // Parse query parameters artistIDStr := r.URL.Query().Get("artist_id") @@ -97,52 +95,56 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc { alias := r.URL.Query().Get("alias") if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { - l.Debug().Msgf("DeleteAliasHandler: Request is missing required parameters") + l.Debug().Msg("DeleteAliasHandler: Request is missing required parameters") utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) return } if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { - l.Debug().Msgf("DeleteAliasHandler: Request is has more than one of artist_id, album_id, and track_id") + l.Debug().Msg("DeleteAliasHandler: Request has more than one of artist_id, album_id, and track_id") utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) return } + var err error if artistIDStr != "" { - artistID, err := strconv.Atoi(artistIDStr) + var artistID int + artistID, err = strconv.Atoi(artistIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid artist id") + l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid artist id") utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) return } err = store.DeleteArtistAlias(ctx, int32(artistID), alias) if err != nil { - l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete artist alias") + l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete artist alias") utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError) return } } else if albumIDStr != "" { - albumID, err := strconv.Atoi(albumIDStr) + var albumID int + albumID, err = strconv.Atoi(albumIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid album id") + l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid album id") utils.WriteError(w, "invalid album_id", http.StatusBadRequest) return } err = store.DeleteAlbumAlias(ctx, int32(albumID), alias) if err != nil { - l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete album alias") + l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete album alias") utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError) return } } else if trackIDStr != "" { - trackID, err := strconv.Atoi(trackIDStr) + var trackID int + trackID, err = strconv.Atoi(trackIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid track id") + l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid track id") utils.WriteError(w, "invalid track_id", http.StatusBadRequest) return } err = store.DeleteTrackAlias(ctx, int32(trackID), alias) if err != nil { - l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete track alias") + l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete track alias") utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError) return } @@ -158,16 +160,18 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msgf("CreateAliasHandler: Got request with params: '%s'", r.URL.Query().Encode()) + l.Debug().Msg("CreateAliasHandler: Got request") err := r.ParseForm() if err != nil { + l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Failed to parse form") utils.WriteError(w, "invalid request body", http.StatusBadRequest) return } alias := r.FormValue("alias") if alias == "" { + l.Debug().Msg("CreateAliasHandler: Alias parameter missing") utils.WriteError(w, "alias must be provided", http.StatusBadRequest) return } @@ -176,53 +180,54 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc { albumIDStr := r.URL.Query().Get("album_id") trackIDStr := r.URL.Query().Get("track_id") - if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { - l.Debug().Msgf("CreateAliasHandler: Request is missing required parameters") - utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) + if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" { + l.Debug().Msg("CreateAliasHandler: Missing ID parameter") + utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest) return } if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { - l.Debug().Msgf("CreateAliasHandler: Request is has more than one of artist_id, album_id, and track_id") - utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) + l.Debug().Msg("CreateAliasHandler: Multiple ID parameters provided") + utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided", http.StatusBadRequest) return } + var id int if artistIDStr != "" { - artistID, err := strconv.Atoi(artistIDStr) + id, err = strconv.Atoi(artistIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid artist id") + l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid artist id") utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) return } - err = store.SaveArtistAliases(ctx, int32(artistID), []string{alias}, "Manual") + err = store.SaveArtistAliases(ctx, int32(id), []string{alias}, "Manual") if err != nil { - l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save artist alias") + l.Error().Err(err).Msg("CreateAliasHandler: Failed to save artist alias") utils.WriteError(w, "failed to save alias", http.StatusInternalServerError) return } } else if albumIDStr != "" { - albumID, err := strconv.Atoi(albumIDStr) + id, err = strconv.Atoi(albumIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid album id") + l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid album id") utils.WriteError(w, "invalid album_id", http.StatusBadRequest) return } - err = store.SaveAlbumAliases(ctx, int32(albumID), []string{alias}, "Manual") + err = store.SaveAlbumAliases(ctx, int32(id), []string{alias}, "Manual") if err != nil { - l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save album alias") + l.Error().Err(err).Msg("CreateAliasHandler: Failed to save album alias") utils.WriteError(w, "failed to save alias", http.StatusInternalServerError) return } } else if trackIDStr != "" { - trackID, err := strconv.Atoi(trackIDStr) + id, err = strconv.Atoi(trackIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid track id") + l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid track id") utils.WriteError(w, "invalid track_id", http.StatusBadRequest) return } - err = store.SaveTrackAliases(ctx, int32(trackID), []string{alias}, "Manual") + err = store.SaveTrackAliases(ctx, int32(id), []string{alias}, "Manual") if err != nil { - l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save track alias") + l.Error().Err(err).Msg("CreateAliasHandler: Failed to save track alias") utils.WriteError(w, "failed to save alias", http.StatusInternalServerError) return } @@ -238,7 +243,7 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msgf("SetPrimaryAliasHandler: Got request with params: '%s'", r.URL.Query().Encode()) + l.Debug().Msg("SetPrimaryAliasHandler: Got request") // Parse query parameters artistIDStr := r.URL.Query().Get("artist_id") @@ -246,53 +251,60 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc { trackIDStr := r.URL.Query().Get("track_id") alias := r.URL.Query().Get("alias") - if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { - l.Debug().Msgf("SetPrimaryAliasHandler: Request is missing required parameters") - utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) + if alias == "" { + l.Debug().Msg("SetPrimaryAliasHandler: Missing alias parameter") + utils.WriteError(w, "alias must be provided", http.StatusBadRequest) + return + } + if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" { + l.Debug().Msg("SetPrimaryAliasHandler: Missing ID parameter") + utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest) return } if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { - l.Debug().Msgf("SetPrimaryAliasHandler: Request is has more than one of artist_id, album_id, and track_id") - utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) + l.Debug().Msg("SetPrimaryAliasHandler: Multiple ID parameters provided") + utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided", http.StatusBadRequest) return } + var id int + var err error if artistIDStr != "" { - artistID, err := strconv.Atoi(artistIDStr) + id, err = strconv.Atoi(artistIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid artist id") + l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid artist id") utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) return } - err = store.SetPrimaryArtistAlias(ctx, int32(artistID), alias) + err = store.SetPrimaryArtistAlias(ctx, int32(id), alias) if err != nil { - l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set artist primary alias") + l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set artist primary alias") utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) return } } else if albumIDStr != "" { - albumID, err := strconv.Atoi(albumIDStr) + id, err = strconv.Atoi(albumIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid album id") + l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid album id") utils.WriteError(w, "invalid album_id", http.StatusBadRequest) return } - err = store.SetPrimaryAlbumAlias(ctx, int32(albumID), alias) + err = store.SetPrimaryAlbumAlias(ctx, int32(id), alias) if err != nil { - l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set album primary alias") + l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set album primary alias") utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) return } } else if trackIDStr != "" { - trackID, err := strconv.Atoi(trackIDStr) + id, err = strconv.Atoi(trackIDStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid track id") + l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid track id") utils.WriteError(w, "invalid track_id", http.StatusBadRequest) return } - err = store.SetPrimaryTrackAlias(ctx, int32(trackID), alias) + err = store.SetPrimaryTrackAlias(ctx, int32(id), alias) if err != nil { - l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set track primary alias") + l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set track primary alias") utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) return } diff --git a/engine/handlers/apikeys.go b/engine/handlers/apikeys.go index 1a3458b..40aa589 100644 --- a/engine/handlers/apikeys.go +++ b/engine/handlers/apikeys.go @@ -1,7 +1,6 @@ package handlers import ( - "fmt" "net/http" "strconv" @@ -16,45 +15,47 @@ func GenerateApiKeyHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msgf("GenerateApiKeyHandler: Received request with params: '%s'", r.URL.Query().Encode()) + l.Debug().Msg("GenerateApiKeyHandler: Received request") user := middleware.GetUserFromContext(ctx) if user == nil { - l.Debug().Msg("GenerateApiKeyHandler: Invalid user retrieved from context") + l.Debug().Msg("GenerateApiKeyHandler: Invalid user context") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } - r.ParseForm() + if err := r.ParseForm(); err != nil { + l.Debug().AnErr("error", err).Msg("GenerateApiKeyHandler: Failed to parse form") + utils.WriteError(w, "invalid request", http.StatusBadRequest) + return + } + label := r.FormValue("label") if label == "" { - l.Debug().Msg("GenerateApiKeyHandler: Request rejected due to missing label") + l.Debug().Msg("GenerateApiKeyHandler: Missing label parameter") utils.WriteError(w, "label is required", http.StatusBadRequest) return } apiKey, err := utils.GenerateRandomString(48) if err != nil { - l.Err(fmt.Errorf("GenerateApiKeyHandler: %w", err)).Msg("Failed to generate API key") + l.Error().Err(err).Msg("GenerateApiKeyHandler: Failed to generate API key") utils.WriteError(w, "failed to generate api key", http.StatusInternalServerError) return } - opts := db.SaveApiKeyOpts{ + key, err := store.SaveApiKey(ctx, db.SaveApiKeyOpts{ UserID: user.ID, Key: apiKey, Label: label, - } - l.Debug().Msgf("GenerateApiKeyHandler: Saving API key with options: %+v", opts) - - key, err := store.SaveApiKey(ctx, opts) + }) if err != nil { - l.Err(fmt.Errorf("GenerateApiKeyHandler: %w", err)).Msg("Failed to save API key") + l.Error().Err(err).Msg("GenerateApiKeyHandler: Failed to save API key") utils.WriteError(w, "failed to save api key", http.StatusInternalServerError) return } - l.Debug().Msgf("GenerateApiKeyHandler: Successfully saved API key with ID: %d", key.ID) + l.Debug().Msgf("GenerateApiKeyHandler: Successfully generated API key ID %d", key.ID) utils.WriteJSON(w, http.StatusCreated, key) } } @@ -64,39 +65,36 @@ func DeleteApiKeyHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msgf("DeleteApiKeyHandler: Received request with params: '%s'", r.URL.Query().Encode()) + l.Debug().Msg("DeleteApiKeyHandler: Received request") user := middleware.GetUserFromContext(ctx) if user == nil { - l.Debug().Msg("DeleteApiKeyHandler: User could not be verified (context user is nil)") + l.Debug().Msg("DeleteApiKeyHandler: Invalid user context") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } idStr := r.URL.Query().Get("id") if idStr == "" { - l.Debug().Msg("DeleteApiKeyHandler: Request rejected due to missing ID") + l.Debug().Msg("DeleteApiKeyHandler: Missing id parameter") utils.WriteError(w, "id is required", http.StatusBadRequest) return } - apiKey, err := strconv.Atoi(idStr) + apiKeyID, err := strconv.Atoi(idStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Invalid API key ID") - utils.WriteError(w, "id is invalid", http.StatusBadRequest) + l.Debug().AnErr("error", err).Msg("DeleteApiKeyHandler: Invalid API key ID") + utils.WriteError(w, "invalid id", http.StatusBadRequest) return } - l.Debug().Msgf("DeleteApiKeyHandler: Deleting API key with ID: %d", apiKey) - - err = store.DeleteApiKey(ctx, int32(apiKey)) - if err != nil { - l.Err(fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Failed to delete API key") + if err := store.DeleteApiKey(ctx, int32(apiKeyID)); err != nil { + l.Error().Err(err).Msg("DeleteApiKeyHandler: Failed to delete API key") utils.WriteError(w, "failed to delete api key", http.StatusInternalServerError) return } - l.Debug().Msgf("DeleteApiKeyHandler: Successfully deleted API key with ID: %d", apiKey) + l.Debug().Msgf("DeleteApiKeyHandler: Successfully deleted API key ID %d", apiKeyID) w.WriteHeader(http.StatusNoContent) } } @@ -106,25 +104,23 @@ func GetApiKeysHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msgf("GetApiKeysHandler: Received request with params: '%s'", r.URL.Query().Encode()) + l.Debug().Msg("GetApiKeysHandler: Received request") user := middleware.GetUserFromContext(ctx) if user == nil { - l.Debug().Msg("GetApiKeysHandler: Invalid user retrieved from context") + l.Debug().Msg("GetApiKeysHandler: Invalid user context") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } - l.Debug().Msgf("GetApiKeysHandler: Retrieving API keys for user ID: %d", user.ID) - apiKeys, err := store.GetApiKeysByUserID(ctx, user.ID) if err != nil { - l.Err(fmt.Errorf("GetApiKeysHandler: %w", err)).Msg("Failed to retrieve API keys") + l.Error().Err(err).Msg("GetApiKeysHandler: Failed to retrieve API keys") utils.WriteError(w, "failed to retrieve api keys", http.StatusInternalServerError) return } - l.Debug().Msgf("GetApiKeysHandler: Successfully retrieved %d API keys for user ID: %d", len(apiKeys), user.ID) + l.Debug().Msgf("GetApiKeysHandler: Retrieved %d API keys", len(apiKeys)) utils.WriteJSON(w, http.StatusOK, apiKeys) } } @@ -134,45 +130,42 @@ func UpdateApiKeyLabelHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msg("UpdateApiKeyLabelHandler: Received request to update API key label") + l.Debug().Msg("UpdateApiKeyLabelHandler: Received request") user := middleware.GetUserFromContext(ctx) if user == nil { - l.Debug().Msg("UpdateApiKeyLabelHandler: Unauthorized request (user context is nil)") + l.Debug().Msg("UpdateApiKeyLabelHandler: Invalid user context") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } idStr := r.URL.Query().Get("id") if idStr == "" { - l.Debug().Msg("UpdateApiKeyLabelHandler: Missing API key ID in request") + l.Debug().Msg("UpdateApiKeyLabelHandler: Missing id parameter") utils.WriteError(w, "id is required", http.StatusBadRequest) return } apiKeyID, err := strconv.Atoi(idStr) if err != nil { - l.Debug().AnErr("error", fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Invalid API key ID") - utils.WriteError(w, "id is invalid", http.StatusBadRequest) + l.Debug().AnErr("error", err).Msg("UpdateApiKeyLabelHandler: Invalid API key ID") + utils.WriteError(w, "invalid id", http.StatusBadRequest) return } label := r.FormValue("label") if label == "" { - l.Debug().Msg("UpdateApiKeyLabelHandler: Missing label in request") + l.Debug().Msg("UpdateApiKeyLabelHandler: Missing label parameter") utils.WriteError(w, "label is required", http.StatusBadRequest) return } - l.Debug().Msgf("UpdateApiKeyLabelHandler: Updating label for API key ID %d", apiKeyID) - - err = store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{ + if err := store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{ UserID: user.ID, ID: int32(apiKeyID), Label: label, - }) - if err != nil { - l.Err(fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Failed to update API key label") + }); err != nil { + l.Error().Err(err).Msg("UpdateApiKeyLabelHandler: Failed to update API key label") utils.WriteError(w, "failed to update api key label", http.StatusInternalServerError) return } diff --git a/engine/handlers/artists.go b/engine/handlers/artists.go new file mode 100644 index 0000000..d8358d6 --- /dev/null +++ b/engine/handlers/artists.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gabehf/koito/internal/db" + "github.com/gabehf/koito/internal/logger" + "github.com/gabehf/koito/internal/models" + "github.com/gabehf/koito/internal/utils" +) + +func SetPrimaryArtistHandler(store db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + // sets the primary alias for albums, artists, and tracks + ctx := r.Context() + l := logger.FromContext(ctx) + + l.Debug().Msg("SetPrimaryArtistHandler: Got request") + + r.ParseForm() + + // Parse query parameters + artistIDStr := r.FormValue("artist_id") + albumIDStr := r.FormValue("album_id") + trackIDStr := r.FormValue("track_id") + isPrimaryStr := r.FormValue("is_primary") + + l.Debug().Str("query", r.Form.Encode()).Msg("Recieved form") + + if artistIDStr == "" { + l.Debug().Msg("SetPrimaryArtistHandler: artist_id must be provided") + utils.WriteError(w, "artist_id must be provided", http.StatusBadRequest) + return + } + + if isPrimaryStr == "" { + l.Debug().Msg("SetPrimaryArtistHandler: is_primary must be provided") + utils.WriteError(w, "is_primary must be provided", http.StatusBadRequest) + return + } + + primary, ok := utils.ParseBool(isPrimaryStr) + if !ok { + l.Debug().Msg("SetPrimaryArtistHandler: is_primary must be either true or false") + utils.WriteError(w, "is_primary must be either true or false", http.StatusBadRequest) + return + } + + artistId, err := strconv.Atoi(artistIDStr) + if err != nil { + l.Debug().Msg("SetPrimaryArtistHandler: artist_id is invalid") + utils.WriteError(w, "artist_id is invalid", http.StatusBadRequest) + return + } + + if albumIDStr == "" && trackIDStr == "" { + l.Debug().Msg("SetPrimaryArtistHandler: Missing album or track id parameter") + utils.WriteError(w, "album_id or track_id must be provided", http.StatusBadRequest) + return + } + if utils.MoreThanOneString(albumIDStr, trackIDStr) { + l.Debug().Msg("SetPrimaryArtistHandler: Multiple ID parameters provided") + utils.WriteError(w, "only one of album_id or track_id can be provided", http.StatusBadRequest) + return + } + + if albumIDStr != "" { + id, err := strconv.Atoi(albumIDStr) + if err != nil { + l.Debug().AnErr("error", err).Msg("SetPrimaryArtistHandler: Invalid album id") + utils.WriteError(w, "invalid album_id", http.StatusBadRequest) + return + } + err = store.SetPrimaryAlbumArtist(ctx, int32(id), int32(artistId), primary) + if err != nil { + l.Error().Err(err).Msg("SetPrimaryArtistHandler: Failed to set album primary alias") + utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) + return + } + } else if trackIDStr != "" { + id, err := strconv.Atoi(trackIDStr) + if err != nil { + l.Debug().AnErr("error", err).Msg("SetPrimaryArtistHandler: Invalid track id") + utils.WriteError(w, "invalid track_id", http.StatusBadRequest) + return + } + err = store.SetPrimaryTrackArtist(ctx, int32(id), int32(artistId), primary) + if err != nil { + l.Error().Err(err).Msg("SetPrimaryArtistHandler: Failed to set track primary alias") + utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusNoContent) + } +} +func GetArtistsForItemHandler(store db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := logger.FromContext(ctx) + + l.Debug().Msg("GetArtistsForItemHandler: Received request to retrieve artists for item") + + albumIDStr := r.URL.Query().Get("album_id") + trackIDStr := r.URL.Query().Get("track_id") + + if albumIDStr == "" && trackIDStr == "" { + l.Debug().Msg("GetArtistsForItemHandler: Missing album or track ID parameter") + utils.WriteError(w, "album_id or track_id must be provided", http.StatusBadRequest) + return + } + + if utils.MoreThanOneString(albumIDStr, trackIDStr) { + l.Debug().Msg("GetArtistsForItemHandler: Multiple ID parameters provided") + utils.WriteError(w, "only one of album_id or track_id can be provided", http.StatusBadRequest) + return + } + + var artists []*models.Artist + var err error + + if albumIDStr != "" { + albumID, convErr := strconv.Atoi(albumIDStr) + if convErr != nil { + l.Debug().AnErr("error", convErr).Msg("GetArtistsForItemHandler: Invalid album ID") + utils.WriteError(w, "invalid album_id", http.StatusBadRequest) + return + } + + l.Debug().Msgf("GetArtistsForItemHandler: Fetching artists for album ID %d", albumID) + artists, err = store.GetArtistsForAlbum(ctx, int32(albumID)) + } else if trackIDStr != "" { + trackID, convErr := strconv.Atoi(trackIDStr) + if convErr != nil { + l.Debug().AnErr("error", convErr).Msg("GetArtistsForItemHandler: Invalid track ID") + utils.WriteError(w, "invalid track_id", http.StatusBadRequest) + return + } + + l.Debug().Msgf("GetArtistsForItemHandler: Fetching artists for track ID %d", trackID) + artists, err = store.GetArtistsForTrack(ctx, int32(trackID)) + } + + if err != nil { + l.Err(err).Msg("GetArtistsForItemHandler: Failed to retrieve artists") + utils.WriteError(w, "failed to retrieve artists", http.StatusInternalServerError) + return + } + + l.Debug().Msg("GetArtistsForItemHandler: Successfully retrieved artists") + utils.WriteJSON(w, http.StatusOK, artists) + } +} diff --git a/engine/handlers/auth.go b/engine/handlers/auth.go index 1b0fa53..2ecc72d 100644 --- a/engine/handlers/auth.go +++ b/engine/handlers/auth.go @@ -18,70 +18,62 @@ func LoginHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msg("LoginHandler: Received login request") + l.Debug().Msg("LoginHandler: Received request") - err := r.ParseForm() - if err != nil { - l.Debug().Msg("LoginHandler: Failed to parse request form") - utils.WriteError(w, "failed to parse request", http.StatusInternalServerError) + if err := r.ParseForm(); err != nil { + l.Debug().AnErr("error", err).Msg("LoginHandler: Failed to parse form") + utils.WriteError(w, "invalid request format", http.StatusBadRequest) return } + username := r.FormValue("username") password := r.FormValue("password") if username == "" || password == "" { - l.Debug().Msg("LoginHandler: Missing username or password") - utils.WriteError(w, "username and password are required", http.StatusBadRequest) + l.Debug().Msg("LoginHandler: Missing credentials") + utils.WriteError(w, "username and password required", http.StatusBadRequest) return } - l.Debug().Msgf("LoginHandler: Searching for user with username '%s'", username) user, err := store.GetUserByUsername(ctx, username) if err != nil { - l.Err(err).Msg("LoginHandler: Error searching for user in database") - utils.WriteError(w, "internal server error", http.StatusInternalServerError) + l.Error().Err(err).Msg("LoginHandler: Database error fetching user") + utils.WriteError(w, "authentication failed", http.StatusInternalServerError) return - } else if user == nil { - l.Debug().Msg("LoginHandler: Username or password is incorrect") - utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest) + } + if user == nil { + l.Debug().Msg("LoginHandler: User not found") + utils.WriteError(w, "invalid credentials", http.StatusUnauthorized) return } - err = bcrypt.CompareHashAndPassword(user.Password, []byte(password)) - if err != nil { - l.Debug().Msg("LoginHandler: Password comparison failed") - utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest) + if err := bcrypt.CompareHashAndPassword(user.Password, []byte(password)); err != nil { + l.Debug().Msg("LoginHandler: Invalid password") + utils.WriteError(w, "invalid credentials", http.StatusUnauthorized) return } - keepSignedIn := false - expiresAt := time.Now().Add(1 * 24 * time.Hour) + expiresAt := time.Now().Add(24 * time.Hour) if strings.ToLower(r.FormValue("remember_me")) == "true" { - keepSignedIn = true expiresAt = time.Now().Add(30 * 24 * time.Hour) } - l.Debug().Msgf("LoginHandler: Creating session for user ID %d", user.ID) - session, err := store.SaveSession(ctx, user.ID, expiresAt, keepSignedIn) + session, err := store.SaveSession(ctx, user.ID, expiresAt, r.FormValue("remember_me") == "true") if err != nil { - l.Err(err).Msg("LoginHandler: Failed to create session") - utils.WriteError(w, "failed to create session", http.StatusInternalServerError) + l.Error().Err(err).Msg("LoginHandler: Failed to create session") + utils.WriteError(w, "authentication failed", http.StatusInternalServerError) return } - cookie := &http.Cookie{ + http.SetCookie(w, &http.Cookie{ Name: "koito_session", Value: session.ID.String(), + Expires: expiresAt, Path: "/", HttpOnly: true, Secure: false, - } + }) - if keepSignedIn { - cookie.Expires = expiresAt - } - - l.Debug().Msgf("LoginHandler: Session created successfully for user ID %d", user.ID) - http.SetCookie(w, cookie) + l.Debug().Msgf("LoginHandler: User %d authenticated", user.ID) w.WriteHeader(http.StatusNoContent) } } @@ -91,34 +83,27 @@ func LogoutHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msg("LogoutHandler: Received logout request") + l.Debug().Msg("LogoutHandler: Received request") + cookie, err := r.Cookie("koito_session") if err == nil { - l.Debug().Msg("LogoutHandler: Found session cookie") sid, err := uuid.Parse(cookie.Value) if err != nil { - l.Debug().AnErr("error", err).Msg("LogoutHandler: Invalid session cookie") - utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized) - return - } - l.Debug().Msgf("LogoutHandler: Deleting session with ID %s", sid) - err = store.DeleteSession(ctx, sid) - if err != nil { - l.Err(err).Msg("LogoutHandler: Failed to delete session") - utils.WriteError(w, "internal server error", http.StatusInternalServerError) - return + l.Debug().AnErr("error", err).Msg("LogoutHandler: Invalid session ID") + } else if err := store.DeleteSession(ctx, sid); err != nil { + l.Error().Err(err).Msg("LogoutHandler: Failed to delete session") } } - l.Debug().Msg("LogoutHandler: Clearing session cookie") http.SetCookie(w, &http.Cookie{ Name: "koito_session", Value: "", Path: "/", HttpOnly: true, - MaxAge: -1, // expire immediately + MaxAge: -1, }) + l.Debug().Msg("LogoutHandler: Session terminated") w.WriteHeader(http.StatusNoContent) } } @@ -128,16 +113,17 @@ func MeHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msg("MeHandler: Received request to retrieve user information") - u := middleware.GetUserFromContext(ctx) - if u == nil { - l.Debug().Msg("MeHandler: Invalid user retrieved from context") + l.Debug().Msg("MeHandler: Received request") + + user := middleware.GetUserFromContext(ctx) + if user == nil { + l.Debug().Msg("MeHandler: Unauthorized access") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } - l.Debug().Msgf("MeHandler: Successfully retrieved user with ID %d", u.ID) - utils.WriteJSON(w, http.StatusOK, u) + l.Debug().Msgf("MeHandler: Returning user data for ID %d", user.ID) + utils.WriteJSON(w, http.StatusOK, user) } } @@ -146,41 +132,42 @@ func UpdateUserHandler(store db.DB) http.HandlerFunc { ctx := r.Context() l := logger.FromContext(ctx) - l.Debug().Msg("UpdateUserHandler: Received request to update user information") - u := middleware.GetUserFromContext(ctx) - if u == nil { - l.Debug().Msg("UpdateUserHandler: Unauthorized request (user context is nil)") + l.Debug().Msg("UpdateUserHandler: Received request") + + user := middleware.GetUserFromContext(ctx) + if user == nil { + l.Debug().Msg("UpdateUserHandler: Unauthorized access") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } - err := r.ParseForm() - if err != nil { - l.Err(err).Msg("UpdateUserHandler: Failed to parse request form") - utils.WriteError(w, "failed to parse request", http.StatusInternalServerError) - return - } - username := r.FormValue("username") - password := r.FormValue("password") - - if username == "" && password == "" { - l.Debug().Msg("UpdateUserHandler: No parameters were recieved") - utils.WriteError(w, "all parameters missing", http.StatusBadRequest) - return - } - l.Debug().Msgf("UpdateUserHandler: Updating user with ID %d", u.ID) - err = store.UpdateUser(ctx, db.UpdateUserOpts{ - ID: u.ID, - Username: username, - Password: password, - }) - if err != nil { - l.Err(err).Msg("UpdateUserHandler: Failed to update user") - utils.WriteError(w, err.Error(), http.StatusBadRequest) + if err := r.ParseForm(); err != nil { + l.Error().Err(err).Msg("UpdateUserHandler: Invalid form data") + utils.WriteError(w, "invalid request", http.StatusBadRequest) return } - l.Debug().Msgf("UpdateUserHandler: Successfully updated user with ID %d", u.ID) + opts := db.UpdateUserOpts{ID: user.ID} + if username := r.FormValue("username"); username != "" { + opts.Username = username + } + if password := r.FormValue("password"); password != "" { + opts.Password = password + } + + if opts.Username == "" && opts.Password == "" { + l.Debug().Msg("UpdateUserHandler: No update parameters provided") + utils.WriteError(w, "no changes specified", http.StatusBadRequest) + return + } + + if err := store.UpdateUser(ctx, opts); err != nil { + l.Error().Err(err).Msg("UpdateUserHandler: Update failed") + utils.WriteError(w, "update failed", http.StatusBadRequest) + return + } + + l.Debug().Msgf("UpdateUserHandler: User %d updated", user.ID) w.WriteHeader(http.StatusNoContent) } } diff --git a/engine/handlers/delete.go b/engine/handlers/delete.go index bb87157..ebd4b3c 100644 --- a/engine/handlers/delete.go +++ b/engine/handlers/delete.go @@ -10,7 +10,6 @@ import ( "github.com/gabehf/koito/internal/utils" ) -// DeleteTrackHandler deletes a track by its ID. func DeleteTrackHandler(store db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -46,7 +45,6 @@ func DeleteTrackHandler(store db.DB) http.HandlerFunc { } } -// DeleteListenHandler deletes a listen record by track ID and timestamp. func DeleteListenHandler(store db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -96,7 +94,6 @@ func DeleteListenHandler(store db.DB) http.HandlerFunc { } } -// DeleteArtistHandler deletes an artist by its ID. func DeleteArtistHandler(store db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -132,7 +129,6 @@ func DeleteArtistHandler(store db.DB) http.HandlerFunc { } } -// DeleteAlbumHandler deletes an album by its ID. func DeleteAlbumHandler(store db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/engine/handlers/image_handler.go b/engine/handlers/image_handler.go index 8ad1c54..4b17c96 100644 --- a/engine/handlers/image_handler.go +++ b/engine/handlers/image_handler.go @@ -117,7 +117,12 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag return } lock.Lock() - utils.CopyFile(path.Join("assets", "default_img"), defaultImagePath) + err = utils.CopyFile(path.Join("assets", "default_img"), defaultImagePath) + if err != nil { + l.Err(err).Msg("serveDefaultImage: Error when copying default image from assets") + w.WriteHeader(http.StatusInternalServerError) + return + } lock.Unlock() } else if err != nil { l.Err(err).Msg("serveDefaultImage: Error when attempting to read default image in cache") @@ -151,7 +156,7 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (string, error) { src, err := store.GetImageSource(ctx, id) if err != nil { - return "", fmt.Errorf("downloadMissingImage: store.GetImageSource: %w", err) + return "", fmt.Errorf("downloadMissingImage: %w", err) } var size catalog.ImageSize if cfg.FullImageCacheEnabled() { @@ -161,7 +166,7 @@ func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (strin } err = catalog.DownloadAndCacheImage(ctx, id, src, size) if err != nil { - return "", fmt.Errorf("downloadMissingImage: catalog.DownloadAndCacheImage: %w", err) + return "", fmt.Errorf("downloadMissingImage: %w", err) } return path.Join(catalog.SourceImageDir(), id.String()), nil } diff --git a/engine/handlers/lbz_submit_listen.go b/engine/handlers/lbz_submit_listen.go index 34004db..5464a24 100644 --- a/engine/handlers/lbz_submit_listen.go +++ b/engine/handlers/lbz_submit_listen.go @@ -137,13 +137,13 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http artistMbzIDs, err := utils.ParseUUIDSlice(payload.TrackMeta.AdditionalInfo.ArtistMBIDs) if err != nil { - l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") + l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") } if len(artistMbzIDs) < 1 { - l.Debug().Err(err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping") + l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping") utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs) if err != nil { - l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") + l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") } } rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID) @@ -191,7 +191,7 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http } mbid, err := uuid.Parse(a.ArtistMBID) if err != nil { - l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName) + l.Debug().AnErr("error", err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName) } artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid}) } diff --git a/engine/long_test.go b/engine/long_test.go index 498bd08..20dcc01 100644 --- a/engine/long_test.go +++ b/engine/long_test.go @@ -53,6 +53,7 @@ func makeAuthRequest(t *testing.T, session, method, endpoint string, body io.Rea Name: "koito_session", Value: session, }) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") t.Logf("Making request to %s with session: %s", endpoint, session) return http.DefaultClient.Do(req) } @@ -512,7 +513,7 @@ func TestAuth(t *testing.T) { encoded = formdata.Encode() resp, err = http.DefaultClient.Post(host()+"/apis/web/v1/login", "application/x-www-form-urlencoded", strings.NewReader(encoded)) require.NoError(t, err) - require.Equal(t, 400, resp.StatusCode) + require.Equal(t, 401, resp.StatusCode) // reset update so other tests dont fail req, err = http.NewRequest("PATCH", host()+fmt.Sprintf("/apis/web/v1/user?username=%s&password=%s", cfg.DefaultUsername(), cfg.DefaultPassword()), nil) @@ -732,3 +733,160 @@ func TestAlbumReplaceImage(t *testing.T) { assert.NotNil(t, a.Image) assert.Equal(t, newid, *a.Image) } + +func TestSetPrimaryArtist(t *testing.T) { + + t.Run("Submit Listens", doSubmitListens) + + ctx := context.Background() + + // set and unset track primary artist + + formdata := url.Values{} + formdata.Set("artist_id", "1") + formdata.Set("track_id", "1") + formdata.Set("is_primary", "false") + body := formdata.Encode() + resp, err := makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + exists, err := store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artist_tracks + WHERE track_id = $1 AND artist_id = $2 AND is_primary = $3 + )`, 1, 1, false) + require.NoError(t, err) + assert.True(t, exists, "expected artist is_primary to be false") + + formdata = url.Values{} + formdata.Set("artist_id", "1") + formdata.Set("track_id", "1") + formdata.Set("is_primary", "true") + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + exists, err = store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artist_tracks + WHERE track_id = $1 AND artist_id = $2 AND is_primary = $3 + )`, 1, 1, true) + require.NoError(t, err) + assert.True(t, exists, "expected artist is_primary to be true") + + // set and unset album primary artist + + formdata = url.Values{} + formdata.Set("artist_id", "1") + formdata.Set("album_id", "1") + formdata.Set("is_primary", "false") + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + exists, err = store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artist_releases + WHERE release_id = $1 AND artist_id = $2 AND is_primary = $3 + )`, 1, 1, false) + require.NoError(t, err) + assert.True(t, exists, "expected artist is_primary to be false") + + formdata = url.Values{} + formdata.Set("artist_id", "1") + formdata.Set("album_id", "1") + formdata.Set("is_primary", "true") + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + exists, err = store.RowExists(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM artist_releases + WHERE release_id = $1 AND artist_id = $2 AND is_primary = $3 + )`, 1, 1, true) + require.NoError(t, err) + assert.True(t, exists, "expected artist is_primary to be true") + + // create a new track with multiple artists to make sure only one is primary at a time + + listenBody := `{ + "listen_type": "single", + "payload": [ + { + "listened_at": 1749475719, + "track_metadata": { + "additional_info": { + "artist_names": [ + "Rat Tally", + "Madeline Kenney" + ], + "duration_ms": 197270, + "submission_client": "navidrome", + "submission_client_version": "0.56.1 (fa2cf362)" + }, + "artist_name": "Rat Tally feat. Madeline Kenney", + "release_name": "In My Car", + "track_name": "In My Car" + } + } + ] + }` + + req, err := http.NewRequest("POST", host()+"/apis/listenbrainz/1/submit-listens", strings.NewReader(listenBody)) + require.NoError(t, err) + req.Header.Add("Authorization", fmt.Sprintf("Token %s", apikey)) + req.Header.Add("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + respBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, `{"status": "ok"}`, string(respBytes)) + + // set both artists as primary + + formdata = url.Values{} + formdata.Set("artist_id", "4") + formdata.Set("album_id", "4") + formdata.Set("is_primary", "true") + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + formdata = url.Values{} + formdata.Set("artist_id", "5") + formdata.Set("album_id", "4") + formdata.Set("is_primary", "true") + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + formdata = url.Values{} + formdata.Set("artist_id", "4") + formdata.Set("track_id", "4") + formdata.Set("is_primary", "true") + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + formdata = url.Values{} + formdata.Set("artist_id", "5") + formdata.Set("track_id", "4") + formdata.Set("is_primary", "true") + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + count, err := store.Count(ctx, `SELECT COUNT(*) FROM artist_releases WHERE release_id = $1 AND is_primary = $2`, 4, true) + require.NoError(t, err) + assert.EqualValues(t, 1, count, "expected only one primary artist for release") + count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE track_id = $1 AND is_primary = $2`, 4, true) + require.NoError(t, err) + assert.EqualValues(t, 1, count, "expected only one primary artist for track") +} diff --git a/engine/routes.go b/engine/routes.go index 18fc164..424daf3 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -36,6 +36,7 @@ func bindRoutes( r.Route("/apis/web/v1", func(r chi.Router) { r.Get("/artist", handlers.GetArtistHandler(db)) + r.Get("/artists", handlers.GetArtistsForItemHandler(db)) r.Get("/album", handlers.GetAlbumHandler(db)) r.Get("/track", handlers.GetTrackHandler(db)) r.Get("/top-tracks", handlers.GetTopTracksHandler(db)) @@ -75,6 +76,7 @@ func bindRoutes( r.Post("/merge/albums", handlers.MergeReleaseGroupsHandler(db)) r.Post("/merge/artists", handlers.MergeArtistsHandler(db)) r.Delete("/artist", handlers.DeleteArtistHandler(db)) + r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db)) r.Delete("/album", handlers.DeleteAlbumHandler(db)) r.Delete("/track", handlers.DeleteTrackHandler(db)) r.Delete("/listen", handlers.DeleteListenHandler(db)) diff --git a/internal/catalog/associate_album.go b/internal/catalog/associate_album.go index af39152..55bc44c 100644 --- a/internal/catalog/associate_album.go +++ b/internal/catalog/associate_album.go @@ -3,6 +3,7 @@ package catalog import ( "context" "errors" + "fmt" "slices" "github.com/gabehf/koito/internal/cfg" @@ -23,12 +24,13 @@ type AssociateAlbumOpts struct { ReleaseName string TrackName string // required Mbzc mbz.MusicBrainzCaller + SkipCacheImage bool } func AssociateAlbum(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) { l := logger.FromContext(ctx) if opts.TrackName == "" { - return nil, errors.New("required parameter TrackName missing") + return nil, errors.New("AssociateAlbum: required parameter TrackName missing") } releaseTitle := opts.ReleaseName if releaseTitle == "" { @@ -56,7 +58,7 @@ func matchAlbumByMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumO Image: a.Image, }, nil } else if !errors.Is(err, pgx.ErrNoRows) { - return nil, err + return nil, fmt.Errorf("matchAlbumByMbzReleaseID: %w", err) } else { l.Debug().Msgf("Album '%s' could not be found by MusicBrainz Release ID", opts.ReleaseName) rg, err := createOrUpdateAlbumWithMbzReleaseID(ctx, d, opts) @@ -69,14 +71,17 @@ func matchAlbumByMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumO func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) { l := logger.FromContext(ctx) + release, err := opts.Mbzc.GetRelease(ctx, opts.ReleaseMbzID) if err != nil { - l.Warn().Msg("MusicBrainz unreachable, falling back to release title matching") + l.Warn().Msg("createOrUpdateAlbumWithMbzReleaseID: MusicBrainz unreachable, falling back to release title matching") return matchAlbumByTitle(ctx, d, opts) } + var album *models.Album titles := []string{release.Title, opts.ReleaseName} utils.Unique(&titles) + l.Debug().Msgf("Searching for albums '%v' from artist id %d in DB", titles, opts.Artists[0].ID) album, err = d.GetAlbum(ctx, db.GetAlbumOpts{ ArtistID: opts.Artists[0].ID, @@ -89,27 +94,29 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso MusicBrainzID: opts.ReleaseMbzID, }) if err != nil { - l.Err(err).Msg("Failed to update album with MusicBrainz Release ID") - return nil, err + l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to update album with MusicBrainz Release ID") + return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err) } l.Debug().Msgf("Updated album '%s' with MusicBrainz Release ID", album.Title) + if opts.ReleaseGroupMbzID != uuid.Nil { aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID) if err == nil { l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title) err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz") if err != nil { - l.Err(err).Msg("Failed to save aliases") + l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to save aliases") } } else { - l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz") + l.Info().AnErr("err", err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to get release group from MusicBrainz") } } } else if !errors.Is(err, pgx.ErrNoRows) { - l.Err(err).Msg("Error while searching for album by MusicBrainz Release ID") - return nil, err + l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: error while searching for album by MusicBrainz Release ID") + return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err) } else { l.Debug().Msgf("Album %s could not be found. Creating...", release.Title) + var variousArtists bool for _, artistCredit := range release.ArtistCredit { if artistCredit.Name == "Various Artists" { @@ -117,6 +124,7 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso variousArtists = true } } + l.Debug().Msg("Searching for album images...") var imgid uuid.UUID imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{ @@ -124,23 +132,28 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso Album: release.Title, ReleaseMbzID: &opts.ReleaseMbzID, }) + if err == nil && imgUrl != "" { - var size ImageSize - if cfg.FullImageCacheEnabled() { - size = ImageSizeFull - } else { - size = ImageSizeLarge - } imgid = uuid.New() - l.Debug().Msg("Downloading album image from source...") - err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) - if err != nil { - l.Err(err).Msg("Failed to cache image") + if !opts.SkipCacheImage { + var size ImageSize + if cfg.FullImageCacheEnabled() { + size = ImageSizeFull + } else { + size = ImageSizeLarge + } + l.Debug().Msg("Downloading album image from source...") + err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) + if err != nil { + l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to cache image") + } } } + if err != nil { - l.Debug().Msgf("Failed to get album images for %s: %s", release.Title, err.Error()) + l.Debug().Msgf("createOrUpdateAlbumWithMbzReleaseID: failed to get album images for %s: %s", release.Title, err.Error()) } + album, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{ Title: release.Title, MusicBrainzID: opts.ReleaseMbzID, @@ -150,22 +163,25 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso ImageSrc: imgUrl, }) if err != nil { - return nil, err + return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err) } + if opts.ReleaseGroupMbzID != uuid.Nil { aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID) if err == nil { l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title) err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz") if err != nil { - l.Err(err).Msg("Failed to save aliases") + l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to save aliases") } } else { - l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz") + l.Info().AnErr("err", err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to get release group from MusicBrainz") } } + l.Info().Msgf("Created album '%s' with MusicBrainz Release ID", album.Title) } + return &models.Album{ ID: album.ID, MbzID: &opts.ReleaseMbzID, @@ -176,12 +192,14 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) { l := logger.FromContext(ctx) + var releaseName string if opts.ReleaseName != "" { releaseName = opts.ReleaseName } else { releaseName = opts.TrackName } + a, err := d.GetAlbum(ctx, db.GetAlbumOpts{ Title: releaseName, ArtistID: opts.Artists[0].ID, @@ -195,11 +213,11 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (* MusicBrainzID: opts.ReleaseMbzID, }) if err != nil { - l.Err(err).Msg("Failed to associate existing release with MusicBrainz ID") + l.Err(err).Msg("matchAlbumByTitle: failed to associate existing release with MusicBrainz ID") } } } else if !errors.Is(err, pgx.ErrNoRows) { - return nil, err + return nil, fmt.Errorf("matchAlbumByTitle: %w", err) } else { var imgid uuid.UUID imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{ @@ -208,22 +226,25 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (* ReleaseMbzID: &opts.ReleaseMbzID, }) if err == nil && imgUrl != "" { - var size ImageSize - if cfg.FullImageCacheEnabled() { - size = ImageSizeFull - } else { - size = ImageSizeLarge - } imgid = uuid.New() - l.Debug().Msg("Downloading album image from source...") - err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) - if err != nil { - l.Err(err).Msg("Failed to cache image") + if !opts.SkipCacheImage { + var size ImageSize + if cfg.FullImageCacheEnabled() { + size = ImageSizeFull + } else { + size = ImageSizeLarge + } + l.Debug().Msg("Downloading album image from source...") + err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) + if err != nil { + l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to cache image") + } } } if err != nil { - l.Debug().Msgf("Failed to get album images for %s: %s", opts.ReleaseName, err.Error()) + l.Debug().AnErr("error", err).Msgf("matchAlbumByTitle: failed to get album images for %s", opts.ReleaseName) } + a, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{ Title: releaseName, ArtistIDs: utils.FlattenArtistIDs(opts.Artists), @@ -232,10 +253,11 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (* ImageSrc: imgUrl, }) if err != nil { - return nil, err + return nil, fmt.Errorf("matchAlbumByTitle: %w", err) } l.Info().Msgf("Created album '%s' with artist and title", a.Title) } + return &models.Album{ ID: a.ID, Title: a.Title, diff --git a/internal/catalog/associate_artists.go b/internal/catalog/associate_artists.go index 3e0adf3..232cac7 100644 --- a/internal/catalog/associate_artists.go +++ b/internal/catalog/associate_artists.go @@ -24,6 +24,8 @@ type AssociateArtistsOpts struct { ArtistName string TrackTitle string Mbzc mbz.MusicBrainzCaller + + SkipCacheImage bool } func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { @@ -36,7 +38,7 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ( l.Debug().Msg("Associating artists by MusicBrainz ID(s) mappings") mbzMatches, err := matchArtistsByMBIDMappings(ctx, d, opts) if err != nil { - return nil, err + return nil, fmt.Errorf("AssociateArtists: %w", err) } result = append(result, mbzMatches...) } @@ -45,16 +47,16 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ( l.Debug().Msg("Associating artists by list of MusicBrainz ID(s)") mbzMatches, err := matchArtistsByMBID(ctx, d, opts, result) if err != nil { - return nil, err + return nil, fmt.Errorf("AssociateArtists: %w", err) } result = append(result, mbzMatches...) } if len(opts.ArtistNames) > len(result) { l.Debug().Msg("Associating artists by list of artist names") - nameMatches, err := matchArtistsByNames(ctx, opts.ArtistNames, result, d) + nameMatches, err := matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts) if err != nil { - return nil, err + return nil, fmt.Errorf("AssociateArtists: %w", err) } result = append(result, nameMatches...) } @@ -62,9 +64,9 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ( if len(result) < 1 { allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle)) l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle) - fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d) + fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts) if err != nil { - return nil, err + return nil, fmt.Errorf("AssociateArtists: %w", err) } result = append(result, fallbackMatches...) } @@ -77,7 +79,6 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti var result []*models.Artist for _, a := range opts.ArtistMbidMap { - // first, try to get by mbid artist, err := d.GetArtist(ctx, db.GetArtistOpts{ MusicBrainzID: a.Mbid, }) @@ -87,18 +88,17 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti continue } if !errors.Is(err, pgx.ErrNoRows) { - return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err) + return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err) } - // then, try to get by mbz name + artist, err = d.GetArtist(ctx, db.GetArtistOpts{ Name: a.Artist, }) if err == nil { l.Debug().Msgf("Artist '%s' found by Name", a.Artist) - // ...associate with mbzid if found err = d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: artist.ID, MusicBrainzID: a.Mbid}) if err != nil { - l.Err(fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)).Msgf("Failed to associate artist '%s' with MusicBrainz ID", artist.Name) + l.Err(err).Msgf("matchArtistsByMBIDMappings: Failed to associate artist '%s' with MusicBrainz ID", artist.Name) } else { artist.MbzID = &a.Mbid } @@ -106,36 +106,51 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti continue } if !errors.Is(err, pgx.ErrNoRows) { - return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err) + return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err) } - // then, try to get by aliases, or create - artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts.Mbzc) + artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts) if err != nil { - // if mbz unreachable, just create a new artist with provided name and mbid - l.Warn().Msg("MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping") + l.Warn().AnErr("error", err).Msg("matchArtistsByMBIDMappings: MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping") + var imgid uuid.UUID - imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{ + imgUrl, imgErr := images.GetArtistImage(ctx, images.ArtistImageOpts{ Aliases: []string{a.Artist}, }) - if err == nil { + if imgErr == nil && imgUrl != "" { imgid = uuid.New() - err = DownloadAndCacheImage(ctx, imgid, imgUrl, ImageSourceSize()) - if err != nil { - l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to download artist image for artist '%s'", a.Artist) - imgid = uuid.Nil + if !opts.SkipCacheImage { + var size ImageSize + if cfg.FullImageCacheEnabled() { + size = ImageSizeFull + } else { + size = ImageSizeLarge + } + l.Debug().Msg("Downloading artist image from source...") + err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) + if err != nil { + l.Err(err).Msg("Failed to cache image") + } } } else { - l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to get artist image for artist '%s'", a.Artist) + l.Err(imgErr).Msgf("matchArtistsByMBIDMappings: Failed to get artist image for artist '%s'", a.Artist) } - artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: a.Artist, MusicBrainzID: a.Mbid, Image: imgid, ImageSrc: imgUrl}) + + artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{ + Name: a.Artist, + MusicBrainzID: a.Mbid, + Image: imgid, + ImageSrc: imgUrl, + }) if err != nil { - l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to create artist '%s' in database", a.Artist) + l.Err(err).Msgf("matchArtistsByMBIDMappings: Failed to create artist '%s' in database", a.Artist) return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err) } } + result = append(result, artist) } + return result, nil } @@ -150,7 +165,7 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts, } if id == uuid.Nil { l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID") - return matchArtistsByNames(ctx, opts.ArtistNames, result, d) + return matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts) } a, err := d.GetArtist(ctx, db.GetArtistOpts{ MusicBrainzID: id, @@ -160,7 +175,6 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts, result = append(result, a) continue } - if !errors.Is(err, pgx.ErrNoRows) { return nil, err } @@ -168,22 +182,25 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts, if len(opts.ArtistNames) < 1 { opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle)) } - a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts.Mbzc) + + a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts) if err != nil { l.Warn().Msg("MusicBrainz unreachable, falling back to artist name matching") - return matchArtistsByNames(ctx, opts.ArtistNames, result, d) - // return nil, err + return matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts) } + result = append(result, a) } + return result, nil } -func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []string, d db.DB, mbz mbz.MusicBrainzCaller) (*models.Artist, error) { + +func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []string, d db.DB, opts AssociateArtistsOpts) (*models.Artist, error) { l := logger.FromContext(ctx) - aliases, err := mbz.GetArtistPrimaryAliases(ctx, mbzID) + aliases, err := opts.Mbzc.GetArtistPrimaryAliases(ctx, mbzID) if err != nil { - return nil, err + return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", err) } l.Debug().Msgf("Got aliases %v from MusicBrainz", aliases) @@ -195,10 +212,10 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st a.MbzID = &mbzID l.Debug().Msgf("Alias '%s' found in DB. Associating with MusicBrainz ID...", alias) if updateErr := d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: a.ID, MusicBrainzID: mbzID}); updateErr != nil { - return nil, updateErr + return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", updateErr) } if saveAliasErr := d.SaveArtistAliases(ctx, a.ID, aliases, "MusicBrainz"); saveAliasErr != nil { - return nil, saveAliasErr + return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", saveAliasErr) } return a, nil } @@ -220,20 +237,22 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st Aliases: aliases, }) if err == nil && imgUrl != "" { - var size ImageSize - if cfg.FullImageCacheEnabled() { - size = ImageSizeFull - } else { - size = ImageSizeLarge - } imgid = uuid.New() - l.Debug().Msg("Downloading artist image from source...") - err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) - if err != nil { - l.Err(err).Msg("Failed to cache image") + if !opts.SkipCacheImage { + var size ImageSize + if cfg.FullImageCacheEnabled() { + size = ImageSizeFull + } else { + size = ImageSizeLarge + } + l.Debug().Msg("Downloading artist image from source...") + err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) + if err != nil { + l.Err(err).Msg("Failed to cache image") + } } } else if err != nil { - l.Warn().Msgf("Failed to get artist image from ImageSrc: %s", err.Error()) + l.Warn().AnErr("error", err).Msg("Failed to get artist image from ImageSrc") } u, err := d.SaveArtist(ctx, db.SaveArtistOpts{ @@ -244,13 +263,13 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st ImageSrc: imgUrl, }) if err != nil { - return nil, err + return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", err) } l.Info().Msgf("Created artist '%s' with MusicBrainz Artist ID", canonical) return u, nil } -func matchArtistsByNames(ctx context.Context, names []string, existing []*models.Artist, d db.DB) ([]*models.Artist, error) { +func matchArtistsByNames(ctx context.Context, names []string, existing []*models.Artist, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { l := logger.FromContext(ctx) var result []*models.Artist @@ -273,29 +292,31 @@ func matchArtistsByNames(ctx context.Context, names []string, existing []*models Aliases: []string{name}, }) if err == nil && imgUrl != "" { - var size ImageSize - if cfg.FullImageCacheEnabled() { - size = ImageSizeFull - } else { - size = ImageSizeLarge - } imgid = uuid.New() - l.Debug().Msg("Downloading artist image from source...") - err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) - if err != nil { - l.Err(err).Msg("Failed to cache image") + if !opts.SkipCacheImage { + var size ImageSize + if cfg.FullImageCacheEnabled() { + size = ImageSizeFull + } else { + size = ImageSizeLarge + } + l.Debug().Msg("Downloading artist image from source...") + err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) + if err != nil { + l.Err(err).Msg("Failed to cache image") + } } } else if err != nil { - l.Debug().Msgf("Failed to get artist images for %s: %s", name, err.Error()) + l.Debug().AnErr("error", err).Msgf("Failed to get artist images for %s", name) } a, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: name, Image: imgid, ImageSrc: imgUrl}) if err != nil { - return nil, err + return nil, fmt.Errorf("matchArtistsByNames: %w", err) } l.Info().Msgf("Created artist '%s' with artist name", name) result = append(result, a) } else { - return nil, err + return nil, fmt.Errorf("matchArtistsByNames: %w", err) } } return result, nil diff --git a/internal/catalog/associate_track.go b/internal/catalog/associate_track.go index 5304c0b..635bdb1 100644 --- a/internal/catalog/associate_track.go +++ b/internal/catalog/associate_track.go @@ -3,6 +3,7 @@ package catalog import ( "context" "errors" + "fmt" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" @@ -24,13 +25,13 @@ type AssociateTrackOpts struct { func AssociateTrack(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) { l := logger.FromContext(ctx) if opts.TrackName == "" { - return nil, errors.New("missing required parameter 'opts.TrackName'") + return nil, errors.New("AssociateTrack: missing required parameter 'opts.TrackName'") } if len(opts.ArtistIDs) < 1 { - return nil, errors.New("at least one artist id must be specified") + return nil, errors.New("AssociateTrack: at least one artist id must be specified") } if opts.AlbumID == 0 { - return nil, errors.New("release group id must be specified") + return nil, errors.New("AssociateTrack: release group id must be specified") } // first, try to match track Mbz ID if opts.TrackMbzID != uuid.Nil { @@ -52,12 +53,12 @@ func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (* l.Debug().Msgf("Found track '%s' by MusicBrainz ID", track.Title) return track, nil } else if !errors.Is(err, pgx.ErrNoRows) { - return nil, err + return nil, fmt.Errorf("matchTrackByMbzID: %w", err) } else { l.Debug().Msgf("Track '%s' could not be found by MusicBrainz ID", opts.TrackName) track, err := matchTrackByTitleAndArtist(ctx, d, opts) if err != nil { - return nil, err + return nil, fmt.Errorf("matchTrackByMbzID: %w", err) } l.Debug().Msgf("Updating track '%s' with MusicBrainz ID %s", opts.TrackName, opts.TrackMbzID) err = d.UpdateTrack(ctx, db.UpdateTrackOpts{ @@ -65,7 +66,7 @@ func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (* MusicBrainzID: opts.TrackMbzID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("matchTrackByMbzID: %w", err) } track.MbzID = &opts.TrackMbzID return track, nil @@ -83,7 +84,7 @@ func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrac l.Debug().Msgf("Track '%s' found by title and artist match", track.Title) return track, nil } else if !errors.Is(err, pgx.ErrNoRows) { - return nil, err + return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err) } else { if opts.TrackMbzID != uuid.Nil { mbzTrack, err := opts.Mbzc.GetTrack(ctx, opts.TrackMbzID) @@ -107,7 +108,7 @@ func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrac Duration: opts.Duration, }) if err != nil { - return nil, err + return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err) } if opts.TrackMbzID == uuid.Nil { l.Info().Msgf("Created track '%s' with title and artist", opts.TrackName) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 26b3a09..c9a9a53 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -6,6 +6,7 @@ package catalog import ( "context" "errors" + "fmt" "regexp" "strings" "time" @@ -39,6 +40,9 @@ type SubmitListenOpts struct { // artist, release, release group, and track in DB SkipSaveListen bool + // When true, skips caching the images and only stores the image url in the db + SkipCacheImage bool + MbzCaller mbz.MusicBrainzCaller ArtistNames []string Artist string @@ -51,8 +55,9 @@ type SubmitListenOpts struct { ReleaseMbzID uuid.UUID ReleaseGroupMbzID uuid.UUID Time time.Time - UserID int32 - Client string + + UserID int32 + Client string } const ( @@ -70,16 +75,17 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error ctx, store, AssociateArtistsOpts{ - ArtistMbzIDs: opts.ArtistMbzIDs, - ArtistNames: opts.ArtistNames, - ArtistName: opts.Artist, - ArtistMbidMap: opts.ArtistMbidMappings, - Mbzc: opts.MbzCaller, - TrackTitle: opts.TrackTitle, + ArtistMbzIDs: opts.ArtistMbzIDs, + ArtistNames: opts.ArtistNames, + ArtistName: opts.Artist, + ArtistMbidMap: opts.ArtistMbidMappings, + Mbzc: opts.MbzCaller, + TrackTitle: opts.TrackTitle, + SkipCacheImage: opts.SkipCacheImage, }) if err != nil { - l.Error().Err(err).Msg("Failed to associate artists to listen") - return err + l.Err(err).Msg("Failed to associate artists to listen") + return fmt.Errorf("SubmitListen: %w", err) } else if len(artists) < 1 { l.Debug().Msg("Failed to associate any artists to release") } @@ -97,10 +103,11 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error TrackName: opts.TrackTitle, Mbzc: opts.MbzCaller, Artists: artists, + SkipCacheImage: opts.SkipCacheImage, }) if err != nil { l.Error().Err(err).Msg("Failed to associate release group to listen") - return err + return fmt.Errorf("SubmitListen: %w", err) } l.Debug().Any("album", rg).Msg("Matched listen to release") @@ -120,7 +127,7 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error }) if err != nil { l.Error().Err(err).Msg("Failed to associate track to listen") - return err + return fmt.Errorf("SubmitListen: %w", err) } l.Debug().Any("track", track).Msg("Matched listen to track") diff --git a/internal/catalog/images.go b/internal/catalog/images.go index ecce26c..bf5aa26 100644 --- a/internal/catalog/images.go +++ b/internal/catalog/images.go @@ -82,17 +82,17 @@ func SourceImageDir() string { func ValidateImageURL(url string) error { resp, err := http.Head(url) if err != nil { - return fmt.Errorf("failed to perform HEAD request: %w", err) + return fmt.Errorf("ValidateImageURL: http.Head: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HEAD request failed, status code: %d", resp.StatusCode) + return fmt.Errorf("ValidateImageURL: HEAD request failed, status code: %d", resp.StatusCode) } contentType := resp.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "image/") { - return fmt.Errorf("URL does not point to an image, content type: %s", contentType) + return fmt.Errorf("ValidateImageURL: URL does not point to an image, content type: %s", contentType) } return nil @@ -103,20 +103,24 @@ func DownloadAndCacheImage(ctx context.Context, id uuid.UUID, url string, size I l := logger.FromContext(ctx) err := ValidateImageURL(url) if err != nil { - return err + return fmt.Errorf("DownloadAndCacheImage: %w", err) } l.Debug().Msgf("Downloading image for ID %s", id) resp, err := http.Get(url) if err != nil { - return fmt.Errorf("failed to download image: %w", err) + return fmt.Errorf("DownloadAndCacheImage: http.Get: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download image, status code: %d", resp.StatusCode) + return fmt.Errorf("DownloadAndCacheImage: failed to download image, status: %s", resp.Status) } - return CompressAndSaveImage(ctx, id.String(), size, resp.Body) + err = CompressAndSaveImage(ctx, id.String(), size, resp.Body) + if err != nil { + return fmt.Errorf("DownloadAndCacheImage: %w", err) + } + return nil } // Compresses an image to the specified size, then saves it to the correct cache folder. @@ -124,16 +128,24 @@ func CompressAndSaveImage(ctx context.Context, filename string, size ImageSize, l := logger.FromContext(ctx) if size == ImageSizeFull { - return saveImage(filename, size, body) + err := saveImage(filename, size, body) + if err != nil { + return fmt.Errorf("CompressAndSaveImage: %w", err) + } + return nil } l.Debug().Msg("Creating resized image") compressed, err := compressImage(size, body) if err != nil { - return err + return fmt.Errorf("CompressAndSaveImage: %w", err) } - return saveImage(filename, size, compressed) + err = saveImage(filename, size, compressed) + if err != nil { + return fmt.Errorf("CompressAndSaveImage: %w", err) + } + return nil } // SaveImage saves an image to the image_cache/{size} folder @@ -144,21 +156,21 @@ func saveImage(filename string, size ImageSize, data io.Reader) error { // Ensure the cache directory exists err := os.MkdirAll(filepath.Join(cacheDir, string(size)), 0744) if err != nil { - return fmt.Errorf("failed to create full image cache directory: %w", err) + return fmt.Errorf("saveImage: failed to create full image cache directory: %w", err) } // Create a file in the cache directory imagePath := filepath.Join(cacheDir, string(size), filename) file, err := os.Create(imagePath) if err != nil { - return fmt.Errorf("failed to create image file: %w", err) + return fmt.Errorf("saveImage: failed to create image file: %w", err) } defer file.Close() // Save the image to the file _, err = io.Copy(file, data) if err != nil { - return fmt.Errorf("failed to save image: %w", err) + return fmt.Errorf("saveImage: failed to save image: %w", err) } return nil @@ -167,7 +179,7 @@ func saveImage(filename string, size ImageSize, data io.Reader) error { func compressImage(size ImageSize, data io.Reader) (io.Reader, error) { imgBytes, err := io.ReadAll(data) if err != nil { - return nil, err + return nil, fmt.Errorf("compressImage: io.ReadAll: %w", err) } px := GetImageSize(size) // Resize with bimg @@ -180,10 +192,10 @@ func compressImage(size ImageSize, data io.Reader) (io.Reader, error) { Type: bimg.WEBP, }) if err != nil { - return nil, err + return nil, fmt.Errorf("compressImage: bimg.NewImage: %w", err) } if len(imgBytes) == 0 { - return nil, fmt.Errorf("compression failed") + return nil, fmt.Errorf("compressImage: failed to compress image: %w", err) } return bytes.NewReader(imgBytes), nil } @@ -198,19 +210,19 @@ func DeleteImage(filename uuid.UUID) error { // } err := os.Remove(path.Join(cacheDir, "full", filename.String())) if err != nil && !os.IsNotExist(err) { - return err + return fmt.Errorf("DeleteImage: %w", err) } err = os.Remove(path.Join(cacheDir, "large", filename.String())) if err != nil && !os.IsNotExist(err) { - return err + return fmt.Errorf("DeleteImage: %w", err) } err = os.Remove(path.Join(cacheDir, "medium", filename.String())) if err != nil && !os.IsNotExist(err) { - return err + return fmt.Errorf("DeleteImage: %w", err) } err = os.Remove(path.Join(cacheDir, "small", filename.String())) if err != nil && !os.IsNotExist(err) { - return err + return fmt.Errorf("DeleteImage: %w", err) } return nil } @@ -230,7 +242,7 @@ func PruneOrphanedImages(ctx context.Context, store db.DB) error { for _, dir := range []string{"large", "medium", "small", "full"} { c, err := pruneDirImgs(ctx, store, path.Join(cacheDir, dir), memo) if err != nil { - return err + return fmt.Errorf("PruneOrphanedImages: %w", err) } count += c } @@ -256,7 +268,7 @@ func pruneDirImgs(ctx context.Context, store db.DB, path string, memo map[string } exists, err := store.ImageHasAssociation(ctx, imageid) if err != nil { - return 0, err + return 0, fmt.Errorf("pruneDirImages: %w", err) } else if exists { continue } diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index ad15869..ca69a25 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -17,30 +17,31 @@ const ( const ( // BASE_URL_ENV = "KOITO_BASE_URL" - DATABASE_URL_ENV = "KOITO_DATABASE_URL" - BIND_ADDR_ENV = "KOITO_BIND_ADDR" - LISTEN_PORT_ENV = "KOITO_LISTEN_PORT" - ENABLE_STRUCTURED_LOGGING_ENV = "KOITO_ENABLE_STRUCTURED_LOGGING" - ENABLE_FULL_IMAGE_CACHE_ENV = "KOITO_ENABLE_FULL_IMAGE_CACHE" - LOG_LEVEL_ENV = "KOITO_LOG_LEVEL" - MUSICBRAINZ_URL_ENV = "KOITO_MUSICBRAINZ_URL" - MUSICBRAINZ_RATE_LIMIT_ENV = "KOITO_MUSICBRAINZ_RATE_LIMIT" - ENABLE_LBZ_RELAY_ENV = "KOITO_ENABLE_LBZ_RELAY" - LBZ_RELAY_URL_ENV = "KOITO_LBZ_RELAY_URL" - LBZ_RELAY_TOKEN_ENV = "KOITO_LBZ_RELAY_TOKEN" - CONFIG_DIR_ENV = "KOITO_CONFIG_DIR" - DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" - DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" - DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" - DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" - DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" - SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" - ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" - CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" - DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT" - THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS" - IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX" - IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX" + DATABASE_URL_ENV = "KOITO_DATABASE_URL" + BIND_ADDR_ENV = "KOITO_BIND_ADDR" + LISTEN_PORT_ENV = "KOITO_LISTEN_PORT" + ENABLE_STRUCTURED_LOGGING_ENV = "KOITO_ENABLE_STRUCTURED_LOGGING" + ENABLE_FULL_IMAGE_CACHE_ENV = "KOITO_ENABLE_FULL_IMAGE_CACHE" + LOG_LEVEL_ENV = "KOITO_LOG_LEVEL" + MUSICBRAINZ_URL_ENV = "KOITO_MUSICBRAINZ_URL" + MUSICBRAINZ_RATE_LIMIT_ENV = "KOITO_MUSICBRAINZ_RATE_LIMIT" + ENABLE_LBZ_RELAY_ENV = "KOITO_ENABLE_LBZ_RELAY" + LBZ_RELAY_URL_ENV = "KOITO_LBZ_RELAY_URL" + LBZ_RELAY_TOKEN_ENV = "KOITO_LBZ_RELAY_TOKEN" + CONFIG_DIR_ENV = "KOITO_CONFIG_DIR" + DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" + DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" + DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" + DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" + DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" + SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" + ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" + CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" + DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT" + THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS" + IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX" + IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX" + FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT" ) type config struct { @@ -48,29 +49,30 @@ type config struct { listenPort int configDir string // baseUrl string - databaseUrl string - musicBrainzUrl string - musicBrainzRateLimit int - logLevel int - structuredLogging bool - enableFullImageCache bool - lbzRelayEnabled bool - lbzRelayUrl string - lbzRelayToken string - defaultPw string - defaultUsername string - disableDeezer bool - disableCAA bool - disableMusicBrainz bool - skipImport bool - allowedHosts []string - allowAllHosts bool - allowedOrigins []string - disableRateLimit bool - importThrottleMs int - userAgent string - importBefore time.Time - importAfter time.Time + databaseUrl string + musicBrainzUrl string + musicBrainzRateLimit int + logLevel int + structuredLogging bool + enableFullImageCache bool + lbzRelayEnabled bool + lbzRelayUrl string + lbzRelayToken string + defaultPw string + defaultUsername string + disableDeezer bool + disableCAA bool + disableMusicBrainz bool + skipImport bool + fetchImageDuringImport bool + allowedHosts []string + allowAllHosts bool + allowedOrigins []string + disableRateLimit bool + importThrottleMs int + userAgent string + importBefore time.Time + importAfter time.Time } var ( @@ -85,7 +87,10 @@ func Load(getenv func(string) string, version string) error { once.Do(func() { globalConfig, err = loadConfig(getenv, version) }) - return err + if err != nil { + return fmt.Errorf("cfg.Load: %w", err) + } + return nil } // loadConfig loads the configuration from environment variables. @@ -94,7 +99,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { cfg.databaseUrl = getenv(DATABASE_URL_ENV) if cfg.databaseUrl == "" { - return nil, errors.New("required parameter " + DATABASE_URL_ENV + " not provided") + return nil, errors.New("loadConfig: required parameter " + DATABASE_URL_ENV + " not provided") } cfg.bindAddr = getenv(BIND_ADDR_ENV) var err error @@ -136,6 +141,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { cfg.disableRateLimit = parseBool(getenv(DISABLE_RATE_LIMIT_ENV)) cfg.structuredLogging = parseBool(getenv(ENABLE_STRUCTURED_LOGGING_ENV)) + cfg.fetchImageDuringImport = parseBool(getenv(FETCH_IMAGES_DURING_IMPORT_ENV)) cfg.enableFullImageCache = parseBool(getenv(ENABLE_FULL_IMAGE_CACHE_ENV)) cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV)) @@ -211,12 +217,6 @@ func ConfigDir() string { return globalConfig.configDir } -// func BaseUrl() string { -// lock.RLock() -// defer lock.RUnlock() -// return globalConfig.baseUrl -// } - func DatabaseUrl() string { lock.RLock() defer lock.RUnlock() @@ -339,5 +339,13 @@ func ThrottleImportMs() int { // returns the before, after times, in that order func ImportWindow() (time.Time, time.Time) { + lock.RLock() + defer lock.RUnlock() return globalConfig.importBefore, globalConfig.importAfter } + +func FetchImagesDuringImport() bool { + lock.RLock() + defer lock.RUnlock() + return globalConfig.fetchImageDuringImport +} diff --git a/internal/db/db.go b/internal/db/db.go index 16cecd1..4eb5458 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -14,6 +14,8 @@ type DB interface { GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error) GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error) GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, error) + GetArtistsForAlbum(ctx context.Context, id int32) ([]*models.Artist, error) + GetArtistsForTrack(ctx context.Context, id int32) ([]*models.Artist, error) GetTopTracksPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Track], error) GetTopArtistsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Artist], error) GetTopAlbumsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Album], error) @@ -48,6 +50,8 @@ type DB interface { SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) error SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) error + SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error + SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error // Delete DeleteArtist(ctx context.Context, id int32) error DeleteAlbum(ctx context.Context, id int32) error diff --git a/internal/db/psql/album.go b/internal/db/psql/album.go index 111b4cc..4392227 100644 --- a/internal/db/psql/album.go +++ b/internal/db/psql/album.go @@ -3,6 +3,7 @@ package psql import ( "context" "errors" + "fmt" "strings" "time" @@ -41,11 +42,11 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu Column1: opts.Titles, }) } else { - return nil, errors.New("insufficient information to get album") + return nil, errors.New("GetAlbum: insufficient information to get album") } if err != nil { - return nil, err + return nil, fmt.Errorf("GetAlbum: %w", err) } count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{ @@ -54,7 +55,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu ReleaseID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetAlbum: CountListensFromRelease: %w", err) } seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ @@ -62,7 +63,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu AlbumID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err) } return &models.Album{ @@ -87,17 +88,17 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al insertImage = &opts.Image } if len(opts.ArtistIDs) < 1 { - return nil, errors.New("required parameter 'ArtistIDs' missing") + return nil, errors.New("SaveAlbum: required parameter 'ArtistIDs' missing") } for _, aid := range opts.ArtistIDs { if aid == 0 { - return nil, errors.New("none of 'ArtistIDs' may be 0") + return nil, errors.New("SaveAlbum: none of 'ArtistIDs' may be 0") } } tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return nil, err + return nil, fmt.Errorf("SaveAlbum: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -109,7 +110,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveAlbum: InsertRelease: %w", err) } for _, artistId := range opts.ArtistIDs { l.Debug().Msgf("Associating release '%s' to artist with ID %d", opts.Title, artistId) @@ -118,7 +119,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al ReleaseID: r.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err) } } l.Debug().Msgf("Saving canonical alias %s for release %d", opts.Title, r.ID) @@ -130,11 +131,12 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al }) if err != nil { l.Err(err).Msgf("Failed to save canonical alias for album %d", r.ID) + return nil, fmt.Errorf("SaveAlbum: InsertReleaseAlias: %w", err) } err = tx.Commit(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveAlbum: Commit: %w", err) } return &models.Album{ @@ -151,7 +153,7 @@ func (d *Psql) AddArtistsToAlbum(ctx context.Context, opts db.AddArtistsToAlbumO tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("AddArtistsToAlbum: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -162,6 +164,7 @@ func (d *Psql) AddArtistsToAlbum(ctx context.Context, opts db.AddArtistsToAlbumO }) if err != nil { l.Error().Err(err).Msgf("Failed to associate release %d with artist %d", opts.AlbumID, id) + return fmt.Errorf("AddArtistsToAlbum: AssociateArtistToRelease: %w", err) } } return tx.Commit(ctx) @@ -175,7 +178,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error { tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("UpdateAlbum: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -186,7 +189,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error { MusicBrainzID: &opts.MusicBrainzID, }) if err != nil { - return err + return fmt.Errorf("UpdateAlbum: UpdateReleaseMbzID: %w", err) } } if opts.Image != uuid.Nil { @@ -197,7 +200,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error { ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, }) if err != nil { - return err + return fmt.Errorf("UpdateAlbum: UpdateReleaseImage: %w", err) } } if opts.VariousArtistsUpdate { @@ -207,7 +210,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error { VariousArtists: opts.VariousArtistsValue, }) if err != nil { - return err + return fmt.Errorf("UpdateAlbum: UpdateReleaseVariousArtists: %w", err) } } return tx.Commit(ctx) @@ -221,13 +224,13 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string, tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("SaveAlbumAliases: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) existing, err := qtx.GetAllReleaseAliases(ctx, id) if err != nil { - return err + return fmt.Errorf("SaveAlbumAliases: GetAllReleaseAliases: %w", err) } for _, v := range existing { aliases = append(aliases, v.Alias) @@ -235,7 +238,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string, utils.Unique(&aliases) for _, alias := range aliases { if strings.TrimSpace(alias) == "" { - return errors.New("aliases cannot be blank") + return errors.New("SaveAlbumAliases: aliases cannot be blank") } err = qtx.InsertReleaseAlias(ctx, repository.InsertReleaseAliasParams{ Alias: strings.TrimSpace(alias), @@ -244,7 +247,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string, IsPrimary: false, }) if err != nil { - return err + return fmt.Errorf("SaveAlbumAliases: InsertReleaseAlias: %w", err) } } return tx.Commit(ctx) @@ -263,7 +266,7 @@ func (d *Psql) DeleteAlbumAlias(ctx context.Context, id int32, alias string) err func (d *Psql) GetAllAlbumAliases(ctx context.Context, id int32) ([]models.Alias, error) { rows, err := d.q.GetAllReleaseAliases(ctx, id) if err != nil { - return nil, err + return nil, fmt.Errorf("GetAllAlbumAliases: GetAllReleaseAliases: %w", err) } aliases := make([]models.Alias, len(rows)) for i, row := range rows { @@ -285,14 +288,14 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("SetPrimaryAlbumAlias: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) // get all aliases aliases, err := qtx.GetAllReleaseAliases(ctx, id) if err != nil { - return err + return fmt.Errorf("SetPrimaryAlbumAlias: GetAllReleaseAliases: %w", err) } primary := "" exists := false @@ -309,7 +312,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) return nil } if !exists { - return errors.New("alias does not exist") + return errors.New("SetPrimaryAlbumAlias: alias does not exist") } err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{ ReleaseID: id, @@ -317,7 +320,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) IsPrimary: true, }) if err != nil { - return err + return fmt.Errorf("SetPrimaryAlbumAlias: SetReleaseAliasPrimaryStatus: %w", err) } err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{ ReleaseID: id, @@ -325,7 +328,61 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) IsPrimary: false, }) if err != nil { - return err + return fmt.Errorf("SetPrimaryAlbumAlias: SetReleaseAliasPrimaryStatus: %w", err) + } + return tx.Commit(ctx) +} + +func (d *Psql) SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error { + l := logger.FromContext(ctx) + if id == 0 { + return errors.New("artist id not specified") + } + tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + l.Err(err).Msg("Failed to begin transaction") + return fmt.Errorf("SetPrimaryAlbumArtist: BeginTx: %w", err) + } + defer tx.Rollback(ctx) + qtx := d.q.WithTx(tx) + // get all artists + artists, err := qtx.GetReleaseArtists(ctx, id) + if err != nil { + return fmt.Errorf("SetPrimaryAlbumArtist: GetReleaseArtists: %w", err) + } + var primary int32 + for _, v := range artists { + // i dont get it??? is_primary is not a nullable column??? why use pgtype.Bool??? + // why not just use boolean??? is sqlc stupid??? am i stupid??????? + if v.IsPrimary.Valid && v.IsPrimary.Bool { + primary = v.ID + } + } + if value && primary == artistId { + // no-op + return nil + } + l.Debug().Msgf("Marking artist with id %d as 'primary = %v' on album with id %d", artistId, value, id) + err = qtx.UpdateReleasePrimaryArtist(ctx, repository.UpdateReleasePrimaryArtistParams{ + ReleaseID: id, + ArtistID: artistId, + IsPrimary: value, + }) + if err != nil { + return fmt.Errorf("SetPrimaryAlbumArtist: UpdateReleasePrimaryArtist: %w", err) + } + if value && primary != 0 { + // if we were marking a new one as primary and there was already one marked as primary, + // unmark that one as there can only be one + l.Debug().Msgf("Unmarking artist with id %d as primary on album with id %d", primary, id) + err = qtx.UpdateReleasePrimaryArtist(ctx, repository.UpdateReleasePrimaryArtistParams{ + ReleaseID: id, + ArtistID: primary, + IsPrimary: false, + }) + if err != nil { + return fmt.Errorf("SetPrimaryAlbumArtist: UpdateReleasePrimaryArtist: %w", err) + } } return tx.Commit(ctx) } diff --git a/internal/db/psql/artist.go b/internal/db/psql/artist.go index 0d9b702..0555111 100644 --- a/internal/db/psql/artist.go +++ b/internal/db/psql/artist.go @@ -3,6 +3,7 @@ package psql import ( "context" "errors" + "fmt" "strings" "time" @@ -23,7 +24,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar l.Debug().Msgf("Fetching artist from DB with id %d", opts.ID) row, err := d.q.GetArtist(ctx, opts.ID) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: GetArtist by ID: %w", err) } count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{ ListenedAt: time.Unix(0, 0), @@ -31,14 +32,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar ArtistID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err) } seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ Period: db.PeriodAllTime, ArtistID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) } return &models.Artist{ ID: row.ID, @@ -53,7 +54,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID) row, err := d.q.GetArtistByMbzID(ctx, &opts.MusicBrainzID) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: GetArtistByMbzID: %w", err) } count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{ ListenedAt: time.Unix(0, 0), @@ -61,14 +62,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar ArtistID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err) } seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ Period: db.PeriodAllTime, ArtistID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) } return &models.Artist{ ID: row.ID, @@ -83,7 +84,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name) row, err := d.q.GetArtistByName(ctx, opts.Name) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: GetArtistByName: %w", err) } count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{ ListenedAt: time.Unix(0, 0), @@ -91,14 +92,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar ArtistID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err) } seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ Period: db.PeriodAllTime, ArtistID: row.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) } return &models.Artist{ ID: row.ID, @@ -118,35 +119,36 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar func (d *Psql) SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error { l := logger.FromContext(ctx) if id == 0 { - return errors.New("artist id not specified") + return errors.New("SaveArtistAliases: artist id not specified") } tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("SaveArtistAliases: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) existing, err := qtx.GetAllArtistAliases(ctx, id) if err != nil { - return err + return fmt.Errorf("SaveArtistAliases: GetAllArtistAliases: %w", err) } for _, v := range existing { aliases = append(aliases, v.Alias) } utils.Unique(&aliases) for _, alias := range aliases { - if strings.TrimSpace(alias) == "" { - return errors.New("aliases cannot be blank") + alias = strings.TrimSpace(alias) + if alias == "" { + return errors.New("SaveArtistAliases: aliases cannot be blank") } err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{ - Alias: strings.TrimSpace(alias), + Alias: alias, ArtistID: id, Source: source, IsPrimary: false, }) if err != nil { - return err + return fmt.Errorf("SaveArtistAliases: InsertArtistAlias: %w", err) } } return tx.Commit(ctx) @@ -170,13 +172,13 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models. tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return nil, err + return nil, fmt.Errorf("SaveArtist: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) opts.Name = strings.TrimSpace(opts.Name) if opts.Name == "" { - return nil, errors.New("name must not be blank") + return nil, errors.New("SaveArtist: name must not be blank") } l.Debug().Msgf("Inserting artist '%s' into DB", opts.Name) a, err := qtx.InsertArtist(ctx, repository.InsertArtistParams{ @@ -185,7 +187,7 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models. ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveArtist: InsertArtist: %w", err) } l.Debug().Msgf("Inserting canonical alias '%s' into DB for artist with id %d", opts.Name, a.ID) err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{ @@ -195,13 +197,13 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models. IsPrimary: true, }) if err != nil { - l.Error().Err(err).Msgf("Error inserting canonical alias for artist '%s'", opts.Name) - return nil, err + l.Err(err).Msgf("SaveArtist: error inserting canonical alias for artist '%s'", opts.Name) + return nil, fmt.Errorf("SaveArtist: InsertArtistAlias: %w", err) } err = tx.Commit(ctx) if err != nil { l.Err(err).Msg("Failed to commit insert artist transaction") - return nil, err + return nil, fmt.Errorf("SaveArtist: Commit: %w", err) } artist := &models.Artist{ ID: a.ID, @@ -214,7 +216,7 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models. l.Debug().Msgf("Inserting aliases '%v' into DB for artist '%s'", opts.Aliases, opts.Name) err = d.SaveArtistAliases(ctx, a.ID, opts.Aliases, "MusicBrainz") if err != nil { - return nil, err + return nil, fmt.Errorf("SaveArtist: SaveArtistAliases: %w", err) } artist.Aliases = opts.Aliases } @@ -224,12 +226,12 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models. func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error { l := logger.FromContext(ctx) if opts.ID == 0 { - return errors.New("artist id not specified") + return errors.New("UpdateArtist: artist id not specified") } tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("UpdateArtist: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -240,7 +242,7 @@ func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error MusicBrainzID: &opts.MusicBrainzID, }) if err != nil { - return err + return fmt.Errorf("UpdateArtist: UpdateArtistMbzID: %w", err) } } if opts.Image != uuid.Nil { @@ -251,10 +253,15 @@ func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, }) if err != nil { - return err + return fmt.Errorf("UpdateArtist: UpdateArtistImage: %w", err) } } - return tx.Commit(ctx) + err = tx.Commit(ctx) + if err != nil { + l.Err(err).Msg("Failed to commit update artist transaction") + return fmt.Errorf("UpdateArtist: Commit: %w", err) + } + return nil } func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) error { @@ -263,10 +270,11 @@ func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) er Alias: alias, }) } + func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alias, error) { rows, err := d.q.GetAllArtistAliases(ctx, id) if err != nil { - return nil, err + return nil, fmt.Errorf("GetAllArtistAliases: %w", err) } aliases := make([]models.Alias, len(rows)) for i, row := range rows { @@ -283,19 +291,18 @@ func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alia func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error { l := logger.FromContext(ctx) if id == 0 { - return errors.New("artist id not specified") + return errors.New("SetPrimaryArtistAlias: artist id not specified") } tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("SetPrimaryArtistAlias: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) - // get all aliases aliases, err := qtx.GetAllArtistAliases(ctx, id) if err != nil { - return err + return fmt.Errorf("SetPrimaryArtistAlias: GetAllArtistAliases: %w", err) } primary := "" exists := false @@ -308,11 +315,10 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string } } if primary == alias { - // no-op rename return nil } if !exists { - return errors.New("alias does not exist") + return errors.New("SetPrimaryArtistAlias: alias does not exist") } err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{ ArtistID: id, @@ -320,7 +326,7 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string IsPrimary: true, }) if err != nil { - return err + return fmt.Errorf("SetPrimaryArtistAlias: SetArtistAliasPrimaryStatus (primary): %w", err) } err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{ ArtistID: id, @@ -328,7 +334,57 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string IsPrimary: false, }) if err != nil { - return err + return fmt.Errorf("SetPrimaryArtistAlias: SetArtistAliasPrimaryStatus (previous primary): %w", err) } - return tx.Commit(ctx) + err = tx.Commit(ctx) + if err != nil { + l.Err(err).Msg("Failed to commit transaction") + return fmt.Errorf("SetPrimaryArtistAlias: Commit: %w", err) + } + return nil +} +func (d *Psql) GetArtistsForAlbum(ctx context.Context, id int32) ([]*models.Artist, error) { + l := logger.FromContext(ctx) + l.Debug().Msgf("Fetching artists for album ID %d", id) + + rows, err := d.q.GetReleaseArtists(ctx, id) + if err != nil { + return nil, fmt.Errorf("GetArtistsForAlbum: %w", err) + } + + artists := make([]*models.Artist, len(rows)) + for i, row := range rows { + artists[i] = &models.Artist{ + ID: row.ID, + Name: row.Name, + MbzID: row.MusicBrainzID, + Image: row.Image, + IsPrimary: row.IsPrimary.Valid && row.IsPrimary.Bool, + } + } + + return artists, nil +} + +func (d *Psql) GetArtistsForTrack(ctx context.Context, id int32) ([]*models.Artist, error) { + l := logger.FromContext(ctx) + l.Debug().Msgf("Fetching artists for track ID %d", id) + + rows, err := d.q.GetTrackArtists(ctx, id) + if err != nil { + return nil, fmt.Errorf("GetArtistsForTrack: %w", err) + } + + artists := make([]*models.Artist, len(rows)) + for i, row := range rows { + artists[i] = &models.Artist{ + ID: row.ID, + Name: row.Name, + MbzID: row.MusicBrainzID, + Image: row.Image, + IsPrimary: row.IsPrimary.Valid && row.IsPrimary.Bool, + } + } + + return artists, nil } diff --git a/internal/db/psql/counts.go b/internal/db/psql/counts.go index c7ab3bb..cecdd8d 100644 --- a/internal/db/psql/counts.go +++ b/internal/db/psql/counts.go @@ -3,6 +3,7 @@ package psql import ( "context" "errors" + "fmt" "time" "github.com/gabehf/koito/internal/db" @@ -17,10 +18,11 @@ func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error ListenedAt_2: t2, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountListens: %w", err) } return count, nil } + func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) { t2 := time.Now() t1 := db.StartTimeFromPeriod(period) @@ -29,10 +31,11 @@ func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) ListenedAt_2: t2, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountTracks: %w", err) } return count, nil } + func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) { t2 := time.Now() t1 := db.StartTimeFromPeriod(period) @@ -41,10 +44,11 @@ func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) ListenedAt_2: t2, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountAlbums: %w", err) } return count, nil } + func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error) { t2 := time.Now() t1 := db.StartTimeFromPeriod(period) @@ -53,10 +57,11 @@ func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error ListenedAt_2: t2, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountArtists: %w", err) } return count, nil } + func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, error) { t2 := time.Now() t1 := db.StartTimeFromPeriod(period) @@ -65,10 +70,11 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, ListenedAt_2: t2, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountTimeListened: %w", err) } return count, nil } + func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) { t2 := time.Now() t1 := db.StartTimeFromPeriod(opts.Period) @@ -80,7 +86,7 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened ArtistID: opts.ArtistID, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountTimeListenedToItem (Artist): %w", err) } return count, nil } else if opts.AlbumID > 0 { @@ -90,10 +96,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened ReleaseID: opts.AlbumID, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountTimeListenedToItem (Album): %w", err) } return count, nil - } else if opts.TrackID > 0 { count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{ ListenedAt: t1, @@ -101,9 +106,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened ID: opts.TrackID, }) if err != nil { - return 0, err + return 0, fmt.Errorf("CountTimeListenedToItem (Track): %w", err) } return count, nil } - return 0, errors.New("an id must be provided") + return 0, errors.New("CountTimeListenedToItem: an id must be provided") } diff --git a/internal/db/psql/images.go b/internal/db/psql/images.go index a2b7710..49e2850 100644 --- a/internal/db/psql/images.go +++ b/internal/db/psql/images.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/models" @@ -15,15 +16,15 @@ import ( func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) { _, err := d.q.GetReleaseByImageID(ctx, &image) if err == nil { - return true, err + return true, nil } else if !errors.Is(err, pgx.ErrNoRows) { - return false, err + return false, fmt.Errorf("ImageHasAssociation: GetReleaseByImageID: %w", err) } _, err = d.q.GetArtistByImage(ctx, &image) if err == nil { - return true, err + return true, nil } else if !errors.Is(err, pgx.ErrNoRows) { - return false, err + return false, fmt.Errorf("ImageHasAssociation: GetArtistByImage: %w", err) } return false, nil } @@ -31,15 +32,15 @@ func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, func (d *Psql) GetImageSource(ctx context.Context, image uuid.UUID) (string, error) { r, err := d.q.GetReleaseByImageID(ctx, &image) if err == nil { - return r.ImageSource.String, err + return r.ImageSource.String, nil } else if !errors.Is(err, pgx.ErrNoRows) { - return "", err + return "", fmt.Errorf("GetImageSource: GetReleaseByImageID: %w", err) } rr, err := d.q.GetArtistByImage(ctx, &image) if err == nil { - return rr.ImageSource.String, err + return rr.ImageSource.String, nil } else if !errors.Is(err, pgx.ErrNoRows) { - return "", err + return "", fmt.Errorf("GetImageSource: GetArtistByImage: %w", err) } return "", nil } @@ -51,14 +52,13 @@ func (d *Psql) AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.A ID: from, }) if err != nil { - return nil, err + return nil, fmt.Errorf("AlbumsWithoutImages: GetReleasesWithoutImages: %w", err) } albums := make([]*models.Album, len(rows)) for i, row := range rows { - artists := make([]models.SimpleArtist, 0) - err = json.Unmarshal(row.Artists, &artists) - if err != nil { - l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID) + var artists []models.SimpleArtist + if err := json.Unmarshal(row.Artists, &artists); err != nil { + l.Err(err).Msgf("AlbumsWithoutImages: error unmarshalling artists for release group with id %d", row.ID) artists = nil } albums[i] = &models.Album{ diff --git a/internal/db/psql/listen.go b/internal/db/psql/listen.go index 0864643..301b6e3 100644 --- a/internal/db/psql/listen.go +++ b/internal/db/psql/listen.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" "github.com/gabehf/koito/internal/db" @@ -18,7 +19,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* offset := (opts.Page - 1) * opts.Limit t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: %w", err) } if opts.Month == 0 && opts.Year == 0 { // use period, not date range @@ -41,7 +42,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* ID: int32(opts.TrackID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromTrackPaginated: %w", err) } listens = make([]*models.Listen, len(rows)) for i, row := range rows { @@ -54,7 +55,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* } err = json.Unmarshal(row.Artists, &t.Track.Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err) } listens[i] = t } @@ -64,7 +65,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* TrackID: int32(opts.TrackID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: CountListensFromTrack: %w", err) } } else if opts.AlbumID > 0 { l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v", @@ -77,7 +78,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* ReleaseID: int32(opts.AlbumID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromReleasePaginated: %w", err) } listens = make([]*models.Listen, len(rows)) for i, row := range rows { @@ -90,7 +91,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* } err = json.Unmarshal(row.Artists, &t.Track.Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err) } listens[i] = t } @@ -100,7 +101,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* ReleaseID: int32(opts.AlbumID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: CountListensFromRelease: %w", err) } } else if opts.ArtistID > 0 { l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v", @@ -113,7 +114,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* ArtistID: int32(opts.ArtistID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromArtistPaginated: %w", err) } listens = make([]*models.Listen, len(rows)) for i, row := range rows { @@ -126,7 +127,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* } err = json.Unmarshal(row.Artists, &t.Track.Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err) } listens[i] = t } @@ -136,7 +137,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* ArtistID: int32(opts.ArtistID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: CountListensFromArtist: %w", err) } } else { l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v", @@ -148,7 +149,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* Offset: int32(offset), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: GetLastListensPaginated: %w", err) } listens = make([]*models.Listen, len(rows)) for i, row := range rows { @@ -161,7 +162,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* } err = json.Unmarshal(row.Artists, &t.Track.Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err) } listens[i] = t } @@ -170,7 +171,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (* ListenedAt_2: t2, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListensPaginated: CountListens: %w", err) } l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count) } diff --git a/internal/db/psql/listen_activity.go b/internal/db/psql/listen_activity.go index 5f57f92..47b1a13 100644 --- a/internal/db/psql/listen_activity.go +++ b/internal/db/psql/listen_activity.go @@ -3,6 +3,7 @@ package psql import ( "context" "errors" + "fmt" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" @@ -30,7 +31,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts ReleaseID: opts.AlbumID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListenActivity: ListenActivityForRelease: %w", err) } listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { @@ -51,7 +52,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts ArtistID: opts.ArtistID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListenActivity: ListenActivityForArtist: %w", err) } listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { @@ -72,7 +73,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts ID: opts.TrackID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListenActivity: ListenActivityForTrack: %w", err) } listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { @@ -92,7 +93,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts Column3: stepToInterval(opts.Step), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetListenActivity: ListenActivity: %w", err) } listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { diff --git a/internal/db/psql/merge.go b/internal/db/psql/merge.go index c7c46fe..d9e24b6 100644 --- a/internal/db/psql/merge.go +++ b/internal/db/psql/merge.go @@ -71,7 +71,7 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage fromArtists, err := qtx.GetReleaseArtists(ctx, fromId) if err != nil { - return fmt.Errorf("MergeTracks: GetReleaseArtists: %w", err) + return fmt.Errorf("MergeAlbums: GetReleaseArtists: %w", err) } err = qtx.UpdateReleaseForAll(ctx, repository.UpdateReleaseForAllParams{ diff --git a/internal/db/psql/psql.go b/internal/db/psql/psql.go index 2e52d94..0a917b5 100644 --- a/internal/db/psql/psql.go +++ b/internal/db/psql/psql.go @@ -34,34 +34,34 @@ func New() (*Psql, error) { config, err := pgxpool.ParseConfig(cfg.DatabaseUrl()) if err != nil { - return nil, fmt.Errorf("failed to parse pgx config: %w", err) + return nil, fmt.Errorf("psql.New: failed to parse pgx config: %w", err) } config.ConnConfig.ConnectTimeout = 15 * time.Second pool, err := pgxpool.NewWithConfig(ctx, config) if err != nil { - return nil, fmt.Errorf("failed to create pgx pool: %w", err) + return nil, fmt.Errorf("psql.New: failed to create pgx pool: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() - return nil, fmt.Errorf("database not reachable: %w", err) + return nil, fmt.Errorf("psql.New: database not reachable: %w", err) } sqlDB, err := sql.Open("pgx", cfg.DatabaseUrl()) if err != nil { - return nil, fmt.Errorf("failed to open db for migrations: %w", err) + return nil, fmt.Errorf("psql.New: failed to open db for migrations: %w", err) } _, filename, _, ok := runtime.Caller(0) if !ok { - return nil, fmt.Errorf("unable to get caller info") + return nil, fmt.Errorf("psql.New: unable to get caller info") } migrationsPath := filepath.Join(filepath.Dir(filename), "..", "..", "..", "db", "migrations") if err := goose.Up(sqlDB, migrationsPath); err != nil { - return nil, fmt.Errorf("goose failed: %w", err) + return nil, fmt.Errorf("psql.New: goose failed: %w", err) } _ = sqlDB.Close() diff --git a/internal/db/psql/search.go b/internal/db/psql/search.go index 675134b..e4ee39e 100644 --- a/internal/db/psql/search.go +++ b/internal/db/psql/search.go @@ -3,6 +3,7 @@ package psql import ( "context" "encoding/json" + "fmt" "github.com/gabehf/koito/internal/models" "github.com/gabehf/koito/internal/repository" @@ -19,7 +20,7 @@ func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, e Limit: searchItemLimit, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchArtist: SearchArtistsBySubstring: %w", err) } ret := make([]*models.Artist, len(rows)) for i, row := range rows { @@ -37,7 +38,7 @@ func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, e Limit: searchItemLimit, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchArtist: SearchArtists: %w", err) } ret := make([]*models.Artist, len(rows)) for i, row := range rows { @@ -59,7 +60,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err Limit: searchItemLimit, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchAlbums: SearchReleasesBySubstring: %w", err) } ret := make([]*models.Album, len(rows)) for i, row := range rows { @@ -72,7 +73,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err } err = json.Unmarshal(row.Artists, &ret[i].Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchAlbums: Unmarshal: %w", err) } } return ret, nil @@ -82,7 +83,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err Limit: searchItemLimit, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchAlbums: SearchReleases: %w", err) } ret := make([]*models.Album, len(rows)) for i, row := range rows { @@ -95,7 +96,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err } err = json.Unmarshal(row.Artists, &ret[i].Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchAlbums: Unmarshal: %w", err) } } return ret, nil @@ -109,7 +110,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err Limit: searchItemLimit, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchTracks: SearchTracksBySubstring: %w", err) } ret := make([]*models.Track, len(rows)) for i, row := range rows { @@ -121,7 +122,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err } err = json.Unmarshal(row.Artists, &ret[i].Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchTracks: Unmarshal: %w", err) } } return ret, nil @@ -131,7 +132,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err Limit: searchItemLimit, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchTracks: SearchTracks: %w", err) } ret := make([]*models.Track, len(rows)) for i, row := range rows { @@ -143,7 +144,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err } err = json.Unmarshal(row.Artists, &ret[i].Artists) if err != nil { - return nil, err + return nil, fmt.Errorf("SearchTracks: Unmarshal: %w", err) } } return ret, nil diff --git a/internal/db/psql/sessions.go b/internal/db/psql/sessions.go index d279121..ece1dc5 100644 --- a/internal/db/psql/sessions.go +++ b/internal/db/psql/sessions.go @@ -3,6 +3,7 @@ package psql import ( "context" "errors" + "fmt" "time" "github.com/gabehf/koito/internal/models" @@ -19,7 +20,7 @@ func (d *Psql) SaveSession(ctx context.Context, userID int32, expiresAt time.Tim Persistent: persistent, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveSession: InsertSession: %w", err) } return &models.Session{ ID: session.ID, @@ -47,7 +48,7 @@ func (d *Psql) GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*mode if errors.Is(err, pgx.ErrNoRows) { return nil, nil } else if err != nil { - return nil, err + return nil, fmt.Errorf("SaveSession: GetUserBySession: %w", err) } return &models.User{ diff --git a/internal/db/psql/top_albums.go b/internal/db/psql/top_albums.go index b44334d..f02f9e3 100644 --- a/internal/db/psql/top_albums.go +++ b/internal/db/psql/top_albums.go @@ -3,6 +3,7 @@ package psql import ( "context" "encoding/json" + "fmt" "time" "github.com/gabehf/koito/internal/db" @@ -17,7 +18,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) offset := (opts.Page - 1) * opts.Limit t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopAlbumsPaginated: %w", err) } if opts.Month == 0 && opts.Year == 0 { // use period, not date range @@ -43,7 +44,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) ListenedAt_2: t2, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesFromArtist: %w", err) } rgs = make([]*models.Album, len(rows)) l.Debug().Msgf("Database responded with %d items", len(rows)) @@ -52,7 +53,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) err = json.Unmarshal(v.Artists, &artists) if err != nil { l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", v.ID) - artists = nil + return nil, fmt.Errorf("GetTopAlbumsPaginated: Unmarshal: %w", err) } rgs[i] = &models.Album{ ID: v.ID, @@ -66,7 +67,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) } count, err = d.q.CountReleasesFromArtist(ctx, int32(opts.ArtistID)) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopAlbumsPaginated: CountReleasesFromArtist: %w", err) } } else { l.Debug().Msgf("Fetching top %d albums with period %s on page %d from range %v to %v", @@ -78,7 +79,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) Offset: int32(offset), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesPaginated: %w", err) } rgs = make([]*models.Album, len(rows)) l.Debug().Msgf("Database responded with %d items", len(rows)) @@ -87,7 +88,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) err = json.Unmarshal(row.Artists, &artists) if err != nil { l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID) - artists = nil + return nil, fmt.Errorf("GetTopAlbumsPaginated: Unmarshal: %w", err) } t := &models.Album{ Title: row.Title, @@ -105,7 +106,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) ListenedAt_2: t2, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopAlbumsPaginated: CountTopReleases: %w", err) } l.Debug().Msgf("Database responded with %d albums out of a total %d", len(rows), count) } diff --git a/internal/db/psql/top_artists.go b/internal/db/psql/top_artists.go index 980f89d..5f9680a 100644 --- a/internal/db/psql/top_artists.go +++ b/internal/db/psql/top_artists.go @@ -2,6 +2,7 @@ package psql import ( "context" + "fmt" "time" "github.com/gabehf/koito/internal/db" @@ -16,7 +17,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts) offset := (opts.Page - 1) * opts.Limit t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopArtistsPaginated: %w", err) } if opts.Month == 0 && opts.Year == 0 { // use period, not date range @@ -35,7 +36,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts) Offset: int32(offset), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopArtistsPaginated: GetTopArtistsPaginated: %w", err) } rgs := make([]*models.Artist, len(rows)) for i, row := range rows { @@ -53,7 +54,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts) ListenedAt_2: t2, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopArtistsPaginated: CountTopArtists: %w", err) } l.Debug().Msgf("Database responded with %d artists out of a total %d", len(rows), count) diff --git a/internal/db/psql/top_tracks.go b/internal/db/psql/top_tracks.go index 765b3a6..5e2d04d 100644 --- a/internal/db/psql/top_tracks.go +++ b/internal/db/psql/top_tracks.go @@ -3,6 +3,7 @@ package psql import ( "context" "encoding/json" + "fmt" "time" "github.com/gabehf/koito/internal/db" @@ -17,7 +18,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) offset := (opts.Page - 1) * opts.Limit t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopTracksPaginated: %w", err) } if opts.Month == 0 && opts.Year == 0 { // use period, not date range @@ -40,7 +41,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) ReleaseID: int32(opts.AlbumID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksInReleasePaginated: %w", err) } tracks = make([]*models.Track, len(rows)) for i, row := range rows { @@ -48,7 +49,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) err = json.Unmarshal(row.Artists, &artists) if err != nil { l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID) - artists = nil + return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err) } t := &models.Track{ Title: row.Title, @@ -80,7 +81,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) ArtistID: int32(opts.ArtistID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksByArtistPaginated: %w", err) } tracks = make([]*models.Track, len(rows)) for i, row := range rows { @@ -88,7 +89,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) err = json.Unmarshal(row.Artists, &artists) if err != nil { l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID) - artists = nil + return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err) } t := &models.Track{ Title: row.Title, @@ -107,7 +108,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) ArtistID: int32(opts.ArtistID), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopTracksPaginated: CountTopTracksByArtist: %w", err) } } else { l.Debug().Msgf("Fetching top %d tracks with period %s on page %d from range %v to %v", @@ -119,7 +120,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) Offset: int32(offset), }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksPaginated: %w", err) } tracks = make([]*models.Track, len(rows)) for i, row := range rows { @@ -127,7 +128,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) err = json.Unmarshal(row.Artists, &artists) if err != nil { l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID) - artists = nil + return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err) } t := &models.Track{ Title: row.Title, @@ -145,7 +146,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) ListenedAt_2: t2, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTopTracksPaginated: CountTopTracks: %w", err) } l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count) } diff --git a/internal/db/psql/track.go b/internal/db/psql/track.go index 5d3961d..0841f36 100644 --- a/internal/db/psql/track.go +++ b/internal/db/psql/track.go @@ -3,6 +3,7 @@ package psql import ( "context" "errors" + "fmt" "strings" "time" @@ -23,7 +24,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac l.Debug().Msgf("Fetching track from DB with id %d", opts.ID) t, err := d.q.GetTrack(ctx, opts.ID) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTrack: GetTrack By ID: %w", err) } track = models.Track{ ID: t.ID, @@ -37,7 +38,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac l.Debug().Msgf("Fetching track from DB with MusicBrainz ID %s", opts.MusicBrainzID) t, err := d.q.GetTrackByMbzID(ctx, &opts.MusicBrainzID) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTrack: GetTrackByMbzID: %w", err) } track = models.Track{ ID: t.ID, @@ -53,7 +54,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac Column2: opts.ArtistIDs, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTrack: GetTrackByTitleAndArtists: %w", err) } track = models.Track{ ID: t.ID, @@ -63,7 +64,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac Duration: t.Duration, } } else { - return nil, errors.New("insufficient information to get track") + return nil, errors.New("GetTrack: insufficient information to get track") } count, err := d.q.CountListensFromTrack(ctx, repository.CountListensFromTrackParams{ @@ -72,7 +73,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac TrackID: track.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTrack: CountListensFromTrack: %w", err) } seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ @@ -80,7 +81,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac TrackID: track.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err) } track.ListenCount = count @@ -97,20 +98,20 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr insertMbzID = &opts.RecordingMbzID } if len(opts.ArtistIDs) < 1 { - return nil, errors.New("required parameter 'ArtistIDs' missing") + return nil, errors.New("SaveTrack: required parameter 'ArtistIDs' missing") } for _, aid := range opts.ArtistIDs { if aid == 0 { - return nil, errors.New("none of 'ArtistIDs' may be 0") + return nil, errors.New("SaveTrack: none of 'ArtistIDs' may be 0") } } if opts.AlbumID == 0 { - return nil, errors.New("required parameter 'AlbumID' missing") + return nil, errors.New("SaveTrack: required parameter 'AlbumID' missing") } tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return nil, err + return nil, fmt.Errorf("SaveTrack: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -120,7 +121,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr ReleaseID: opts.AlbumID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveTrack: InsertTrack: %w", err) } // insert associated artists for _, aid := range opts.ArtistIDs { @@ -129,7 +130,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr TrackID: trackRow.ID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err) } } // insert primary alias @@ -140,11 +141,11 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr IsPrimary: true, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveTrack: InsertTrackAlias: %w", err) } err = tx.Commit(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveTrack: Commit: %w", err) } return &models.Track{ ID: trackRow.ID, @@ -156,12 +157,12 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error { l := logger.FromContext(ctx) if opts.ID == 0 { - return errors.New("track id not specified") + return errors.New("UpdateTrack: track id not specified") } tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("UpdateTrack: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -172,7 +173,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error { MusicBrainzID: &opts.MusicBrainzID, }) if err != nil { - return err + return fmt.Errorf("UpdateTrack: UpdateTrackMbzID: %w", err) } } if opts.Duration != 0 { @@ -182,7 +183,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error { Duration: opts.Duration, }) if err != nil { - return err + return fmt.Errorf("UpdateTrack: UpdateTrackDuration: %w", err) } } return tx.Commit(ctx) @@ -191,18 +192,18 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error { func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string, source string) error { l := logger.FromContext(ctx) if id == 0 { - return errors.New("track id not specified") + return errors.New("SaveTrackAliases: track id not specified") } tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("SaveTrackAliases: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) existing, err := qtx.GetAllTrackAliases(ctx, id) if err != nil { - return err + return fmt.Errorf("SaveTrackAliases: GetAllTrackAliases: %w", err) } for _, v := range existing { aliases = append(aliases, v.Alias) @@ -219,7 +220,7 @@ func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string, IsPrimary: false, }) if err != nil { - return err + return fmt.Errorf("SaveTrackAliases: InsertTrackAlias: %w", err) } } return tx.Commit(ctx) @@ -239,7 +240,7 @@ func (d *Psql) DeleteTrackAlias(ctx context.Context, id int32, alias string) err func (d *Psql) GetAllTrackAliases(ctx context.Context, id int32) ([]models.Alias, error) { rows, err := d.q.GetAllTrackAliases(ctx, id) if err != nil { - return nil, err + return nil, fmt.Errorf("GetAllTrackAliases: GetAllTrackAliases: %w", err) } aliases := make([]models.Alias, len(rows)) for i, row := range rows { @@ -261,14 +262,14 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("SetPrimaryTrackAlias: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) // get all aliases aliases, err := qtx.GetAllTrackAliases(ctx, id) if err != nil { - return err + return fmt.Errorf("SetPrimaryTrackAlias: GetAllTrackAliases: %w", err) } primary := "" exists := false @@ -293,7 +294,7 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) IsPrimary: true, }) if err != nil { - return err + return fmt.Errorf("SetPrimaryTrackAlias: SetTrackAliasPrimaryStatus: %w", err) } err = qtx.SetTrackAliasPrimaryStatus(ctx, repository.SetTrackAliasPrimaryStatusParams{ TrackID: id, @@ -301,7 +302,61 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) IsPrimary: false, }) if err != nil { - return err + return fmt.Errorf("SetPrimaryTrackAlias: SetTrackAliasPrimaryStatus: %w", err) + } + return tx.Commit(ctx) +} + +func (d *Psql) SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error { + l := logger.FromContext(ctx) + if id == 0 { + return errors.New("artist id not specified") + } + tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + l.Err(err).Msg("Failed to begin transaction") + return fmt.Errorf("SetPrimaryTrackArtist: BeginTx: %w", err) + } + defer tx.Rollback(ctx) + qtx := d.q.WithTx(tx) + // get all artists + artists, err := qtx.GetTrackArtists(ctx, id) + if err != nil { + return fmt.Errorf("SetPrimaryTrackArtist: GetTrackArtists: %w", err) + } + var primary int32 + for _, v := range artists { + // i dont get it??? is_primary is not a nullable column??? why use pgtype.Bool??? + // why not just use boolean??? is sqlc stupid??? am i stupid??????? + if v.IsPrimary.Valid && v.IsPrimary.Bool { + primary = v.ID + } + } + if value && primary == artistId { + // no-op + return nil + } + l.Debug().Msgf("Marking artist with id %d as 'primary = %v' on track with id %d", artistId, value, id) + err = qtx.UpdateTrackPrimaryArtist(ctx, repository.UpdateTrackPrimaryArtistParams{ + TrackID: id, + ArtistID: artistId, + IsPrimary: value, + }) + if err != nil { + return fmt.Errorf("SetPrimaryTrackArtist: UpdateTrackPrimaryArtist: %w", err) + } + if value && primary != 0 { + l.Debug().Msgf("Unmarking artist with id %d as primary on track with id %d", primary, id) + // if we were marking a new one as primary and there was already one marked as primary, + // unmark that one as there can only be one + err = qtx.UpdateTrackPrimaryArtist(ctx, repository.UpdateTrackPrimaryArtistParams{ + TrackID: id, + ArtistID: primary, + IsPrimary: false, + }) + if err != nil { + return fmt.Errorf("SetPrimaryTrackArtist: UpdateTrackPrimaryArtist: %w", err) + } } return tx.Commit(ctx) } diff --git a/internal/db/psql/user.go b/internal/db/psql/user.go index cfc8dc7..33a8cf9 100644 --- a/internal/db/psql/user.go +++ b/internal/db/psql/user.go @@ -3,6 +3,7 @@ package psql import ( "context" "errors" + "fmt" "regexp" "strings" "unicode/utf8" @@ -21,7 +22,7 @@ func (d *Psql) GetUserByUsername(ctx context.Context, username string) (*models. if errors.Is(err, pgx.ErrNoRows) { return nil, nil } else if err != nil { - return nil, err + return nil, fmt.Errorf("GetUserByUsername: %w", err) } return &models.User{ ID: row.ID, @@ -37,7 +38,7 @@ func (d *Psql) GetUserByApiKey(ctx context.Context, key string) (*models.User, e if errors.Is(err, pgx.ErrNoRows) { return nil, nil } else if err != nil { - return nil, err + return nil, fmt.Errorf("GetUserByApiKey: %w", err) } return &models.User{ ID: row.ID, @@ -52,12 +53,12 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User err := ValidateUsername(opts.Username) if err != nil { l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username) - return nil, err + return nil, fmt.Errorf("SaveUser: ValidateUsername: %w", err) } pw, err := ValidateAndNormalizePassword(opts.Password) if err != nil { l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation") - return nil, err + return nil, fmt.Errorf("SaveUser: ValidateAndNormalizePassword: %w", err) } if opts.Role == "" { opts.Role = models.UserRoleUser @@ -65,7 +66,7 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) if err != nil { l.Err(err).Msg("Failed to generate hashed password") - return nil, err + return nil, fmt.Errorf("SaveUser: bcrypt.GenerateFromPassword: %w", err) } u, err := d.q.InsertUser(ctx, repository.InsertUserParams{ Username: strings.ToLower(opts.Username), @@ -73,7 +74,7 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User Role: repository.Role(opts.Role), }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveUser: InsertUser: %w", err) } return &models.User{ ID: u.ID, @@ -88,7 +89,7 @@ func (d *Psql) SaveApiKey(ctx context.Context, opts db.SaveApiKeyOpts) (*models. UserID: opts.UserID, }) if err != nil { - return nil, err + return nil, fmt.Errorf("SaveApiKey: InsertApiKey: %w", err) } return &models.ApiKey{ ID: row.ID, @@ -107,7 +108,7 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error { tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { l.Err(err).Msg("Failed to begin transaction") - return err + return fmt.Errorf("UpdateUser: BeginTx: %w", err) } defer tx.Rollback(ctx) qtx := d.q.WithTx(tx) @@ -115,33 +116,33 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error { err := ValidateUsername(opts.Username) if err != nil { l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username) - return err + return fmt.Errorf("UpdateUser: ValidateUsername: %w", err) } err = qtx.UpdateUserUsername(ctx, repository.UpdateUserUsernameParams{ ID: opts.ID, Username: opts.Username, }) if err != nil { - return err + return fmt.Errorf("UpdateUser: UpdateUserUsername: %w", err) } } if opts.Password != "" { pw, err := ValidateAndNormalizePassword(opts.Password) if err != nil { l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation") - return err + return fmt.Errorf("UpdateUser: ValidateAndNormalizePassword: %w", err) } hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) if err != nil { l.Err(err).Msg("Failed to generate hashed password") - return err + return fmt.Errorf("UpdateUser: bcrypt.GenerateFromPassword: %w", err) } err = qtx.UpdateUserPassword(ctx, repository.UpdateUserPasswordParams{ ID: opts.ID, Password: hashPw, }) if err != nil { - return err + return fmt.Errorf("UpdateUser: UpdateUserPassword: %w", err) } } return tx.Commit(ctx) @@ -150,7 +151,7 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error { func (d *Psql) GetApiKeysByUserID(ctx context.Context, id int32) ([]models.ApiKey, error) { rows, err := d.q.GetAllApiKeysByUserID(ctx, id) if err != nil { - return nil, err + return nil, fmt.Errorf("GetApiKeysByUserID: %w", err) } keys := make([]models.ApiKey, len(rows)) for i, row := range rows { diff --git a/internal/images/deezer.go b/internal/images/deezer.go index f3c7bae..8fb7b27 100644 --- a/internal/images/deezer.go +++ b/internal/images/deezer.go @@ -53,7 +53,7 @@ func NewDeezerClient() *DeezerClient { ret := new(DeezerClient) ret.url = deezerBaseUrl ret.userAgent = cfg.UserAgent() - ret.requestQueue = queue.NewRequestQueue(1, 1) + ret.requestQueue = queue.NewRequestQueue(5, 5) return ret } @@ -92,19 +92,19 @@ func (c *DeezerClient) getEntity(ctx context.Context, endpoint string, result an l.Debug().Msgf("Sending request to ImageSrc: GET %s", url) req, err := http.NewRequest("GET", url, nil) if err != nil { - return err + return fmt.Errorf("getEntity: %w", err) } l.Debug().Msg("Adding ImageSrc request to queue") body, err := c.queue(ctx, req) if err != nil { l.Err(err).Msg("Deezer request failed") - return err + return fmt.Errorf("getEntity: %w", err) } err = json.Unmarshal(body, result) if err != nil { l.Err(err).Msg("Failed to unmarshal Deezer response") - return err + return fmt.Errorf("getEntity: %w", err) } return nil @@ -121,10 +121,10 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s for _, a := range aliasesAscii { err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp) if err != nil { - return "", err + return "", fmt.Errorf("GetArtistImages: %w", err) } if len(resp.Data) < 1 { - return "", errors.New("artist image not found") + return "", errors.New("GetArtistImages: artist image not found") } for _, v := range resp.Data { if strings.EqualFold(v.Name, a) { @@ -139,10 +139,10 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s for _, a := range utils.RemoveInBoth(aliasesUniq, aliasesAscii) { err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp) if err != nil { - return "", err + return "", fmt.Errorf("GetArtistImages: %w", err) } if len(resp.Data) < 1 { - return "", errors.New("artist image not found") + return "", errors.New("GetArtistImages: artist image not found") } for _, v := range resp.Data { if strings.EqualFold(v.Name, a) { @@ -152,7 +152,7 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s } } } - return "", errors.New("artist image not found") + return "", errors.New("GetArtistImages: artist image not found") } func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, album string) (string, error) { @@ -163,7 +163,7 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb for _, alias := range artists { err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"album:\"%s\"", alias, album))), resp) if err != nil { - return "", err + return "", fmt.Errorf("GetAlbumImages: %w", err) } if len(resp.Data) > 0 { for _, v := range resp.Data { @@ -179,7 +179,7 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb // if none are found, try to find an album just by album title err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("album:\"%s\"", album))), resp) if err != nil { - return "", err + return "", fmt.Errorf("GetAlbumImages: %w", err) } for _, v := range resp.Data { if strings.EqualFold(v.Title, album) { @@ -189,5 +189,5 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb } } - return "", errors.New("album image not found") + return "", errors.New("GetAlbumImages: album image not found") } diff --git a/internal/images/imagesrc.go b/internal/images/imagesrc.go index 4b65a66..e906c4d 100644 --- a/internal/images/imagesrc.go +++ b/internal/images/imagesrc.go @@ -64,7 +64,7 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) { } return img, nil } - l.Warn().Msg("No image providers are enabled") + l.Warn().Msg("GetArtistImage: No image providers are enabled") return "", nil } func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { @@ -102,6 +102,6 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { } return img, nil } - l.Warn().Msg("No image providers are enabled") + l.Warn().Msg("GetAlbumImage: No image providers are enabled") return "", nil } diff --git a/internal/importer/lastfm.go b/internal/importer/lastfm.go index d3e0028..763d7fa 100644 --- a/internal/importer/lastfm.go +++ b/internal/importer/lastfm.go @@ -3,6 +3,7 @@ package importer import ( "context" "encoding/json" + "fmt" "os" "path" "strconv" @@ -46,7 +47,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename)) if err != nil { l.Err(err).Msgf("Failed to read import file: %s", filename) - return err + return fmt.Errorf("ImportLastFMFile: %w", err) } defer file.Close() var throttleFunc = func() {} @@ -58,7 +59,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall export := make([]LastFMExportPage, 0) err = json.NewDecoder(file).Decode(&export) if err != nil { - return err + return fmt.Errorf("ImportLastFMFile: %w", err) } count := 0 for _, item := range export { @@ -88,7 +89,8 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall if err != nil { ts, err = time.Parse("02 Jan 2006, 15:04", track.Date.Text) if err != nil { - ts = time.Now().UTC() + l.Err(err).Msg("Could not parse time from listen activity, skipping...") + continue } } else { ts = time.Unix(unix, 0).UTC() @@ -116,11 +118,12 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall Client: "lastfm", Time: ts, UserID: 1, + SkipCacheImage: !cfg.FetchImagesDuringImport(), } err = catalog.SubmitListen(ctx, store, opts) if err != nil { l.Err(err).Msg("Failed to import LastFM playback item") - return err + return fmt.Errorf("ImportLastFMFile: %w", err) } count++ throttleFunc() diff --git a/internal/importer/listenbrainz.go b/internal/importer/listenbrainz.go index f8a8218..79d58d3 100644 --- a/internal/importer/listenbrainz.go +++ b/internal/importer/listenbrainz.go @@ -141,11 +141,12 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai Time: ts, UserID: 1, Client: client, + SkipCacheImage: !cfg.FetchImagesDuringImport(), } err = catalog.SubmitListen(ctx, store, opts) if err != nil { l.Err(err).Msg("Failed to import LastFM playback item") - return err + return fmt.Errorf("ImportListenBrainzFile: %w", err) } count++ throttleFunc() diff --git a/internal/importer/maloja.go b/internal/importer/maloja.go index 4265b98..8d7c041 100644 --- a/internal/importer/maloja.go +++ b/internal/importer/maloja.go @@ -3,6 +3,7 @@ package importer import ( "context" "encoding/json" + "fmt" "os" "path" "strings" @@ -37,7 +38,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error { file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename)) if err != nil { l.Err(err).Msgf("Failed to read import file: %s", filename) - return err + return fmt.Errorf("ImportMalojaFile: %w", err) } defer file.Close() var throttleFunc = func() {} @@ -49,7 +50,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error { export := new(MalojaExport) err = json.NewDecoder(file).Decode(&export) if err != nil { - return err + return fmt.Errorf("ImportMalojaFile: %w", err) } for _, item := range export.Scrobbles { martists := make([]string, 0) @@ -71,19 +72,20 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error { continue } opts := catalog.SubmitListenOpts{ - MbzCaller: &mbz.MusicBrainzClient{}, - Artist: item.Track.Artists[0], - ArtistNames: artists, - TrackTitle: item.Track.Title, - ReleaseTitle: item.Track.Album.Title, - Time: ts.Local(), - Client: "maloja", - UserID: 1, + MbzCaller: &mbz.MusicBrainzClient{}, + Artist: item.Track.Artists[0], + ArtistNames: artists, + TrackTitle: item.Track.Title, + ReleaseTitle: item.Track.Album.Title, + Time: ts.Local(), + Client: "maloja", + UserID: 1, + SkipCacheImage: !cfg.FetchImagesDuringImport(), } err = catalog.SubmitListen(ctx, store, opts) if err != nil { l.Err(err).Msg("Failed to import maloja playback item") - return err + return fmt.Errorf("ImportMalojaFile: %w", err) } throttleFunc() } diff --git a/internal/importer/spotify.go b/internal/importer/spotify.go index 9e9073c..5594fc2 100644 --- a/internal/importer/spotify.go +++ b/internal/importer/spotify.go @@ -3,6 +3,7 @@ package importer import ( "context" "encoding/json" + "fmt" "os" "path" "time" @@ -29,7 +30,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename)) if err != nil { l.Err(err).Msgf("Failed to read import file: %s", filename) - return err + return fmt.Errorf("ImportSpotifyFile: %w", err) } defer file.Close() var throttleFunc = func() {} @@ -41,7 +42,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error export := make([]SpotifyExportItem, 0) err = json.NewDecoder(file).Decode(&export) if err != nil { - return err + return fmt.Errorf("ImportSpotifyFile: %w", err) } for _, item := range export { @@ -58,19 +59,20 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error continue } opts := catalog.SubmitListenOpts{ - MbzCaller: &mbz.MusicBrainzClient{}, - Artist: item.ArtistName, - TrackTitle: item.TrackName, - ReleaseTitle: item.AlbumName, - Duration: dur / 1000, - Time: item.Timestamp, - Client: "spotify", - UserID: 1, + MbzCaller: &mbz.MusicBrainzClient{}, + Artist: item.ArtistName, + TrackTitle: item.TrackName, + ReleaseTitle: item.AlbumName, + Duration: dur / 1000, + Time: item.Timestamp, + Client: "spotify", + UserID: 1, + SkipCacheImage: !cfg.FetchImagesDuringImport(), } err = catalog.SubmitListen(ctx, store, opts) if err != nil { l.Err(err).Msg("Failed to import spotify playback item") - return err + return fmt.Errorf("ImportSpotifyFile: %w", err) } throttleFunc() } diff --git a/internal/mbz/artist.go b/internal/mbz/artist.go index 8ebeb2e..f8e563a 100644 --- a/internal/mbz/artist.go +++ b/internal/mbz/artist.go @@ -3,6 +3,7 @@ package mbz import ( "context" "errors" + "fmt" "slices" "github.com/gabehf/koito/internal/logger" @@ -28,7 +29,7 @@ func (c *MusicBrainzClient) getArtist(ctx context.Context, id uuid.UUID) (*Music mbzArtist := new(MusicBrainzArtist) err := c.getEntity(ctx, artistAliasFmtStr, id, mbzArtist) if err != nil { - return nil, err + return nil, fmt.Errorf("getArtist: %w", err) } return mbzArtist, nil } @@ -38,10 +39,10 @@ func (c *MusicBrainzClient) GetArtistPrimaryAliases(ctx context.Context, id uuid l := logger.FromContext(ctx) artist, err := c.getArtist(ctx, id) if err != nil { - return nil, err + return nil, fmt.Errorf("GetArtistPrimaryAliases: %w", err) } if artist == nil { - return nil, errors.New("artist could not be found by musicbrainz") + return nil, errors.New("GetArtistPrimaryAliases: artist could not be found by musicbrainz") } used := make(map[string]bool) ret := make([]string, 1) diff --git a/internal/mbz/mbz.go b/internal/mbz/mbz.go index 46d516b..9e3f52e 100644 --- a/internal/mbz/mbz.go +++ b/internal/mbz/mbz.go @@ -52,19 +52,19 @@ func (c *MusicBrainzClient) getEntity(ctx context.Context, fmtStr string, id uui req, err := http.NewRequest("GET", url, nil) if err != nil { l.Err(err).Msg("Failed to build MusicBrainz request") - return err + return fmt.Errorf("getEntity: %w", err) } l.Debug().Msg("Adding MusicBrainz request to queue") body, err := c.queue(ctx, req) if err != nil { l.Err(err).Msg("MusicBrainz request failed") - return err + return fmt.Errorf("getEntity: %w", err) } err = json.Unmarshal(body, result) if err != nil { l.Err(err).Str("body", string(body)).Msg("Failed to unmarshal MusicBrainz response body") - return err + return fmt.Errorf("getEntity: %w", err) } return nil diff --git a/internal/mbz/release.go b/internal/mbz/release.go index 594e576..0dcacfd 100644 --- a/internal/mbz/release.go +++ b/internal/mbz/release.go @@ -2,6 +2,7 @@ package mbz import ( "context" + "fmt" "slices" "github.com/google/uuid" @@ -36,7 +37,7 @@ func (c *MusicBrainzClient) GetReleaseGroup(ctx context.Context, id uuid.UUID) ( mbzRG := new(MusicBrainzReleaseGroup) err := c.getEntity(ctx, releaseGroupFmtStr, id, mbzRG) if err != nil { - return nil, err + return nil, fmt.Errorf("GetReleaseGroup: %w", err) } return mbzRG, nil } @@ -45,7 +46,7 @@ func (c *MusicBrainzClient) GetRelease(ctx context.Context, id uuid.UUID) (*Musi mbzRelease := new(MusicBrainzRelease) err := c.getEntity(ctx, releaseFmtStr, id, mbzRelease) if err != nil { - return nil, err + return nil, fmt.Errorf("GetRelease: %w", err) } return mbzRelease, nil } @@ -53,7 +54,7 @@ func (c *MusicBrainzClient) GetRelease(ctx context.Context, id uuid.UUID) (*Musi func (c *MusicBrainzClient) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) { releaseGroup, err := c.GetReleaseGroup(ctx, RGID) if err != nil { - return nil, err + return nil, fmt.Errorf("GetReleaseTitles: %w", err) } var titles []string @@ -80,7 +81,7 @@ func ReleaseGroupToTitles(rg *MusicBrainzReleaseGroup) []string { func (c *MusicBrainzClient) GetLatinTitles(ctx context.Context, id uuid.UUID) ([]string, error) { rg, err := c.GetReleaseGroup(ctx, id) if err != nil { - return nil, err + return nil, fmt.Errorf("GetLatinTitles: %w", err) } titles := make([]string, 0) for _, r := range rg.Releases { diff --git a/internal/mbz/track.go b/internal/mbz/track.go index 6998a9f..a7d8a12 100644 --- a/internal/mbz/track.go +++ b/internal/mbz/track.go @@ -2,6 +2,7 @@ package mbz import ( "context" + "fmt" "github.com/google/uuid" ) @@ -17,7 +18,7 @@ func (c *MusicBrainzClient) GetTrack(ctx context.Context, id uuid.UUID) (*MusicB track := new(MusicBrainzTrack) err := c.getEntity(ctx, recordingFmtStr, id, track) if err != nil { - return nil, err + return nil, fmt.Errorf("GetTrack: %w", err) } return track, nil } diff --git a/internal/models/artist.go b/internal/models/artist.go index b515414..3501573 100644 --- a/internal/models/artist.go +++ b/internal/models/artist.go @@ -10,6 +10,7 @@ type Artist struct { Image *uuid.UUID `json:"image"` ListenCount int64 `json:"listen_count"` TimeListened int64 `json:"time_listened"` + IsPrimary bool `json:"is_primary,omitempty"` } type SimpleArtist struct { diff --git a/internal/repository/artist.sql.go b/internal/repository/artist.sql.go index 3d01e1a..23a7a6f 100644 --- a/internal/repository/artist.sql.go +++ b/internal/repository/artist.sql.go @@ -199,28 +199,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, alias string) (GetArtistB const getReleaseArtists = `-- name: GetReleaseArtists :many SELECT - a.id, a.musicbrainz_id, a.image, a.image_source, a.name + a.id, a.musicbrainz_id, a.image, a.image_source, a.name, + ar.is_primary as is_primary FROM artists_with_name a LEFT JOIN artist_releases ar ON a.id = ar.artist_id WHERE ar.release_id = $1 -GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name +GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary ` -func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]ArtistsWithName, error) { +type GetReleaseArtistsRow struct { + ID int32 + MusicBrainzID *uuid.UUID + Image *uuid.UUID + ImageSource pgtype.Text + Name string + IsPrimary pgtype.Bool +} + +func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]GetReleaseArtistsRow, error) { rows, err := q.db.Query(ctx, getReleaseArtists, releaseID) if err != nil { return nil, err } defer rows.Close() - var items []ArtistsWithName + var items []GetReleaseArtistsRow for rows.Next() { - var i ArtistsWithName + var i GetReleaseArtistsRow if err := rows.Scan( &i.ID, &i.MusicBrainzID, &i.Image, &i.ImageSource, &i.Name, + &i.IsPrimary, ); err != nil { return nil, err } @@ -297,28 +308,39 @@ func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsP const getTrackArtists = `-- name: GetTrackArtists :many SELECT - a.id, a.musicbrainz_id, a.image, a.image_source, a.name + a.id, a.musicbrainz_id, a.image, a.image_source, a.name, + at.is_primary as is_primary FROM artists_with_name a LEFT JOIN artist_tracks at ON a.id = at.artist_id WHERE at.track_id = $1 -GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name +GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary ` -func (q *Queries) GetTrackArtists(ctx context.Context, trackID int32) ([]ArtistsWithName, error) { +type GetTrackArtistsRow struct { + ID int32 + MusicBrainzID *uuid.UUID + Image *uuid.UUID + ImageSource pgtype.Text + Name string + IsPrimary pgtype.Bool +} + +func (q *Queries) GetTrackArtists(ctx context.Context, trackID int32) ([]GetTrackArtistsRow, error) { rows, err := q.db.Query(ctx, getTrackArtists, trackID) if err != nil { return nil, err } defer rows.Close() - var items []ArtistsWithName + var items []GetTrackArtistsRow for rows.Next() { - var i ArtistsWithName + var i GetTrackArtistsRow if err := rows.Scan( &i.ID, &i.MusicBrainzID, &i.Image, &i.ImageSource, &i.Name, + &i.IsPrimary, ); err != nil { return nil, err } diff --git a/internal/repository/listen.sql.go b/internal/repository/listen.sql.go index d3567c3..fa23bae 100644 --- a/internal/repository/listen.sql.go +++ b/internal/repository/listen.sql.go @@ -194,12 +194,7 @@ SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN artist_tracks at ON t.id = at.track_id @@ -266,12 +261,7 @@ SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id WHERE l.listened_at BETWEEN $1 AND $2 @@ -337,12 +327,7 @@ SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id WHERE l.listened_at BETWEEN $1 AND $2 @@ -408,12 +393,7 @@ SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id WHERE l.listened_at BETWEEN $1 AND $2 diff --git a/internal/repository/models.go b/internal/repository/models.go index d1dc41f..df26d4d 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -80,11 +80,13 @@ type ArtistAlias struct { type ArtistRelease struct { ArtistID int32 ReleaseID int32 + IsPrimary bool } type ArtistTrack struct { - ArtistID int32 - TrackID int32 + ArtistID int32 + TrackID int32 + IsPrimary bool } type ArtistsWithName struct { diff --git a/internal/repository/release.sql.go b/internal/repository/release.sql.go index 6d5cc68..11e8030 100644 --- a/internal/repository/release.sql.go +++ b/internal/repository/release.sql.go @@ -197,12 +197,7 @@ func (q *Queries) GetReleaseByMbzID(ctx context.Context, musicbrainzID *uuid.UUI const getReleasesWithoutImages = `-- name: GetReleasesWithoutImages :many SELECT r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON a.id = ar.artist_id - WHERE ar.release_id = r.id - ) AS artists + get_artists_for_release(r.id) AS artists FROM releases_with_title r WHERE r.image IS NULL AND r.id > $2 @@ -257,12 +252,7 @@ const getTopReleasesFromArtist = `-- name: GetTopReleasesFromArtist :many SELECT r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title, COUNT(*) AS listen_count, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = r.id - ) AS artists + get_artists_for_release(r.id) AS artists FROM listens l JOIN tracks t ON l.track_id = t.id JOIN releases_with_title r ON t.release_id = r.id @@ -332,12 +322,7 @@ const getTopReleasesPaginated = `-- name: GetTopReleasesPaginated :many SELECT r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title, COUNT(*) AS listen_count, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = r.id - ) AS artists + get_artists_for_release(r.id) AS artists FROM listens l JOIN tracks t ON l.track_id = t.id JOIN releases_with_title r ON t.release_id = r.id @@ -461,6 +446,22 @@ func (q *Queries) UpdateReleaseMbzID(ctx context.Context, arg UpdateReleaseMbzID return err } +const updateReleasePrimaryArtist = `-- name: UpdateReleasePrimaryArtist :exec +UPDATE artist_releases SET is_primary = $3 +WHERE artist_id = $1 AND release_id = $2 +` + +type UpdateReleasePrimaryArtistParams struct { + ArtistID int32 + ReleaseID int32 + IsPrimary bool +} + +func (q *Queries) UpdateReleasePrimaryArtist(ctx context.Context, arg UpdateReleasePrimaryArtistParams) error { + _, err := q.db.Exec(ctx, updateReleasePrimaryArtist, arg.ArtistID, arg.ReleaseID, arg.IsPrimary) + return err +} + const updateReleaseVariousArtists = `-- name: UpdateReleaseVariousArtists :exec UPDATE releases SET various_artists = $2 WHERE id = $1 diff --git a/internal/repository/search.sql.go b/internal/repository/search.sql.go index 5e4e038..82a381b 100644 --- a/internal/repository/search.sql.go +++ b/internal/repository/search.sql.go @@ -136,12 +136,7 @@ SELECT ranked.image, ranked.various_artists, ranked.score, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = ranked.id - ) AS artists + get_artists_for_release(ranked.id) AS artists FROM ( SELECT r.id, @@ -211,12 +206,7 @@ SELECT ranked.image, ranked.various_artists, ranked.score, - ( - SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) - FROM artists_with_name a - JOIN artist_releases ar ON ar.artist_id = a.id - WHERE ar.release_id = ranked.id - ) AS artists + get_artists_for_release(ranked.id) AS artists FROM ( SELECT r.id, @@ -286,12 +276,7 @@ SELECT ranked.release_id, ranked.image, ranked.score, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = ranked.id - ) AS artists + get_artists_for_track(ranked.id) AS artists FROM ( SELECT t.id, @@ -362,12 +347,7 @@ SELECT ranked.release_id, ranked.image, ranked.score, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = ranked.id - ) AS artists + get_artists_for_track(ranked.id) AS artists FROM ( SELECT t.id, diff --git a/internal/repository/track.sql.go b/internal/repository/track.sql.go index a31316b..7365225 100644 --- a/internal/repository/track.sql.go +++ b/internal/repository/track.sql.go @@ -138,12 +138,7 @@ SELECT t.release_id, r.image, COUNT(*) AS listen_count, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at2 - JOIN artists_with_name a ON a.id = at2.artist_id - WHERE at2.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN releases r ON t.release_id = r.id @@ -215,12 +210,7 @@ SELECT t.release_id, r.image, COUNT(*) AS listen_count, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at2 - JOIN artists_with_name a ON a.id = at2.artist_id - WHERE at2.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN releases r ON t.release_id = r.id @@ -291,12 +281,7 @@ SELECT t.release_id, r.image, COUNT(*) AS listen_count, - ( - SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) - FROM artist_tracks at - JOIN artists_with_name a ON a.id = at.artist_id - WHERE at.track_id = t.id - ) AS artists + get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id JOIN releases r ON t.release_id = r.id @@ -502,3 +487,19 @@ func (q *Queries) UpdateTrackMbzID(ctx context.Context, arg UpdateTrackMbzIDPara _, err := q.db.Exec(ctx, updateTrackMbzID, arg.ID, arg.MusicBrainzID) return err } + +const updateTrackPrimaryArtist = `-- name: UpdateTrackPrimaryArtist :exec +UPDATE artist_tracks SET is_primary = $3 +WHERE artist_id = $1 AND track_id = $2 +` + +type UpdateTrackPrimaryArtistParams struct { + ArtistID int32 + TrackID int32 + IsPrimary bool +} + +func (q *Queries) UpdateTrackPrimaryArtist(ctx context.Context, arg UpdateTrackPrimaryArtistParams) error { + _, err := q.db.Exec(ctx, updateTrackPrimaryArtist, arg.ArtistID, arg.TrackID, arg.IsPrimary) + return err +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index fdd2b80..0c75c69 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -90,22 +90,22 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) { } if month != 0 && (month < 1 || month > 12) { - return time.Time{}, time.Time{}, errors.New("invalid month") + return time.Time{}, time.Time{}, errors.New("DateRange: invalid month") } if week != 0 && (week < 1 || week > 53) { - return time.Time{}, time.Time{}, errors.New("invalid week") + return time.Time{}, time.Time{}, errors.New("DateRange: invalid week") } if year < 1 { - return time.Time{}, time.Time{}, errors.New("invalid year") + return time.Time{}, time.Time{}, errors.New("DateRange: invalid year") } loc := time.Local if week != 0 { if month != 0 { - return time.Time{}, time.Time{}, errors.New("cannot specify both week and month") + return time.Time{}, time.Time{}, errors.New("DateRange: cannot specify both week and month") } // Specific week start := time.Date(year, 1, 1, 0, 0, 0, 0, loc) @@ -133,31 +133,34 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) { func CopyFile(src, dst string) (err error) { sfi, err := os.Stat(src) if err != nil { - return + return fmt.Errorf("CopyFile: %w", err) } if !sfi.Mode().IsRegular() { // cannot copy non-regular files (e.g., directories, // symlinks, devices, etc.) - return fmt.Errorf("non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String()) + return fmt.Errorf("CopyFile: non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String()) } dfi, err := os.Stat(dst) if err != nil { if !os.IsNotExist(err) { - return + return fmt.Errorf("CopyFile: %w", err) } } else { if !(dfi.Mode().IsRegular()) { - return fmt.Errorf("non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String()) + return fmt.Errorf("CopyFile: non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String()) } if os.SameFile(sfi, dfi) { - return + return fmt.Errorf("CopyFile: %w", err) } } if err = os.Link(src, dst); err == nil { - return + return fmt.Errorf("CopyFile: %w", err) } err = copyFileContents(src, dst) - return + if err != nil { + return fmt.Errorf("CopyFile: %w", err) + } + return nil } // copyFileContents copies the contents of the file named src to the file named @@ -167,24 +170,22 @@ func CopyFile(src, dst string) (err error) { func copyFileContents(src, dst string) (err error) { in, err := os.Open(src) if err != nil { - return + return fmt.Errorf("copyFileContents: %w", err) } defer in.Close() out, err := os.Create(dst) if err != nil { - return + return fmt.Errorf("copyFileContents: %w", err) } - defer func() { - cerr := out.Close() - if err == nil { - err = cerr - } - }() + defer out.Close() if _, err = io.Copy(out, in); err != nil { - return + return fmt.Errorf("copyFileContents: %w", err) } err = out.Sync() - return + if err != nil { + return fmt.Errorf("copyFileContents: %w", err) + } + return nil } // Returns the same slice, but with all strings that are equal (with strings.EqualFold) @@ -281,7 +282,7 @@ func GenerateRandomString(length int) (string, error) { for i := range length { num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) if err != nil { - return "", err + return "", fmt.Errorf("GenerateRandomString: %w", err) } ret[i] = letters[num.Int64()] } @@ -311,3 +312,18 @@ func MoreThanOneString(s ...string) bool { } return count > 1 } + +func ParseBool(s string) (value, ok bool) { + if strings.ToLower(s) == "true" { + value = true + ok = true + return + } else if strings.ToLower(s) == "false" { + value = false + ok = true + return + } else { + ok = false + return + } +} From 31d57fd79ae91aea41347477d1e1a71b88db356b Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Tue, 17 Jun 2025 17:08:09 -0400 Subject: [PATCH 15/91] fix: strip sub-second precision from incoming listens --- internal/catalog/catalog.go | 3 +++ internal/catalog/submit_listen_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index c9a9a53..4fe5754 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -71,6 +71,9 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error return errors.New("track name and artist are required") } + // bandaid to ensure new activity does not have sub-second precision + opts.Time = opts.Time.Truncate(time.Second) + artists, err := AssociateArtists( ctx, store, diff --git a/internal/catalog/submit_listen_test.go b/internal/catalog/submit_listen_test.go index 35cb0c1..c82f48a 100644 --- a/internal/catalog/submit_listen_test.go +++ b/internal/catalog/submit_listen_test.go @@ -67,7 +67,7 @@ func TestSubmitListen_CreateAllMbzIDs(t *testing.T) { require.NoError(t, err) require.Len(t, p.Items, 1) l := p.Items[0] - EqualTime(t, opts.Time, l.Time) + EqualTime(t, opts.Time.Truncate(time.Second), l.Time) } func TestSubmitListen_CreateAllMbzIDsNoReleaseGroupID(t *testing.T) { From 486f5d026990cf2ca687fd7e9c49b9d71787461b Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Tue, 17 Jun 2025 17:14:12 -0400 Subject: [PATCH 16/91] chore: update changelog --- CHANGELOG.md | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f5535..fd91fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,4 @@ -# v0.0.8 -## Features -- An album artist can now be set as primary so that they are shown as the album artist in the top albums list - -## Enhancements -- Show a few more items under "Last Played" on the home page -- Importing is now 4-5x faster +# v0.0.9 ## Fixes -- Merge selections now function correctly when selecting an item while another is selected -- Use anchor tags for top tracks and top albums -- UI fixes - -## Updates -- Improved logging and error traces in logs - -## Docs -- Add KOITO_FETCH_IMAGES_DURING_IMPORT to config reference \ No newline at end of file +- Sub-second precision is stripped from incoming listens to ensure they can be deleted reliably \ No newline at end of file From c16b557c21edc065becc6bdf1743d98de2a52bb9 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:48:19 -0400 Subject: [PATCH 17/91] feat: v0.0.10 (#23) * feat: single SOT for themes + basic custom support * fix: adjust colors for yuu theme * feat: Allow loading of environment variables from file (#20) * feat: allow loading of environment variables from file * Panic if a file for an environment variable cannot be read * Use log.Fatalf + os.Exit instead of panic * fix: remove supurfluous call to os.Exit() --------- Co-authored-by: adaexec Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * chore: add pr test workflow * chore: changelog * feat: make all activity grids configurable * fix: adjust activity grid style * fix: make background gradient consistent size * revert: remove year from activity grid opts * style: adjust top item list min size to 200px * feat: add support for custom themes * fix: stabilized the order of top items * chore: update changelog * feat: native import & export * fix: use correct request body for alias requests * fix: clear input when closing edit modal * chore: changelog * docs: make endpoint clearer for some apps * feat: add ui and handler for export * fix: fix pr test workflow --------- Co-authored-by: adaexec <78047743+adaexec@users.noreply.github.com> Co-authored-by: adaexec --- .github/workflows/test.yml | 32 ++ CHANGELOG.md | 22 +- client/api/api.ts | 36 +- client/app/app.css | 7 + client/app/components/ActivityGrid.tsx | 122 +++--- .../app/components/ActivityOptsSelector.tsx | 114 +++--- client/app/components/LastPlays.tsx | 1 - client/app/components/TopItemList.tsx | 2 +- .../components/modals/EditModal/EditModal.tsx | 6 +- client/app/components/modals/ExportModal.tsx | 45 ++ .../app/components/modals/SettingsModal.tsx | 16 +- .../themeSwitcher/ThemeSwitcher.tsx | 71 +++- client/app/providers/ThemeProvider.tsx | 338 ++++----------- client/app/routes/Home.tsx | 2 +- client/app/routes/MediaItems/Album.tsx | 2 +- client/app/routes/MediaItems/Artist.tsx | 2 +- client/app/routes/MediaItems/MediaLayout.tsx | 2 +- client/app/routes/MediaItems/Track.tsx | 2 +- client/app/routes/ThemeHelper.tsx | 112 +++-- client/app/styles/themes.css.ts | 256 ++++++++++++ client/app/styles/vars.css.ts | 16 + client/app/themes.css | 386 ------------------ client/package.json | 2 + client/vite.config.ts | 3 +- client/yarn.lock | 227 +++++++++- cmd/api/main.go | 24 +- db/queries/artist.sql | 2 +- db/queries/listen.sql | 69 +++- db/queries/release.sql | 4 +- db/queries/track.sql | 6 +- docs/src/content/docs/guides/scrobbler.md | 2 +- .../content/docs/reference/configuration.md | 16 +- engine/engine.go | 14 + engine/handlers/alias.go | 40 +- engine/handlers/apikeys.go | 9 +- engine/handlers/export.go | 33 ++ engine/routes.go | 3 +- internal/db/db.go | 1 + internal/db/opts.go | 7 + internal/db/psql/artist.go | 3 + internal/db/psql/exports.go | 59 +++ internal/db/types.go | 20 + internal/export/export.go | 145 +++++++ internal/importer/koito.go | 172 ++++++++ internal/models/alias.go | 2 +- internal/models/artist.go | 12 + internal/repository/artist.sql.go | 2 +- internal/repository/listen.sql.go | 133 ++++++ internal/repository/release.sql.go | 4 +- internal/repository/track.sql.go | 6 +- internal/utils/utils.go | 8 + 51 files changed, 1754 insertions(+), 866 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 client/app/components/modals/ExportModal.tsx create mode 100644 client/app/styles/themes.css.ts create mode 100644 client/app/styles/vars.css.ts create mode 100644 engine/handlers/export.go create mode 100644 internal/db/psql/exports.go create mode 100644 internal/export/export.go create mode 100644 internal/importer/koito.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f37ea6a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Test + +on: + pull_request: + branches: + - main + +jobs: + test: + name: Go Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install libvips + run: | + sudo apt-get update + sudo apt-get install -y libvips-dev + + - name: Verify libvips install + run: vips --version + + - name: Build + run: go build -v ./... + + - name: Test + uses: robherley/go-test-action@v0 diff --git a/CHANGELOG.md b/CHANGELOG.md index fd91fc3..f2dc277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,22 @@ -# v0.0.9 +# v0.0.10 + +## Features +- Support for custom themes added! You can find the custom theme input in the Appearance menu. +- Allow loading environment variables from files using the _FILE suffix (#20) +- All activity grids (calendar heatmaps) are now configurable +- Native import and export + +## Enhancements +- The activity grid on the home page is now configurable ## Fixes -- Sub-second precision is stripped from incoming listens to ensure they can be deleted reliably \ No newline at end of file +- Sub-second precision is stripped from incoming listens to ensure they can be deleted reliably +- Top items are now sorted by id for stability +- Clear input when closing edit modal +- Use correct request body for create and delete alias requests + +## Updates +- Adjusted colors for the "Yuu" theme +- Themes now have a single source of truth in themes.css.ts +- Configurable activity grids now have a re-styled, collapsible menu +- The year option for activity grids has been removed \ No newline at end of file diff --git a/client/api/api.ts b/client/api/api.ts index ca2cf91..be2cda0 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -53,6 +53,7 @@ function getStats(period: string): Promise { } function search(q: string): Promise { + q = encodeURIComponent(q) return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise) } @@ -131,8 +132,12 @@ function deleteApiKey(id: number): Promise { }) } function updateApiKeyLabel(id: number, label: string): Promise { - return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, { - method: "PATCH" + const form = new URLSearchParams + form.append('id', String(id)) + form.append('label', label) + return fetch(`/apis/web/v1/user/apikeys`, { + method: "PATCH", + body: form, }) } @@ -154,18 +159,30 @@ function getAliases(type: string, id: number): Promise { return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise) } function createAlias(type: string, id: number, alias: string): Promise { - return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { - method: 'POST' + const form = new URLSearchParams + form.append(`${type}_id`, String(id)) + form.append('alias', alias) + return fetch(`/apis/web/v1/aliases`, { + method: 'POST', + body: form, }) } function deleteAlias(type: string, id: number, alias: string): Promise { - return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { - method: "DELETE" + const form = new URLSearchParams + form.append(`${type}_id`, String(id)) + form.append('alias', alias) + return fetch(`/apis/web/v1/aliases/delete`, { + method: "POST", + body: form, }) } function setPrimaryAlias(type: string, id: number, alias: string): Promise { - return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, { - method: "POST" + const form = new URLSearchParams + form.append(`${type}_id`, String(id)) + form.append('alias', alias) + return fetch(`/apis/web/v1/aliases/primary`, { + method: "POST", + body: form, }) } function getAlbum(id: number): Promise { @@ -179,6 +196,8 @@ function deleteListen(listen: Listen): Promise { method: "DELETE" }) } +function getExport() { +} export { getLastListens, @@ -207,6 +226,7 @@ export { updateApiKeyLabel, deleteListen, getAlbum, + getExport, } type Track = { id: number diff --git a/client/app/app.css b/client/app/app.css index f0b786e..143572c 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -139,6 +139,13 @@ input[type="text"]:focus { outline: none; border: 1px solid var(--color-fg-tertiary); } +textarea { + border: 1px solid var(--color-bg); +} +textarea:focus { + outline: none; + border: 1px solid var(--color-fg-tertiary); +} input[type="password"] { border: 1px solid var(--color-bg); } diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 818d6e3..16953df 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -45,7 +45,6 @@ export default function ActivityGrid({ albumId = 0, trackId = 0, configurable = false, - autoAdjust = false, }: Props) { const [color, setColor] = useState(getPrimaryColor()) @@ -111,24 +110,26 @@ export default function ActivityGrid({ const getDarkenAmount = (v: number, t: number): number => { - if (autoAdjust) { - // automatically adjust the target value based on step - // the smartest way to do this would be to have the api return the - // highest value in the range. too bad im not smart - switch (stepState) { - case 'day': - t = 10 - break; - case 'week': - t = 20 - break; - case 'month': - t = 50 - break; - case 'year': - t = 100 - break; - } + // really ugly way to just check if this is for all items and not a specific item. + // is it jsut better to just pass the target in as a var? probably. + const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1 + + // automatically adjust the target value based on step + // the smartest way to do this would be to have the api return the + // highest value in the range. too bad im not smart + switch (stepState) { + case 'day': + t = 10 * adjustment + break; + case 'week': + t = 20 * adjustment + break; + case 'month': + t = 50 * adjustment + break; + case 'year': + t = 100 * adjustment + break; } v = Math.min(v, t) @@ -142,45 +143,58 @@ export default function ActivityGrid({ } } - return (
-

Activity

- {configurable ? ( - - ) : ( - '' - )} -
- {data.map((item) => ( + const CHUNK_SIZE = 26 * 7; + const chunks = []; + + for (let i = 0; i < data.length; i += CHUNK_SIZE) { + chunks.push(data.slice(i, i + CHUNK_SIZE)); + } + + return ( +
+

Activity

+ {configurable ? ( + + ) : null} + + {chunks.map((chunk, index) => (
- + {chunk.map((item) => (
0 - ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100)) - : 'var(--color-bg-secondary)', - }} - className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`} - >
-
+ key={new Date(item.start_time).toString()} + className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]" + > + +
0 + ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100)) + : 'var(--color-bg-secondary)', + }} + className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${ + item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)' + }`} + >
+
+
+ ))}
))}
-
- ); -} +} diff --git a/client/app/components/ActivityOptsSelector.tsx b/client/app/components/ActivityOptsSelector.tsx index 213f8a6..803cb0d 100644 --- a/client/app/components/ActivityOptsSelector.tsx +++ b/client/app/components/ActivityOptsSelector.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useEffect, useState } from "react"; interface Props { stepSetter: (value: string) => void; @@ -15,18 +16,15 @@ export default function ActivityOptsSelector({ currentRange, disableCache = false, }: Props) { - const stepPeriods = ['day', 'week', 'month', 'year']; - const rangePeriods = [105, 182, 365]; + const stepPeriods = ['day', 'week', 'month']; + const rangePeriods = [105, 182, 364]; + const [collapsed, setCollapsed] = useState(true); - const stepDisplay = (str: string): string => { - return str.split('_').map(w => - w.split('').map((char, index) => - index === 0 ? char.toUpperCase() : char).join('') - ).join(' '); - }; - - const rangeDisplay = (r: number): string => { - return `${r}` + const setMenuOpen = (val: boolean) => { + setCollapsed(val) + if (!disableCache) { + localStorage.setItem('activity_configuring_' + window.location.pathname.split('/')[1], String(!val)); + } } const setStep = (val: string) => { @@ -42,56 +40,66 @@ export default function ActivityOptsSelector({ localStorage.setItem('activity_range_' + window.location.pathname.split('/')[1], String(val)); } }; - + useEffect(() => { if (!disableCache) { const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35'); - if (cachedRange) { - rangeSetter(cachedRange); - } + if (cachedRange) rangeSetter(cachedRange); const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]); - if (cachedStep) { - stepSetter(cachedStep); - } + if (cachedStep) stepSetter(cachedStep); + const cachedConfiguring = localStorage.getItem('activity_configuring_' + window.location.pathname.split('/')[1]); + if (cachedStep) setMenuOpen(cachedConfiguring !== "true"); } - }, []); + }, []); return ( -
-
-

Step:

- {stepPeriods.map((p, i) => ( -
- - - {i !== stepPeriods.length - 1 ? '|' : ''} - -
- ))} -
+
+ -
-

Range:

- {rangePeriods.map((r, i) => ( -
- - - {i !== rangePeriods.length - 1 ? '|' : ''} - +
+
+
+ Step: + {stepPeriods.map((p) => ( + + ))}
- ))} + +
+ Range: + {rangePeriods.map((r) => ( + + ))} +
+
); diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index c1e1add..9463245 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -16,7 +16,6 @@ interface Props { export default function LastPlays(props: Props) { const { user } = useAppContext() - console.log(user) const { isPending, isError, data, error } = useQuery({ queryKey: ['last-listens', { limit: props.limit, diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 491625e..5b20d39 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -14,7 +14,7 @@ interface Props { export default function TopItemList({ data, separators, type, className }: Props) { return ( -
+
{data.items.map((item, index) => { const key = `${type}-${item.id}`; return ( diff --git a/client/app/components/modals/EditModal/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx index 78ce169..23796f1 100644 --- a/client/app/components/modals/EditModal/EditModal.tsx +++ b/client/app/components/modals/EditModal/EditModal.tsx @@ -95,11 +95,15 @@ export default function EditModal({ open, setOpen, type, id }: Props) { } }) setLoading(false) + } + const handleClose = () => { + setOpen(false) + setInput('') } return ( - setOpen(false)}> +

Alias Manager

diff --git a/client/app/components/modals/ExportModal.tsx b/client/app/components/modals/ExportModal.tsx new file mode 100644 index 0000000..25c8ddf --- /dev/null +++ b/client/app/components/modals/ExportModal.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { AsyncButton } from "../AsyncButton"; +import { getExport } from "api/api"; + +export default function ExportModal() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleExport = () => { + setLoading(true) + fetch(`/apis/web/v1/export`, { + method: "GET" + }) + .then(res => { + if (res.ok) { + res.blob() + .then(blob => { + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "koito_export.json" + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(url) + setLoading(false) + }) + } else { + res.json().then(r => setError(r.error)) + setLoading(false) + } + }).catch(err => { + setError(err) + setLoading(false) + }) + } + + return ( +
+

Export

+ Export Data + {error &&

{error}

} +
+ ) +} \ No newline at end of file diff --git a/client/app/components/modals/SettingsModal.tsx b/client/app/components/modals/SettingsModal.tsx index 4ae62d6..31d915b 100644 --- a/client/app/components/modals/SettingsModal.tsx +++ b/client/app/components/modals/SettingsModal.tsx @@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher"; import ThemeHelper from "../../routes/ThemeHelper"; import { useAppContext } from "~/providers/AppProvider"; import ApiKeysModal from "./ApiKeysModal"; +import { AsyncButton } from "../AsyncButton"; +import ExportModal from "./ExportModal"; interface Props { open: boolean @@ -19,7 +21,7 @@ export default function SettingsModal({ open, setOpen } : Props) { const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto" return ( - setOpen(false)} maxW={900}> + setOpen(false)} maxW={900}> Appearance Account {user && ( - - API Keys - + <> + + API Keys + + Export + )} @@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) { + + + ) diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index e051f50..14eda1e 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -1,36 +1,69 @@ // ThemeSwitcher.tsx -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useTheme } from '../../hooks/useTheme'; -import { themes } from '~/providers/ThemeProvider'; +import themes from '~/styles/themes.css'; import ThemeOption from './ThemeOption'; +import { AsyncButton } from '../AsyncButton'; export function ThemeSwitcher() { const { theme, setTheme } = useTheme(); - - - useEffect(() => { - const saved = localStorage.getItem('theme'); - if (saved && saved !== theme) { - setTheme(saved); - } else if (!saved) { - localStorage.setItem('theme', theme) + const initialTheme = { + bg: "#1e1816", + bgSecondary: "#2f2623", + bgTertiary: "#453733", + fg: "#f8f3ec", + fgSecondary: "#d6ccc2", + fgTertiary: "#b4a89c", + primary: "#f5a97f", + primaryDim: "#d88b65", + accent: "#f9db6d", + accentDim: "#d9bc55", + error: "#e26c6a", + warning: "#f5b851", + success: "#8fc48f", + info: "#87b8dd", + } + + const { setCustomTheme, getCustomTheme } = useTheme() + const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")) + + const handleCustomTheme = () => { + console.log(custom) + try { + const theme = JSON.parse(custom) + theme.name = "custom" + setCustomTheme(theme) + delete theme.name + setCustom(JSON.stringify(theme, null, " ")) + console.log(theme) + } catch(err) { + console.log(err) + } } - }, []); useEffect(() => { if (theme) { - localStorage.setItem('theme', theme) + setTheme(theme) } }, [theme]); return ( - <> -

Select Theme

-
- {themes.map((t) => ( - - ))} +
+
+

Select Theme

+
+ {themes.map((t) => ( + + ))} +
+
+
+

Use Custom Theme

+
+