-
+
handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete"
+ hidden={user === null || user === undefined}
>
×
diff --git a/client/app/routes/Charts/TrackChart.tsx b/client/app/routes/Charts/TrackChart.tsx
index eeeb145..450d022 100644
--- a/client/app/routes/Charts/TrackChart.tsx
+++ b/client/app/routes/Charts/TrackChart.tsx
@@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
-import { type Album, type PaginatedResponse } from "api/api";
+import { type Track, type PaginatedResponse, type Ranked } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
- url.searchParams.set('page', page)
+ url.searchParams.set("page", page);
const res = await fetch(
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
@@ -15,12 +15,14 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
throw new Response("Failed to load top tracks", { status: 500 });
}
- const top_tracks: PaginatedResponse = await res.json();
+ const top_tracks: PaginatedResponse = await res.json();
return { top_tracks };
}
export default function TrackChart() {
- const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse }>();
+ const { top_tracks: initialData } = useLoaderData<{
+ top_tracks: PaginatedResponse>;
+ }>();
return (
(
-
-
-
- Prev
-
-
- Next
-
-
+
+
+
+ Prev
+
+
+ Next
+
+
Prev
-
+
Next
diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx
index 8af882b..597c563 100644
--- a/client/app/routes/Home.tsx
+++ b/client/app/routes/Home.tsx
@@ -10,30 +10,30 @@ import PeriodSelector from "~/components/PeriodSelector";
import { useAppContext } from "~/providers/AppProvider";
export function meta({}: Route.MetaArgs) {
- return [
- { title: "Koito" },
- { name: "description", content: "Koito" },
- ];
+ return [{ title: "Koito" }, { name: "description", content: "Koito" }];
}
export default function Home() {
- const [period, setPeriod] = useState('week')
+ const [period, setPeriod] = useState("week");
const { homeItems } = useAppContext();
return (
-
-
+
+
diff --git a/client/app/routes/MediaItems/Album.tsx b/client/app/routes/MediaItems/Album.tsx
index 654fc9e..e6f413e 100644
--- a/client/app/routes/MediaItems/Album.tsx
+++ b/client/app/routes/MediaItems/Album.tsx
@@ -6,6 +6,8 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
+import { timeListenedString } from "~/utils/utils";
+import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
@@ -18,40 +20,62 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Album() {
const album = useLoaderData() as Album;
- const [period, setPeriod] = useState('week')
+ const [period, setPeriod] = useState("week");
- console.log(album)
+ console.log(album);
return (
-
{
- r.artists = []
- r.tracks = []
- for (let i = 0; i < r.albums.length; i ++) {
- if (r.albums[i].id === id) {
- delete r.albums[i]
- }
- }
- return r
- }}
- subContent={<>
- {album.listen_count && {album.listen_count} play{ album.listen_count > 1 ? 's' : ''}
}
- >}
+ {
+ r.artists = [];
+ r.tracks = [];
+ for (let i = 0; i < r.albums.length; i++) {
+ if (r.albums[i].id === id) {
+ delete r.albums[i];
+ }
+ }
+ return r;
+ }}
+ subContent={
+
+ {album.listen_count !== 0 && (
+
+ {album.listen_count} play{album.listen_count > 1 ? "s" : ""}
+
+ )}
+ {album.time_listened !== 0 && (
+
+ {timeListenedString(album.time_listened)}
+
+ )}
+ {album.first_listen > 0 && (
+
+ Listening since{" "}
+ {new Date(album.first_listen * 1000).toLocaleDateString()}
+
+ )}
+
+ }
>
-
-
-
-
-
+
+
);
}
diff --git a/client/app/routes/MediaItems/Artist.tsx b/client/app/routes/MediaItems/Artist.tsx
index b742f56..a23e4cd 100644
--- a/client/app/routes/MediaItems/Artist.tsx
+++ b/client/app/routes/MediaItems/Artist.tsx
@@ -7,6 +7,8 @@ import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid";
+import { timeListenedString } from "~/utils/utils";
+import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
@@ -19,48 +21,70 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Artist() {
const artist = useLoaderData() as Artist;
- const [period, setPeriod] = useState('week')
+ const [period, setPeriod] = useState("week");
// remove canonical name from alias list
- console.log(artist.aliases)
+ console.log(artist.aliases);
let index = artist.aliases.indexOf(artist.name);
if (index !== -1) {
artist.aliases.splice(index, 1);
}
return (
-
{
- r.albums = []
- r.tracks = []
- for (let i = 0; i < r.artists.length; i ++) {
- if (r.artists[i].id === id) {
- delete r.artists[i]
- }
- }
- return r
- }}
- subContent={<>
- {artist.listen_count && {artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}
}
- >}
+ {
+ r.albums = [];
+ r.tracks = [];
+ for (let i = 0; i < r.artists.length; i++) {
+ if (r.artists[i].id === id) {
+ delete r.artists[i];
+ }
+ }
+ return r;
+ }}
+ subContent={
+
+ {artist.listen_count && (
+
+ {artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
+
+ )}
+ {artist.time_listened !== 0 && (
+
+ {timeListenedString(artist.time_listened)}
+
+ )}
+ {artist.first_listen > 0 && (
+
+ Listening since{" "}
+ {new Date(artist.first_listen * 1000).toLocaleDateString()}
+
+ )}
+
+ }
>
-
-
-
-
+
+
);
}
diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx
index 18a8b78..eaf100b 100644
--- a/client/app/routes/MediaItems/MediaLayout.tsx
+++ b/client/app/routes/MediaItems/MediaLayout.tsx
@@ -2,96 +2,208 @@ import React, { useEffect, useState } from "react";
import { average } from "color.js";
import { imageUrl, type SearchResponse } from "api/api";
import ImageDropHandler from "~/components/ImageDropHandler";
-import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
+import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
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/EditModal";
+import EditModal from "~/components/modals/EditModal/EditModal";
+import AddListenModal from "~/components/modals/AddListenModal";
+import MbzIcon from "~/components/icons/MbzIcon";
+import { Link } from "react-router";
-export type MergeFunc = (from: number, to: number) => Promise
-export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
+export type MergeFunc = (
+ from: number,
+ to: number,
+ replaceImage: boolean
+) => Promise;
+export type MergeSearchCleanerFunc = (
+ r: SearchResponse,
+ id: number
+) => SearchResponse;
interface Props {
- type: "Track" | "Album" | "Artist"
- title: string
- img: string
- id: number
- musicbrainzId: string
- imgItemId: number
- mergeFunc: MergeFunc
- mergeCleanerFunc: MergeSearchCleanerFunc
- children: React.ReactNode
- subContent: React.ReactNode
+ type: "Track" | "Album" | "Artist";
+ title: string;
+ img: string;
+ id: number;
+ rank: number;
+ musicbrainzId: string;
+ imgItemId: number;
+ mergeFunc: MergeFunc;
+ mergeCleanerFunc: MergeSearchCleanerFunc;
+ children: React.ReactNode;
+ subContent: React.ReactNode;
}
export default function MediaLayout(props: Props) {
- const [bgColor, setBgColor] = useState("(--color-bg)");
- const [mergeModalOpen, setMergeModalOpen] = useState(false);
- const [deleteModalOpen, setDeleteModalOpen] = useState(false);
- const [imageModalOpen, setImageModalOpen] = useState(false);
- const [renameModalOpen, setRenameModalOpen] = useState(false);
- const { user } = useAppContext();
+ const [bgColor, setBgColor] = useState("(--color-bg)");
+ const [mergeModalOpen, setMergeModalOpen] = useState(false);
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [imageModalOpen, setImageModalOpen] = useState(false);
+ const [renameModalOpen, setRenameModalOpen] = useState(false);
+ const [addListenModalOpen, setAddListenModalOpen] = useState(false);
+ const { user } = useAppContext();
- useEffect(() => {
- average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
- setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
- });
- }, [props.img]);
+ useEffect(() => {
+ average(imageUrl(props.img, "small"), { amount: 1 }).then((color) => {
+ setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
+ });
+ }, [props.img]);
- const replaceImageCallback = () => {
- window.location.reload()
- }
+ const replaceImageCallback = () => {
+ window.location.reload();
+ };
- const title = `${props.title} - Koito`
+ const title = `${props.title} - Koito`;
- const mobileIconSize = 22
- const normalIconSize = 30
+ const mobileIconSize = 22;
+ const normalIconSize = 30;
- let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
+ let vw = Math.max(
+ document.documentElement.clientWidth || 0,
+ window.innerWidth || 0
+ );
- let iconSize = vw > 768 ? normalIconSize : mobileIconSize
+ let iconSize = vw > 768 ? normalIconSize : mobileIconSize;
- return (
-
-
- {title}
-
-
-
-
-
-
-
-
-
{props.type}
- {props.title}
- {props.subContent}
-
- { user &&
-
- setRenameModalOpen(true)}>
- setImageModalOpen(true)}>
- setMergeModalOpen(true)}>
- setDeleteModalOpen(true)}>
-
-
-
-
-
- }
-
- {props.children}
+ console.log("MBZ:", props.musicbrainzId);
+
+ return (
+
+
+ {title}
+
+
+
+
+
+
+
+
+
{props.type}
+
+
+ {props.title}
+
+ {" "}
+ #{props.rank}
+
+
-
- );
+ {props.subContent}
+
+
+ {props.musicbrainzId && (
+
+
+
+ )}
+ {user && (
+ <>
+ {props.type === "Track" && (
+ <>
+
setAddListenModalOpen(true)}
+ >
+
+
+
+ >
+ )}
+
setRenameModalOpen(true)}
+ >
+
+
+
+ {props.type !== "Track" && (
+
setImageModalOpen(true)}
+ >
+
+
+ )}
+
setMergeModalOpen(true)}
+ >
+
+
+
setDeleteModalOpen(true)}
+ >
+
+
+
+
+
+
+ >
+ )}
+
+
+ {props.children}
+
+
+ );
}
diff --git a/client/app/routes/MediaItems/Track.tsx b/client/app/routes/MediaItems/Track.tsx
index bd08a8f..6b6690e 100644
--- a/client/app/routes/MediaItems/Track.tsx
+++ b/client/app/routes/MediaItems/Track.tsx
@@ -5,55 +5,86 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
+import { timeListenedString } from "~/utils/utils";
+import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
- let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
- if (!res.ok) {
- throw new Response("Failed to load track", { status: res.status });
- }
- const track: Track = await res.json();
- res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
- if (!res.ok) {
- throw new Response("Failed to load album for track", { status: res.status })
- }
- const album: Album = await res.json()
- return {track: track, album: album};
+ let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
+ if (!res.ok) {
+ throw new Response("Failed to load track", { status: res.status });
+ }
+ const track: Track = await res.json();
+ res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
+ if (!res.ok) {
+ throw new Response("Failed to load album for track", {
+ status: res.status,
+ });
+ }
+ const album: Album = await res.json();
+ return { track: track, album: album };
}
export default function Track() {
- const { track, album } = useLoaderData();
- const [period, setPeriod] = useState('week')
+ const { track, album } = useLoaderData();
+ const [period, setPeriod] = useState("week");
- return (
-
{
- r.albums = []
- r.artists = []
- for (let i = 0; i < r.tracks.length; i ++) {
- if (r.tracks[i].id === id) {
- delete r.tracks[i]
- }
- }
- return r
- }}
- subContent={
-
appears on {album.title}
- {track.listen_count &&
{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}
}
-
}
- >
-
-
-
- )
+ return (
+
{
+ r.albums = [];
+ r.artists = [];
+ for (let i = 0; i < r.tracks.length; i++) {
+ if (r.tracks[i].id === id) {
+ delete r.tracks[i];
+ }
+ }
+ return r;
+ }}
+ subContent={
+
+
+ Appears on{" "}
+
+ {album.title}
+
+
+ {track.listen_count !== 0 && (
+
+ {track.listen_count} play{track.listen_count > 1 ? "s" : ""}
+
+ )}
+ {track.time_listened !== 0 && (
+
+ {timeListenedString(track.time_listened)}
+
+ )}
+ {track.first_listen > 0 && (
+
+ Listening since{" "}
+ {new Date(track.first_listen * 1000).toLocaleDateString()}
+
+ )}
+
+ }
+ >
+
+
+
+ );
}
diff --git a/client/app/routes/RewindPage.tsx b/client/app/routes/RewindPage.tsx
new file mode 100644
index 0000000..ad92497
--- /dev/null
+++ b/client/app/routes/RewindPage.tsx
@@ -0,0 +1,213 @@
+import Rewind from "~/components/rewind/Rewind";
+import type { Route } from "./+types/Home";
+import { imageUrl, type RewindStats } from "api/api";
+import { useEffect, useState } from "react";
+import type { LoaderFunctionArgs } from "react-router";
+import { useLoaderData } from "react-router";
+import { getRewindParams, getRewindYear } from "~/utils/utils";
+import { useNavigate } from "react-router";
+import { average } from "color.js";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+// TODO: Bind year and month selectors to what data actually exists
+
+const months = [
+ "Full Year",
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+export async function clientLoader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const year = parseInt(
+ url.searchParams.get("year") || getRewindParams().year.toString()
+ );
+ const month = parseInt(
+ url.searchParams.get("month") || getRewindParams().month.toString()
+ );
+
+ const res = await fetch(`/apis/web/v1/summary?year=${year}&month=${month}`);
+ if (!res.ok) {
+ throw new Response("Failed to load summary", { status: 500 });
+ }
+
+ const stats: RewindStats = await res.json();
+ stats.title = `Your ${month === 0 ? "" : months[month]} ${year} Rewind`;
+ return { stats };
+}
+
+export default function RewindPage() {
+ const currentParams = new URLSearchParams(location.search);
+ let year = parseInt(
+ currentParams.get("year") || getRewindParams().year.toString()
+ );
+ let month = parseInt(
+ currentParams.get("month") || getRewindParams().month.toString()
+ );
+ const navigate = useNavigate();
+ const [showTime, setShowTime] = useState(false);
+ const { stats: stats } = useLoaderData<{ stats: RewindStats }>();
+
+ const [bgColor, setBgColor] = useState
("(--color-bg)");
+
+ useEffect(() => {
+ if (!stats.top_artists[0]) return;
+
+ const img = (stats.top_artists[0] as any)?.item.image;
+ if (!img) return;
+
+ average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
+ setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
+ });
+ }, [stats]);
+
+ const updateParams = (params: Record) => {
+ const nextParams = new URLSearchParams(location.search);
+
+ for (const key in params) {
+ const val = params[key];
+
+ if (val !== null) {
+ nextParams.set(key, val);
+ }
+ }
+
+ const url = `/rewind?${nextParams.toString()}`;
+
+ navigate(url, { replace: false });
+ };
+
+ const navigateMonth = (direction: "prev" | "next") => {
+ if (direction === "next") {
+ if (month === 12) {
+ month = 0;
+ } else {
+ month += 1;
+ }
+ } else {
+ if (month === 0) {
+ month = 12;
+ } else {
+ month -= 1;
+ }
+ }
+ console.log(`Month: ${month}`);
+
+ updateParams({
+ year: year.toString(),
+ month: month.toString(),
+ });
+ };
+ const navigateYear = (direction: "prev" | "next") => {
+ if (direction === "next") {
+ year += 1;
+ } else {
+ year -= 1;
+ }
+
+ updateParams({
+ year: year.toString(),
+ month: month.toString(),
+ });
+ };
+
+ const pgTitle = `${stats.title} - Koito`;
+
+ return (
+
+
+
{pgTitle}
+
+
+
+
+
+
+
navigateMonth("prev")}
+ className="p-2 disabled:text-(--color-fg-tertiary)"
+ disabled={
+ // Previous month is in the future OR
+ new Date(year, month - 2) > new Date() ||
+ // We are looking at current year and prev would take us to full year
+ (new Date().getFullYear() === year && month === 1)
+ }
+ >
+
+
+
+ {months[month]}
+
+
navigateMonth("next")}
+ className="p-2 disabled:text-(--color-fg-tertiary)"
+ disabled={
+ // next month is current or future month and
+ month >= new Date().getMonth() &&
+ // we are looking at current (or future) year
+ year >= new Date().getFullYear()
+ }
+ >
+
+
+
+
+
navigateYear("prev")}
+ className="p-2 disabled:text-(--color-fg-tertiary)"
+ disabled={new Date(year - 1, month) > new Date()}
+ >
+
+
+
{year}
+
navigateYear("next")}
+ className="p-2 disabled:text-(--color-fg-tertiary)"
+ disabled={
+ // Next year date is in the future OR
+ new Date(year + 1, month - 1) > new Date() ||
+ // Next year date is current full year OR
+ (month == 0 && new Date().getFullYear() === year + 1) ||
+ // Next year date is current month
+ (new Date().getMonth() === month - 1 &&
+ new Date().getFullYear() === year + 1)
+ }
+ >
+
+
+
+
+
+ Show time listened?
+ setShowTime(!showTime)}
+ >
+
+
+ {stats !== undefined && (
+
+ )}
+
+
+
+ );
+}
diff --git a/client/app/routes/ThemeHelper.tsx b/client/app/routes/ThemeHelper.tsx
index 7c65c6a..fc5b7e4 100644
--- a/client/app/routes/ThemeHelper.tsx
+++ b/client/app/routes/ThemeHelper.tsx
@@ -7,8 +7,40 @@ import LastPlays from "~/components/LastPlays"
import TopAlbums from "~/components/TopAlbums"
import TopArtists from "~/components/TopArtists"
import TopTracks from "~/components/TopTracks"
+import { useTheme } from "~/hooks/useTheme"
+import { themes, type Theme } from "~/styles/themes.css"
export default function ThemeHelper() {
+ 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 [custom, setCustom] = useState(JSON.stringify(initialTheme, null, " "))
+ const { setCustomTheme } = useTheme()
+
+ const handleCustomTheme = () => {
+ console.log(custom)
+ try {
+ const theme = JSON.parse(custom) as Theme
+ console.log(theme)
+ setCustomTheme(theme)
+ } catch(err) {
+ console.log(err)
+ }
+ }
const homeItems = 3
@@ -24,43 +56,49 @@ export default function ThemeHelper() {
-
-
-
You're logged in as Example User
-
{}}>Logout
+
+
-
-
-
{}}>Submit
+
+
+
You"re logged in as Example User
+
{}}>Logout
+
+
+
+
+ {}} />
+ Example checkbox
+
+
successfully displayed example text
+
this is an example of error text
+
here is an informational example
+
heed this warning, traveller
-
-
- {}} />
- Example checkbox
-
-
successfully displayed example text
-
this is an example of error text
-
here is an informational example
-
heed this warning, traveller
)
diff --git a/client/app/styles/themes.css.ts b/client/app/styles/themes.css.ts
new file mode 100644
index 0000000..1a3a57d
--- /dev/null
+++ b/client/app/styles/themes.css.ts
@@ -0,0 +1,241 @@
+import { globalStyle } from "@vanilla-extract/css";
+import { themeVars } from "./vars.css";
+
+export type Theme = {
+ bg: string;
+ bgSecondary: string;
+ bgTertiary: string;
+ fg: string;
+ fgSecondary: string;
+ fgTertiary: string;
+ primary: string;
+ primaryDim: string;
+ accent: string;
+ accentDim: string;
+ error: string;
+ warning: string;
+ info: string;
+ success: string;
+};
+
+export const THEME_KEYS = ["--color"];
+
+export const themes: Record
= {
+ yuu: {
+ bg: "#1e1816",
+ bgSecondary: "#2f2623",
+ bgTertiary: "#453733",
+ fg: "#f8f3ec",
+ fgSecondary: "#d6ccc2",
+ fgTertiary: "#b4a89c",
+ primary: "#fc9174",
+ primaryDim: "#d88b65",
+ accent: "#f9db6d",
+ accentDim: "#d9bc55",
+ error: "#e26c6a",
+ warning: "#f5b851",
+ success: "#8fc48f",
+ info: "#87b8dd",
+ },
+ varia: {
+ bg: "rgb(25, 25, 29)",
+ bgSecondary: "#222222",
+ bgTertiary: "#333333",
+ fg: "#eeeeee",
+ fgSecondary: "#aaaaaa",
+ fgTertiary: "#888888",
+ primary: "rgb(203, 110, 240)",
+ primaryDim: "#c28379",
+ accent: "#f0ad0a",
+ accentDim: "#d08d08",
+ error: "#f44336",
+ warning: "#ff9800",
+ success: "#4caf50",
+ info: "#2196f3",
+ },
+ midnight: {
+ bg: "rgb(8, 15, 24)",
+ bgSecondary: "rgb(15, 27, 46)",
+ bgTertiary: "rgb(15, 41, 70)",
+ fg: "#dbdfe7",
+ fgSecondary: "#9ea3a8",
+ fgTertiary: "#74787c",
+ primary: "#1a97eb",
+ primaryDim: "#2680aa",
+ accent: "#f0ad0a",
+ accentDim: "#d08d08",
+ error: "#f44336",
+ warning: "#ff9800",
+ success: "#4caf50",
+ info: "#2196f3",
+ },
+ catppuccin: {
+ bg: "#1e1e2e",
+ bgSecondary: "#181825",
+ bgTertiary: "#11111b",
+ fg: "#cdd6f4",
+ fgSecondary: "#a6adc8",
+ fgTertiary: "#9399b2",
+ primary: "#89b4fa",
+ primaryDim: "#739df0",
+ accent: "#f38ba8",
+ accentDim: "#d67b94",
+ error: "#f38ba8",
+ warning: "#f9e2af",
+ success: "#a6e3a1",
+ info: "#89dceb",
+ },
+ autumn: {
+ bg: "rgb(44, 25, 18)",
+ bgSecondary: "rgb(70, 40, 18)",
+ bgTertiary: "#4b2f1c",
+ fg: "#fef9f3",
+ fgSecondary: "#dbc6b0",
+ fgTertiary: "#a3917a",
+ primary: "#F0850A",
+ primaryDim: "#b45309",
+ accent: "#8c4c28",
+ accentDim: "#6b3b1f",
+ error: "#d1433f",
+ warning: "#e38b29",
+ success: "#6b8e23",
+ info: "#c084fc",
+ },
+ black: {
+ bg: "#000000",
+ bgSecondary: "#1a1a1a",
+ bgTertiary: "#2a2a2a",
+ fg: "#dddddd",
+ fgSecondary: "#aaaaaa",
+ fgTertiary: "#888888",
+ primary: "#08c08c",
+ primaryDim: "#08c08c",
+ accent: "#f0ad0a",
+ accentDim: "#d08d08",
+ error: "#f44336",
+ warning: "#ff9800",
+ success: "#4caf50",
+ info: "#2196f3",
+ },
+ wine: {
+ bg: "#23181E",
+ bgSecondary: "#2C1C25",
+ bgTertiary: "#422A37",
+ fg: "#FCE0B3",
+ fgSecondary: "#C7AC81",
+ fgTertiary: "#A78E64",
+ primary: "#EA8A64",
+ primaryDim: "#BD7255",
+ accent: "#FAE99B",
+ accentDim: "#C6B464",
+ error: "#fca5a5",
+ warning: "#fde68a",
+ success: "#bbf7d0",
+ info: "#bae6fd",
+ },
+ pearl: {
+ bg: "#FFFFFF",
+ bgSecondary: "#EEEEEE",
+ bgTertiary: "#E0E0E0",
+ fg: "#333333",
+ fgSecondary: "#555555",
+ fgTertiary: "#777777",
+ primary: "#007BFF",
+ primaryDim: "#0056B3",
+ accent: "#28A745",
+ accentDim: "#1E7E34",
+ error: "#DC3545",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+ rosebud: {
+ bg: "#260d19",
+ bgSecondary: "#3A1325",
+ bgTertiary: "#45182D",
+ fg: "#F3CAD8",
+ fgSecondary: "#C88B99",
+ fgTertiary: "#B2677D",
+ primary: "#d76fa2",
+ primaryDim: "#b06687",
+ accent: "#e79cb8",
+ accentDim: "#c27d8c",
+ error: "#e84b73",
+ warning: "#f2b38c",
+ success: "#6FC4A6",
+ info: "#6BAEDC",
+ },
+ urim: {
+ bg: "#101713",
+ bgSecondary: "#1B2921",
+ bgTertiary: "#273B30",
+ fg: "#D2E79E",
+ fgSecondary: "#B4DA55",
+ fgTertiary: "#7E9F2A",
+ primary: "#ead500",
+ primaryDim: "#C1B210",
+ accent: "#28A745",
+ accentDim: "#1E7E34",
+ error: "#EE5237",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+ match: {
+ bg: "#071014",
+ bgSecondary: "#0A181E",
+ bgTertiary: "#112A34",
+ fg: "#ebeaeb",
+ fgSecondary: "#BDBDBD",
+ fgTertiary: "#A2A2A2",
+ primary: "#fda827",
+ primaryDim: "#C78420",
+ accent: "#277CFD",
+ accentDim: "#1F60C1",
+ error: "#F14426",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+ lemon: {
+ bg: "#1a171a",
+ bgSecondary: "#2E272E",
+ bgTertiary: "#443844",
+ fg: "#E6E2DC",
+ fgSecondary: "#B2ACA1",
+ fgTertiary: "#968F82",
+ primary: "#f5c737",
+ primaryDim: "#C29D2F",
+ accent: "#277CFD",
+ accentDim: "#1F60C1",
+ error: "#F14426",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+};
+
+export default themes;
+
+Object.entries(themes).forEach(([name, theme]) => {
+ const selector = `[data-theme="${name}"]`;
+
+ globalStyle(selector, {
+ vars: {
+ [themeVars.bg]: theme.bg,
+ [themeVars.bgSecondary]: theme.bgSecondary,
+ [themeVars.bgTertiary]: theme.bgTertiary,
+ [themeVars.fg]: theme.fg,
+ [themeVars.fgSecondary]: theme.fgSecondary,
+ [themeVars.fgTertiary]: theme.fgTertiary,
+ [themeVars.primary]: theme.primary,
+ [themeVars.primaryDim]: theme.primaryDim,
+ [themeVars.accent]: theme.accent,
+ [themeVars.accentDim]: theme.accentDim,
+ [themeVars.error]: theme.error,
+ [themeVars.warning]: theme.warning,
+ [themeVars.success]: theme.success,
+ [themeVars.info]: theme.info,
+ },
+ });
+});
diff --git a/client/app/styles/vars.css.ts b/client/app/styles/vars.css.ts
new file mode 100644
index 0000000..668d89c
--- /dev/null
+++ b/client/app/styles/vars.css.ts
@@ -0,0 +1,16 @@
+export const themeVars = {
+ bg: '--color-bg',
+ bgSecondary: '--color-bg-secondary',
+ bgTertiary: '--color-bg-tertiary',
+ fg: '--color-fg',
+ fgSecondary: '--color-fg-secondary',
+ fgTertiary: '--color-fg-tertiary',
+ primary: '--color-primary',
+ primaryDim: '--color-primary-dim',
+ accent: '--color-accent',
+ accentDim: '--color-accent-dim',
+ error: '--color-error',
+ warning: '--color-warning',
+ info: '--color-info',
+ success: '--color-success',
+}
\ No newline at end of file
diff --git a/client/app/themes.css b/client/app/themes.css
index b29001f..0024c36 100644
--- a/client/app/themes.css
+++ b/client/app/themes.css
@@ -1,391 +1,5 @@
/* Theme Definitions */
-[data-theme="varia"]{
- /* Backgrounds */
- --color-bg:rgb(25, 25, 29);
- --color-bg-secondary: #222222;
- --color-bg-tertiary: #333333;
-
- /* Foregrounds */
- --color-fg: #eeeeee;
- --color-fg-secondary: #aaaaaa;
- --color-fg-tertiary: #888888;
-
- /* Accents */
- --color-primary:rgb(203, 110, 240);
- --color-primary-dim: #c28379;
- --color-accent: #f0ad0a;
- --color-accent-dim: #d08d08;
-
- /* Status Colors */
- --color-error: #f44336;
- --color-warning: #ff9800;
- --color-success: #4caf50;
- --color-info: #2196f3;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.5);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="wine"] {
- /* Backgrounds */
- --color-bg: #23181E;
- --color-bg-secondary: #2C1C25;
- --color-bg-tertiary: #422A37;
-
- /* Foregrounds */
- --color-fg: #FCE0B3;
- --color-fg-secondary:#C7AC81;
- --color-fg-tertiary:#A78E64;
-
- /* Accents */
- --color-primary: #EA8A64;
- --color-primary-dim: #BD7255;
- --color-accent: #FAE99B;
- --color-accent-dim: #C6B464;
-
- /* Status Colors */
- --color-error: #fca5a5;
- --color-warning: #fde68a;
- --color-success: #bbf7d0;
- --color-info: #bae6fd;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.05);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="asuka"] {
- /* Backgrounds */
- --color-bg: #3B1212;
- --color-bg-secondary: #471B1B;
- --color-bg-tertiary: #020202;
-
- /* Foregrounds */
- --color-fg: #F1E9E6;
- --color-fg-secondary: #CCB6AE;
- --color-fg-tertiary: #9F8176;
-
- /* Accents */
- --color-primary: #F1E9E6;
- --color-primary-dim: #CCB6AE;
- --color-accent: #41CE41;
- --color-accent-dim: #3BA03B;
-
- /* Status Colors */
- --color-error: #EB97A8;
- --color-warning: #FFD700;
- --color-success: #32CD32;
- --color-info: #1E90FF;
-
- /* Borders and Shadows (derived from existing colors for consistency) */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.1); /* Slightly more prominent shadow for contrast */
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="midnight"] {
- /* Backgrounds */
- --color-bg:rgb(8, 15, 24);
- --color-bg-secondary:rgb(15, 27, 46);
- --color-bg-tertiary:rgb(15, 41, 70);
-
- /* Foregrounds */
- --color-fg: #dbdfe7;
- --color-fg-secondary: #9ea3a8;
- --color-fg-tertiary: #74787c;
-
- /* Accents */
- --color-primary: #1a97eb;
- --color-primary-dim: #2680aa;
- --color-accent: #f0ad0a;
- --color-accent-dim: #d08d08;
-
- /* Status Colors */
- --color-error: #f44336;
- --color-warning: #ff9800;
- --color-success: #4caf50;
- --color-info: #2196f3;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.5);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-/* TODO: Adjust */
-[data-theme="catppuccin"] {
- /* Backgrounds */
- --color-bg: #1e1e2e;
- --color-bg-secondary: #181825;
- --color-bg-tertiary: #11111b;
-
- /* Foregrounds */
- --color-fg: #cdd6f4;
- --color-fg-secondary: #a6adc8;
- --color-fg-tertiary: #9399b2;
-
- /* Accents */
- --color-primary: #cba6f7;
- --color-primary-dim: #739df0;
- --color-accent: #f38ba8;
- --color-accent-dim: #d67b94;
-
- /* Status Colors */
- --color-error: #f38ba8;
- --color-warning: #f9e2af;
- --color-success: #a6e3a1;
- --color-info: #89dceb;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.5);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="pearl"] {
- /* Backgrounds */
- --color-bg: #FFFFFF;
- --color-bg-secondary: #EEEEEE;
- --color-bg-tertiary: #E0E0E0;
-
- /* Foregrounds */
- --color-fg: #333333;
- --color-fg-secondary: #555555;
- --color-fg-tertiary: #777777;
-
- /* Accents */
- --color-primary: #007BFF;
- --color-primary-dim: #0056B3;
- --color-accent: #28A745;
- --color-accent-dim: #1E7E34;
-
- /* Status Colors */
- --color-error: #DC3545;
- --color-warning: #CE9B00;
- --color-success: #099B2B;
- --color-info: #02B3CE;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.1);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="urim"] {
- /* Backgrounds */
- --color-bg: #101713;
- --color-bg-secondary: #1B2921;
- --color-bg-tertiary: #273B30;
-
- /* Foregrounds */
- --color-fg: #D2E79E;
- --color-fg-secondary: #B4DA55;
- --color-fg-tertiary: #7E9F2A;
-
- /* Accents */
- --color-primary: #ead500;
- --color-primary-dim: #C1B210;
- --color-accent: #28A745;
- --color-accent-dim: #1E7E34;
-
- /* Status Colors */
- --color-error: #EE5237;
- --color-warning: #FFC107;
- --color-success: #28A745;
- --color-info: #17A2B8;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.1);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="yuu"] {
- /* Backgrounds */
- --color-bg: #161312;
- --color-bg-secondary: #272120;
- --color-bg-tertiary: #382F2E;
-
- /* Foregrounds */
- --color-fg: #faf5f4;
- --color-fg-secondary: #CCC7C6;
- --color-fg-tertiary: #B0A3A1;
-
- /* Accents */
- --color-primary: #ff826d;
- --color-primary-dim: #CE6654;
- --color-accent: #464DAE;
- --color-accent-dim: #393D74;
-
- /* Status Colors */
- --color-error: #FF6247;
- --color-warning: #FFC107;
- --color-success: #3ECE5F;
- --color-info: #41C4D8;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.1);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="match"] {
- /* Backgrounds */
- --color-bg: #071014;
- --color-bg-secondary: #0A181E;
- --color-bg-tertiary: #112A34;
-
- /* Foregrounds */
- --color-fg: #ebeaeb;
- --color-fg-secondary: #BDBDBD;
- --color-fg-tertiary: #A2A2A2;
-
- /* Accents */
- --color-primary: #fda827;
- --color-primary-dim: #C78420;
- --color-accent: #277CFD;
- --color-accent-dim: #1F60C1;
-
- /* Status Colors */
- --color-error: #F14426;
- --color-warning: #FFC107;
- --color-success: #28A745;
- --color-info: #17A2B8;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.1);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="lemon"] {
- /* Backgrounds */
- --color-bg: #1a171a;
- --color-bg-secondary: #2E272E;
- --color-bg-tertiary: #443844;
-
- /* Foregrounds */
- --color-fg: #E6E2DC;
- --color-fg-secondary: #B2ACA1;
- --color-fg-tertiary: #968F82;
-
- /* Accents */
- --color-primary: #f5c737;
- --color-primary-dim: #C29D2F;
- --color-accent: #277CFD;
- --color-accent-dim: #1F60C1;
-
- /* Status Colors */
- --color-error: #F14426;
- --color-warning: #FFC107;
- --color-success: #28A745;
- --color-info: #17A2B8;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.1);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="autumn"] {
- /* Backgrounds */
- --color-bg:rgb(44, 25, 18);
- --color-bg-secondary:rgb(70, 40, 18);
- --color-bg-tertiary: #4b2f1c;
-
- /* Foregrounds */
- --color-fg: #fef9f3;
- --color-fg-secondary: #dbc6b0;
- --color-fg-tertiary: #a3917a;
-
- /* Accents */
- --color-primary: #d97706;
- --color-primary-dim: #b45309;
- --color-accent: #8c4c28;
- --color-accent-dim: #6b3b1f;
-
- /* Status Colors */
- --color-error: #d1433f;
- --color-warning: #e38b29;
- --color-success: #6b8e23;
- --color-info: #c084fc;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.4);
-
- /* Interactive Elements */
- --color-link: var(--color-primary);
- --color-link-hover: var(--color-primary-dim);
-}
-
-[data-theme="black"] {
- /* Backgrounds */
- --color-bg: #000000;
- --color-bg-secondary: #1a1a1a;
- --color-bg-tertiary: #2a2a2a;
-
- /* Foregrounds */
- --color-fg: #dddddd;
- --color-fg-secondary: #aaaaaa;
- --color-fg-tertiary: #888888;
-
- /* Accents */
- --color-primary: #08c08c;
- --color-primary-dim: #08c08c;
- --color-accent: #f0ad0a;
- --color-accent-dim: #d08d08;
-
- /* Status Colors */
- --color-error: #f44336;
- --color-warning: #ff9800;
- --color-success: #4caf50;
- --color-info: #2196f3;
-
- /* Borders and Shadows */
- --color-border: var(--color-bg-tertiary);
- --color-shadow: rgba(0, 0, 0, 0.5);
-
- /* Interactive Elements */
- --color-link: #0af0af;
- --color-link-hover: #08c08c;
-}
-
-
/* Theme Helper Classes */
/* Foreground Text */
diff --git a/client/app/tz.ts b/client/app/tz.ts
new file mode 100644
index 0000000..3d82e0c
--- /dev/null
+++ b/client/app/tz.ts
@@ -0,0 +1,10 @@
+export function initTimezoneCookie() {
+ if (typeof window === "undefined") return;
+
+ if (document.cookie.includes("tz=")) return;
+
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ if (!tz) return;
+
+ document.cookie = `tz=${tz}; Path=/; Max-Age=31536000; SameSite=Lax`;
+}
diff --git a/client/app/utils/utils.ts b/client/app/utils/utils.ts
index 0cf0b33..4acbad5 100644
--- a/client/app/utils/utils.ts
+++ b/client/app/utils/utils.ts
@@ -1,90 +1,121 @@
-import Timeframe from "~/types/timeframe"
+import Timeframe from "~/types/timeframe";
const timeframeToInterval = (timeframe: Timeframe): string => {
- switch (timeframe) {
- case Timeframe.Day:
- return "1 day"
- case Timeframe.Week:
- return "1 week"
- case Timeframe.Month:
- return "1 month"
- case Timeframe.Year:
- return "1 year"
- case Timeframe.AllTime:
- return "99 years"
- }
-}
-
-function timeSince(date: Date) {
- const now = new Date();
- const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
-
- const intervals = [
- { label: 'year', seconds: 31536000 },
- { label: 'month', seconds: 2592000 },
- { label: 'week', seconds: 604800 },
- { label: 'day', seconds: 86400 },
- { label: 'hour', seconds: 3600 },
- { label: 'minute', seconds: 60 },
- { label: 'second', seconds: 1 },
- ];
-
- for (const interval of intervals) {
- const count = Math.floor(seconds / interval.seconds);
- if (count >= 1) {
- return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
- }
- }
-
- return 'just now';
-}
-
-export { timeSince }
-
-type hsl = {
- h: number,
- s: number,
- l: number,
-}
-
-const hexToHSL = (hex: string): hsl => {
- let r = 0, g = 0, b = 0;
- hex = hex.replace('#', '');
-
- if (hex.length === 3) {
- r = parseInt(hex[0] + hex[0], 16);
- g = parseInt(hex[1] + hex[1], 16);
- b = parseInt(hex[2] + hex[2], 16);
- } else if (hex.length === 6) {
- r = parseInt(hex.substring(0, 2), 16);
- g = parseInt(hex.substring(2, 4), 16);
- b = parseInt(hex.substring(4, 6), 16);
- }
-
- r /= 255;
- g /= 255;
- b /= 255;
-
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
- let h = 0, s = 0, l = (max + min) / 2;
-
- if (max !== min) {
- const d = max - min;
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
- switch (max) {
- case r: h = ((g - b) / d + (g < b ? 6 : 0)); break;
- case g: h = ((b - r) / d + 2); break;
- case b: h = ((r - g) / d + 4); break;
- }
- h /= 6;
- }
-
- return {
- h: Math.round(h * 360),
- s: Math.round(s * 100),
- l: Math.round(l * 100)
- };
+ switch (timeframe) {
+ case Timeframe.Day:
+ return "1 day";
+ case Timeframe.Week:
+ return "1 week";
+ case Timeframe.Month:
+ return "1 month";
+ case Timeframe.Year:
+ return "1 year";
+ case Timeframe.AllTime:
+ return "99 years";
+ }
};
-export {hexToHSL}
-export type {hsl}
\ No newline at end of file
+const getRewindYear = (): number => {
+ return new Date().getFullYear() - 1;
+};
+
+const getRewindParams = (): { month: number; year: number } => {
+ const today = new Date();
+ if (today.getMonth() == 0) {
+ return { month: 0, year: today.getFullYear() - 1 };
+ } else {
+ return { month: today.getMonth(), year: today.getFullYear() };
+ }
+};
+
+function timeSince(date: Date) {
+ const now = new Date();
+ const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
+
+ const intervals = [
+ { label: "year", seconds: 31536000 },
+ { label: "month", seconds: 2592000 },
+ { label: "week", seconds: 604800 },
+ { label: "day", seconds: 86400 },
+ { label: "hour", seconds: 3600 },
+ { label: "minute", seconds: 60 },
+ { label: "second", seconds: 1 },
+ ];
+
+ for (const interval of intervals) {
+ const count = Math.floor(seconds / interval.seconds);
+ if (count >= 1) {
+ return `${count} ${interval.label}${count !== 1 ? "s" : ""} ago`;
+ }
+ }
+
+ return "just now";
+}
+
+export { timeSince };
+
+type hsl = {
+ h: number;
+ s: number;
+ l: number;
+};
+
+const hexToHSL = (hex: string): hsl => {
+ let r = 0,
+ g = 0,
+ b = 0;
+ hex = hex.replace("#", "");
+
+ if (hex.length === 3) {
+ r = parseInt(hex[0] + hex[0], 16);
+ g = parseInt(hex[1] + hex[1], 16);
+ b = parseInt(hex[2] + hex[2], 16);
+ } else if (hex.length === 6) {
+ r = parseInt(hex.substring(0, 2), 16);
+ g = parseInt(hex.substring(2, 4), 16);
+ b = parseInt(hex.substring(4, 6), 16);
+ }
+
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+ const max = Math.max(r, g, b),
+ min = Math.min(r, g, b);
+ let h = 0,
+ s = 0,
+ l = (max + min) / 2;
+
+ if (max !== min) {
+ const d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ switch (max) {
+ case r:
+ h = (g - b) / d + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = (b - r) / d + 2;
+ break;
+ case b:
+ h = (r - g) / d + 4;
+ break;
+ }
+ h /= 6;
+ }
+
+ return {
+ h: Math.round(h * 360),
+ s: Math.round(s * 100),
+ l: Math.round(l * 100),
+ };
+};
+
+const timeListenedString = (seconds: number) => {
+ if (!seconds) return "";
+
+ let minutes = Math.floor(seconds / 60);
+ return `${minutes} minutes listened`;
+};
+
+export { hexToHSL, timeListenedString, getRewindYear, getRewindParams };
+export type { hsl };
diff --git a/client/package.json b/client/package.json
index 78cfbea..eee0653 100644
--- a/client/package.json
+++ b/client/package.json
@@ -13,13 +13,17 @@
"@radix-ui/react-tabs": "^1.1.12",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
+ "@recharts/devtools": "^0.0.7",
"@tanstack/react-query": "^5.80.6",
+ "@vanilla-extract/css": "^1.17.4",
"color.js": "^1.2.0",
"isbot": "^5.1.27",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
- "react-router": "^7.5.3"
+ "react-is": "^19.2.3",
+ "react-router": "^7.5.3",
+ "recharts": "^3.6.0"
},
"devDependencies": {
"@react-router/dev": "^7.5.3",
@@ -27,6 +31,7 @@
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
+ "@vanilla-extract/vite-plugin": "^5.0.6",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.3",
diff --git a/client/public/site.webmanifest b/client/public/site.webmanifest
index ccf313a..2c03356 100644
--- a/client/public/site.webmanifest
+++ b/client/public/site.webmanifest
@@ -1,6 +1,6 @@
{
- "name": "MyWebSite",
- "short_name": "MySite",
+ "name": "Koito",
+ "short_name": "Koito",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 7feebd6..9a264e6 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -2,11 +2,12 @@ import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'
const isDocker = process.env.BUILD_TARGET === 'docker';
export default defineConfig({
- plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
+ plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), vanillaExtractPlugin()],
server: {
proxy: {
'/apis': {
diff --git a/client/yarn.lock b/client/yarn.lock
index 136000e..48a33db 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -24,7 +24,7 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82"
integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==
-"@babel/core@^7.21.8", "@babel/core@^7.23.7":
+"@babel/core@^7.21.8", "@babel/core@^7.23.7", "@babel/core@^7.23.9":
version "7.27.4"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce"
integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==
@@ -185,7 +185,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-syntax-typescript@^7.27.1":
+"@babel/plugin-syntax-typescript@^7.23.3", "@babel/plugin-syntax-typescript@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18"
integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==
@@ -222,6 +222,11 @@
"@babel/plugin-transform-modules-commonjs" "^7.27.1"
"@babel/plugin-transform-typescript" "^7.27.1"
+"@babel/runtime@^7.12.5":
+ version "7.27.6"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
+ integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
+
"@babel/template@^7.27.2":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
@@ -274,6 +279,11 @@
dependencies:
tslib "^2.4.0"
+"@emotion/hash@^0.9.0":
+ version "0.9.2"
+ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b"
+ integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
+
"@esbuild/aix-ppc64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
@@ -679,6 +689,23 @@
morgan "^1.10.0"
source-map-support "^0.5.21"
+"@recharts/devtools@^0.0.7":
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/@recharts/devtools/-/devtools-0.0.7.tgz#a909d102efd76fc45bc2b7a150e67a02da04b4c1"
+ integrity sha512-ud66rUf3FYf1yQLGSCowI50EQyC/rcZblvDgNvfUIVaEXyQtr5K2DFgwegziqbVclsVBQLTxyntVViJN5H4oWQ==
+
+"@reduxjs/toolkit@1.x.x || 2.x.x":
+ version "2.11.2"
+ resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82"
+ integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==
+ dependencies:
+ "@standard-schema/spec" "^1.0.0"
+ "@standard-schema/utils" "^0.3.0"
+ immer "^11.0.0"
+ redux "^5.0.1"
+ redux-thunk "^3.1.0"
+ reselect "^5.1.0"
+
"@rollup/rollup-android-arm-eabi@4.42.0":
version "4.42.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4"
@@ -779,6 +806,16 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7"
integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==
+"@standard-schema/spec@^1.0.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
+ integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
+
+"@standard-schema/utils@^0.3.0":
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
+ integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
+
"@tailwindcss/node@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
@@ -908,11 +945,69 @@
dependencies:
tslib "^2.4.0"
+"@types/d3-array@^3.0.3":
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c"
+ integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==
+
+"@types/d3-color@*":
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
+ integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
+
+"@types/d3-ease@^3.0.0":
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
+ integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
+
+"@types/d3-interpolate@^3.0.1":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
+ integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
+ dependencies:
+ "@types/d3-color" "*"
+
+"@types/d3-path@*":
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a"
+ integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==
+
+"@types/d3-scale@^4.0.2":
+ version "4.0.9"
+ resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb"
+ integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==
+ dependencies:
+ "@types/d3-time" "*"
+
+"@types/d3-shape@^3.1.0":
+ version "3.1.8"
+ resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3"
+ integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==
+ dependencies:
+ "@types/d3-path" "*"
+
+"@types/d3-time@*", "@types/d3-time@^3.0.0":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f"
+ integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==
+
+"@types/d3-timer@^3.0.0":
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
+ integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
+
"@types/estree@1.0.7":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
+"@types/node@*":
+ version "24.0.3"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.3.tgz#f935910f3eece3a3a2f8be86b96ba833dc286cab"
+ integrity sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==
+ dependencies:
+ undici-types "~7.8.0"
+
"@types/node@^20":
version "20.19.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b"
@@ -932,6 +1027,75 @@
dependencies:
csstype "^3.0.2"
+"@types/use-sync-external-store@^0.0.6":
+ version "0.0.6"
+ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
+ integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
+
+"@vanilla-extract/babel-plugin-debug-ids@^1.2.2":
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz#0bcb26614d8c6c4c0d95f8f583d838ce71294633"
+ integrity sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==
+ dependencies:
+ "@babel/core" "^7.23.9"
+
+"@vanilla-extract/compiler@^0.2.3":
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/@vanilla-extract/compiler/-/compiler-0.2.3.tgz#97c4bb989aea92ee8329f1ad0a3ec01bf3aa8479"
+ integrity sha512-SFEDLbvd5rhpjhrLp9BtvvVNHNxWupiUht/yrsHQ7xfkpEn4xg45gbfma7aX9fsOpi82ebqFmowHd/g6jHDQnA==
+ dependencies:
+ "@vanilla-extract/css" "^1.17.4"
+ "@vanilla-extract/integration" "^8.0.4"
+ vite "^5.0.0 || ^6.0.0"
+ vite-node "^3.2.2"
+
+"@vanilla-extract/css@^1.17.4":
+ version "1.17.4"
+ resolved "https://registry.yarnpkg.com/@vanilla-extract/css/-/css-1.17.4.tgz#c73353992b8243e8ab140582bf6d673ebc709b0a"
+ integrity sha512-m3g9nQDWPtL+sTFdtCGRMI1Vrp86Ay4PBYq1Bo7Bnchj5ElNtAJpOqD+zg+apthVA4fB7oVpMWNjwpa6ElDWFQ==
+ dependencies:
+ "@emotion/hash" "^0.9.0"
+ "@vanilla-extract/private" "^1.0.9"
+ css-what "^6.1.0"
+ cssesc "^3.0.0"
+ csstype "^3.0.7"
+ dedent "^1.5.3"
+ deep-object-diff "^1.1.9"
+ deepmerge "^4.2.2"
+ lru-cache "^10.4.3"
+ media-query-parser "^2.0.2"
+ modern-ahocorasick "^1.0.0"
+ picocolors "^1.0.0"
+
+"@vanilla-extract/integration@^8.0.4":
+ version "8.0.4"
+ resolved "https://registry.yarnpkg.com/@vanilla-extract/integration/-/integration-8.0.4.tgz#eb176376b3b03c44713bf596cc41d6d97ba9f5d3"
+ integrity sha512-cmOb7tR+g3ulKvFtSbmdw3YUyIS1d7MQqN+FcbwNhdieyno5xzUyfDCMjeWJhmCSMvZ6WlinkrOkgs6SHB+FRg==
+ dependencies:
+ "@babel/core" "^7.23.9"
+ "@babel/plugin-syntax-typescript" "^7.23.3"
+ "@vanilla-extract/babel-plugin-debug-ids" "^1.2.2"
+ "@vanilla-extract/css" "^1.17.4"
+ dedent "^1.5.3"
+ esbuild "npm:esbuild@>=0.17.6 <0.26.0"
+ eval "0.1.8"
+ find-up "^5.0.0"
+ javascript-stringify "^2.0.1"
+ mlly "^1.4.2"
+
+"@vanilla-extract/private@^1.0.9":
+ version "1.0.9"
+ resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.9.tgz#bb8aaf72d2e04439792f2e389d9b705cfe691bc0"
+ integrity sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==
+
+"@vanilla-extract/vite-plugin@^5.0.6":
+ version "5.0.6"
+ resolved "https://registry.yarnpkg.com/@vanilla-extract/vite-plugin/-/vite-plugin-5.0.6.tgz#00084be8e872519dde5152d92241ad8ad1e85396"
+ integrity sha512-9dSPIuxR2NULvVk9bqCoTaZz3CtfBrvo5hImWaiWCblWZXzCcD7jIg7Nbcpdz9MvytO+mNta82/qCWj1G9mEMQ==
+ dependencies:
+ "@vanilla-extract/compiler" "^0.2.3"
+ "@vanilla-extract/integration" "^8.0.4"
+
accepts@~1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
@@ -940,6 +1104,11 @@ accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
+acorn@^8.14.0:
+ version "8.15.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
+ integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
+
ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
@@ -1077,6 +1246,11 @@ chownr@^3.0.0:
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
+clsx@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
+ integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
+
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@@ -1114,6 +1288,11 @@ compression@^1.7.4:
safe-buffer "5.2.1"
vary "~1.1.2"
+confbox@^0.1.8:
+ version "0.1.8"
+ resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
+ integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
+
content-disposition@0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@@ -1155,11 +1334,92 @@ cross-spawn@^7.0.6:
shebang-command "^2.0.0"
which "^2.0.1"
-csstype@^3.0.2:
+css-what@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
+ integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
+
+cssesc@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+ integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
+csstype@^3.0.2, csstype@^3.0.7:
version "3.1.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
+"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
+ version "3.2.4"
+ resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
+ integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
+ dependencies:
+ internmap "1 - 2"
+
+"d3-color@1 - 3":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
+ integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
+
+d3-ease@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
+ integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
+
+"d3-format@1 - 3":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
+ integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
+
+"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
+ integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
+ dependencies:
+ d3-color "1 - 3"
+
+d3-path@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
+ integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
+
+d3-scale@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
+ integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
+ dependencies:
+ d3-array "2.10.0 - 3"
+ d3-format "1 - 3"
+ d3-interpolate "1.2.0 - 3"
+ d3-time "2.1.1 - 3"
+ d3-time-format "2 - 4"
+
+d3-shape@^3.1.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
+ integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
+ dependencies:
+ d3-path "^3.1.0"
+
+"d3-time-format@2 - 4":
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
+ integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
+ dependencies:
+ d3-time "1 - 3"
+
+"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
+ integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
+ dependencies:
+ d3-array "2 - 3"
+
+d3-timer@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
+ integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
+
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -1174,11 +1434,26 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.1:
dependencies:
ms "^2.1.3"
+decimal.js-light@^2.5.1:
+ version "2.5.1"
+ resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
+ integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
+
dedent@^1.5.3:
version "1.6.0"
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==
+deep-object-diff@^1.1.9:
+ version "1.1.9"
+ resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595"
+ integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==
+
+deepmerge@^4.2.2:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
+ integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
+
depd@2.0.0, depd@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@@ -1273,7 +1548,12 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
dependencies:
es-errors "^1.3.0"
-esbuild@^0.25.0:
+es-toolkit@^1.39.3:
+ version "1.43.0"
+ resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.43.0.tgz#2c278d55ffeb30421e6e73a009738ed37b10ef61"
+ integrity sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==
+
+esbuild@^0.25.0, "esbuild@npm:esbuild@>=0.17.6 <0.26.0":
version "0.25.5"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==
@@ -1319,6 +1599,19 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+eval@0.1.8:
+ version "0.1.8"
+ resolved "https://registry.yarnpkg.com/eval/-/eval-0.1.8.tgz#2b903473b8cc1d1989b83a1e7923f883eb357f85"
+ integrity sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==
+ dependencies:
+ "@types/node" "*"
+ require-like ">= 0.1.1"
+
+eventemitter3@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
+ integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
+
exit-hook@2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
@@ -1379,6 +1672,14 @@ finalhandler@1.3.1:
statuses "2.0.1"
unpipe "~1.0.0"
+find-up@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
+ integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
+ dependencies:
+ locate-path "^6.0.0"
+ path-exists "^4.0.0"
+
foreground-child@^3.1.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
@@ -1519,11 +1820,26 @@ iconv-lite@0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
+immer@^10.1.1:
+ version "10.2.0"
+ resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1"
+ integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==
+
+immer@^11.0.0:
+ version "11.1.3"
+ resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6"
+ integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==
+
inherits@2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+"internmap@1 - 2":
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
+ integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
+
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -1560,6 +1876,11 @@ jackspeak@^3.1.2:
optionalDependencies:
"@pkgjs/parseargs" "^0.11.0"
+javascript-stringify@^2.0.1:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
+ integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
+
jiti@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
@@ -1667,12 +1988,19 @@ lightningcss@1.30.1:
lightningcss-win32-arm64-msvc "1.30.1"
lightningcss-win32-x64-msvc "1.30.1"
+locate-path@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
+ integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
+ dependencies:
+ p-locate "^5.0.0"
+
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
-lru-cache@^10.2.0:
+lru-cache@^10.2.0, lru-cache@^10.4.3:
version "10.4.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
@@ -1706,6 +2034,13 @@ math-intrinsics@^1.1.0:
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
+media-query-parser@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29"
+ integrity sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -1767,6 +2102,21 @@ mkdirp@^3.0.1:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
+mlly@^1.4.2, mlly@^1.7.4:
+ version "1.7.4"
+ resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
+ integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
+ dependencies:
+ acorn "^8.14.0"
+ pathe "^2.0.1"
+ pkg-types "^1.3.0"
+ ufo "^1.5.4"
+
+modern-ahocorasick@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz#9b1fa15d4f654be20a2ad7ecc44ec9d7645bb420"
+ integrity sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==
+
morgan@^1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
@@ -1874,6 +2224,20 @@ on-headers@~1.0.2:
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
+p-limit@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
+ integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
+ dependencies:
+ yocto-queue "^0.1.0"
+
+p-locate@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
+ integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
+ dependencies:
+ p-limit "^3.0.2"
+
package-json-from-dist@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
@@ -1884,6 +2248,11 @@ parseurl@~1.3.3:
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+path-exists@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+ integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
path-key@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
@@ -1907,12 +2276,12 @@ pathe@^1.1.2:
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
-pathe@^2.0.3:
+pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
-picocolors@^1.1.1:
+picocolors@^1.0.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
@@ -1922,6 +2291,15 @@ picomatch@^4.0.2:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
+pkg-types@^1.3.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
+ integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
+ dependencies:
+ confbox "^0.1.8"
+ mlly "^1.7.4"
+ pathe "^2.0.1"
+
postcss@^8.5.3:
version "8.5.4"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0"
@@ -1991,6 +2369,19 @@ react-dom@^19.1.0:
dependencies:
scheduler "^0.26.0"
+react-is@^19.2.3:
+ version "19.2.3"
+ resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
+ integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==
+
+"react-redux@8.x.x || 9.x.x":
+ version "9.2.0"
+ resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5"
+ integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==
+ dependencies:
+ "@types/use-sync-external-store" "^0.0.6"
+ use-sync-external-store "^1.4.0"
+
react-refresh@^0.14.0:
version "0.14.2"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
@@ -2014,6 +2405,43 @@ readdirp@^4.0.1:
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
+recharts@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.6.0.tgz#403f0606581153601857e46733277d1411633df3"
+ integrity sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==
+ dependencies:
+ "@reduxjs/toolkit" "1.x.x || 2.x.x"
+ clsx "^2.1.1"
+ decimal.js-light "^2.5.1"
+ es-toolkit "^1.39.3"
+ eventemitter3 "^5.0.1"
+ immer "^10.1.1"
+ react-redux "8.x.x || 9.x.x"
+ reselect "5.1.1"
+ tiny-invariant "^1.3.3"
+ use-sync-external-store "^1.2.2"
+ victory-vendor "^37.0.2"
+
+redux-thunk@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
+ integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
+
+redux@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
+ integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
+
+"require-like@>= 0.1.1":
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa"
+ integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==
+
+reselect@5.1.1, reselect@^5.1.0:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e"
+ integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==
+
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
@@ -2298,6 +2726,11 @@ tar@^7.4.3:
mkdirp "^3.0.1"
yallist "^5.0.0"
+tiny-invariant@^1.3.3:
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
+ integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
+
tinyglobby@^0.2.13:
version "0.2.14"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d"
@@ -2334,11 +2767,21 @@ typescript@^5.8.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
+ufo@^1.5.4:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"
+ integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==
+
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
+undici-types@~7.8.0:
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
+ integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
+
undici@^6.19.2:
version "6.21.3"
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a"
@@ -2362,6 +2805,11 @@ update-browserslist-db@^1.1.3:
escalade "^3.2.0"
picocolors "^1.1.1"
+use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
+ integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
+
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
@@ -2390,7 +2838,27 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
-vite-node@^3.1.4:
+victory-vendor@^37.0.2:
+ version "37.3.6"
+ resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da"
+ integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==
+ dependencies:
+ "@types/d3-array" "^3.0.3"
+ "@types/d3-ease" "^3.0.0"
+ "@types/d3-interpolate" "^3.0.1"
+ "@types/d3-scale" "^4.0.2"
+ "@types/d3-shape" "^3.1.0"
+ "@types/d3-time" "^3.0.0"
+ "@types/d3-timer" "^3.0.0"
+ d3-array "^3.1.6"
+ d3-ease "^3.0.1"
+ d3-interpolate "^3.0.1"
+ d3-scale "^4.0.2"
+ d3-shape "^3.1.0"
+ d3-time "^3.0.0"
+ d3-timer "^3.0.1"
+
+vite-node@^3.1.4, vite-node@^3.2.2:
version "3.2.3"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==
@@ -2410,7 +2878,7 @@ vite-tsconfig-paths@^5.1.4:
globrex "^0.1.2"
tsconfck "^3.0.3"
-"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
+"vite@^5.0.0 || ^6.0.0", "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
version "6.3.5"
resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3"
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
@@ -2465,3 +2933,8 @@ yallist@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
+
+yocto-queue@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
+ integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
diff --git a/cmd/api/main.go b/cmd/api/main.go
index 54a3076..2b64e15 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -3,6 +3,8 @@ package main
import (
"fmt"
"os"
+ "strings"
+ "log"
"github.com/gabehf/koito/engine"
)
@@ -11,7 +13,7 @@ var Version = "dev"
func main() {
if err := engine.Run(
- os.Getenv,
+ readEnvOrFile,
os.Stdout,
Version,
); err != nil {
@@ -19,3 +21,23 @@ func main() {
os.Exit(1)
}
}
+
+func readEnvOrFile(envName string) string {
+ envContent := os.Getenv(envName)
+
+ if envContent == "" {
+ filename := os.Getenv(envName + "_FILE")
+
+ if filename != "" {
+ b, err := os.ReadFile(filename)
+
+ if err != nil {
+ log.Fatalf("Failed to load file for %s_FILE (%s): %s", envName, filename, err)
+ }
+
+ envContent = strings.TrimSpace(string(b))
+ }
+ }
+
+ return envContent
+}
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/migrations/000004_fix_usernames.sql b/db/migrations/000004_fix_usernames.sql
new file mode 100644
index 0000000..58b13e6
--- /dev/null
+++ b/db/migrations/000004_fix_usernames.sql
@@ -0,0 +1,3 @@
+-- +goose Up
+UPDATE users
+SET username = LOWER(username);
\ No newline at end of file
diff --git a/db/migrations/000005_rm_orphan_artist_releases.sql b/db/migrations/000005_rm_orphan_artist_releases.sql
new file mode 100644
index 0000000..bfb361f
--- /dev/null
+++ b/db/migrations/000005_rm_orphan_artist_releases.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+DELETE FROM artist_releases ar
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM artist_tracks at
+ JOIN tracks t ON at.track_id = t.id
+ WHERE at.artist_id = ar.artist_id
+ AND t.release_id = ar.release_id
+);
diff --git a/db/migrations/migrations.go b/db/migrations/migrations.go
new file mode 100644
index 0000000..381665a
--- /dev/null
+++ b/db/migrations/migrations.go
@@ -0,0 +1,6 @@
+package migrations
+
+import "embed"
+
+//go:embed *.sql
+var Files embed.FS
diff --git a/db/queries/artist.sql b/db/queries/artist.sql
index 89eef45..70a2fdd 100644
--- a/db/queries/artist.sql
+++ b/db/queries/artist.sql
@@ -4,7 +4,7 @@ VALUES ($1, $2, $3)
RETURNING *;
-- name: GetArtist :one
-SELECT
+SELECT
a.*,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@@ -13,27 +13,29 @@ WHERE a.id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
-- name: GetTrackArtists :many
-SELECT
- a.*
+SELECT
+ 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.*
+SELECT
+ 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 (
- SELECT
+ SELECT
a.*,
COALESCE(array_agg(aa.alias), '{}')::text[] AS aliases
FROM artists_with_name a
@@ -46,7 +48,7 @@ WITH artist_with_aliases AS (
SELECT * FROM artist_with_aliases;
-- name: GetArtistByMbzID :one
-SELECT
+SELECT
a.*,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@@ -54,28 +56,77 @@ LEFT JOIN artist_aliases aa ON a.id = aa.artist_id
WHERE a.musicbrainz_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
+-- name: GetArtistsWithoutImages :many
+SELECT
+ *
+FROM artists_with_name
+WHERE image IS NULL
+ AND id > $2
+ORDER BY id ASC
+LIMIT $1;
+
-- name: GetTopArtistsPaginated :many
SELECT
+ x.id,
+ x.name,
+ x.musicbrainz_id,
+ x.image,
+ x.listen_count,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+FROM (
+ SELECT
a.id,
a.name,
a.musicbrainz_id,
a.image,
COUNT(*) AS listen_count
-FROM listens l
-JOIN tracks t ON l.track_id = t.id
-JOIN artist_tracks at ON at.track_id = t.id
-JOIN artists_with_name a ON a.id = at.artist_id
-WHERE l.listened_at BETWEEN $1 AND $2
-GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name
-ORDER BY listen_count DESC
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN artist_tracks at ON at.track_id = t.id
+ JOIN artists_with_name a ON a.id = at.artist_id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ GROUP BY a.id, a.name, a.musicbrainz_id, a.image
+) x
+ORDER BY x.listen_count DESC, x.id
LIMIT $3 OFFSET $4;
+-- name: GetArtistAllTimeRank :one
+SELECT
+ artist_id,
+ rank
+FROM (
+ SELECT
+ x.artist_id,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+ FROM (
+ SELECT
+ at.artist_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN artist_tracks at ON t.id = at.track_id
+ GROUP BY at.artist_id
+ ) x
+ )
+WHERE artist_id = $1;
+
-- name: CountTopArtists :one
SELECT COUNT(DISTINCT at.artist_id) AS total_count
FROM listens l
JOIN artist_tracks at ON l.track_id = at.track_id
WHERE l.listened_at BETWEEN $1 AND $2;
+-- name: CountNewArtists :one
+SELECT COUNT(*) AS total_count
+FROM (
+ SELECT at.artist_id
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN artist_tracks at ON t.id = at.track_id
+ GROUP BY at.artist_id
+ HAVING MIN(l.listened_at) BETWEEN $1 AND $2
+) first_appearances;
+
-- name: UpdateArtistMbzID :exec
UPDATE artists SET musicbrainz_id = $2
WHERE id = $1;
@@ -109,4 +160,4 @@ SET artist_id = $2
WHERE artist_id = $1;
-- name: DeleteArtist :exec
-DELETE FROM artists WHERE id = $1;
\ No newline at end of file
+DELETE FROM artists WHERE id = $1;
diff --git a/db/queries/etc.sql b/db/queries/etc.sql
index 44139b8..38465f2 100644
--- a/db/queries/etc.sql
+++ b/db/queries/etc.sql
@@ -3,7 +3,13 @@ DO $$
BEGIN
DELETE FROM tracks WHERE id NOT IN (SELECT l.track_id FROM listens l);
DELETE FROM releases WHERE id NOT IN (SELECT t.release_id FROM tracks t);
--- DELETE FROM releases WHERE release_group_id NOT IN (SELECT t.release_group_id FROM tracks t);
--- DELETE FROM releases WHERE release_group_id NOT IN (SELECT rg.id FROM release_groups rg);
DELETE FROM artists WHERE id NOT IN (SELECT at.artist_id FROM artist_tracks at);
+ DELETE FROM artist_releases ar
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM artist_tracks at
+ JOIN tracks t ON at.track_id = t.id
+ WHERE at.artist_id = ar.artist_id
+ AND t.release_id = ar.release_id
+ );
END $$;
diff --git a/db/queries/interest.sql b/db/queries/interest.sql
new file mode 100644
index 0000000..874f4cd
--- /dev/null
+++ b/db/queries/interest.sql
@@ -0,0 +1,139 @@
+-- name: GetGroupedListensFromArtist :many
+WITH bounds AS (
+ SELECT
+ MIN(l.listened_at) AS start_time,
+ NOW() AS end_time
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ JOIN artist_tracks at ON at.track_id = t.id
+ WHERE at.artist_id = $1
+),
+stats AS (
+ SELECT
+ start_time,
+ end_time,
+ EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
+ ((end_time - start_time) / sqlc.arg(bucket_count)::int) AS bucket_interval
+ FROM bounds
+),
+bucket_series AS (
+ SELECT generate_series(0, sqlc.arg(bucket_count)::int - 1) AS idx
+),
+listen_indices AS (
+ SELECT
+ LEAST(
+ sqlc.arg(bucket_count)::int - 1,
+ FLOOR(
+ (EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
+ * sqlc.arg(bucket_count)::int
+ )::int
+ ) AS bucket_idx
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ JOIN artist_tracks at ON at.track_id = t.id
+ CROSS JOIN stats s
+ WHERE at.artist_id = $1
+ AND s.start_time IS NOT NULL
+)
+SELECT
+ (s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
+ (s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
+ COUNT(li.bucket_idx) AS listen_count
+FROM bucket_series bs
+CROSS JOIN stats s
+LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
+WHERE s.start_time IS NOT NULL
+GROUP BY bs.idx, s.start_time, s.bucket_interval
+ORDER BY bs.idx;
+
+-- name: GetGroupedListensFromRelease :many
+WITH bounds AS (
+ SELECT
+ MIN(l.listened_at) AS start_time,
+ NOW() AS end_time
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ WHERE t.release_id = $1
+),
+stats AS (
+ SELECT
+ start_time,
+ end_time,
+ EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
+ ((end_time - start_time) / sqlc.arg(bucket_count)::int) AS bucket_interval
+ FROM bounds
+),
+bucket_series AS (
+ SELECT generate_series(0, sqlc.arg(bucket_count)::int - 1) AS idx
+),
+listen_indices AS (
+ SELECT
+ LEAST(
+ sqlc.arg(bucket_count)::int - 1,
+ FLOOR(
+ (EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
+ * sqlc.arg(bucket_count)::int
+ )::int
+ ) AS bucket_idx
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ CROSS JOIN stats s
+ WHERE t.release_id = $1
+ AND s.start_time IS NOT NULL
+)
+SELECT
+ (s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
+ (s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
+ COUNT(li.bucket_idx) AS listen_count
+FROM bucket_series bs
+CROSS JOIN stats s
+LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
+WHERE s.start_time IS NOT NULL
+GROUP BY bs.idx, s.start_time, s.bucket_interval
+ORDER BY bs.idx;
+
+-- name: GetGroupedListensFromTrack :many
+WITH bounds AS (
+ SELECT
+ MIN(l.listened_at) AS start_time,
+ NOW() AS end_time
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ WHERE t.id = $1
+),
+stats AS (
+ SELECT
+ start_time,
+ end_time,
+ EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
+ ((end_time - start_time) / sqlc.arg(bucket_count)::int) AS bucket_interval
+ FROM bounds
+),
+bucket_series AS (
+ SELECT generate_series(0, sqlc.arg(bucket_count)::int - 1) AS idx
+),
+listen_indices AS (
+ SELECT
+ LEAST(
+ sqlc.arg(bucket_count)::int - 1,
+ FLOOR(
+ (EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
+ * sqlc.arg(bucket_count)::int
+ )::int
+ ) AS bucket_idx
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ CROSS JOIN stats s
+ WHERE t.id = $1
+ AND s.start_time IS NOT NULL
+)
+SELECT
+ (s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
+ (s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
+ COUNT(li.bucket_idx) AS listen_count
+FROM bucket_series bs
+CROSS JOIN stats s
+LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
+WHERE s.start_time IS NOT NULL
+GROUP BY bs.idx, s.start_time, s.bucket_interval
+ORDER BY bs.idx;
diff --git a/db/queries/listen.sql b/db/queries/listen.sql
index 9049c4e..fab9687 100644
--- a/db/queries/listen.sql
+++ b/db/queries/listen.sql
@@ -4,16 +4,11 @@ VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING;
-- name: GetLastListensPaginated :many
-SELECT
+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
@@ -21,35 +16,35 @@ ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
-- name: GetLastListensFromArtistPaginated :many
-SELECT
+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
+JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $5
AND l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
+-- name: GetFirstListenFromArtist :one
+SELECT
+ l.*
+FROM listens l
+JOIN tracks_with_title t ON l.track_id = t.id
+JOIN artist_tracks at ON t.id = at.track_id
+WHERE at.artist_id = $1
+ORDER BY l.listened_at ASC
+LIMIT 1;
+
-- name: GetLastListensFromReleasePaginated :many
-SELECT
+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
@@ -57,17 +52,21 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
+-- name: GetFirstListenFromRelease :one
+SELECT
+ l.*
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE t.release_id = $1
+ORDER BY l.listened_at ASC
+LIMIT 1;
+
-- name: GetLastListensFromTrackPaginated :many
-SELECT
+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
@@ -75,6 +74,22 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
+-- name: GetFirstListenFromTrack :one
+SELECT
+ l.*
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE t.id = $1
+ORDER BY l.listened_at ASC
+LIMIT 1;
+
+-- name: GetFirstListen :one
+SELECT
+ *
+FROM listens
+ORDER BY listened_at ASC
+LIMIT 1;
+
-- name: CountListens :one
SELECT COUNT(*) AS total_count
FROM listens l
@@ -129,94 +144,122 @@ WHERE l.listened_at BETWEEN $1 AND $2
AND t.id = $3;
-- name: ListenActivity :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT * FROM bucketed_listens;
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens
+WHERE listened_at >= $2
+AND listened_at < $3
+GROUP BY day
+ORDER BY day;
-- name: ListenActivityForArtist :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-filtered_listens AS (
- SELECT l.*
- FROM listens l
- JOIN artist_tracks t ON l.track_id = t.track_id
- WHERE t.artist_id = $4
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN filtered_listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT * FROM bucketed_listens;
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+JOIN artist_tracks at ON t.id = at.track_id
+WHERE l.listened_at >= $2
+AND l.listened_at < $3
+AND at.artist_id = $4
+GROUP BY day
+ORDER BY day;
-- name: ListenActivityForRelease :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-filtered_listens AS (
- SELECT l.*
- FROM listens l
- JOIN tracks t ON l.track_id = t.id
- WHERE t.release_id = $4
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN filtered_listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT * FROM bucketed_listens;
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE l.listened_at >= $2
+AND l.listened_at < $3
+AND t.release_id = $4
+GROUP BY day
+ORDER BY day;
-- name: ListenActivityForTrack :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-filtered_listens AS (
- SELECT l.*
- FROM listens l
- JOIN tracks t ON l.track_id = t.id
- WHERE t.id = $4
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN filtered_listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT * FROM bucketed_listens;
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE l.listened_at >= $2
+AND l.listened_at < $3
+AND t.id = $4
+GROUP BY day
+ORDER BY day;
-- name: UpdateTrackIdForListens :exec
UPDATE listens SET track_id = $2
WHERE track_id = $1;
-- name: DeleteListen :exec
-DELETE FROM listens WHERE track_id = $1 AND listened_at = $2;
\ No newline at end of file
+DELETE FROM listens WHERE track_id = $1 AND listened_at = $2;
+
+-- name: GetListensExportPage :many
+SELECT
+ l.listened_at,
+ l.user_id,
+ l.client,
+
+ -- Track info
+ t.id AS track_id,
+ t.musicbrainz_id AS track_mbid,
+ t.duration AS track_duration,
+ (
+ SELECT json_agg(json_build_object(
+ 'alias', ta.alias,
+ 'source', ta.source,
+ 'is_primary', ta.is_primary
+ ))
+ FROM track_aliases ta
+ WHERE ta.track_id = t.id
+ ) AS track_aliases,
+
+ -- Release info
+ r.id AS release_id,
+ r.musicbrainz_id AS release_mbid,
+ r.image AS release_image,
+ r.image_source AS release_image_source,
+ r.various_artists,
+ (
+ SELECT json_agg(json_build_object(
+ 'alias', ra.alias,
+ 'source', ra.source,
+ 'is_primary', ra.is_primary
+ ))
+ FROM release_aliases ra
+ WHERE ra.release_id = r.id
+ ) AS release_aliases,
+
+ -- Artists
+ (
+ SELECT json_agg(json_build_object(
+ 'id', a.id,
+ 'musicbrainz_id', a.musicbrainz_id,
+ 'image', a.image,
+ 'image_source', a.image_source,
+ 'aliases', (
+ SELECT json_agg(json_build_object(
+ 'alias', aa.alias,
+ 'source', aa.source,
+ 'is_primary', aa.is_primary
+ ))
+ FROM artist_aliases aa
+ WHERE aa.artist_id = a.id
+ )
+ ))
+ FROM artist_tracks at
+ JOIN artists a ON a.id = at.artist_id
+ WHERE at.track_id = t.id
+ ) AS artists
+
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+JOIN releases r ON t.release_id = r.id
+
+WHERE l.user_id = @user_id::int
+ AND (l.listened_at, l.track_id) > (@listened_at::timestamptz, @track_id::int)
+ORDER BY l.listened_at, l.track_id
+LIMIT $1;
diff --git a/db/queries/release.sql b/db/queries/release.sql
index e90d95e..23bd2f2 100644
--- a/db/queries/release.sql
+++ b/db/queries/release.sql
@@ -4,7 +4,10 @@ VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetRelease :one
-SELECT * FROM releases_with_title
+SELECT
+ *,
+ get_artists_for_release(id) AS artists
+FROM releases_with_title
WHERE id = $1 LIMIT 1;
-- name: GetReleaseByMbzID :one
@@ -29,44 +32,76 @@ JOIN artist_releases ar ON r.id = ar.release_id
WHERE r.title = ANY ($1::TEXT[]) AND ar.artist_id = $2
LIMIT 1;
+-- name: GetReleaseByArtistAndTitlesNoMbzID :one
+SELECT r.*
+FROM releases_with_title r
+JOIN artist_releases ar ON r.id = ar.release_id
+WHERE r.title = ANY ($1::TEXT[])
+ AND ar.artist_id = $2
+ AND EXISTS (
+ SELECT 1
+ FROM releases r2
+ WHERE r2.id = r.id
+ AND r2.musicbrainz_id IS NULL
+ );
+
-- name: GetTopReleasesFromArtist :many
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
-FROM listens l
-JOIN tracks t ON l.track_id = t.id
-JOIN releases_with_title r ON t.release_id = r.id
-JOIN artist_releases ar ON r.id = ar.release_id
-WHERE ar.artist_id = $5
- AND l.listened_at BETWEEN $1 AND $2
-GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
-ORDER BY listen_count DESC
+ x.*,
+ get_artists_for_release(x.id) AS artists,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+FROM (
+ SELECT
+ r.*,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN releases_with_title r ON t.release_id = r.id
+ JOIN artist_releases ar ON r.id = ar.release_id
+ WHERE ar.artist_id = $5
+ AND l.listened_at BETWEEN $1 AND $2
+ GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
+) x
+ORDER BY listen_count DESC, x.id
LIMIT $3 OFFSET $4;
-- name: GetTopReleasesPaginated :many
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
-FROM listens l
-JOIN tracks t ON l.track_id = t.id
-JOIN releases_with_title r ON t.release_id = r.id
-WHERE l.listened_at BETWEEN $1 AND $2
-GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
-ORDER BY listen_count DESC
+ x.*,
+ get_artists_for_release(x.id) AS artists,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+FROM (
+ SELECT
+ r.*,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN releases_with_title r ON t.release_id = r.id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
+) x
+ORDER BY listen_count DESC, x.id
LIMIT $3 OFFSET $4;
+-- name: GetReleaseAllTimeRank :one
+SELECT
+ release_id,
+ rank
+FROM (
+ SELECT
+ x.release_id,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+ FROM (
+ SELECT
+ t.release_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ GROUP BY t.release_id
+ ) x
+ )
+WHERE release_id = $1;
+
-- name: CountTopReleases :one
SELECT COUNT(DISTINCT r.id) AS total_count
FROM listens l
@@ -76,26 +111,31 @@ WHERE l.listened_at BETWEEN $1 AND $2;
-- name: CountReleasesFromArtist :one
SELECT COUNT(*)
-FROM releases r
+FROM releases r
JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $1;
+-- name: CountNewReleases :one
+SELECT COUNT(*) AS total_count
+FROM (
+ SELECT t.release_id
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ GROUP BY t.release_id
+ HAVING MIN(l.listened_at) BETWEEN $1 AND $2
+) first_appearances;
+
-- name: AssociateArtistToRelease :exec
-INSERT INTO artist_releases (artist_id, release_id)
-VALUES ($1, $2)
+INSERT INTO artist_releases (artist_id, release_id, is_primary)
+VALUES ($1, $2, $3)
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
-FROM releases_with_title r
-WHERE r.image IS NULL
+ get_artists_for_release(r.id) AS artists
+FROM releases_with_title r
+WHERE r.image IS NULL
AND r.id > $2
ORDER BY r.id ASC
LIMIT $1;
@@ -104,6 +144,14 @@ LIMIT $1;
UPDATE releases SET musicbrainz_id = $2
WHERE id = $1;
+-- name: UpdateReleaseVariousArtists :exec
+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;
@@ -111,8 +159,8 @@ WHERE id = $1;
-- name: DeleteRelease :exec
DELETE FROM releases WHERE id = $1;
--- name: DeleteReleasesFromArtist :exec
+-- name: DeleteReleasesFromArtist :exec
DELETE FROM releases r
USING artist_releases ar
WHERE ar.release_id = r.id
- AND ar.artist_id = $1;
\ No newline at end of file
+ AND ar.artist_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..3be4d7e 100644
--- a/db/queries/track.sql
+++ b/db/queries/track.sql
@@ -4,13 +4,14 @@ VALUES ($1, $2, $3)
RETURNING *;
-- name: AssociateArtistToTrack :exec
-INSERT INTO artist_tracks (artist_id, track_id)
-VALUES ($1, $2)
+INSERT INTO artist_tracks (artist_id, track_id, is_primary)
+VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING;
-- name: GetTrack :one
-SELECT
+SELECT
t.*,
+ get_artists_for_track(t.id) AS artists,
r.image
FROM tracks_with_title t
JOIN releases r ON t.release_id = r.id
@@ -26,83 +27,112 @@ FROM tracks_with_title t
JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1;
--- name: GetTrackByTitleAndArtists :one
+-- name: GetTrackByTrackInfo :one
SELECT t.*
FROM tracks_with_title t
JOIN artist_tracks at ON at.track_id = t.id
WHERE t.title = $1
- AND at.artist_id = ANY($2::int[])
+ AND at.artist_id = ANY($3::int[])
+ AND t.release_id = $2
GROUP BY t.id, t.title, t.musicbrainz_id, t.duration, t.release_id
-HAVING COUNT(DISTINCT at.artist_id) = cardinality($2::int[]);
+HAVING COUNT(DISTINCT at.artist_id) = cardinality($3::int[]);
-- name: GetTopTracksPaginated :many
SELECT
- t.id,
+ x.track_id AS id,
t.title,
t.musicbrainz_id,
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
-FROM listens l
-JOIN tracks_with_title t ON l.track_id = t.id
+ x.listen_count,
+ get_artists_for_track(x.track_id) AS artists,
+ x.rank
+FROM (
+ SELECT
+ track_id,
+ COUNT(*) AS listen_count,
+ RANK() OVER (ORDER BY COUNT(*) DESC) as rank
+ FROM listens
+ WHERE listened_at BETWEEN $1 AND $2
+ GROUP BY track_id
+ ORDER BY listen_count DESC
+ LIMIT $3 OFFSET $4
+) x
+JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id
-WHERE l.listened_at BETWEEN $1 AND $2
-GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
-ORDER BY listen_count DESC
-LIMIT $3 OFFSET $4;
+ORDER BY x.listen_count DESC, x.track_id;
-- name: GetTopTracksByArtistPaginated :many
SELECT
- t.id,
+ x.track_id AS id,
t.title,
t.musicbrainz_id,
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
-FROM listens l
-JOIN tracks_with_title t ON l.track_id = t.id
+ x.listen_count,
+ get_artists_for_track(x.track_id) AS artists,
+ x.rank
+FROM (
+ SELECT
+ l.track_id,
+ COUNT(*) AS listen_count,
+ RANK() OVER (ORDER BY COUNT(*) DESC) as rank
+ FROM listens l
+ JOIN artist_tracks at ON l.track_id = at.track_id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ AND at.artist_id = $5
+ GROUP BY l.track_id
+ ORDER BY listen_count DESC
+ LIMIT $3 OFFSET $4
+) x
+JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id
-JOIN artist_tracks at ON at.track_id = t.id
-WHERE l.listened_at BETWEEN $1 AND $2
- AND at.artist_id = $5
-GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
-ORDER BY listen_count DESC
-LIMIT $3 OFFSET $4;
+ORDER BY x.listen_count DESC, x.track_id;
-- name: GetTopTracksInReleasePaginated :many
SELECT
- t.id,
+ x.track_id AS id,
t.title,
t.musicbrainz_id,
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
-FROM listens l
-JOIN tracks_with_title t ON l.track_id = t.id
+ x.listen_count,
+ get_artists_for_track(x.track_id) AS artists,
+ x.rank
+FROM (
+ SELECT
+ l.track_id,
+ COUNT(*) AS listen_count,
+ RANK() OVER (ORDER BY COUNT(*) DESC) as rank
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ AND t.release_id = $5
+ GROUP BY l.track_id
+ ORDER BY listen_count DESC
+ LIMIT $3 OFFSET $4
+) x
+JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id
-WHERE l.listened_at BETWEEN $1 AND $2
- AND t.release_id = $5
-GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
-ORDER BY listen_count DESC
-LIMIT $3 OFFSET $4;
+ORDER BY x.listen_count DESC, x.track_id;
+
+-- name: GetTrackAllTimeRank :one
+SELECT
+ id,
+ rank
+FROM (
+ SELECT
+ x.id,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+ FROM (
+ SELECT
+ t.id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks_with_title t ON l.track_id = t.id
+ GROUP BY t.id) x
+ ) y
+WHERE id = $1;
-- name: CountTopTracks :one
SELECT COUNT(DISTINCT l.track_id) AS total_count
@@ -123,6 +153,15 @@ JOIN tracks t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2
AND t.release_id = $3;
+-- name: CountNewTracks :one
+SELECT COUNT(*) AS total_count
+FROM (
+ SELECT track_id
+ FROM listens
+ GROUP BY track_id
+ HAVING MIN(listened_at) BETWEEN $1 AND $2
+) first_appearances;
+
-- name: UpdateTrackMbzID :exec
UPDATE tracks SET musicbrainz_id = $2
WHERE id = $1;
@@ -135,5 +174,19 @@ 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
+DELETE FROM tracks WHERE id = $1;
+
+-- name: GetTracksWithNoDurationButHaveMbzID :many
+SELECT
+ *
+FROM tracks_with_title
+WHERE duration = 0
+ AND musicbrainz_id IS NOT NULL
+ AND id > $2
+ORDER BY id ASC
+LIMIT $1;
diff --git a/db/queries/year.sql b/db/queries/year.sql
new file mode 100644
index 0000000..d6e8017
--- /dev/null
+++ b/db/queries/year.sql
@@ -0,0 +1,374 @@
+-- name: GetMostReplayedTrackInYear :one
+WITH ordered_listens AS (
+ SELECT
+ user_id,
+ track_id,
+ listened_at,
+ ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY listened_at) AS rn
+ FROM listens
+ WHERE EXTRACT(YEAR FROM listened_at) = @year::int
+),
+streaks AS (
+ SELECT
+ user_id,
+ track_id,
+ listened_at,
+ rn,
+ ROW_NUMBER() OVER (PARTITION BY user_id, track_id ORDER BY listened_at) AS track_rn
+ FROM ordered_listens
+),
+grouped_streaks AS (
+ SELECT
+ user_id,
+ track_id,
+ rn - track_rn AS group_id,
+ COUNT(*) AS streak_length
+ FROM streaks
+ GROUP BY user_id, track_id, rn - track_rn
+),
+ranked_streaks AS (
+ SELECT *,
+ RANK() OVER (PARTITION BY user_id ORDER BY streak_length DESC) AS r
+ FROM grouped_streaks
+)
+SELECT
+ t.*,
+ get_artists_for_track(t.id) as artists,
+ streak_length
+FROM ranked_streaks rs JOIN tracks_with_title t ON rs.track_id = t.id
+WHERE user_id = @user_id::int AND r = 1;
+
+-- name: TracksOnlyPlayedOnceInYear :many
+SELECT
+ t.id AS track_id,
+ t.title,
+ get_artists_for_track(t.id) as artists,
+ COUNT(l.*) AS listen_count
+FROM listens l
+JOIN tracks_with_title t ON t.id = l.track_id
+WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int AND l.user_id = @user_id::int
+GROUP BY t.id, t.title
+HAVING COUNT(*) = 1
+LIMIT $1;
+
+-- name: ArtistsOnlyPlayedOnceInYear :many
+SELECT
+ a.id AS artist_id,
+ a.name,
+ COUNT(l.*) AS listen_count
+FROM listens l
+JOIN artist_tracks at ON at.track_id = l.track_id
+JOIN artists_with_name a ON a.id = at.artist_id
+WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int AND l.user_id = @user_id::int
+GROUP BY a.id, a.name
+HAVING COUNT(*) = 1;
+
+-- GetNewTrackWithMostListensInYear :one
+WITH first_plays_in_year AS (
+ SELECT
+ l.user_id,
+ l.track_id,
+ MIN(l.listened_at) AS first_listen
+ FROM listens l
+ WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
+ AND NOT EXISTS (
+ SELECT 1
+ FROM listens l2
+ WHERE l2.user_id = l.user_id
+ AND l2.track_id = l.track_id
+ AND l2.listened_at < @first_day_of_year::date
+ )
+ GROUP BY l.user_id, l.track_id
+),
+seven_day_window AS (
+ SELECT
+ f.user_id,
+ f.track_id,
+ f.first_listen,
+ COUNT(l.*) AS plays_in_7_days
+ FROM first_plays_in_year f
+ JOIN listens l
+ ON l.user_id = f.user_id
+ AND l.track_id = f.track_id
+ AND l.listened_at >= f.first_listen
+ AND l.listened_at < f.first_listen + INTERVAL '7 days'
+ GROUP BY f.user_id, f.track_id, f.first_listen
+),
+ranked AS (
+ SELECT *,
+ RANK() OVER (PARTITION BY user_id ORDER BY plays_in_7_days DESC) AS r
+ FROM seven_day_window
+)
+SELECT
+ s.user_id,
+ s.track_id,
+ t.title,
+ get_artists_for_track(t.id) as artists,
+ s.first_listen,
+ s.plays_in_7_days
+FROM ranked s
+JOIN tracks_with_title t ON t.id = s.track_id
+WHERE r = 1;
+
+-- GetTopThreeNewArtistsInYear :many
+WITH first_artist_plays_in_year AS (
+ SELECT
+ l.user_id,
+ at.artist_id,
+ MIN(l.listened_at) AS first_listen
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
+ AND NOT EXISTS (
+ SELECT 1
+ FROM listens l2
+ JOIN artist_tracks at2 ON at2.track_id = l2.track_id
+ WHERE l2.user_id = l.user_id
+ AND at2.artist_id = at.artist_id
+ AND l2.listened_at < @first_day_of_year::date
+ )
+ GROUP BY l.user_id, at.artist_id
+),
+artist_plays_in_year AS (
+ SELECT
+ f.user_id,
+ f.artist_id,
+ f.first_listen,
+ COUNT(l.*) AS total_plays_in_year
+ FROM first_artist_plays_in_year f
+ JOIN listens l ON l.user_id = f.user_id
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE at.artist_id = f.artist_id
+ AND EXTRACT(YEAR FROM l.listened_at) = @year::int
+ GROUP BY f.user_id, f.artist_id, f.first_listen
+),
+ranked AS (
+ SELECT *,
+ RANK() OVER (PARTITION BY user_id ORDER BY total_plays_in_year DESC) AS r
+ FROM artist_plays_in_year
+)
+SELECT
+ a.user_id,
+ a.artist_id,
+ awn.name AS artist_name,
+ a.first_listen,
+ a.total_plays_in_year
+FROM ranked a
+JOIN artists_with_name awn ON awn.id = a.artist_id
+WHERE r <= 3;
+
+-- name: GetArtistWithLongestGapInYear :one
+WITH first_listens AS (
+ SELECT
+ l.user_id,
+ at.artist_id,
+ MIN(l.listened_at::date) AS first_listen_of_year
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
+ GROUP BY l.user_id, at.artist_id
+),
+last_listens AS (
+ SELECT
+ l.user_id,
+ at.artist_id,
+ MAX(l.listened_at::date) AS last_listen
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE l.listened_at < @first_day_of_year::date
+ GROUP BY l.user_id, at.artist_id
+),
+comebacks AS (
+ SELECT
+ f.user_id,
+ f.artist_id,
+ f.first_listen_of_year,
+ p.last_listen,
+ (f.first_listen_of_year - p.last_listen) AS gap_days
+ FROM first_listens f
+ JOIN last_listens p
+ ON f.user_id = p.user_id AND f.artist_id = p.artist_id
+),
+ranked AS (
+ SELECT *,
+ RANK() OVER (PARTITION BY user_id ORDER BY gap_days DESC) AS r
+ FROM comebacks
+)
+SELECT
+ c.user_id,
+ c.artist_id,
+ awn.name AS artist_name,
+ c.last_listen,
+ c.first_listen_of_year,
+ c.gap_days
+FROM ranked c
+JOIN artists_with_name awn ON awn.id = c.artist_id
+WHERE r = 1;
+
+-- name: GetFirstListenInYear :one
+SELECT
+ l.*,
+ t.*,
+ get_artists_for_track(t.id) as artists
+FROM listens l
+LEFT JOIN tracks_with_title t ON l.track_id = t.id
+WHERE EXTRACT(YEAR FROM l.listened_at) = 2025
+ORDER BY l.listened_at ASC
+LIMIT 1;
+
+-- name: GetTracksPlayedAtLeastOncePerMonthInYear :many
+WITH monthly_plays AS (
+ SELECT
+ l.track_id,
+ EXTRACT(MONTH FROM l.listened_at) AS month
+ FROM listens l
+ WHERE EXTRACT(YEAR FROM l.listened_at) = @user_id::int
+ GROUP BY l.track_id, EXTRACT(MONTH FROM l.listened_at)
+),
+monthly_counts AS (
+ SELECT
+ track_id,
+ COUNT(DISTINCT month) AS months_played
+ FROM monthly_plays
+ GROUP BY track_id
+)
+SELECT
+ t.id AS track_id,
+ t.title
+FROM monthly_counts mc
+JOIN tracks_with_title t ON t.id = mc.track_id
+WHERE mc.months_played = 12;
+
+-- name: GetWeekWithMostListensInYear :one
+SELECT
+ DATE_TRUNC('week', listened_at + INTERVAL '1 day') - INTERVAL '1 day' AS week_start,
+ COUNT(*) AS listen_count
+FROM listens
+WHERE EXTRACT(YEAR FROM listened_at) = @year::int
+ AND user_id = @user_id::int
+GROUP BY week_start
+ORDER BY listen_count DESC
+LIMIT 1;
+
+-- name: GetPercentageOfTotalListensFromTopTracksInYear :one
+WITH user_listens AS (
+ SELECT
+ l.track_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ WHERE l.user_id = @user_id::int
+ AND EXTRACT(YEAR FROM l.listened_at) = @year::int
+ GROUP BY l.track_id
+),
+top_tracks AS (
+ SELECT
+ track_id,
+ listen_count
+ FROM user_listens
+ ORDER BY listen_count DESC
+ LIMIT $1
+),
+totals AS (
+ SELECT
+ (SELECT SUM(listen_count) FROM top_tracks) AS top_tracks_total,
+ (SELECT SUM(listen_count) FROM user_listens) AS overall_total
+)
+SELECT
+ top_tracks_total,
+ overall_total,
+ ROUND((top_tracks_total::decimal / overall_total) * 100, 2) AS percent_of_total
+FROM totals;
+
+-- name: GetPercentageOfTotalListensFromTopArtistsInYear :one
+WITH user_artist_listens AS (
+ SELECT
+ at.artist_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE l.user_id = @user_id::int
+ AND EXTRACT(YEAR FROM l.listened_at) = @year::int
+ GROUP BY at.artist_id
+),
+top_artists AS (
+ SELECT
+ artist_id,
+ listen_count
+ FROM user_artist_listens
+ ORDER BY listen_count DESC
+ LIMIT $1
+),
+totals AS (
+ SELECT
+ (SELECT SUM(listen_count) FROM top_artists) AS top_artist_total,
+ (SELECT SUM(listen_count) FROM user_artist_listens) AS overall_total
+)
+SELECT
+ top_artist_total,
+ overall_total,
+ ROUND((top_artist_total::decimal / overall_total) * 100, 2) AS percent_of_total
+FROM totals;
+
+-- name: GetArtistsWithOnlyOnePlayInYear :many
+WITH first_artist_plays_in_year AS (
+ SELECT
+ l.user_id,
+ at.artist_id,
+ MIN(l.listened_at) AS first_listen
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE EXTRACT(YEAR FROM l.listened_at) = 2024
+ AND NOT EXISTS (
+ SELECT 1
+ FROM listens l2
+ JOIN artist_tracks at2 ON at2.track_id = l2.track_id
+ WHERE l2.user_id = l.user_id
+ AND at2.artist_id = at.artist_id
+ AND l2.listened_at < DATE '2024-01-01'
+ )
+ GROUP BY l.user_id, at.artist_id
+)
+SELECT
+ f.user_id,
+ f.artist_id,
+ f.first_listen, a.name,
+ COUNT(l.*) AS total_plays_in_year
+FROM first_artist_plays_in_year f
+JOIN listens l ON l.user_id = f.user_id
+JOIN artist_tracks at ON at.track_id = l.track_id JOIN artists_with_name a ON at.artist_id = a.id
+WHERE at.artist_id = f.artist_id
+ AND EXTRACT(YEAR FROM l.listened_at) = 2024
+GROUP BY f.user_id, f.artist_id, f.first_listen, a.name HAVING COUNT(*) = 1;
+
+-- name: GetArtistCountInYear :one
+SELECT
+ COUNT(DISTINCT at.artist_id) AS artist_count
+FROM listens l
+JOIN artist_tracks at ON at.track_id = l.track_id
+WHERE l.user_id = @user_id::int
+ AND EXTRACT(YEAR FROM l.listened_at) = @year::int;
+
+-- name: GetListenPercentageInTimeWindowInYear :one
+WITH user_listens_in_year AS (
+ SELECT
+ listened_at
+ FROM listens
+ WHERE user_id = @user_id::int
+ AND EXTRACT(YEAR FROM listened_at) = @year::int
+),
+windowed AS (
+ SELECT
+ COUNT(*) AS in_window
+ FROM user_listens_in_year
+ WHERE EXTRACT(HOUR FROM listened_at) >= @hour_window_start::int
+ AND EXTRACT(HOUR FROM listened_at) < @hour_window_end::int
+),
+total AS (
+ SELECT COUNT(*) AS total_listens
+ FROM user_listens_in_year
+)
+SELECT
+ w.in_window,
+ t.total_listens,
+ ROUND((w.in_window::decimal / t.total_listens) * 100, 2) AS percent_of_total
+FROM windowed w, total t;
\ No newline at end of file
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 845acb4..7875016 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -1,57 +1,69 @@
// @ts-check
-import { defineConfig } from 'astro/config';
-import starlight from '@astrojs/starlight';
+import { defineConfig } from "astro/config";
+import starlight from "@astrojs/starlight";
-import tailwindcss from '@tailwindcss/vite';
+import tailwindcss from "@tailwindcss/vite";
// https://astro.build/config
export default defineConfig({
integrations: [
- starlight({
- head: [
- {
- tag: 'script',
- attrs: {
- src: 'https://static.cloudflareinsights.com/beacon.min.js',
- 'data-cf-beacon': '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
- defer: true,
- }
- }
- ],
- title: 'Koito',
- logo: {
- src: './src/assets/logo_text.png',
- replacesTitle: true,
+ starlight({
+ head: [
+ {
+ tag: "script",
+ attrs: {
+ src: "https://static.cloudflareinsights.com/beacon.min.js",
+ "data-cf-beacon": '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
+ defer: true,
},
- social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/gabehf/koito' }],
- sidebar: [
- {
- label: 'Guides',
- items: [
- // Each item here is one entry in the navigation menu.
- { label: 'Installation', slug: 'guides/installation' },
- { label: 'Importing Data', slug: 'guides/importing' },
- { label: 'Setting up the Scrobbler', slug: 'guides/scrobbler' },
- { label: 'Editing Data', slug: 'guides/editing' },
- ],
- },
- {
- label: 'Reference',
- items: [
- { label: 'Configuration Options', slug: 'reference/configuration' },
- ]
- },
+ },
+ ],
+ title: "Koito",
+ logo: {
+ src: "./src/assets/logo_text.png",
+ replacesTitle: true,
+ },
+ social: [
+ {
+ icon: "github",
+ label: "GitHub",
+ href: "https://github.com/gabehf/koito",
+ },
+ ],
+ sidebar: [
+ {
+ label: "Guides",
+ items: [
+ // Each item here is one entry in the navigation menu.
+ { label: "Installation", slug: "guides/installation" },
+ { label: "Importing Data", slug: "guides/importing" },
+ { label: "Setting up the Scrobbler", slug: "guides/scrobbler" },
+ { label: "Editing Data", slug: "guides/editing" },
],
- customCss: [
- // Path to your Tailwind base styles:
- './src/styles/global.css',
- ],
- }),
- ],
+ },
+ {
+ label: "Quickstart",
+ items: [
+ { label: "Setup with Navidrome", slug: "quickstart/navidrome" },
+ ],
+ },
+ {
+ label: "Reference",
+ items: [
+ { label: "Configuration Options", slug: "reference/configuration" },
+ ],
+ },
+ ],
+ customCss: [
+ // Path to your Tailwind base styles:
+ "./src/styles/global.css",
+ ],
+ }),
+ ],
site: "https://koito.io",
vite: {
plugins: [tailwindcss()],
},
-});
\ No newline at end of file
+});
diff --git a/docs/src/assets/navidrome_lbz_switch.png b/docs/src/assets/navidrome_lbz_switch.png
new file mode 100644
index 0000000..a8b44be
Binary files /dev/null and b/docs/src/assets/navidrome_lbz_switch.png differ
diff --git a/docs/src/content/docs/guides/editing.md b/docs/src/content/docs/guides/editing.md
index 298cf67..caf4150 100644
--- a/docs/src/content/docs/guides/editing.md
+++ b/docs/src/content/docs/guides/editing.md
@@ -60,6 +60,8 @@ Once merged, we can see that all of the listen activity for Tsumugu has been asi

+You can also search for items when merging by their ID using the format `id:1234`.
+
#### Deleting Items
To delete at item, just click the trash icon, which is the fourth and final icon in the editing options. Doing so will open a confirmation dialogue. Once confirmed, the item you delete, as well as all of its children
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/docs/src/content/docs/guides/scrobbler.md b/docs/src/content/docs/guides/scrobbler.md
index 8eb3c9a..2178abb 100644
--- a/docs/src/content/docs/guides/scrobbler.md
+++ b/docs/src/content/docs/guides/scrobbler.md
@@ -3,7 +3,7 @@ title: Setting up the Scrobber
description: How to relay listens submitted to Koito to another ListenBrainz compatible server.
---
-To use the ListenBrainz API, you need to get your generated api key from the UI.
+To use the ListenBrainz API, you need to get your generated API key from the UI. The API key is what you will use as the ListenBrainz token.
First, open the settings in your Koito instance by clicking on the settings icon or pressing `\`.
@@ -20,7 +20,7 @@ After logging in, open the settings menu again and find the `API Keys` tab. On t
If you are not running Koito on an `https://` connection or `localhost`, the click-to-copy button will not work. Instead, just click on the key itself to highlight and copy it.
:::
-Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` and provide the api key from the UI as the token.
+Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` (or `{your_koito_address}/apis/listenbrainz` for some applications) and provide the API key from the UI as the token.
## Set up a relay
@@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
:::note
Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`.
+For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`.
:::
diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx
index 63f0403..f590ebb 100644
--- a/docs/src/content/docs/index.mdx
+++ b/docs/src/content/docs/index.mdx
@@ -28,12 +28,12 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
Koito can be connected to any music server or client that allows for custom ListenBrainz URLs.
- Automatically relay listens submitted to your Koito instance to other ListenBrainz compatble servers.
+ Automatically relay listens submitted to your Koito instance to other ListenBrainz compatible servers.
Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server.
- Koito ships with twelve different themes, with custom theme options to be added soon™.
+ Koito ships with twelve different themes, now with support for custom themes!
diff --git a/docs/src/content/docs/quickstart/navidrome.md b/docs/src/content/docs/quickstart/navidrome.md
new file mode 100644
index 0000000..b24bdb8
--- /dev/null
+++ b/docs/src/content/docs/quickstart/navidrome.md
@@ -0,0 +1,68 @@
+---
+title: Navidrome Quickstart
+description: How to set up Koito to work with your Navidrome instance.
+---
+
+## Configure Koito
+This quickstart assumes you are using Docker compose. Below is an example file, adjusted from the actual file I use personally.
+```yaml title="compose.yaml"
+services:
+ koito:
+ image: gabehf/koito:latest
+ container_name: koito
+ depends_on:
+ - db
+ user: 1000:1000
+ environment:
+ - KOITO_DATABASE_URL=postgres://postgres:@db:5432/koitodb
+ - KOITO_ALLOWED_HOSTS=koito.mydomain.com,192.168.1.100
+ - KOITO_SUBSONIC_URL=https://navidrome.mydomain.com # the url to your navidrome instance
+ - KOITO_SUBSONIC_PARAMS=u=&t=&s=
+ - KOITO_DEFAULT_THEME=black # i like this theme, use whatever you want
+ ports:
+ - "4110:4110"
+ volumes:
+ - ./koito-data:/etc/koito
+ restart: unless-stopped
+
+ db:
+ user: 1000:1000
+ image: postgres:16
+ container_name: psql
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: koitodb
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD:
+ volumes:
+ - ./db-data:/var/lib/postgresql/data
+```
+
+### How do I get the Subsonic params?
+The easiest way to get your Subsonic parameters to open your browser and sign into Navidrome, then press F12 to get to
+the developer options and navigate to the **Network** tab. Find a `getCoverArt` request (there should be a lot on the home
+page) and look for the part of the URL that looks like `u=&t=&s=`. This
+is what you need to copy and provide to Koito.
+:::note
+If you don't want to use Navidrome to provide images to Koito, you can skip the `KOITO_SUBSONIC_URL` and `KOITO_SUBSONIC_PARAMS`
+variables entirely.
+:::
+
+## Configure Navidrome
+You have to provide Navidrome with the environment variables `ND_LISTENBRAINZ_ENABLED=true` and
+`ND_LISTENBRAINZ_BASEURL=/apis/listenbrainz/1`. The place where you edit these environment variables will change
+depending on how you have chosen to deploy Navidrome.
+
+## Enable ListenBrainz in Navidrome
+In Navidome, click on **Settings** in the top right, then click **Personal**.
+
+Here, you will see that **Scrobble to ListenBrainz** is turned off. Flip that switch on.
+
+
+When you flip it on, Navidrome will prompt you for a ListenBrainz token. To get this token, open your Koito page and sign in.
+Press the settings button (or hit `\`) and go to the **API Keys** tab. Copy the autogenerated API key by either clicking the
+copy button, or clicking on the key itself and copying with ctrl+c.
+
+After hitting **Save** in Navidrome, your listen activity will start being sent to Koito as you listen to tracks.
+
+Happy scrobbling!
diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md
index 6524e43..2af573c 100644
--- a/docs/src/content/docs/reference/configuration.md
+++ b/docs/src/content/docs/reference/configuration.md
@@ -5,6 +5,12 @@ description: The available configuration options when setting up Koito.
Koito is configured using **environment variables**. This is the full list of configuration options supported by Koito.
+The suffix `_FILE` is also supported for every environment variable. This allows the use of Docker secrets, for example: `KOITO_DATABASE_URL_FILE=/run/secrets/database-url` will load the content of the file at `/run/secrets/database-url` for the environment variable `KOITO_DATABASE_URL`.
+
+:::caution
+If the environment variable is defined without **and** with the suffix at the same time, the content of the environment variable without the `_FILE` suffix will have the higher priority.
+:::
+
##### KOITO_DATABASE_URL
- Required: `true`
- Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information.
@@ -14,9 +20,15 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_DEFAULT_USERNAME
- Default: `admin`
- Description: The username for the user that is created on first startup. Only applies when running Koito for the first time.
-##### KOITO_DEFAULT_PASSWORD
+##### KOITO_DEFAULT_PASSWORD
- Default: `changeme`
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
+##### KOITO_DEFAULT_THEME
+- Default: `yuu`
+- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
+##### KOITO_LOGIN_GATE
+- Default: `false`
+- Description: When `true`, Koito will not show any statistics unless the user is logged in.
##### KOITO_BIND_ADDR
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT
@@ -31,6 +43,9 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_LOG_LEVEL
- Default: `info`
- Description: One of `debug | info | warn | error | fatal`
+##### KOITO_ARTIST_SEPARATORS_REGEX
+- Default: `\s+·\s+`
+- Description: The list of regex patterns Koito will use to separate artist strings, separated by two semicolons (`;;`).
##### KOITO_MUSICBRAINZ_URL
- Default: `https://musicbrainz.org`
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
@@ -42,21 +57,36 @@ Koito is configured using **environment variables**. This is the full list of co
- Description: Set to `true` if you want to relay requests from the ListenBrainz endpoints on your Koito server to another ListenBrainz compatible server.
##### KOITO_LBZ_RELAY_URL
- Required: `true` if relays are enabled.
-- Description: The URL to which relayed requests will be sent to.
+- Description: The URL to which relayed requests will be sent to.
##### KOITO_LBZ_RELAY_TOKEN
-- Required: `true` if relays are enabled.
+- Required: `true` if relays are enabled.
- Description: The user token to send with the relayed ListenBrainz requests.
##### KOITO_CONFIG_DIR
- Default: `/etc/koito`
- Description: The location where import folders and image caches are stored.
-##### KOITO_DISABLE_DEEZER
+##### KOITO_FORCE_TZ
+- Description: A canonical IANA database time zone name (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) that Koito will use to serve all clients. Overrides any timezones requested via a `tz` cookie or `tz` query parameter. Koito will fail to start if this value is invalid.
+##### KOITO_DISABLE_DEEZER
- Default: `false`
- Description: Disables Deezer as a source for finding artist and album images.
-##### KOITO_DISABLE_COVER_ART_ARCHIVE
+##### KOITO_DISABLE_COVER_ART_ARCHIVE
- Default: `false`
- Description: Disables Cover Art Archive as a source for finding album images.
##### KOITO_DISABLE_MUSICBRAINZ
- Default: `false`
+##### KOITO_SUBSONIC_URL
+- Required: `true` if KOITO_SUBSONIC_PARAMS is set
+- Description: The URL of your subsonic compatible music server. For example, `https://navidrome.mydomain.com`.
+##### KOITO_SUBSONIC_PARAMS
+- Required: `true` if KOITO_SUBSONIC_URL is set
+- Description: The `u`, `t`, and `s` authentication parameters to use for authenticated requests to your subsonic server, in the format `u=XXX&t=XXX&s=XXX`. An easy way to find them is to open the network tab in the developer tools of your browser of choice and copy them from a request.
+:::caution
+If Koito is unable to validate your Subsonic configuration, it will fail to start. If you notice your container isn't running after
+changing these parameters, check the logs!
+:::
+##### KOITO_LASTFM_API_KEY
+- Required: `false`
+- Description: Your LastFM API key, which will be used for fetching images if provided. You can get an API key [here](https://www.last.fm/api/authentication),
##### KOITO_SKIP_IMPORT
- Default: `false`
- Description: Skips running the importer on startup.
@@ -70,6 +100,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
+- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
diff --git a/engine/engine.go b/engine/engine.go
index 2cbcb28..979667e 100644
--- a/engine/engine.go
+++ b/engine/engine.go
@@ -2,6 +2,7 @@ package engine
import (
"context"
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -95,6 +96,10 @@ func Run(
defer store.Close(ctx)
l.Info().Msg("Engine: Database connection established")
+ if cfg.ForceTZ() != nil {
+ l.Debug().Msgf("Engine: Forcing the use of timezone '%s'", cfg.ForceTZ().String())
+ }
+
l.Debug().Msg("Engine: Initializing MusicBrainz client")
var mbzC mbz.MusicBrainzCaller
if !cfg.MusicBrainzDisabled() {
@@ -105,11 +110,39 @@ func Run(
l.Warn().Msg("Engine: MusicBrainz client disabled")
}
+ if cfg.SubsonicEnabled() {
+ l.Debug().Msg("Engine: Checking Subsonic configuration")
+ pingURL := cfg.SubsonicUrl() + "/rest/ping.view?" + cfg.SubsonicParams() + "&f=json&v=1&c=koito"
+
+ resp, err := http.Get(pingURL)
+ if err != nil {
+ l.Fatal().Err(err).Msg("Engine: Failed to contact Subsonic server! Ensure the provided URL is correct")
+ } else {
+ defer resp.Body.Close()
+
+ var result struct {
+ Response struct {
+ Status string `json:"status"`
+ } `json:"subsonic-response"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ l.Fatal().Err(err).Msg("Engine: Failed to parse Subsonic response")
+ } else if result.Response.Status != "ok" {
+ l.Fatal().Msg("Engine: Provided Subsonic credentials are invalid")
+ } else {
+ l.Info().Msg("Engine: Subsonic credentials validated successfully")
+ }
+ }
+ }
+
l.Debug().Msg("Engine: Initializing image sources")
images.Initialize(images.ImageSourceOpts{
- UserAgent: cfg.UserAgent(),
- EnableCAA: !cfg.CoverArtArchiveDisabled(),
- EnableDeezer: !cfg.DeezerDisabled(),
+ UserAgent: cfg.UserAgent(),
+ EnableCAA: !cfg.CoverArtArchiveDisabled(),
+ EnableDeezer: !cfg.DeezerDisabled(),
+ EnableSubsonic: cfg.SubsonicEnabled(),
+ EnableLastFM: cfg.LastFMApiKey() != "",
})
l.Info().Msg("Engine: Image sources initialized")
@@ -183,6 +216,8 @@ func Run(
}
}()
+ l.Info().Msg("Engine: Beginning startup tasks...")
+
l.Debug().Msg("Engine: Checking import configuration")
if !cfg.SkipImport() {
go func() {
@@ -192,6 +227,12 @@ func Run(
l.Info().Msg("Engine: Pruning orphaned images")
go catalog.PruneOrphanedImages(logger.NewContext(l), store)
+ l.Info().Msg("Engine: Running duration backfill task")
+ go catalog.BackfillTrackDurationsFromMusicBrainz(ctx, store, mbzC)
+ l.Info().Msg("Engine: Attempting to fetch missing artist images")
+ go catalog.FetchMissingArtistImages(ctx, store)
+ l.Info().Msg("Engine: Attempting to fetch missing album images")
+ go catalog.FetchMissingAlbumImages(ctx, store)
l.Info().Msg("Engine: Initialization finished")
quit := make(chan os.Signal, 1)
@@ -212,19 +253,19 @@ func Run(
}
func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
- l.Debug().Msg("Checking for import files...")
+ l.Debug().Msg("Importer: Checking for import files...")
files, err := os.ReadDir(path.Join(cfg.ConfigDir(), "import"))
if err != nil {
- l.Err(err).Msg("Failed to read files from import dir")
+ l.Err(err).Msg("Importer: Failed to read files from import dir")
}
if len(files) > 0 {
- l.Info().Msg("Files found in import directory. Attempting to import...")
+ l.Info().Msg("Importer: Files found in import directory. Attempting to import...")
} else {
return
}
defer func() {
if r := recover(); r != nil {
- l.Error().Interface("recover", r).Msg("Panic when importing files")
+ l.Error().Interface("recover", r).Msg("Importer: Panic when importing files")
}
}()
for _, file := range files {
@@ -232,31 +273,37 @@ func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
continue
}
if strings.Contains(file.Name(), "Streaming_History_Audio") {
- l.Info().Msgf("Import file %s detecting as being Spotify export", file.Name())
+ l.Info().Msgf("Importer: Import file %s detecting as being Spotify export", file.Name())
err := importer.ImportSpotifyFile(logger.NewContext(l), store, file.Name())
if err != nil {
- l.Err(err).Msgf("Failed to import file: %s", file.Name())
+ l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
}
} else if strings.Contains(file.Name(), "maloja") {
- l.Info().Msgf("Import file %s detecting as being Maloja export", file.Name())
+ l.Info().Msgf("Importer: Import file %s detecting as being Maloja export", file.Name())
err := importer.ImportMalojaFile(logger.NewContext(l), store, file.Name())
if err != nil {
- l.Err(err).Msgf("Failed to import file: %s", file.Name())
+ l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
}
} else if strings.Contains(file.Name(), "recenttracks") {
- l.Info().Msgf("Import file %s detecting as being ghan.nl LastFM export", file.Name())
+ l.Info().Msgf("Importer: Import file %s detecting as being ghan.nl LastFM export", file.Name())
err := importer.ImportLastFMFile(logger.NewContext(l), store, mbzc, file.Name())
if err != nil {
- l.Err(err).Msgf("Failed to import file: %s", file.Name())
+ l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
}
} else if strings.Contains(file.Name(), "listenbrainz") {
- l.Info().Msgf("Import file %s detecting as being ListenBrainz export", file.Name())
+ l.Info().Msgf("Importer: Import file %s detecting as being ListenBrainz export", file.Name())
err := importer.ImportListenBrainzExport(logger.NewContext(l), store, mbzc, file.Name())
if err != nil {
- l.Err(err).Msgf("Failed to import file: %s", file.Name())
+ l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
+ }
+ } else if strings.Contains(file.Name(), "koito") {
+ l.Info().Msgf("Importer: Import file %s detecting as being Koito export", file.Name())
+ err := importer.ImportKoitoFile(logger.NewContext(l), store, file.Name())
+ if err != nil {
+ l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
}
} else {
- l.Warn().Msgf("File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
+ l.Warn().Msgf("Importer: File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
}
}
}
diff --git a/engine/handlers/alias.go b/engine/handlers/alias.go
index add1b09..ef5ca02 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,61 +86,71 @@ 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")
+
+ err := r.ParseForm()
+ if err != nil {
+ l.Debug().Msg("DeleteAliasHandler: Failed to parse form")
+ utils.WriteError(w, "form is invalid", http.StatusBadRequest)
+ return
+ }
// Parse query parameters
- artistIDStr := r.URL.Query().Get("artist_id")
- albumIDStr := r.URL.Query().Get("album_id")
- trackIDStr := r.URL.Query().Get("track_id")
- alias := r.URL.Query().Get("alias")
+ artistIDStr := r.FormValue("artist_id")
+ albumIDStr := r.FormValue("album_id")
+ trackIDStr := r.FormValue("track_id")
+ alias := r.FormValue("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
}
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,71 +166,74 @@ 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
}
- artistIDStr := r.URL.Query().Get("artist_id")
- albumIDStr := r.URL.Query().Get("album_id")
- trackIDStr := r.URL.Query().Get("track_id")
+ artistIDStr := r.FormValue("artist_id")
+ albumIDStr := r.FormValue("album_id")
+ trackIDStr := r.FormValue("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,61 +249,76 @@ 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")
+
+ err := r.ParseForm()
+ if err != nil {
+ l.Debug().Msg("SetPrimaryAliasHandler: Failed to parse form")
+ utils.WriteError(w, "form is invalid", http.StatusBadRequest)
+ return
+ }
// Parse query parameters
- artistIDStr := r.URL.Query().Get("artist_id")
- albumIDStr := r.URL.Query().Get("album_id")
- trackIDStr := r.URL.Query().Get("track_id")
- alias := r.URL.Query().Get("alias")
+ artistIDStr := r.FormValue("artist_id")
+ albumIDStr := r.FormValue("album_id")
+ trackIDStr := r.FormValue("track_id")
+ alias := r.FormValue("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)
+ l.Debug().Msgf("Alias: %s", alias)
+
+ 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
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..590a771 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,49 @@ 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")
+ err := r.ParseForm()
+ if err != nil {
+ l.Debug().Msg("UpdateApiKeyLabelHandler: Failed to parse form")
+ utils.WriteError(w, "form is invalid", http.StatusBadRequest)
+ return
+ }
+
+ idStr := r.FormValue("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 c8edce6..2ecc72d 100644
--- a/engine/handlers/auth.go
+++ b/engine/handlers/auth.go
@@ -18,65 +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")
+
+ 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
+ }
- r.ParseForm()
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)
}
}
@@ -86,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)
}
}
@@ -123,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)
}
}
@@ -141,31 +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
}
- r.ParseForm()
- username := r.FormValue("username")
- password := r.FormValue("password")
-
- 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/export.go b/engine/handlers/export.go
new file mode 100644
index 0000000..da8b89b
--- /dev/null
+++ b/engine/handlers/export.go
@@ -0,0 +1,33 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gabehf/koito/engine/middleware"
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/export"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/utils"
+)
+
+func ExportHandler(store db.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Disposition", `attachment; filename="koito_export.json"`)
+ ctx := r.Context()
+ l := logger.FromContext(ctx)
+ l.Debug().Msg("ExportHandler: Recieved request for export file")
+ u := middleware.GetUserFromContext(ctx)
+ if u == nil {
+ l.Debug().Msg("ExportHandler: Unauthorized access")
+ utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ err := export.ExportData(ctx, u, store, w)
+ if err != nil {
+ l.Err(err).Msg("ExportHandler: Failed to create export file")
+ utils.WriteError(w, "failed to create export file", http.StatusInternalServerError)
+ return
+ }
+ }
+}
diff --git a/engine/handlers/get_listen_activity.go b/engine/handlers/get_listen_activity.go
index 86cf71a..c11ed3e 100644
--- a/engine/handlers/get_listen_activity.go
+++ b/engine/handlers/get_listen_activity.go
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"strings"
+ "time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
@@ -19,7 +20,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
rangeStr := r.URL.Query().Get("range")
_range, err := strconv.Atoi(rangeStr)
- if err != nil {
+ if err != nil && rangeStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter")
utils.WriteError(w, "invalid range parameter", http.StatusBadRequest)
return
@@ -27,7 +28,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
monthStr := r.URL.Query().Get("month")
month, err := strconv.Atoi(monthStr)
- if err != nil {
+ if err != nil && monthStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter")
utils.WriteError(w, "invalid month parameter", http.StatusBadRequest)
return
@@ -35,7 +36,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
yearStr := r.URL.Query().Get("year")
year, err := strconv.Atoi(yearStr)
- if err != nil {
+ if err != nil && yearStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter")
utils.WriteError(w, "invalid year parameter", http.StatusBadRequest)
return
@@ -43,7 +44,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
artistIdStr := r.URL.Query().Get("artist_id")
artistId, err := strconv.Atoi(artistIdStr)
- if err != nil {
+ if err != nil && artistIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter")
utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest)
return
@@ -51,7 +52,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
albumIdStr := r.URL.Query().Get("album_id")
albumId, err := strconv.Atoi(albumIdStr)
- if err != nil {
+ if err != nil && albumIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter")
utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest)
return
@@ -59,7 +60,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
trackIdStr := r.URL.Query().Get("track_id")
trackId, err := strconv.Atoi(trackIdStr)
- if err != nil {
+ if err != nil && trackIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter")
utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest)
return
@@ -85,11 +86,17 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
Range: _range,
Month: month,
Year: year,
+ Timezone: parseTZ(r),
AlbumID: int32(albumId),
ArtistID: int32(artistId),
TrackID: int32(trackId),
}
+ if strings.ToLower(opts.Timezone.String()) == "local" {
+ opts.Timezone, _ = time.LoadLocation("UTC")
+ l.Warn().Msg("GetListenActivityHandler: Timezone is unset, using UTC")
+ }
+
l.Debug().Msgf("GetListenActivityHandler: Retrieving listen activity with options: %+v", opts)
activity, err := store.GetListenActivity(ctx, opts)
@@ -99,7 +106,72 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
return
}
+ activity = processActivity(activity, opts)
+
l.Debug().Msg("GetListenActivityHandler: Successfully retrieved listen activity")
utils.WriteJSON(w, http.StatusOK, activity)
}
}
+
+// ngl i hate this
+func processActivity(
+ items []db.ListenActivityItem,
+ opts db.ListenActivityOpts,
+) []db.ListenActivityItem {
+ from, to := db.ListenActivityOptsToTimes(opts)
+
+ buckets := make(map[string]int64)
+
+ for _, item := range items {
+ bucketStart := normalizeToStep(item.Start, opts.Step)
+ key := bucketStart.Format("2006-01-02")
+ buckets[key] += item.Listens
+ }
+
+ var result []db.ListenActivityItem
+
+ for t := normalizeToStep(from, opts.Step); t.Before(to); t = addStep(t, opts.Step) {
+ key := t.Format("2006-01-02")
+
+ result = append(result, db.ListenActivityItem{
+ Start: t,
+ Listens: buckets[key],
+ })
+ }
+
+ return result
+}
+
+func normalizeToStep(t time.Time, step db.StepInterval) time.Time {
+ switch step {
+ case db.StepDay:
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+
+ case db.StepWeek:
+ weekday := int(t.Weekday())
+ if weekday == 0 {
+ weekday = 7
+ }
+ start := t.AddDate(0, 0, -(weekday - 1))
+ return time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, t.Location())
+
+ case db.StepMonth:
+ return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+
+ default:
+ return t
+ }
+}
+
+func addStep(t time.Time, step db.StepInterval) time.Time {
+ switch step {
+ case db.StepDay:
+ return t.AddDate(0, 0, 1)
+ case db.StepWeek:
+ return t.AddDate(0, 0, 7)
+ case db.StepMonth:
+ return t.AddDate(0, 1, 0)
+ default:
+ return t.AddDate(0, 0, 1)
+ }
+}
diff --git a/engine/handlers/get_summary.go b/engine/handlers/get_summary.go
new file mode 100644
index 0000000..ec15f19
--- /dev/null
+++ b/engine/handlers/get_summary.go
@@ -0,0 +1,28 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/summary"
+ "github.com/gabehf/koito/internal/utils"
+)
+
+func SummaryHandler(store db.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ l := logger.FromContext(ctx)
+ l.Debug().Msg("SummaryHandler: Received request to retrieve summary")
+ timeframe := TimeframeFromRequest(r)
+
+ summary, err := summary.GenerateSummary(ctx, store, 1, timeframe, "")
+ if err != nil {
+ l.Err(err).Int("userid", 1).Any("timeframe", timeframe).Msgf("SummaryHandler: Failed to generate summary")
+ utils.WriteError(w, "failed to generate summary", http.StatusInternalServerError)
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, summary)
+ }
+}
diff --git a/engine/handlers/handlers.go b/engine/handlers/handlers.go
index 28908f6..78bc228 100644
--- a/engine/handlers/handlers.go
+++ b/engine/handlers/handlers.go
@@ -5,7 +5,10 @@ import (
"net/http"
"strconv"
"strings"
+ "time"
+ _ "time/tzdata"
+ "github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
)
@@ -36,13 +39,6 @@ func OptsFromRequest(r *http.Request) db.GetItemsOpts {
page = 1
}
- weekStr := r.URL.Query().Get("week")
- week, _ := strconv.Atoi(weekStr)
- monthStr := r.URL.Query().Get("month")
- month, _ := strconv.Atoi(monthStr)
- yearStr := r.URL.Query().Get("year")
- year, _ := strconv.Atoi(yearStr)
-
artistIdStr := r.URL.Query().Get("artist_id")
artistId, _ := strconv.Atoi(artistIdStr)
albumIdStr := r.URL.Query().Get("album_id")
@@ -50,6 +46,8 @@ func OptsFromRequest(r *http.Request) db.GetItemsOpts {
trackIdStr := r.URL.Query().Get("track_id")
trackId, _ := strconv.Atoi(trackIdStr)
+ tf := TimeframeFromRequest(r)
+
var period db.Period
switch strings.ToLower(r.URL.Query().Get("period")) {
case "day":
@@ -62,23 +60,195 @@ func OptsFromRequest(r *http.Request) db.GetItemsOpts {
period = db.PeriodYear
case "all_time":
period = db.PeriodAllTime
- default:
- l.Debug().Msgf("OptsFromRequest: Using default value '%s' for period", db.PeriodDay)
- period = db.PeriodDay
}
- l.Debug().Msgf("OptsFromRequest: Parsed options: limit=%d, page=%d, week=%d, month=%d, year=%d, artist_id=%d, album_id=%d, track_id=%d, period=%s",
- limit, page, week, month, year, artistId, albumId, trackId, period)
+ l.Debug().Msgf("OptsFromRequest: Parsed options: limit=%d, page=%d, week=%d, month=%d, year=%d, from=%d, to=%d, artist_id=%d, album_id=%d, track_id=%d, period=%s",
+ limit, page, tf.Week, tf.Month, tf.Year, tf.FromUnix, tf.ToUnix, artistId, albumId, trackId, period)
return db.GetItemsOpts{
- Limit: limit,
- Period: period,
- Page: page,
- Week: week,
- Month: month,
- Year: year,
- ArtistID: artistId,
- AlbumID: albumId,
- TrackID: trackId,
+ Limit: limit,
+ Page: page,
+ Timeframe: tf,
+ ArtistID: artistId,
+ AlbumID: albumId,
+ TrackID: trackId,
}
}
+
+func TimeframeFromRequest(r *http.Request) db.Timeframe {
+ q := r.URL.Query()
+
+ parseInt := func(key string) int {
+ v := q.Get(key)
+ if v == "" {
+ return 0
+ }
+ i, _ := strconv.Atoi(v)
+ return i
+ }
+
+ parseInt64 := func(key string) int64 {
+ v := q.Get(key)
+ if v == "" {
+ return 0
+ }
+ i, _ := strconv.ParseInt(v, 10, 64)
+ return i
+ }
+
+ return db.Timeframe{
+ Period: db.Period(q.Get("period")),
+ Year: parseInt("year"),
+ Month: parseInt("month"),
+ Week: parseInt("week"),
+ FromUnix: parseInt64("from"),
+ ToUnix: parseInt64("to"),
+ Timezone: parseTZ(r),
+ }
+}
+
+func parseTZ(r *http.Request) *time.Location {
+
+ // this map is obviously AI.
+ // i manually referenced as many links as I could and couldn't find any
+ // incorrect entries here so hopefully it is all correct.
+ overrides := map[string]string{
+ // --- North America ---
+ "America/Indianapolis": "America/Indiana/Indianapolis",
+ "America/Knoxville": "America/Indiana/Knoxville",
+ "America/Louisville": "America/Kentucky/Louisville",
+ "America/Montreal": "America/Toronto",
+ "America/Shiprock": "America/Denver",
+ "America/Fort_Wayne": "America/Indiana/Indianapolis",
+ "America/Virgin": "America/Port_of_Spain",
+ "America/Santa_Isabel": "America/Tijuana",
+ "America/Ensenada": "America/Tijuana",
+ "America/Rosario": "America/Argentina/Cordoba",
+ "America/Jujuy": "America/Argentina/Jujuy",
+ "America/Mendoza": "America/Argentina/Mendoza",
+ "America/Catamarca": "America/Argentina/Catamarca",
+ "America/Cordoba": "America/Argentina/Cordoba",
+ "America/Buenos_Aires": "America/Argentina/Buenos_Aires",
+ "America/Coral_Harbour": "America/Atikokan",
+ "America/Atka": "America/Adak",
+ "US/Alaska": "America/Anchorage",
+ "US/Aleutian": "America/Adak",
+ "US/Arizona": "America/Phoenix",
+ "US/Central": "America/Chicago",
+ "US/Eastern": "America/New_York",
+ "US/East-Indiana": "America/Indiana/Indianapolis",
+ "US/Hawaii": "Pacific/Honolulu",
+ "US/Indiana-Starke": "America/Indiana/Knoxville",
+ "US/Michigan": "America/Detroit",
+ "US/Mountain": "America/Denver",
+ "US/Pacific": "America/Los_Angeles",
+ "US/Samoa": "Pacific/Pago_Pago",
+ "Canada/Atlantic": "America/Halifax",
+ "Canada/Central": "America/Winnipeg",
+ "Canada/Eastern": "America/Toronto",
+ "Canada/Mountain": "America/Edmonton",
+ "Canada/Newfoundland": "America/St_Johns",
+ "Canada/Pacific": "America/Vancouver",
+
+ // --- Asia ---
+ "Asia/Calcutta": "Asia/Kolkata",
+ "Asia/Saigon": "Asia/Ho_Chi_Minh",
+ "Asia/Katmandu": "Asia/Kathmandu",
+ "Asia/Rangoon": "Asia/Yangon",
+ "Asia/Ulan_Bator": "Asia/Ulaanbaatar",
+ "Asia/Macao": "Asia/Macau",
+ "Asia/Tel_Aviv": "Asia/Jerusalem",
+ "Asia/Ashkhabad": "Asia/Ashgabat",
+ "Asia/Chungking": "Asia/Chongqing",
+ "Asia/Dacca": "Asia/Dhaka",
+ "Asia/Istanbul": "Europe/Istanbul",
+ "Asia/Kashgar": "Asia/Urumqi",
+ "Asia/Thimbu": "Asia/Thimphu",
+ "Asia/Ujung_Pandang": "Asia/Makassar",
+ "ROC": "Asia/Taipei",
+ "Iran": "Asia/Tehran",
+ "Israel": "Asia/Jerusalem",
+ "Japan": "Asia/Tokyo",
+ "Singapore": "Asia/Singapore",
+ "Hongkong": "Asia/Hong_Kong",
+
+ // --- Europe ---
+ "Europe/Kiev": "Europe/Kyiv",
+ "Europe/Belfast": "Europe/London",
+ "Europe/Tiraspol": "Europe/Chisinau",
+ "Europe/Nicosia": "Asia/Nicosia",
+ "Europe/Moscow": "Europe/Moscow",
+ "W-SU": "Europe/Moscow",
+ "GB": "Europe/London",
+ "GB-Eire": "Europe/London",
+ "Eire": "Europe/Dublin",
+ "Poland": "Europe/Warsaw",
+ "Portugal": "Europe/Lisbon",
+ "Turkey": "Europe/Istanbul",
+
+ // --- Australia / Pacific ---
+ "Australia/ACT": "Australia/Sydney",
+ "Australia/Canberra": "Australia/Sydney",
+ "Australia/LHI": "Australia/Lord_Howe",
+ "Australia/North": "Australia/Darwin",
+ "Australia/NSW": "Australia/Sydney",
+ "Australia/Queensland": "Australia/Brisbane",
+ "Australia/South": "Australia/Adelaide",
+ "Australia/Tasmania": "Australia/Hobart",
+ "Australia/Victoria": "Australia/Melbourne",
+ "Australia/West": "Australia/Perth",
+ "Australia/Yancowinna": "Australia/Broken_Hill",
+ "Pacific/Samoa": "Pacific/Pago_Pago",
+ "Pacific/Yap": "Pacific/Chuuk",
+ "Pacific/Truk": "Pacific/Chuuk",
+ "Pacific/Ponape": "Pacific/Pohnpei",
+ "NZ": "Pacific/Auckland",
+ "NZ-CHAT": "Pacific/Chatham",
+
+ // --- Africa ---
+ "Africa/Asmera": "Africa/Asmara",
+ "Africa/Timbuktu": "Africa/Bamako",
+ "Egypt": "Africa/Cairo",
+ "Libya": "Africa/Tripoli",
+
+ // --- Atlantic ---
+ "Atlantic/Faeroe": "Atlantic/Faroe",
+ "Atlantic/Jan_Mayen": "Europe/Oslo",
+ "Iceland": "Atlantic/Reykjavik",
+
+ // --- Etc / Misc ---
+ "UTC": "UTC",
+ "Etc/UTC": "UTC",
+ "Etc/GMT": "UTC",
+ "GMT": "UTC",
+ "Zulu": "UTC",
+ "Universal": "UTC",
+ }
+
+ if cfg.ForceTZ() != nil {
+ return cfg.ForceTZ()
+ }
+
+ if tz := r.URL.Query().Get("tz"); tz != "" {
+ if fixedTz, exists := overrides[tz]; exists {
+ tz = fixedTz
+ }
+ if loc, err := time.LoadLocation(tz); err == nil {
+ return loc
+ }
+ }
+
+ if c, err := r.Cookie("tz"); err == nil {
+ var tz string
+ if fixedTz, exists := overrides[c.Value]; exists {
+ tz = fixedTz
+ } else {
+ tz = c.Value
+ }
+ if loc, err := time.LoadLocation(tz); err == nil {
+ return loc
+ }
+ }
+
+ return time.Now().Location()
+}
diff --git a/engine/handlers/image_handler.go b/engine/handlers/image_handler.go
index 84d8681..4b17c96 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)
@@ -110,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")
@@ -139,3 +151,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: %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: %w", err)
+ }
+ return path.Join(catalog.SourceImageDir(), id.String()), nil
+}
diff --git a/engine/handlers/interest.go b/engine/handlers/interest.go
new file mode 100644
index 0000000..9787c45
--- /dev/null
+++ b/engine/handlers/interest.go
@@ -0,0 +1,47 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/utils"
+)
+
+func GetInterestHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ l := logger.FromContext(ctx)
+
+ l.Debug().Msg("GetInterestHandler: Received request to retrieve interest")
+
+ // im just using this to parse the artist/album/track id, which is bad
+ parsed := OptsFromRequest(r)
+
+ bucketCountStr := r.URL.Query().Get("buckets")
+ var buckets = 0
+ var err error
+ if buckets, err = strconv.Atoi(bucketCountStr); err != nil {
+ l.Debug().Msg("GetInterestHandler: Buckets is not an integer")
+ utils.WriteError(w, "parameter 'buckets' must be an integer", http.StatusBadRequest)
+ return
+ }
+
+ opts := db.GetInterestOpts{
+ Buckets: buckets,
+ AlbumID: int32(parsed.AlbumID),
+ ArtistID: int32(parsed.ArtistID),
+ TrackID: int32(parsed.TrackID),
+ }
+
+ interest, err := store.GetInterest(ctx, opts)
+ if err != nil {
+ l.Err(err).Msg("GetInterestHandler: Failed to query interest")
+ utils.WriteError(w, "Failed to retrieve interest: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, interest)
+ }
+}
diff --git a/engine/handlers/lbz_submit_listen.go b/engine/handlers/lbz_submit_listen.go
index 6a0dad1..daf7969 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"`
@@ -79,6 +90,11 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
utils.WriteError(w, "failed to read request body", http.StatusBadRequest)
return
}
+
+ if cfg.LbzRelayEnabled() {
+ go doLbzRelay(requestBytes, l)
+ }
+
if err := json.NewDecoder(bytes.NewBuffer(requestBytes)).Decode(&req); err != nil {
l.Err(err).Msg("LbzSubmitListenHandler: Failed to decode request")
utils.WriteError(w, "failed to decode request", http.StatusBadRequest)
@@ -92,7 +108,7 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
return
}
- l.Debug().Any("request_body", req).Msg("LbzSubmitListenHandler: Parsed request body")
+ l.Info().Any("request_body", req).Msg("LbzSubmitListenHandler: Parsed request body")
if len(req.Payload) < 1 {
l.Debug().Msg("LbzSubmitListenHandler: Payload is empty")
@@ -126,7 +142,14 @@ 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().AnErr("error", err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping")
+ utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs)
+ if err != nil {
+ l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
+ }
}
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
if err != nil {
@@ -134,11 +157,17 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
}
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,24 +189,35 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
listenedAt = time.Unix(payload.ListenedAt, 0)
}
- 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,
+ 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.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})
}
- if req.ListenType == ListenTypePlayingNow {
- opts.SkipSaveListen = true
+ 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,
+ ArtistMbidMappings: artistMbidMap,
+ Duration: duration,
+ Time: listenedAt,
+ UserID: u.ID,
+ Client: client,
+ IsNowPlaying: req.ListenType == ListenTypePlayingNow,
+ SkipSaveListen: req.ListenType == ListenTypePlayingNow,
}
_, err, shared := sfGroup.Do(buildCaolescingKey(payload), func() (interface{}, error) {
@@ -199,10 +239,6 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"status\": \"ok\"}"))
-
- if cfg.LbzRelayEnabled() {
- go doLbzRelay(requestBytes, l)
- }
}
}
diff --git a/engine/handlers/manual_scrobble.go b/engine/handlers/manual_scrobble.go
new file mode 100644
index 0000000..3eff40f
--- /dev/null
+++ b/engine/handlers/manual_scrobble.go
@@ -0,0 +1,77 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/gabehf/koito/engine/middleware"
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/utils"
+)
+
+func SubmitListenWithIDHandler(store db.DB) http.HandlerFunc {
+
+ var defaultClientStr = "Koito Web UI"
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ l := logger.FromContext(ctx)
+
+ l.Debug().Msg("SubmitListenWithIDHandler: Got request")
+
+ u := middleware.GetUserFromContext(ctx)
+ if u == nil {
+ l.Debug().Msg("SubmitListenWithIDHandler: Unauthorized request (user context is nil)")
+ utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ err := r.ParseForm()
+ if err != nil {
+ l.Debug().Msg("SubmitListenWithIDHandler: Failed to parse form")
+ utils.WriteError(w, "form is invalid", http.StatusBadRequest)
+ return
+ }
+
+ trackIDStr := r.FormValue("track_id")
+ timestampStr := r.FormValue("unix")
+ client := r.FormValue("client")
+ if client == "" {
+ client = defaultClientStr
+ }
+
+ if trackIDStr == "" || timestampStr == "" {
+ l.Debug().Msg("SubmitListenWithIDHandler: Request is missing required parameters")
+ utils.WriteError(w, "track_id and unix (timestamp) must be provided", http.StatusBadRequest)
+ return
+ }
+ trackID, err := strconv.Atoi(trackIDStr)
+ if err != nil {
+ l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid track id")
+ utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
+ return
+ }
+ unix, err := strconv.ParseInt(timestampStr, 10, 64)
+ if err != nil || time.Now().Unix() < unix {
+ l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid unix timestamp")
+ utils.WriteError(w, "invalid timestamp", http.StatusBadRequest)
+ return
+ }
+
+ ts := time.Unix(unix, 0)
+ err = store.SaveListen(ctx, db.SaveListenOpts{
+ TrackID: int32(trackID),
+ Time: ts,
+ UserID: u.ID,
+ Client: client,
+ })
+ if err != nil {
+ l.Err(err).Msg("SubmitListenWithIDHandler: Failed to submit listen")
+ utils.WriteError(w, "failed to submit listen", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusCreated)
+ }
+}
diff --git a/engine/handlers/mbzid.go b/engine/handlers/mbzid.go
new file mode 100644
index 0000000..e7aafd8
--- /dev/null
+++ b/engine/handlers/mbzid.go
@@ -0,0 +1,105 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/utils"
+ "github.com/google/uuid"
+)
+
+func UpdateMbzIdHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ l := logger.FromContext(ctx)
+
+ l.Debug().Msg("UpdateMbzIdHandler: Received request to set update MusicBrainz ID")
+
+ err := r.ParseForm()
+ if err != nil {
+ l.Debug().Msg("UpdateMbzIdHandler: Failed to parse form")
+ utils.WriteError(w, "form is invalid", http.StatusBadRequest)
+ return
+ }
+
+ // Parse query parameters
+ artistIDStr := r.FormValue("artist_id")
+ albumIDStr := r.FormValue("album_id")
+ trackIDStr := r.FormValue("track_id")
+ mbzidStr := r.FormValue("mbz_id")
+
+ if mbzidStr == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
+ l.Debug().Msg("UpdateMbzIdHandler: Request is missing required parameters")
+ utils.WriteError(w, "mbzid and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
+ return
+ }
+ if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
+ l.Debug().Msg("UpdateMbzIdHandler: 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 mbzid uuid.UUID
+ if mbzid, err = uuid.Parse(mbzidStr); err != nil {
+ l.Debug().Msg("UpdateMbzIdHandler: Provided MusicBrainz ID is invalid")
+ utils.WriteError(w, "provided musicbrainz id is invalid", http.StatusBadRequest)
+ return
+ }
+
+ if artistIDStr != "" {
+ var artistID int
+ artistID, err = strconv.Atoi(artistIDStr)
+ if err != nil {
+ l.Debug().AnErr("error", err).Msg("UpdateMbzIdHandler: Invalid artist id")
+ utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
+ return
+ }
+ err = store.UpdateArtist(ctx, db.UpdateArtistOpts{
+ ID: int32(artistID),
+ MusicBrainzID: mbzid,
+ })
+ if err != nil {
+ l.Error().Err(err).Msg("UpdateMbzIdHandler: Failed to update musicbrainz id")
+ utils.WriteError(w, "failed to update musicbrainz id", http.StatusInternalServerError)
+ return
+ }
+ } else if albumIDStr != "" {
+ var albumID int
+ albumID, err = strconv.Atoi(albumIDStr)
+ if err != nil {
+ l.Debug().AnErr("error", err).Msg("UpdateMbzIdHandler: Invalid album id")
+ utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
+ return
+ }
+ err = store.UpdateAlbum(ctx, db.UpdateAlbumOpts{
+ ID: int32(albumID),
+ MusicBrainzID: mbzid,
+ })
+ if err != nil {
+ l.Error().Err(err).Msg("UpdateMbzIdHandler: Failed to update musicbrainz id")
+ utils.WriteError(w, "failed to update musicbrainz id", http.StatusInternalServerError)
+ return
+ }
+ } else if trackIDStr != "" {
+ var trackID int
+ trackID, err = strconv.Atoi(trackIDStr)
+ if err != nil {
+ l.Debug().AnErr("error", err).Msg("UpdateMbzIdHandler: Invalid track id")
+ utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
+ return
+ }
+ err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
+ ID: int32(trackID),
+ MusicBrainzID: mbzid,
+ })
+ if err != nil {
+ l.Error().Err(err).Msg("UpdateMbzIdHandler: Failed to update musicbrainz id")
+ utils.WriteError(w, "failed to update musicbrainz id", http.StatusInternalServerError)
+ return
+ }
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
diff --git a/engine/handlers/merge.go b/engine/handlers/merge.go
index 03e83b8..26da665 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)
@@ -116,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/handlers/now_playing.go b/engine/handlers/now_playing.go
new file mode 100644
index 0000000..78a51f7
--- /dev/null
+++ b/engine/handlers/now_playing.go
@@ -0,0 +1,41 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/memkv"
+ "github.com/gabehf/koito/internal/models"
+ "github.com/gabehf/koito/internal/utils"
+)
+
+type NowPlayingResponse struct {
+ CurrentlyPlaying bool `json:"currently_playing"`
+ Track models.Track `json:"track"`
+}
+
+func NowPlayingHandler(store db.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ l := logger.FromContext(ctx)
+
+ l.Debug().Msg("NowPlayingHandler: Got request")
+
+ // Hardcoded user id as 1. Not great but it works until (if) multi-user is supported.
+ if trackIdI, ok := memkv.Store.Get("1"); !ok {
+ utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: false})
+ } else if trackId, ok := trackIdI.(int32); !ok {
+ l.Debug().Msg("NowPlayingHandler: Failed type assertion for trackIdI")
+ utils.WriteError(w, "internal server error", http.StatusInternalServerError)
+ } else {
+ track, err := store.GetTrack(ctx, db.GetTrackOpts{ID: trackId})
+ if err != nil {
+ l.Error().Err(err).Msg("NowPlayingHandler: Failed to get track from database")
+ utils.WriteError(w, "failed to fetch currently playing track from database", http.StatusInternalServerError)
+ } else {
+ utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: true, Track: *track})
+ }
+ }
+ }
+}
diff --git a/engine/handlers/replace_image.go b/engine/handlers/replace_image.go
index 66c0bbe..9a2835d 100644
--- a/engine/handlers/replace_image.go
+++ b/engine/handlers/replace_image.go
@@ -9,6 +9,7 @@ import (
"github.com/gabehf/koito/internal/catalog"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/images"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
@@ -75,7 +76,7 @@ func ReplaceImageHandler(store db.DB) http.HandlerFunc {
fileUrl := r.FormValue("image_url")
if fileUrl != "" {
l.Debug().Msg("ReplaceImageHandler: Image identified as remote file")
- err = catalog.ValidateImageURL(fileUrl)
+ err = images.ValidateImageURL(fileUrl)
if err != nil {
l.Debug().AnErr("error", err).Msg("ReplaceImageHandler: Invalid image URL")
utils.WriteError(w, "url is invalid or not an image file", http.StatusBadRequest)
diff --git a/engine/handlers/search.go b/engine/handlers/search.go
index 9f29c75..ee90936 100644
--- a/engine/handlers/search.go
+++ b/engine/handlers/search.go
@@ -2,6 +2,8 @@ package handlers
import (
"net/http"
+ "strconv"
+ "strings"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
@@ -20,27 +22,62 @@ func SearchHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
l := logger.FromContext(ctx)
q := r.URL.Query().Get("q")
- artists, err := store.SearchArtists(ctx, q)
l.Debug().Msgf("SearchHandler: Received search with query: %s", r.URL.Query().Encode())
- if err != nil {
- l.Err(err).Msg("Failed to search for artists")
- utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
- return
- }
- albums, err := store.SearchAlbums(ctx, q)
- if err != nil {
- l.Err(err).Msg("Failed to search for albums")
- utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
- return
- }
- tracks, err := store.SearchTracks(ctx, q)
- if err != nil {
- l.Err(err).Msg("Failed to search for tracks")
- utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
- return
+ var artists []*models.Artist
+ var albums []*models.Album
+ var tracks []*models.Track
+
+ if strings.HasPrefix(q, "id:") {
+ idStr := strings.TrimPrefix(q, "id:")
+ id, _ := strconv.Atoi(idStr)
+
+ artist, err := store.GetArtist(ctx, db.GetArtistOpts{ID: int32(id)})
+ if err != nil {
+ l.Debug().Msg("No artists found with id")
+ }
+ if artist != nil {
+ artists = append(artists, artist)
+ }
+
+ album, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: int32(id)})
+ if err != nil {
+ l.Debug().Msg("No albums found with id")
+ }
+ if album != nil {
+ albums = append(albums, album)
+ }
+
+ track, err := store.GetTrack(ctx, db.GetTrackOpts{ID: int32(id)})
+ if err != nil {
+ l.Debug().Msg("No tracks found with id")
+ }
+ if track != nil {
+ tracks = append(tracks, track)
+ }
+ } else {
+ var err error
+ artists, err = store.SearchArtists(ctx, q)
+ if err != nil {
+ l.Err(err).Msg("Failed to search for artists")
+ utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
+ return
+ }
+ albums, err = store.SearchAlbums(ctx, q)
+ if err != nil {
+ l.Err(err).Msg("Failed to search for albums")
+ utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
+ return
+ }
+ tracks, err = store.SearchTracks(ctx, q)
+ if err != nil {
+ l.Err(err).Msg("Failed to search for tracks")
+ utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
+ return
+ }
}
+
utils.WriteJSON(w, http.StatusOK, SearchResults{
Artists: artists,
Albums: albums,
diff --git a/engine/handlers/server_cfg.go b/engine/handlers/server_cfg.go
new file mode 100644
index 0000000..ebc7b9f
--- /dev/null
+++ b/engine/handlers/server_cfg.go
@@ -0,0 +1,18 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gabehf/koito/internal/cfg"
+ "github.com/gabehf/koito/internal/utils"
+)
+
+type ServerConfig struct {
+ DefaultTheme string `json:"default_theme"`
+}
+
+func GetCfgHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()})
+ }
+}
diff --git a/engine/handlers/stats.go b/engine/handlers/stats.go
index 3e01816..accd9e7 100644
--- a/engine/handlers/stats.go
+++ b/engine/handlers/stats.go
@@ -2,7 +2,6 @@ package handlers
import (
"net/http"
- "strings"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
@@ -10,11 +9,11 @@ import (
)
type StatsResponse struct {
- ListenCount int64 `json:"listen_count"`
- TrackCount int64 `json:"track_count"`
- AlbumCount int64 `json:"album_count"`
- ArtistCount int64 `json:"artist_count"`
- HoursListened int64 `json:"hours_listened"`
+ ListenCount int64 `json:"listen_count"`
+ TrackCount int64 `json:"track_count"`
+ AlbumCount int64 `json:"album_count"`
+ ArtistCount int64 `json:"artist_count"`
+ MinutesListened int64 `json:"minutes_listened"`
}
func StatsHandler(store db.DB) http.HandlerFunc {
@@ -23,54 +22,39 @@ func StatsHandler(store db.DB) http.HandlerFunc {
l.Debug().Msg("StatsHandler: Received request to retrieve statistics")
- var period db.Period
- switch strings.ToLower(r.URL.Query().Get("period")) {
- case "day":
- period = db.PeriodDay
- case "week":
- period = db.PeriodWeek
- case "month":
- period = db.PeriodMonth
- case "year":
- period = db.PeriodYear
- case "all_time":
- period = db.PeriodAllTime
- default:
- l.Debug().Msgf("StatsHandler: Using default value '%s' for period", db.PeriodDay)
- period = db.PeriodDay
- }
+ tf := TimeframeFromRequest(r)
- l.Debug().Msgf("StatsHandler: Fetching statistics for period '%s'", period)
+ l.Debug().Msg("StatsHandler: Fetching statistics")
- listens, err := store.CountListens(r.Context(), period)
+ listens, err := store.CountListens(r.Context(), tf)
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch listen count")
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return
}
- tracks, err := store.CountTracks(r.Context(), period)
+ tracks, err := store.CountTracks(r.Context(), tf)
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch track count")
utils.WriteError(w, "failed to get tracks: "+err.Error(), http.StatusInternalServerError)
return
}
- albums, err := store.CountAlbums(r.Context(), period)
+ albums, err := store.CountAlbums(r.Context(), tf)
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch album count")
utils.WriteError(w, "failed to get albums: "+err.Error(), http.StatusInternalServerError)
return
}
- artists, err := store.CountArtists(r.Context(), period)
+ artists, err := store.CountArtists(r.Context(), tf)
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch artist count")
utils.WriteError(w, "failed to get artists: "+err.Error(), http.StatusInternalServerError)
return
}
- timeListenedS, err := store.CountTimeListened(r.Context(), period)
+ timeListenedS, err := store.CountTimeListened(r.Context(), tf)
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch time listened")
utils.WriteError(w, "failed to get time listened: "+err.Error(), http.StatusInternalServerError)
@@ -79,11 +63,11 @@ func StatsHandler(store db.DB) http.HandlerFunc {
l.Debug().Msg("StatsHandler: Successfully fetched statistics")
utils.WriteJSON(w, http.StatusOK, StatsResponse{
- ListenCount: listens,
- TrackCount: tracks,
- AlbumCount: albums,
- ArtistCount: artists,
- HoursListened: timeListenedS / 60 / 60,
+ ListenCount: listens,
+ TrackCount: tracks,
+ AlbumCount: albums,
+ ArtistCount: artists,
+ MinutesListened: timeListenedS / 60,
})
}
}
diff --git a/engine/import_test.go b/engine/import_test.go
index b128232..fa69e73 100644
--- a/engine/import_test.go
+++ b/engine/import_test.go
@@ -13,6 +13,7 @@ import (
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
+ "github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -60,7 +61,9 @@ func TestImportSpotify(t *testing.T) {
a, err := store.GetArtist(context.Background(), db.GetArtistOpts{Name: "The Story So Far"})
require.NoError(t, err)
- track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "Clairvoyant", ArtistIDs: []int32{a.ID}})
+ r, err := store.GetAlbum(context.Background(), db.GetAlbumOpts{ArtistID: a.ID, Title: "The Story So Far / Stick To Your Guns Split"})
+ require.NoError(t, err)
+ track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "Clairvoyant", ReleaseID: r.ID, ArtistIDs: []int32{a.ID}})
require.NoError(t, err)
t.Log(track)
assert.Equal(t, "Clairvoyant", track.Title)
@@ -106,15 +109,49 @@ func TestImportLastFM(t *testing.T) {
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"})
+ artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{Name: "Necry Talkie"})
require.NoError(t, err)
- track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "because I'm stupid?", ArtistIDs: []int32{artist.ID}})
+ track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "放課後の記憶", ReleaseID: album.ID, 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})
+ listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Timeframe: db.Timeframe{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)
+ assert.WithinDuration(t, time.Unix(1749774900, 0), listens.Items[0].Time, 1*time.Second)
+
+ 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: "Necry Talkie"})
+ require.NoError(t, err)
+ track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "放課後の記憶", ReleaseID: album.ID, ArtistIDs: []int32{artist.ID}})
+ require.NoError(t, err)
+ t.Log(track)
+ listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
+ require.NoError(t, err)
+ require.Len(t, listens.Items, 1)
+ assert.WithinDuration(t, time.Unix(1749774900, 0), listens.Items[0].Time, 1*time.Second)
truncateTestData(t)
}
@@ -181,10 +218,162 @@ func TestImportListenBrainz(t *testing.T) {
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})
+ listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Timeframe: db.Timeframe{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)
}
+
+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), Timeframe: db.Timeframe{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)
+}
+
+func TestImportListenBrainz_MBIDMapping(t *testing.T) {
+
+ src := path.Join("..", "test_assets", "listenbrainz_shoko1_123456789.zip")
+ destDir := filepath.Join(cfg.ConfigDir(), "import")
+ dest := filepath.Join(destDir, "listenbrainz_shoko1_123456789.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("177ebc28-0115-3897-8eb3-ebf74ce23790")})
+ require.NoError(t, err)
+ assert.Equal(t, "Zombie", album.Title)
+ artist, err := store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("c98d40fd-f6cf-4b26-883e-eaa515ee2851")})
+ require.NoError(t, err)
+ assert.Equal(t, "The Cranberries", artist.Name)
+ track, err := store.GetTrack(context.Background(), db.GetTrackOpts{MusicBrainzID: uuid.MustParse("3bbeb4e3-ab6d-460d-bfc5-de49e4251061")})
+ require.NoError(t, err)
+ assert.Equal(t, "Zombie", track.Title)
+
+ truncateTestData(t)
+}
+
+func TestImportKoito(t *testing.T) {
+
+ src := path.Join("..", "test_assets", "koito_export_test.json")
+ destDir := filepath.Join(cfg.ConfigDir(), "import")
+ dest := filepath.Join(destDir, "koito_export_test.json")
+
+ ctx := context.Background()
+
+ // 4 every wave to ever rise, 3 i can't feel you, 5 giri giri, 1 nijinoiroyo
+ giriReleaseMBID := uuid.MustParse("ac1f8da0-21d7-426e-83b0-befff06f0871")
+ suzukiMBID := uuid.MustParse("30f851bb-dba3-4e9b-811c-5f27f595c86a")
+ nijinoTrackMBID := uuid.MustParse("a4f26836-3894-46c1-acac-227808308687")
+ lp3MBID := uuid.MustParse("d0ec30bd-7cdc-417c-979d-5a0631b8a161")
+
+ 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{})
+
+ // ensure all artists are saved
+ _, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "American Football"})
+ assert.NoError(t, err)
+ _, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "Rachel Goswell"})
+ assert.NoError(t, err)
+ _, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "Elizabeth Powell"})
+ assert.NoError(t, err)
+
+ // ensure artist aliases are saved
+ artist, err := store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: suzukiMBID})
+ require.NoError(t, err)
+ assert.Equal(t, "鈴木雅之", artist.Name)
+ assert.Contains(t, artist.Aliases, "Masayuki Suzuki")
+ _, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "すぅ"})
+ require.NoError(t, err)
+
+ // ensure albums are saved
+ album, err := store.GetAlbum(ctx, db.GetAlbumOpts{MusicBrainzID: giriReleaseMBID})
+ require.NoError(t, err)
+ assert.Equal(t, "GIRI GIRI", album.Title)
+ // ensure album aliases are saved
+ artist, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "NELKE"})
+ require.NoError(t, err)
+ album, err = store.GetAlbum(ctx, db.GetAlbumOpts{Title: "虹の色よ鮮やかであれ (NELKE ver.)", ArtistID: artist.ID})
+ require.NoError(t, err)
+ aliases, err := store.GetAllAlbumAliases(ctx, album.ID)
+ require.NoError(t, err)
+ assert.Contains(t, utils.FlattenAliases(aliases), "Nijinoiroyo Azayakadeare (NELKE ver.)")
+ // ensure album associations are saved
+ album, err = store.GetAlbum(ctx, db.GetAlbumOpts{MusicBrainzID: lp3MBID})
+ require.NoError(t, err)
+ assert.Contains(t, utils.FlattenSimpleArtistNames(album.Artists), "Elizabeth Powell")
+ assert.Contains(t, utils.FlattenSimpleArtistNames(album.Artists), "Rachel Goswell")
+ assert.Contains(t, utils.FlattenSimpleArtistNames(album.Artists), "American Football")
+
+ // ensure all tracks are saved
+ track, err := store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: nijinoTrackMBID})
+ require.NoError(t, err)
+ assert.Equal(t, "虹の色よ鮮やかであれ (NELKE ver.)", track.Title)
+ aliases, err = store.GetAllTrackAliases(ctx, track.ID)
+ require.NoError(t, err)
+ assert.Contains(t, utils.FlattenAliases(aliases), "Nijinoiroyo Azayakadeare (NELKE ver.)")
+ // ensure track duration is saved
+ assert.EqualValues(t, 218, track.Duration)
+
+ artist, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: suzukiMBID})
+ require.NoError(t, err)
+ album, err = store.GetAlbum(ctx, db.GetAlbumOpts{ArtistID: artist.ID, Title: "GIRI GIRI"})
+ require.NoError(t, err)
+ _, err = store.GetTrack(ctx, db.GetTrackOpts{Title: "GIRI GIRI", ReleaseID: album.ID, ArtistIDs: []int32{artist.ID}})
+ require.NoError(t, err)
+
+ count, err := store.CountTracks(ctx, db.Timeframe{Period: db.PeriodAllTime})
+ require.NoError(t, err)
+ assert.EqualValues(t, 4, count)
+ count, err = store.CountAlbums(ctx, db.Timeframe{Period: db.PeriodAllTime})
+ require.NoError(t, err)
+ assert.EqualValues(t, 3, count)
+ count, err = store.CountArtists(ctx, db.Timeframe{Period: db.PeriodAllTime})
+ require.NoError(t, err)
+ assert.EqualValues(t, 6, count)
+
+ truncateTestData(t)
+}
diff --git a/engine/long_test.go b/engine/long_test.go
index 498bd08..db86ac2 100644
--- a/engine/long_test.go
+++ b/engine/long_test.go
@@ -11,6 +11,7 @@ import (
"net/url"
"os"
"path"
+ "strconv"
"strings"
"sync"
"testing"
@@ -53,6 +54,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)
}
@@ -72,15 +74,15 @@ func getApiKey(t *testing.T, session string) {
func truncateTestData(t *testing.T) {
err := store.Exec(context.Background(),
- `TRUNCATE
- artists,
+ `TRUNCATE
+ artists,
artist_aliases,
- tracks,
- artist_tracks,
- releases,
- artist_releases,
- release_aliases,
- listens
+ tracks,
+ artist_tracks,
+ releases,
+ artist_releases,
+ release_aliases,
+ listens
RESTART IDENTITY CASCADE`)
require.NoError(t, err)
}
@@ -209,7 +211,7 @@ func TestGetters(t *testing.T) {
assert.Equal(t, "花の塔", track.Title)
// Listen was saved
- resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/listens")
+ resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/listens?period=all_time")
assert.NoError(t, err)
var listens db.PaginatedResponse[models.Listen]
err = json.NewDecoder(resp.Body).Decode(&listens)
@@ -218,21 +220,21 @@ func TestGetters(t *testing.T) {
assert.EqualValues(t, 2, listens.Items[0].Track.ID)
assert.Equal(t, "Where Our Blue Is", listens.Items[0].Track.Title)
- resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/top-artists")
+ resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/top-artists?period=all_time")
assert.NoError(t, err)
var artists db.PaginatedResponse[models.Artist]
err = json.NewDecoder(resp.Body).Decode(&artists)
require.NoError(t, err)
require.Len(t, artists.Items, 3)
- resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/top-albums")
+ resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/top-albums?period=all_time")
assert.NoError(t, err)
var albums db.PaginatedResponse[models.Album]
err = json.NewDecoder(resp.Body).Decode(&albums)
require.NoError(t, err)
require.Len(t, albums.Items, 3)
- resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/top-tracks")
+ resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/top-tracks?period=all_time")
assert.NoError(t, err)
var tracks db.PaginatedResponse[models.Track]
err = json.NewDecoder(resp.Body).Decode(&tracks)
@@ -354,6 +356,51 @@ func TestDelete(t *testing.T) {
truncateTestData(t)
}
+func TestLoginGate(t *testing.T) {
+
+ t.Run("Submit Listens", doSubmitListens)
+
+ req, err := http.NewRequest("DELETE", host()+"/apis/web/v1/artist?id=1", nil)
+ require.NoError(t, err)
+ req.Header.Add("Authorization", "Token "+apikey)
+ resp, err := http.DefaultClient.Do(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 204, resp.StatusCode)
+
+ req, err = http.NewRequest("GET", host()+"/apis/web/v1/artist?id=3", nil)
+ require.NoError(t, err)
+ resp, err = http.DefaultClient.Do(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 200, resp.StatusCode)
+ var artist models.Artist
+ err = json.NewDecoder(resp.Body).Decode(&artist)
+ require.NoError(t, err)
+ assert.Equal(t, "ネクライトーキー", artist.Name)
+
+ cfg.SetLoginGate(true)
+
+ req, err = http.NewRequest("GET", host()+"/apis/web/v1/artist?id=3", nil)
+ require.NoError(t, err)
+ // req.Header.Add("Authorization", "Token "+apikey)
+ resp, err = http.DefaultClient.Do(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 401, resp.StatusCode)
+
+ req, err = http.NewRequest("GET", host()+"/apis/web/v1/artist?id=3", nil)
+ require.NoError(t, err)
+ req.Header.Add("Authorization", "Token "+apikey)
+ resp, err = http.DefaultClient.Do(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 200, resp.StatusCode)
+ err = json.NewDecoder(resp.Body).Decode(&artist)
+ require.NoError(t, err)
+ assert.Equal(t, "ネクライトーキー", artist.Name)
+
+ cfg.SetLoginGate(false)
+
+ truncateTestData(t)
+}
+
func TestAliasesAndSearch(t *testing.T) {
t.Run("Submit Listens", doSubmitListens)
@@ -437,7 +484,7 @@ func TestStats(t *testing.T) {
t.Run("Submit Listens", doSubmitListens)
- resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/stats")
+ resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/stats?period=all_time")
t.Log(resp)
require.NoError(t, err)
var actual handlers.StatsResponse
@@ -446,7 +493,7 @@ func TestStats(t *testing.T) {
assert.EqualValues(t, 3, actual.TrackCount)
assert.EqualValues(t, 3, actual.AlbumCount)
assert.EqualValues(t, 3, actual.ArtistCount)
- assert.EqualValues(t, 0, actual.HoursListened)
+ assert.EqualValues(t, 11, actual.MinutesListened)
}
func TestListenActivity(t *testing.T) {
@@ -512,7 +559,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 +779,243 @@ 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")
+}
+
+func TestManualListen(t *testing.T) {
+
+ t.Run("Submit Listens", doSubmitListens)
+
+ ctx := context.Background()
+
+ // happy
+ formdata := url.Values{}
+ formdata.Set("track_id", "1")
+ formdata.Set("unix", strconv.FormatInt(time.Now().Unix()-60, 10))
+ body := formdata.Encode()
+ resp, err := makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body))
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusCreated, resp.StatusCode)
+ count, _ := store.Count(ctx, `SELECT COUNT(*) FROM listens WHERE track_id = $1`, 1)
+ assert.Equal(t, 2, count)
+
+ // 400
+ formdata.Set("track_id", "1")
+ formdata.Set("unix", strconv.FormatInt(time.Now().Unix()+60, 10))
+ body = formdata.Encode()
+ resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body))
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
+}
+
+func TestNowPlaying(t *testing.T) {
+
+ t.Run("Submit Listens", doSubmitListens)
+
+ // no playing
+ resp, err := http.DefaultClient.Get(host() + "/apis/web/v1/now-playing")
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ var result handlers.NowPlayingResponse
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
+ require.False(t, result.CurrentlyPlaying)
+
+ body := `{
+ "listen_type": "playing_now",
+ "payload": [
+ {
+ "track_metadata": {
+ "additional_info": {
+ "artist_mbids": [
+ "efc787f0-046f-4a60-beff-77b398c8cdf4"
+ ],
+ "artist_names": [
+ "さユり"
+ ],
+ "duration_ms": 275960,
+ "recording_mbid": "21524d55-b1f8-45d1-b172-976cba447199",
+ "release_group_mbid": "3281e0d9-fa44-4337-a8ce-6f264beeae16",
+ "release_mbid": "eb790e90-0065-4852-b47d-bbeede4aa9fc",
+ "submission_client": "navidrome",
+ "submission_client_version": "0.56.1 (fa2cf362)"
+ },
+ "artist_name": "さユり",
+ "release_name": "酸欠少女",
+ "track_name": "花の塔"
+ }
+ }
+ ]
+ }`
+
+ req, err := http.NewRequest("POST", host()+"/apis/listenbrainz/1/submit-listens", strings.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Add("Authorization", fmt.Sprintf("Token %s", apikey))
+ 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))
+
+ // yes playing
+ resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/now-playing")
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
+ require.True(t, result.CurrentlyPlaying)
+ require.Equal(t, "花の塔", result.Track.Title)
+}
diff --git a/engine/middleware/authenticate.go b/engine/middleware/authenticate.go
new file mode 100644
index 0000000..830fb78
--- /dev/null
+++ b/engine/middleware/authenticate.go
@@ -0,0 +1,166 @@
+package middleware
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gabehf/koito/internal/cfg"
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/models"
+ "github.com/gabehf/koito/internal/utils"
+ "github.com/google/uuid"
+)
+
+type MiddlwareContextKey string
+
+const (
+ UserContextKey MiddlwareContextKey = "user"
+ apikeyContextKey MiddlwareContextKey = "apikeyID"
+)
+
+type AuthMode int
+
+const (
+ AuthModeSessionCookie AuthMode = iota
+ AuthModeAPIKey
+ AuthModeSessionOrAPIKey
+ AuthModeLoginGate
+)
+
+func Authenticate(store db.DB, mode AuthMode) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ l := logger.FromContext(ctx)
+
+ var user *models.User
+ var err error
+
+ switch mode {
+ case AuthModeSessionCookie:
+ user, err = validateSession(ctx, store, r)
+
+ case AuthModeAPIKey:
+ user, err = validateAPIKey(ctx, store, r)
+
+ case AuthModeSessionOrAPIKey:
+ user, err = validateSession(ctx, store, r)
+ if err != nil || user == nil {
+ user, err = validateAPIKey(ctx, store, r)
+ }
+
+ case AuthModeLoginGate:
+ if cfg.LoginGate() {
+ user, err = validateSession(ctx, store, r)
+ if err != nil || user == nil {
+ user, err = validateAPIKey(ctx, store, r)
+ }
+ } else {
+ next.ServeHTTP(w, r)
+ return
+ }
+ }
+
+ if err != nil {
+ l.Err(err).Msg("authentication failed")
+ utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if user == nil {
+ utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ ctx = context.WithValue(ctx, UserContextKey, user)
+ r = r.WithContext(ctx)
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func validateSession(ctx context.Context, store db.DB, r *http.Request) (*models.User, error) {
+ l := logger.FromContext(r.Context())
+
+ l.Debug().Msgf("ValidateSession: Checking user authentication via session cookie")
+
+ cookie, err := r.Cookie("koito_session")
+ var sid uuid.UUID
+ if err == nil {
+ sid, err = uuid.Parse(cookie.Value)
+ if err != nil {
+ l.Err(err).Msg("ValidateSession: Could not parse UUID from session cookie")
+ return nil, errors.New("session cookie is invalid")
+ }
+ } else {
+ l.Debug().Msgf("ValidateSession: No session cookie found; attempting API key authentication")
+ return nil, errors.New("session cookie is missing")
+ }
+
+ l.Debug().Msg("ValidateSession: Retrieved login cookie from request")
+
+ u, err := store.GetUserBySession(r.Context(), sid)
+ if err != nil {
+ l.Err(fmt.Errorf("ValidateSession: %w", err)).Msg("Error accessing database")
+ return nil, errors.New("internal server error")
+ }
+ if u == nil {
+ l.Debug().Msg("ValidateSession: No user with session id found")
+ return nil, errors.New("no user with session id found")
+ }
+
+ ctx = context.WithValue(r.Context(), UserContextKey, u)
+ r = r.WithContext(ctx)
+
+ l.Debug().Msgf("ValidateSession: Refreshing session for user '%s'", u.Username)
+
+ store.RefreshSession(r.Context(), sid, time.Now().Add(30*24*time.Hour))
+
+ l.Debug().Msgf("ValidateSession: Refreshed session for user '%s'", u.Username)
+
+ return u, nil
+}
+
+func validateAPIKey(ctx context.Context, store db.DB, r *http.Request) (*models.User, error) {
+ l := logger.FromContext(ctx)
+
+ l.Debug().Msg("ValidateApiKey: Checking if user is already authenticated")
+
+ authH := r.Header.Get("Authorization")
+ 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}'")
+ return nil, errors.New("authorization header is invalid")
+ }
+
+ u, err := store.GetUserByApiKey(ctx, token)
+ if err != nil {
+ l.Err(err).Msg("ValidateApiKey: Failed to get user from database using api key")
+ return nil, errors.New("internal server error")
+ }
+ if u == nil {
+ l.Debug().Msg("ValidateApiKey: API key does not exist")
+ return nil, errors.New("authorization token is invalid")
+ }
+
+ ctx = context.WithValue(r.Context(), UserContextKey, u)
+ r = r.WithContext(ctx)
+
+ return u, nil
+}
+
+func GetUserFromContext(ctx context.Context) *models.User {
+ user, ok := ctx.Value(UserContextKey).(*models.User)
+ if !ok {
+ return nil
+ }
+ return user
+}
diff --git a/engine/middleware/validate.go b/engine/middleware/validate.go
deleted file mode 100644
index fc08a4f..0000000
--- a/engine/middleware/validate.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package middleware
-
-import (
- "context"
- "fmt"
- "net/http"
- "strings"
- "time"
-
- "github.com/gabehf/koito/internal/db"
- "github.com/gabehf/koito/internal/logger"
- "github.com/gabehf/koito/internal/models"
- "github.com/gabehf/koito/internal/utils"
- "github.com/google/uuid"
-)
-
-type MiddlwareContextKey string
-
-const (
- UserContextKey MiddlwareContextKey = "user"
- apikeyContextKey MiddlwareContextKey = "apikeyID"
-)
-
-func ValidateSession(store db.DB) func(next http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- l := logger.FromContext(r.Context())
-
- l.Debug().Msgf("ValidateSession: Checking user authentication via session cookie")
-
- cookie, err := r.Cookie("koito_session")
- var sid uuid.UUID
- if err == nil {
- sid, err = uuid.Parse(cookie.Value)
- if err != nil {
- l.Err(err).Msg("ValidateSession: Could not parse UUID from session cookie")
- utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized)
- return
- }
- } else {
- l.Debug().Msgf("ValidateSession: No session cookie found; attempting API key authentication")
- utils.WriteError(w, "session cookie is missing", http.StatusUnauthorized)
- return
- }
-
- l.Debug().Msg("ValidateSession: Retrieved login cookie from request")
-
- u, err := store.GetUserBySession(r.Context(), sid)
- if err != nil {
- l.Err(fmt.Errorf("ValidateSession: %w", err)).Msg("Error accessing database")
- utils.WriteError(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if u == nil {
- l.Debug().Msg("ValidateSession: No user with session id found")
- utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- ctx := context.WithValue(r.Context(), UserContextKey, u)
- r = r.WithContext(ctx)
-
- l.Debug().Msgf("ValidateSession: Refreshing session for user '%s'", u.Username)
-
- store.RefreshSession(r.Context(), sid, time.Now().Add(30*24*time.Hour))
-
- l.Debug().Msgf("ValidateSession: Refreshed session for user '%s'", u.Username)
-
- next.ServeHTTP(w, r)
- })
- }
-}
-
-func ValidateApiKey(store db.DB) func(next http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- l := logger.FromContext(ctx)
-
- l.Debug().Msg("ValidateApiKey: Checking if user is already authenticated")
-
- u := GetUserFromContext(ctx)
- if u != nil {
- l.Debug().Msg("ValidateApiKey: User is already authenticated; skipping API key authentication")
- next.ServeHTTP(w, r)
- return
- }
-
- 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}'")
- utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- key := s[1]
-
- u, err := store.GetUserByApiKey(ctx, key)
- if err != nil {
- l.Err(err).Msg("Failed to get user from database using api key")
- utils.WriteError(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if u == nil {
- l.Debug().Msg("Api key does not exist")
- utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- ctx = context.WithValue(r.Context(), UserContextKey, u)
- r = r.WithContext(ctx)
-
- next.ServeHTTP(w, r)
- })
- }
-}
-
-func GetUserFromContext(ctx context.Context) *models.User {
- user, ok := ctx.Value(UserContextKey).(*models.User)
- if !ok {
- return nil
- }
- return user
-}
diff --git a/engine/routes.go b/engine/routes.go
index 4b7d302..c62edf5 100644
--- a/engine/routes.go
+++ b/engine/routes.go
@@ -35,17 +35,26 @@ func bindRoutes(
Get("/images/{size}/{filename}", handlers.ImageHandler(db))
r.Route("/apis/web/v1", func(r chi.Router) {
- r.Get("/artist", handlers.GetArtistHandler(db))
- r.Get("/album", handlers.GetAlbumHandler(db))
- r.Get("/track", handlers.GetTrackHandler(db))
- r.Get("/top-tracks", handlers.GetTopTracksHandler(db))
- r.Get("/top-albums", handlers.GetTopAlbumsHandler(db))
- r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
- r.Get("/listens", handlers.GetListensHandler(db))
- r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
- r.Get("/stats", handlers.StatsHandler(db))
- r.Get("/search", handlers.SearchHandler(db))
- r.Get("/aliases", handlers.GetAliasesHandler(db))
+ r.Get("/config", handlers.GetCfgHandler())
+
+ r.Group(func(r chi.Router) {
+ r.Use(middleware.Authenticate(db, middleware.AuthModeLoginGate))
+ 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))
+ r.Get("/top-albums", handlers.GetTopAlbumsHandler(db))
+ r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
+ r.Get("/listens", handlers.GetListensHandler(db))
+ r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
+ r.Get("/now-playing", handlers.NowPlayingHandler(db))
+ r.Get("/stats", handlers.StatsHandler(db))
+ r.Get("/search", handlers.SearchHandler(db))
+ r.Get("/aliases", handlers.GetAliasesHandler(db))
+ r.Get("/summary", handlers.SummaryHandler(db))
+ r.Get("/interest", handlers.GetInterestHandler(db))
+ })
r.Post("/logout", handlers.LogoutHandler(db))
if !cfg.RateLimitDisabled() {
r.With(httprate.Limit(
@@ -68,18 +77,23 @@ func bindRoutes(
})
r.Group(func(r chi.Router) {
- r.Use(middleware.ValidateSession(db))
+ r.Use(middleware.Authenticate(db, middleware.AuthModeSessionOrAPIKey))
+ r.Get("/export", handlers.ExportHandler(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))
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.Post("/listen", handlers.SubmitListenWithIDHandler(db))
r.Delete("/listen", handlers.DeleteListenHandler(db))
r.Post("/aliases", handlers.CreateAliasHandler(db))
- r.Delete("/aliases", handlers.DeleteAliasHandler(db))
+ r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))
r.Post("/aliases/primary", handlers.SetPrimaryAliasHandler(db))
+ r.Patch("/mbzid", handlers.UpdateMbzIdHandler(db))
r.Get("/user/apikeys", handlers.GetApiKeysHandler(db))
r.Post("/user/apikeys", handlers.GenerateApiKeyHandler(db))
r.Patch("/user/apikeys", handlers.UpdateApiKeyLabelHandler(db))
@@ -95,8 +109,10 @@ func bindRoutes(
AllowedHeaders: []string{"Content-Type", "Authorization"},
}))
- r.With(middleware.ValidateApiKey(db)).Post("/submit-listens", handlers.LbzSubmitListenHandler(db, mbz))
- r.With(middleware.ValidateApiKey(db)).Get("/validate-token", handlers.LbzValidateTokenHandler(db))
+ r.With(middleware.Authenticate(db, middleware.AuthModeAPIKey)).
+ Post("/submit-listens", handlers.LbzSubmitListenHandler(db, mbz))
+ r.With(middleware.Authenticate(db, middleware.AuthModeAPIKey)).
+ Get("/validate-token", handlers.LbzValidateTokenHandler(db))
})
// serve react client
diff --git a/go.mod b/go.mod
index 874f117..2ef5086 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
@@ -12,7 +12,7 @@ require (
github.com/pressly/goose/v3 v3.24.3
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
- golang.org/x/sync v0.14.0
+ golang.org/x/sync v0.18.0
golang.org/x/time v0.11.0
)
@@ -60,7 +60,8 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/image v0.33.0 // indirect
golang.org/x/sys v0.33.0 // indirect
- golang.org/x/text v0.25.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 6ab8ff6..81e9b42 100644
--- a/go.sum
+++ b/go.sum
@@ -136,6 +136,8 @@ golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -147,6 +149,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -161,6 +165,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/internal/catalog/associate_album.go b/internal/catalog/associate_album.go
index af39152..3a63c58 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,19 +71,19 @@ 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,
- Titles: titles,
- })
+
+ l.Debug().Msgf("Searching for albums '%v' from artist id %d and no associated MusicBrainz ID in DB", titles, opts.Artists[0].ID)
+ album, err = d.GetAlbumWithNoMbzIDByTitles(ctx, opts.Artists[0].ID, titles)
if err == nil {
l.Debug().Msgf("Found album %s, updating with MusicBrainz Release ID...", album.Title)
err := d.UpdateAlbum(ctx, db.UpdateAlbumOpts{
@@ -89,27 +91,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 +121,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 +129,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 +160,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 +189,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 +210,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 +223,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 +250,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 0014b3e..15b91c9 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,14 @@ 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
+
+ SkipCacheImage bool
}
func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
@@ -29,30 +33,40 @@ 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
+ return nil, fmt.Errorf("AssociateArtists: %w", 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, 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...)
}
if len(result) < 1 {
- allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
+ allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
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...)
}
@@ -60,14 +74,111 @@ 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 {
+ 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)
+ }
+
+ artist, err = d.GetArtist(ctx, db.GetArtistOpts{
+ Name: a.Artist,
+ })
+ if err == nil {
+ l.Debug().Msgf("Artist '%s' found by Name", a.Artist)
+ if artist.MbzID == nil {
+ err := d.UpdateArtist(ctx, db.UpdateArtistOpts{
+ ID: artist.ID,
+ MusicBrainzID: a.Mbid,
+ })
+ if err != nil {
+ l.Err(err).Msg("matchArtistsByMBIDMappings: failed to update artist with MusicBrainz ID")
+ return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
+ }
+ l.Debug().Msgf("Updated artist '%s' with MusicBrainz ID", artist.Name)
+ } else {
+ l.Warn().Msgf("Attempted to update artist %s with MusicBrainz ID, but an existing ID was already found", artist.Name)
+ }
+ err = d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: artist.ID, MusicBrainzID: a.Mbid})
+ if err != nil {
+ l.Err(err).Msgf("matchArtistsByMBIDMappings: 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)
+ }
+
+ artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts)
+ if err != nil {
+ l.Warn().AnErr("error", err).Msg("matchArtistsByMBIDMappings: MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping")
+
+ var imgid uuid.UUID
+ imgUrl, imgErr := images.GetArtistImage(ctx, images.ArtistImageOpts{
+ Aliases: []string{a.Artist},
+ })
+ if imgErr == nil && imgUrl != "" {
+ imgid = uuid.New()
+ 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(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,
+ })
+ if err != nil {
+ 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
+}
+
+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)
+ return matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts)
}
a, err := d.GetArtist(ctx, db.GetArtistOpts{
MusicBrainzID: id,
@@ -77,30 +188,32 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts)
result = append(result, a)
continue
}
-
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if len(opts.ArtistNames) < 1 {
- opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
+ opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
}
- 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)
@@ -112,10 +225,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
}
@@ -137,20 +250,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{
@@ -161,13 +276,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
@@ -190,29 +305,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
@@ -229,3 +346,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/associate_track.go b/internal/catalog/associate_track.go
index 5304c0b..3fa1fbc 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 {
@@ -38,7 +39,7 @@ func AssociateTrack(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*mod
return matchTrackByMbzID(ctx, d, opts)
} else {
l.Debug().Msgf("Associating track '%s' by title and artist", opts.TrackName)
- return matchTrackByTitleAndArtist(ctx, d, opts)
+ return matchTrackByTrackInfo(ctx, d, opts)
}
}
@@ -52,48 +53,56 @@ 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)
+ track, err := matchTrackByTrackInfo(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{
- ID: track.ID,
- MusicBrainzID: opts.TrackMbzID,
- })
- if err != nil {
- return nil, err
+ if track.MbzID == nil || *track.MbzID == uuid.Nil {
+ err := d.UpdateTrack(ctx, db.UpdateTrackOpts{
+ ID: track.ID,
+ MusicBrainzID: opts.TrackMbzID,
+ })
+ if err != nil {
+ l.Err(err).Msg("matchArtistsByMBIDMappings: failed to update track with MusicBrainz ID")
+ return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
+ }
+ l.Debug().Msgf("Updated track '%s' with MusicBrainz ID", track.Title)
+ } else {
+ l.Warn().Msgf("Attempted to update track %s with MusicBrainz ID, but an existing ID was already found", track.Title)
}
track.MbzID = &opts.TrackMbzID
return track, nil
}
}
-func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) {
+func matchTrackByTrackInfo(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) {
l := logger.FromContext(ctx)
// try provided track title
track, err := d.GetTrack(ctx, db.GetTrackOpts{
Title: opts.TrackName,
+ ReleaseID: opts.AlbumID,
ArtistIDs: opts.ArtistIDs,
})
if err == nil {
- l.Debug().Msgf("Track '%s' found by title and artist match", track.Title)
+ l.Debug().Msgf("Track '%s' found by title, release and artist match", track.Title)
return track, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
- return nil, err
+ return nil, fmt.Errorf("matchTrackByTrackInfo: %w", err)
} else {
if opts.TrackMbzID != uuid.Nil {
mbzTrack, err := opts.Mbzc.GetTrack(ctx, opts.TrackMbzID)
if err == nil {
track, err := d.GetTrack(ctx, db.GetTrackOpts{
Title: mbzTrack.Title,
+ ReleaseID: opts.AlbumID,
ArtistIDs: opts.ArtistIDs,
})
if err == nil {
- l.Debug().Msgf("Track '%s' found by MusicBrainz title and artist match", opts.TrackName)
+ l.Debug().Msgf("Track '%s' found by MusicBrainz title, release and artist match", opts.TrackName)
return track, nil
}
}
@@ -107,7 +116,7 @@ func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrac
Duration: opts.Duration,
})
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("matchTrackByTrackInfo: %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 e7d3641..e94db03 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -6,13 +6,16 @@ package catalog
import (
"context"
"errors"
+ "fmt"
"regexp"
+ "strconv"
"strings"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
+ "github.com/gabehf/koito/internal/memkv"
"github.com/gabehf/koito/internal/models"
"github.com/google/uuid"
)
@@ -29,24 +32,35 @@ 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
+ // When true, skips caching the images and only stores the image url in the db
+ SkipCacheImage bool
+
+ 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
+ IsNowPlaying bool
}
const (
@@ -60,19 +74,24 @@ 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,
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,
+ 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")
}
@@ -90,10 +109,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")
@@ -113,19 +133,47 @@ 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")
- if track.Duration == 0 && opts.Duration != 0 {
- err := store.UpdateTrack(ctx, db.UpdateTrackOpts{
- ID: track.ID,
- Duration: opts.Duration,
- })
- if err != nil {
- l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
+ if track.Duration == 0 {
+ if opts.Duration != 0 {
+ l.Debug().Msg("Updating duration using request information")
+ err := store.UpdateTrack(ctx, db.UpdateTrackOpts{
+ ID: track.ID,
+ Duration: opts.Duration,
+ })
+ if err != nil {
+ l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
+ } else {
+ l.Info().Msgf("Duration updated to %d for track '%s'", opts.Duration, track.Title)
+ }
+ } else if track.MbzID != nil && *track.MbzID != uuid.Nil {
+ l.Debug().Msg("Attempting to update duration using MusicBrainz ID")
+ mbztrack, err := opts.MbzCaller.GetTrack(ctx, *track.MbzID)
+ if err != nil {
+ l.Err(err).Msg("Failed to make request to MusicBrainz")
+ } else {
+ err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
+ ID: track.ID,
+ Duration: int32(mbztrack.LengthMs / 1000),
+ })
+ if err != nil {
+ l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
+ } else {
+ l.Info().Msgf("Duration updated to %d for track '%s'", mbztrack.LengthMs/1000, track.Title)
+ }
+ }
+ }
+ }
+
+ if opts.IsNowPlaying {
+ if track.Duration == 0 {
+ memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID)
+ } else {
+ memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID, time.Duration(track.Duration)*time.Second)
}
- l.Info().Msgf("Duration updated to %d for track '%s'", opts.Duration, track.Title)
}
if opts.SkipSaveListen {
@@ -153,21 +201,18 @@ func buildArtistStr(artists []*models.Artist) string {
var (
// Bracketed feat patterns
bracketFeatPatterns = []*regexp.Regexp{
- regexp.MustCompile(`(?i)\(feat\. ([^)]*)\)`),
- regexp.MustCompile(`(?i)\[feat\. ([^\]]*)\]`),
+ regexp.MustCompile(`(?i)\([fF]eat\. ([^)]*)\)`),
+ regexp.MustCompile(`(?i)\[[fF]eat\. ([^\]]*)\]`),
}
// Inline feat (not in brackets)
- inlineFeatPattern = regexp.MustCompile(`(?i)feat\. ([^()\[\]]+)$`)
+ inlineFeatPattern = regexp.MustCompile(`(?i)[fF]eat\. ([^()\[\]]+)$`)
// Delimiters only used inside feat. sections
featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`)
-
- // Delimiter for separating artists in main string (rare but real usage)
- mainArtistDotSplitter = regexp.MustCompile(`\s+·\s+`)
)
// ParseArtists extracts all contributing artist names from the artist and title strings
-func ParseArtists(artist string, title string) []string {
+func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp) []string {
seen := make(map[string]struct{})
var out []string
@@ -182,12 +227,9 @@ func ParseArtists(artist string, title string) []string {
}
}
- foundFeat := false
-
// Extract bracketed features from artist
for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(artist); matches != nil {
- foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
@@ -196,7 +238,6 @@ func ParseArtists(artist string, title string) []string {
}
// Extract inline feat. from artist
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
- foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
@@ -204,14 +245,19 @@ func ParseArtists(artist string, title string) []string {
}
// Add base artist(s)
- if foundFeat {
- add(strings.TrimSpace(artist))
- } else {
- // Only split on " · " in base artist string
- for _, name := range mainArtistDotSplitter.Split(artist, -1) {
+ l1 := len(out)
+ for _, re := range addlSeparators {
+ for _, name := range re.Split(artist, -1) {
+ if name == artist {
+ continue
+ }
add(name)
}
}
+ // Only add the full artist string if no splitters were matched
+ if l1 == len(out) {
+ add(artist)
+ }
// Extract features from title
for _, re := range bracketFeatPatterns {
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 039fe1c..c56ba47 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
+ "regexp"
"testing"
"time"
@@ -134,7 +135,8 @@ var (
}
mbzTrackData = map[uuid.UUID]*mbz.MusicBrainzTrack{
uuid.MustParse("00000000-0000-0000-0000-000000001001"): {
- Title: "Tokyo Calling",
+ Title: "Tokyo Calling",
+ LengthMs: 191000,
},
}
)
@@ -166,15 +168,15 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
func truncateTestData(t *testing.T) {
err := store.Exec(context.Background(),
- `TRUNCATE
- artists,
+ `TRUNCATE
+ artists,
artist_aliases,
- tracks,
- artist_tracks,
- releases,
- artist_releases,
+ tracks,
+ artist_tracks,
+ releases,
+ artist_releases,
release_aliases,
- listens
+ listens
RESTART IDENTITY CASCADE`)
require.NoError(t, err)
}
@@ -183,23 +185,23 @@ func setupTestDataWithMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
- `INSERT INTO artists (musicbrainz_id)
+ `INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
+ `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO releases (musicbrainz_id)
+ `INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000101')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO release_aliases (release_id, alias, source, is_primary)
+ `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO artist_releases (artist_id, release_id)
+ `INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@@ -220,23 +222,23 @@ func setupTestDataSansMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
- `INSERT INTO artists (musicbrainz_id)
+ `INSERT INTO artists (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
+ `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO releases (musicbrainz_id)
+ `INSERT INTO releases (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO release_aliases (release_id, alias, source, is_primary)
+ `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO artist_releases (artist_id, release_id)
+ `INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@@ -357,10 +359,16 @@ func TestArtistStringParse(t *testing.T) {
// artists in both
{"Daft Punk feat. Julian Casablancas", "Instant Crush (feat. Julian Casablancas)"}: {"Daft Punk", "Julian Casablancas"},
{"Paramore (feat. Joy Williams)", "Hate to See Your Heart Break feat. Joy Williams"}: {"Paramore", "Joy Williams"},
+ {"MINSU", "오해 금지 (Feat. BIG Naughty)"}: {"MINSU", "BIG Naughty"},
+ {"MINSU", "오해 금지 [Feat. BIG Naughty]"}: {"MINSU", "BIG Naughty"},
+ {"MINSU", "오해 금지 Feat. BIG Naughty"}: {"MINSU", "BIG Naughty"},
+
+ // custom separator
+ {"MIMiNARI//楠木ともり", "眠れない"}: {"MIMiNARI", "楠木ともり"},
}
for in, out := range cases {
- artists := catalog.ParseArtists(in.Name, in.Title)
+ artists := catalog.ParseArtists(in.Name, in.Title, []*regexp.Regexp{regexp.MustCompile(`\s*//\s*`), regexp.MustCompile(`\s+·\s+`)})
assert.ElementsMatch(t, out, artists)
}
}
diff --git a/internal/catalog/duration.go b/internal/catalog/duration.go
new file mode 100644
index 0000000..6217dd6
--- /dev/null
+++ b/internal/catalog/duration.go
@@ -0,0 +1,85 @@
+package catalog
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/mbz"
+ "github.com/google/uuid"
+)
+
+func BackfillTrackDurationsFromMusicBrainz(
+ ctx context.Context,
+ store db.DB,
+ mbzCaller mbz.MusicBrainzCaller,
+) error {
+ l := logger.FromContext(ctx)
+ l.Info().Msg("BackfillTrackDurationsFromMusicBrainz: Starting backfill of track durations from MusicBrainz")
+
+ var from int32 = 0
+
+ for {
+ l.Debug().Int32("ID", from).Msg("Fetching tracks to backfill from ID")
+ tracks, err := store.GetTracksWithNoDurationButHaveMbzID(ctx, from)
+ if err != nil {
+ return fmt.Errorf("BackfillTrackDurationsFromMusicBrainz: failed to fetch tracks for duration backfill: %w", err)
+ }
+
+ // nil, nil means no more results
+ if len(tracks) == 0 {
+ if from == 0 {
+ l.Info().Msg("BackfillTrackDurationsFromMusicBrainz: No tracks need updating. Skipping backfill...")
+ } else {
+ l.Info().Msg("BackfillTrackDurationsFromMusicBrainz: Backfill complete")
+ }
+ return nil
+ }
+
+ for _, track := range tracks {
+ from = track.ID
+
+ if track.MbzID == nil || *track.MbzID == uuid.Nil {
+ continue
+ }
+
+ l.Debug().
+ Str("title", track.Title).
+ Str("mbz_id", track.MbzID.String()).
+ Msg("BackfillTrackDurationsFromMusicBrainz: Backfilling duration from MusicBrainz")
+
+ mbzTrack, err := mbzCaller.GetTrack(ctx, *track.MbzID)
+ if err != nil {
+ l.Err(err).
+ Str("title", track.Title).
+ Msg("BackfillTrackDurationsFromMusicBrainz: Failed to fetch track from MusicBrainz")
+ continue
+ }
+
+ if mbzTrack.LengthMs <= 0 {
+ l.Debug().
+ Str("title", track.Title).
+ Msg("BackfillTrackDurationsFromMusicBrainz: MusicBrainz track has no duration")
+ continue
+ }
+
+ durationSeconds := int32(mbzTrack.LengthMs / 1000)
+
+ err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
+ ID: track.ID,
+ Duration: durationSeconds,
+ })
+ if err != nil {
+ l.Err(err).
+ Str("title", track.Title).
+ Msg("BackfillTrackDurationsFromMusicBrainz: Failed to update track duration")
+ } else {
+ l.Info().
+ Str("title", track.Title).
+ Int32("duration_seconds", durationSeconds).
+ Msg("BackfillTrackDurationsFromMusicBrainz: Track duration backfilled successfully")
+ }
+ }
+ }
+}
diff --git a/internal/catalog/duration_test.go b/internal/catalog/duration_test.go
new file mode 100644
index 0000000..911e345
--- /dev/null
+++ b/internal/catalog/duration_test.go
@@ -0,0 +1,36 @@
+package catalog_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/gabehf/koito/internal/catalog"
+ "github.com/gabehf/koito/internal/mbz"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBackfillDuration(t *testing.T) {
+ setupTestDataWithMbzIDs(t)
+
+ ctx := context.Background()
+ mbzc := &mbz.MbzMockCaller{
+ Artists: mbzArtistData,
+ Releases: mbzReleaseData,
+ Tracks: mbzTrackData,
+ }
+
+ var err error
+
+ err = catalog.BackfillTrackDurationsFromMusicBrainz(context.Background(), store, &mbz.MbzErrorCaller{})
+ assert.NoError(t, err)
+
+ err = catalog.BackfillTrackDurationsFromMusicBrainz(ctx, store, mbzc)
+ assert.NoError(t, err)
+
+ count, err := store.Count(ctx, `
+ SELECT COUNT(*) FROM tracks_with_title WHERE title = $1 AND duration > 0
+ `, "Tokyo Calling")
+ require.NoError(t, err)
+ assert.Equal(t, 1, count, "track was not updated with duration")
+}
diff --git a/internal/catalog/images.go b/internal/catalog/images.go
index 1d6f421..72b6efd 100644
--- a/internal/catalog/images.go
+++ b/internal/catalog/images.go
@@ -13,7 +13,9 @@ import (
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/images"
"github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
"github.com/h2non/bimg"
)
@@ -30,6 +32,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":
@@ -69,45 +80,29 @@ func SourceImageDir() string {
}
}
-// ValidateImageURL checks if the URL points to a valid image by performing a HEAD request.
-func ValidateImageURL(url string) error {
- resp, err := http.Head(url)
- if err != nil {
- return fmt.Errorf("failed to perform HEAD request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("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 nil
-}
-
// DownloadAndCacheImage downloads an image from the given URL, then calls CompressAndSaveImage.
func DownloadAndCacheImage(ctx context.Context, id uuid.UUID, url string, size ImageSize) error {
l := logger.FromContext(ctx)
- err := ValidateImageURL(url)
+ err := images.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.
@@ -115,16 +110,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
@@ -135,21 +138,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
@@ -158,7 +161,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
@@ -171,10 +174,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
}
@@ -189,19 +192,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
}
@@ -221,7 +224,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
}
@@ -247,7 +250,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
}
@@ -264,3 +267,127 @@ func pruneDirImgs(ctx context.Context, store db.DB, path string, memo map[string
}
return count, nil
}
+
+func FetchMissingArtistImages(ctx context.Context, store db.DB) error {
+ l := logger.FromContext(ctx)
+ l.Info().Msg("FetchMissingArtistImages: Starting backfill of missing artist images")
+
+ var from int32 = 0
+
+ for {
+ l.Debug().Int32("ID", from).Msg("Fetching artist images to backfill from ID")
+ artists, err := store.ArtistsWithoutImages(ctx, from)
+ if err != nil {
+ return fmt.Errorf("FetchMissingArtistImages: failed to fetch artists for image backfill: %w", err)
+ }
+
+ if len(artists) == 0 {
+ if from == 0 {
+ l.Info().Msg("FetchMissingArtistImages: No artists with missing images found")
+ } else {
+ l.Info().Msg("FetchMissingArtistImages: Finished fetching missing artist images")
+ }
+ return nil
+ }
+
+ for _, artist := range artists {
+ from = artist.ID
+
+ l.Debug().
+ Str("title", artist.Name).
+ Msg("FetchMissingArtistImages: Attempting to fetch missing artist image")
+
+ var aliases []string
+ if aliasrow, err := store.GetAllArtistAliases(ctx, artist.ID); err != nil {
+ aliases = utils.FlattenAliases(aliasrow)
+ } else {
+ aliases = []string{artist.Name}
+ }
+
+ var imgid uuid.UUID
+ imgUrl, imgErr := images.GetArtistImage(ctx, images.ArtistImageOpts{
+ Aliases: aliases,
+ })
+ if imgErr == nil && imgUrl != "" {
+ imgid = uuid.New()
+ err = store.UpdateArtist(ctx, db.UpdateArtistOpts{
+ ID: artist.ID,
+ Image: imgid,
+ ImageSrc: imgUrl,
+ })
+ if err != nil {
+ l.Err(err).
+ Str("title", artist.Name).
+ Msg("FetchMissingArtistImages: Failed to update artist with image in database")
+ continue
+ }
+ l.Info().
+ Str("name", artist.Name).
+ Msg("FetchMissingArtistImages: Successfully fetched missing artist image")
+ } else {
+ l.Err(err).
+ Str("name", artist.Name).
+ Msg("FetchMissingArtistImages: Failed to fetch artist image")
+ }
+ }
+ }
+}
+func FetchMissingAlbumImages(ctx context.Context, store db.DB) error {
+ l := logger.FromContext(ctx)
+ l.Info().Msg("FetchMissingAlbumImages: Starting backfill of missing album images")
+
+ var from int32 = 0
+
+ for {
+ l.Debug().Int32("ID", from).Msg("Fetching album images to backfill from ID")
+ albums, err := store.AlbumsWithoutImages(ctx, from)
+ if err != nil {
+ return fmt.Errorf("FetchMissingAlbumImages: failed to fetch albums for image backfill: %w", err)
+ }
+
+ if len(albums) == 0 {
+ if from == 0 {
+ l.Info().Msg("FetchMissingAlbumImages: No albums with missing images found")
+ } else {
+ l.Info().Msg("FetchMissingAlbumImages: Finished fetching missing album images")
+ }
+ return nil
+ }
+
+ for _, album := range albums {
+ from = album.ID
+
+ l.Debug().
+ Str("title", album.Title).
+ Msg("FetchMissingAlbumImages: Attempting to fetch missing album image")
+
+ var imgid uuid.UUID
+ imgUrl, imgErr := images.GetAlbumImage(ctx, images.AlbumImageOpts{
+ Artists: utils.FlattenSimpleArtistNames(album.Artists),
+ Album: album.Title,
+ ReleaseMbzID: album.MbzID,
+ })
+ if imgErr == nil && imgUrl != "" {
+ imgid = uuid.New()
+ err = store.UpdateAlbum(ctx, db.UpdateAlbumOpts{
+ ID: album.ID,
+ Image: imgid,
+ ImageSrc: imgUrl,
+ })
+ if err != nil {
+ l.Err(err).
+ Str("title", album.Title).
+ Msg("FetchMissingAlbumImages: Failed to update album with image in database")
+ continue
+ }
+ l.Info().
+ Str("name", album.Title).
+ Msg("FetchMissingAlbumImages: Successfully fetched missing album image")
+ } else {
+ l.Err(err).
+ Str("name", album.Title).
+ Msg("FetchMissingAlbumImages: Failed to fetch album image")
+ }
+ }
+ }
+}
diff --git a/internal/catalog/submit_listen_test.go b/internal/catalog/submit_listen_test.go
index 5fcea61..1548776 100644
--- a/internal/catalog/submit_listen_test.go
+++ b/internal/catalog/submit_listen_test.go
@@ -63,11 +63,11 @@ func TestSubmitListen_CreateAllMbzIDs(t *testing.T) {
assert.True(t, exists, "expected listen row to exist")
// Verify that listen time is correct
- p, err := store.GetListensPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 1})
+ p, err := store.GetListensPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 1, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
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) {
@@ -203,6 +203,22 @@ func TestSubmitListen_CreateAllNoMbzIDsNoArtistNamesNoReleaseTitle(t *testing.T)
)`, "Madeline Kenney")
require.NoError(t, err)
assert.True(t, exists, "expected featured artist to be created")
+
+ // assert that Rat Tally is the primary artist
+ exists, err = store.RowExists(ctx, `
+ SELECT EXISTS (
+ SELECT 1 FROM artist_tracks
+ WHERE artist_id = $1 AND is_primary = $2
+ )`, 1, true)
+ require.NoError(t, err)
+ assert.True(t, exists, "expected primary artist to be marked as primary for track")
+ exists, err = store.RowExists(ctx, `
+ SELECT EXISTS (
+ SELECT 1 FROM artist_releases
+ WHERE artist_id = $1 AND is_primary = $2
+ )`, 1, true)
+ require.NoError(t, err)
+ assert.True(t, exists, "expected primary artist to be marked as primary for release")
}
func TestSubmitListen_MatchAllMbzIDs(t *testing.T) {
@@ -266,6 +282,73 @@ func TestSubmitListen_MatchAllMbzIDs(t *testing.T) {
assert.Equal(t, 1, count, "duplicate artist created")
}
+func TestSubmitListen_DoNotOverwriteMbzIDs(t *testing.T) {
+ setupTestDataWithMbzIDs(t)
+
+ // artist gets matched with musicbrainz id
+ // release gets matched with mbz id
+ // track gets matched with mbz id
+
+ ctx := context.Background()
+ mbzc := &mbz.MbzMockCaller{
+ Artists: mbzArtistData,
+ Releases: mbzReleaseData,
+ Tracks: mbzTrackData,
+ }
+ artistMbzID := uuid.MustParse("10000000-0000-0000-0000-000000000000")
+ releaseMbzID := uuid.MustParse("01000000-0000-0000-0000-000000000000")
+ existingReleaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
+ trackMbzID := uuid.MustParse("00100000-0000-0000-0000-000000000000")
+ opts := catalog.SubmitListenOpts{
+ MbzCaller: mbzc,
+ ArtistNames: []string{"ATARASHII GAKKO!"},
+ Artist: "ATARASHII GAKKO!",
+ ArtistMbzIDs: []uuid.UUID{
+ artistMbzID,
+ },
+ TrackTitle: "Tokyo Calling",
+ RecordingMbzID: trackMbzID,
+ ReleaseTitle: "AG! Calling",
+ ReleaseMbzID: releaseMbzID,
+ 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 track, release group, and artist are existing ones and not duplicates
+ count, err := store.Count(ctx, `
+ SELECT COUNT(*) FROM tracks_with_title WHERE musicbrainz_id = $1
+ `, trackMbzID)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count, "duplicate track created")
+ count, err = store.Count(ctx, `
+ SELECT COUNT(*) FROM releases_with_title WHERE musicbrainz_id = $1
+ `, releaseMbzID)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count, "duplicate release group created")
+ count, err = store.Count(ctx, `
+ SELECT COUNT(*) FROM releases_with_title WHERE musicbrainz_id = $1
+ `, existingReleaseMbzID)
+ require.NoError(t, err)
+ assert.Equal(t, 1, count, "existing release group should not be overwritten")
+ count, err = store.Count(ctx, `
+ SELECT COUNT(*) FROM artists_with_name WHERE musicbrainz_id = $1
+ `, artistMbzID)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count, "duplicate artist created")
+}
+
func TestSubmitListen_MatchTrackFromMbzTitle(t *testing.T) {
setupTestDataSansMbzIDs(t)
@@ -554,6 +637,43 @@ func TestSubmitListen_UpdateTrackDuration(t *testing.T) {
assert.Equal(t, 1, count, "expected duration to be updated")
}
+func TestSubmitListen_UpdateTrackDurationWithMbz(t *testing.T) {
+ setupTestDataSansMbzIDs(t)
+
+ ctx := context.Background()
+ mbzc := &mbz.MbzMockCaller{
+ Tracks: mbzTrackData,
+ }
+ opts := catalog.SubmitListenOpts{
+ MbzCaller: mbzc,
+ ArtistNames: []string{"ATARASHII GAKKO!"},
+ Artist: "ATARASHII GAKKO!",
+ TrackTitle: "Tokyo Calling",
+ RecordingMbzID: uuid.MustParse("00000000-0000-0000-0000-000000001001"),
+ ReleaseTitle: "AG! Calling",
+ 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")
+
+ count, err := store.Count(ctx, `
+ SELECT COUNT(*) FROM tracks_with_title WHERE title = $1 AND duration = 191
+ `, "Tokyo Calling")
+ require.NoError(t, err)
+ assert.Equal(t, 1, count, "expected duration to be updated")
+}
+
func TestSubmitListen_MatchFromTrackTitleNoMbzIDs(t *testing.T) {
setupTestDataSansMbzIDs(t)
@@ -856,3 +976,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/cfg/cfg.go b/internal/cfg/cfg.go
index ad15869..0cfc7bb 100644
--- a/internal/cfg/cfg.go
+++ b/internal/cfg/cfg.go
@@ -3,6 +3,7 @@ package cfg
import (
"errors"
"fmt"
+ "regexp"
"strconv"
"strings"
"sync"
@@ -17,30 +18,38 @@ 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"
+ DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
+ DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
+ DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
+ DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
+ SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL"
+ SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS"
+ LASTFM_API_KEY_ENV = "KOITO_LASTFM_API_KEY"
+ 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"
+ ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX"
+ LOGIN_GATE_ENV = "KOITO_LOGIN_GATE"
+ FORCE_TZ = "KOITO_FORCE_TZ"
)
type config struct {
@@ -48,29 +57,38 @@ 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
+ defaultTheme string
+ disableDeezer bool
+ disableCAA bool
+ disableMusicBrainz bool
+ subsonicUrl string
+ subsonicParams string
+ lastfmApiKey string
+ subsonicEnabled bool
+ skipImport bool
+ fetchImageDuringImport bool
+ allowedHosts []string
+ allowAllHosts bool
+ allowedOrigins []string
+ disableRateLimit bool
+ importThrottleMs int
+ userAgent string
+ importBefore time.Time
+ importAfter time.Time
+ artistSeparators []*regexp.Regexp
+ loginGate bool
+ forceTZ *time.Location
}
var (
@@ -85,7 +103,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 +115,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,11 +157,19 @@ 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))
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV))
cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV))
+ cfg.subsonicUrl = getenv(SUBSONIC_URL_ENV)
+ cfg.subsonicParams = getenv(SUBSONIC_PARAMS_ENV)
+ cfg.subsonicEnabled = cfg.subsonicUrl != "" && cfg.subsonicParams != ""
+ if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") {
+ return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV)
+ }
+ cfg.lastfmApiKey = getenv(LASTFM_API_KEY_ENV)
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
@@ -156,6 +185,8 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
}
+ cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
+
cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" {
cfg.configDir = "/etc/koito"
@@ -168,6 +199,29 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
rawCors := getenv(CORS_ORIGINS_ENV)
cfg.allowedOrigins = strings.Split(rawCors, ",")
+ if getenv(ARTIST_SEPARATORS_ENV) != "" {
+ for pattern := range strings.SplitSeq(getenv(ARTIST_SEPARATORS_ENV), ";;") {
+ regex, err := regexp.Compile(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("failed to compile regex pattern %s", pattern)
+ }
+ cfg.artistSeparators = append(cfg.artistSeparators, regex)
+ }
+ } else {
+ cfg.artistSeparators = []*regexp.Regexp{regexp.MustCompile(`\s+·\s+`)}
+ }
+
+ if strings.ToLower(getenv(LOGIN_GATE_ENV)) == "true" {
+ cfg.loginGate = true
+ }
+
+ if getenv(FORCE_TZ) != "" {
+ cfg.forceTZ, err = time.LoadLocation(getenv(FORCE_TZ))
+ if err != nil {
+ return nil, fmt.Errorf("forced timezone '%s' is not a valid timezone", getenv(FORCE_TZ))
+ }
+ }
+
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
case "debug":
cfg.logLevel = 0
@@ -190,154 +244,3 @@ func parseBool(s string) bool {
return false
}
}
-
-// Global accessors for configuration values
-
-func UserAgent() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.userAgent
-}
-
-func ListenAddr() string {
- lock.RLock()
- defer lock.RUnlock()
- return fmt.Sprintf("%s:%d", globalConfig.bindAddr, globalConfig.listenPort)
-}
-
-func ConfigDir() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.configDir
-}
-
-// func BaseUrl() string {
-// lock.RLock()
-// defer lock.RUnlock()
-// return globalConfig.baseUrl
-// }
-
-func DatabaseUrl() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.databaseUrl
-}
-
-func MusicBrainzUrl() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.musicBrainzUrl
-}
-
-func MusicBrainzRateLimit() int {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.musicBrainzRateLimit
-}
-
-func LogLevel() int {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.logLevel
-}
-
-func StructuredLogging() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.structuredLogging
-}
-
-func LbzRelayEnabled() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.lbzRelayEnabled
-}
-
-func LbzRelayUrl() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.lbzRelayUrl
-}
-
-func LbzRelayToken() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.lbzRelayToken
-}
-
-func DefaultPassword() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.defaultPw
-}
-
-func DefaultUsername() string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.defaultUsername
-}
-
-func FullImageCacheEnabled() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.enableFullImageCache
-}
-
-func DeezerDisabled() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.disableDeezer
-}
-
-func CoverArtArchiveDisabled() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.disableCAA
-}
-
-func MusicBrainzDisabled() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.disableMusicBrainz
-}
-
-func SkipImport() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.skipImport
-}
-
-func AllowedHosts() []string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.allowedHosts
-}
-
-func AllowAllHosts() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.allowAllHosts
-}
-
-func AllowedOrigins() []string {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.allowedOrigins
-}
-
-func RateLimitDisabled() bool {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.disableRateLimit
-}
-
-func ThrottleImportMs() int {
- lock.RLock()
- defer lock.RUnlock()
- return globalConfig.importThrottleMs
-}
-
-// returns the before, after times, in that order
-func ImportWindow() (time.Time, time.Time) {
- return globalConfig.importBefore, globalConfig.importAfter
-}
diff --git a/internal/cfg/getters.go b/internal/cfg/getters.go
new file mode 100644
index 0000000..596ca9d
--- /dev/null
+++ b/internal/cfg/getters.go
@@ -0,0 +1,206 @@
+package cfg
+
+import (
+ "fmt"
+ "regexp"
+ "time"
+)
+
+func UserAgent() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.userAgent
+}
+
+func ListenAddr() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return fmt.Sprintf("%s:%d", globalConfig.bindAddr, globalConfig.listenPort)
+}
+
+func ConfigDir() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.configDir
+}
+
+func DatabaseUrl() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.databaseUrl
+}
+
+func MusicBrainzUrl() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.musicBrainzUrl
+}
+
+func MusicBrainzRateLimit() int {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.musicBrainzRateLimit
+}
+
+func LogLevel() int {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.logLevel
+}
+
+func StructuredLogging() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.structuredLogging
+}
+
+func LbzRelayEnabled() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.lbzRelayEnabled
+}
+
+func LbzRelayUrl() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.lbzRelayUrl
+}
+
+func LbzRelayToken() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.lbzRelayToken
+}
+
+func DefaultPassword() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.defaultPw
+}
+
+func DefaultUsername() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.defaultUsername
+}
+
+func DefaultTheme() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.defaultTheme
+}
+
+func FullImageCacheEnabled() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.enableFullImageCache
+}
+
+func DeezerDisabled() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.disableDeezer
+}
+
+func CoverArtArchiveDisabled() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.disableCAA
+}
+
+func MusicBrainzDisabled() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.disableMusicBrainz
+}
+
+func SubsonicEnabled() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.subsonicEnabled
+}
+
+func SubsonicUrl() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.subsonicUrl
+}
+
+func SubsonicParams() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.subsonicParams
+}
+
+func LastFMApiKey() string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.lastfmApiKey
+}
+
+func SkipImport() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.skipImport
+}
+
+func AllowedHosts() []string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.allowedHosts
+}
+
+func AllowAllHosts() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.allowAllHosts
+}
+
+func AllowedOrigins() []string {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.allowedOrigins
+}
+
+func RateLimitDisabled() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.disableRateLimit
+}
+
+func ThrottleImportMs() int {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.importThrottleMs
+}
+
+// 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
+}
+
+func ArtistSeparators() []*regexp.Regexp {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.artistSeparators
+}
+
+func LoginGate() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.loginGate
+}
+
+func ForceTZ() *time.Location {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.forceTZ
+}
diff --git a/internal/cfg/setters.go b/internal/cfg/setters.go
new file mode 100644
index 0000000..8458780
--- /dev/null
+++ b/internal/cfg/setters.go
@@ -0,0 +1,7 @@
+package cfg
+
+func SetLoginGate(val bool) {
+ lock.Lock()
+ defer lock.Unlock()
+ globalConfig.loginGate = val
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index 637a51f..97badac 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -11,12 +11,17 @@ import (
type DB interface {
// Get
+
GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error)
GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error)
+ GetAlbumWithNoMbzIDByTitles(ctx context.Context, artistId int32, titles []string) (*models.Album, error)
GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, 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)
+ GetTracksWithNoDurationButHaveMbzID(ctx context.Context, from int32) ([]*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[RankedItem[*models.Track]], error)
+ GetTopArtistsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[RankedItem[*models.Artist]], error)
+ GetTopAlbumsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[RankedItem[*models.Album]], error)
GetListensPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Listen], error)
GetListenActivity(ctx context.Context, opts ListenActivityOpts) ([]ListenActivityItem, error)
GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alias, error)
@@ -26,7 +31,10 @@ type DB interface {
GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*models.User, error)
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
GetUserByApiKey(ctx context.Context, key string) (*models.User, error)
+ GetInterest(ctx context.Context, opts GetInterestOpts) ([]InterestBucket, error)
+
// Save
+
SaveArtist(ctx context.Context, opts SaveArtistOpts) (*models.Artist, error)
SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error
SaveAlbum(ctx context.Context, opts SaveAlbumOpts) (*models.Album, error)
@@ -37,7 +45,9 @@ type DB interface {
SaveUser(ctx context.Context, opts SaveUserOpts) (*models.User, error)
SaveApiKey(ctx context.Context, opts SaveApiKeyOpts) (*models.ApiKey, error)
SaveSession(ctx context.Context, userId int32, expiresAt time.Time, persistent bool) (*models.Session, error)
+
// Update
+
UpdateArtist(ctx context.Context, opts UpdateArtistOpts) error
UpdateTrack(ctx context.Context, opts UpdateTrackOpts) error
UpdateAlbum(ctx context.Context, opts UpdateAlbumOpts) error
@@ -48,7 +58,11 @@ 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
DeleteTrack(ctx context.Context, id int32) error
@@ -58,25 +72,42 @@ type DB interface {
DeleteTrackAlias(ctx context.Context, id int32, alias string) error
DeleteSession(ctx context.Context, sessionId uuid.UUID) error
DeleteApiKey(ctx context.Context, id int32) error
+
// Count
- CountListens(ctx context.Context, period Period) (int64, error)
- CountTracks(ctx context.Context, period Period) (int64, error)
- CountAlbums(ctx context.Context, period Period) (int64, error)
- CountArtists(ctx context.Context, period Period) (int64, error)
- CountTimeListened(ctx context.Context, period Period) (int64, error)
+
+ CountListens(ctx context.Context, timeframe Timeframe) (int64, error)
+ CountListensToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
+ CountTracks(ctx context.Context, timeframe Timeframe) (int64, error)
+ CountAlbums(ctx context.Context, timeframe Timeframe) (int64, error)
+ CountArtists(ctx context.Context, timeframe Timeframe) (int64, error)
+ CountNewTracks(ctx context.Context, timeframe Timeframe) (int64, error)
+ CountNewAlbums(ctx context.Context, timeframe Timeframe) (int64, error)
+ CountNewArtists(ctx context.Context, timeframe Timeframe) (int64, error)
+ // in seconds
+ CountTimeListened(ctx context.Context, timeframe Timeframe) (int64, error)
+ // in seconds
+ CountTimeListenedToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
CountUsers(ctx context.Context) (int64, error)
+
// Search
+
SearchArtists(ctx context.Context, q string) ([]*models.Artist, error)
SearchAlbums(ctx context.Context, q string) ([]*models.Album, error)
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)
AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.Album, error)
+ ArtistsWithoutImages(ctx context.Context, from int32) ([]*models.Artist, error)
+ GetExportPage(ctx context.Context, opts GetExportPageOpts) ([]*ExportItem, error)
Ping(ctx context.Context) error
Close(ctx context.Context)
}
diff --git a/internal/db/opts.go b/internal/db/opts.go
index 481ccc3..cb23bd3 100644
--- a/internal/db/opts.go
+++ b/internal/db/opts.go
@@ -27,6 +27,7 @@ type GetTrackOpts struct {
ID int32
MusicBrainzID uuid.UUID
Title string
+ ReleaseID int32
ArtistIDs []int32
}
@@ -96,10 +97,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 {
@@ -114,12 +117,9 @@ type AddArtistsToAlbumOpts struct {
}
type GetItemsOpts struct {
- Limit int
- Period Period
- Page int
- Week int // 1-52
- Month int // 1-12
- Year int
+ Limit int
+ Page int
+ Timeframe Timeframe
// Used only for getting top tracks
ArtistID int
@@ -134,6 +134,28 @@ type ListenActivityOpts struct {
Range int
Month int
Year int
+ Timezone *time.Location
+ AlbumID int32
+ ArtistID int32
+ TrackID int32
+}
+
+type TimeListenedOpts struct {
+ Timeframe Timeframe
+ AlbumID int32
+ ArtistID int32
+ TrackID int32
+}
+
+type GetExportPageOpts struct {
+ UserID int32
+ ListenedAt time.Time
+ TrackID int32
+ Limit int32
+}
+
+type GetInterestOpts struct {
+ Buckets int
AlbumID int32
ArtistID int32
TrackID int32
diff --git a/internal/db/period.go b/internal/db/period.go
index 5711d05..00c4886 100644
--- a/internal/db/period.go
+++ b/internal/db/period.go
@@ -14,9 +14,12 @@ const (
PeriodMonth Period = "month"
PeriodYear Period = "year"
PeriodAllTime Period = "all_time"
- PeriodDefault Period = "day"
)
+func (p Period) IsZero() bool {
+ return p == ""
+}
+
func StartTimeFromPeriod(p Period) time.Time {
now := time.Now()
switch p {
@@ -54,17 +57,21 @@ const (
// and end will be 23:59:59 on Saturday at the end of the current week.
// If opts.Year (or opts.Year + opts.Month) is provided, start and end will simply by the start and end times of that year/month.
func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) {
- now := time.Now()
+ loc := opts.Timezone
+ if loc == nil {
+ loc, _ = time.LoadLocation("UTC")
+ }
+ now := time.Now().In(loc)
// If Year (and optionally Month) are specified, use calendar boundaries
if opts.Year != 0 {
if opts.Month != 0 {
// Specific month of a specific year
- start = time.Date(opts.Year, time.Month(opts.Month), 1, 0, 0, 0, 0, now.Location())
+ start = time.Date(opts.Year, time.Month(opts.Month), 1, 0, 0, 0, 0, loc)
end = start.AddDate(0, 1, 0).Add(-time.Nanosecond)
} else {
// Whole year
- start = time.Date(opts.Year, 1, 1, 0, 0, 0, 0, now.Location())
+ start = time.Date(opts.Year, 1, 1, 0, 0, 0, 0, loc)
end = start.AddDate(1, 0, 0).Add(-time.Nanosecond)
}
return start, end
@@ -76,30 +83,32 @@ func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) {
// Determine step and align accordingly
switch opts.Step {
case StepDay:
- today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
start = today.AddDate(0, 0, -opts.Range)
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
case StepWeek:
// Align to most recent Sunday
weekday := int(now.Weekday()) // Sunday = 0
- startOfThisWeek := time.Date(now.Year(), now.Month(), now.Day()-weekday, 0, 0, 0, 0, now.Location())
- start = startOfThisWeek.AddDate(0, 0, -7*opts.Range)
+ startOfThisWeek := time.Date(now.Year(), now.Month(), now.Day()-weekday, 0, 0, 0, 0, loc)
+ // need to subtract 1 from range for week because we are going back from the beginning of this
+ // week, so we sort of already went back a week
+ start = startOfThisWeek.AddDate(0, 0, -7*(opts.Range-1))
end = startOfThisWeek.AddDate(0, 0, 7).Add(-time.Nanosecond)
case StepMonth:
- firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+ firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
start = firstOfThisMonth.AddDate(0, -opts.Range, 0)
end = firstOfThisMonth.AddDate(0, 1, 0).Add(-time.Nanosecond)
case StepYear:
- firstOfThisYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
+ firstOfThisYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, loc)
start = firstOfThisYear.AddDate(-opts.Range, 0, 0)
end = firstOfThisYear.AddDate(1, 0, 0).Add(-time.Nanosecond)
default:
// Default to daily
- today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
start = today.AddDate(0, 0, -opts.Range)
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
}
diff --git a/internal/db/period_test.go b/internal/db/period_test.go
index 8705ce7..0878637 100644
--- a/internal/db/period_test.go
+++ b/internal/db/period_test.go
@@ -3,6 +3,9 @@ package db_test
import (
"testing"
"time"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/stretchr/testify/require"
)
func TestListenActivityOptsToTimes(t *testing.T) {
@@ -21,6 +24,11 @@ func eod(t time.Time) time.Time {
return time.Date(year, month, day, 23, 59, 59, 0, loc)
}
+func TestPeriodUnset(t *testing.T) {
+ var p db.Period
+ require.True(t, p.IsZero())
+}
+
func bod(t time.Time) time.Time {
year, month, day := t.Date()
loc := t.Location()
diff --git a/internal/db/psql/album.go b/internal/db/psql/album.go
index 0444b45..758c287 100644
--- a/internal/db/psql/album.go
+++ b/internal/db/psql/album.go
@@ -2,7 +2,9 @@ package psql
import (
"context"
+ "encoding/json"
"errors"
+ "fmt"
"strings"
"time"
@@ -18,53 +20,136 @@ import (
func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx)
-
- var row repository.ReleasesWithTitle
var err error
+ var ret = new(models.Album)
- if opts.ID != 0 {
- l.Debug().Msgf("Fetching album from DB with id %d", opts.ID)
- row, err = d.q.GetRelease(ctx, opts.ID)
- } else if opts.MusicBrainzID != uuid.Nil {
+ if opts.MusicBrainzID != uuid.Nil {
l.Debug().Msgf("Fetching album from DB with MusicBrainz Release ID %s", opts.MusicBrainzID)
- row, err = d.q.GetReleaseByMbzID(ctx, &opts.MusicBrainzID)
+ row, err := d.q.GetReleaseByMbzID(ctx, &opts.MusicBrainzID)
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbum: %w", err)
+ }
+ opts.ID = row.ID
} else if opts.ArtistID != 0 && opts.Title != "" {
l.Debug().Msgf("Fetching album from DB with artist_id %d and title %s", opts.ArtistID, opts.Title)
- row, err = d.q.GetReleaseByArtistAndTitle(ctx, repository.GetReleaseByArtistAndTitleParams{
+ row, err := d.q.GetReleaseByArtistAndTitle(ctx, repository.GetReleaseByArtistAndTitleParams{
ArtistID: opts.ArtistID,
Title: opts.Title,
})
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbum: %w", err)
+ }
+ opts.ID = row.ID
} else if opts.ArtistID != 0 && len(opts.Titles) > 0 {
l.Debug().Msgf("Fetching release group from DB with artist_id %d and titles %v", opts.ArtistID, opts.Titles)
- row, err = d.q.GetReleaseByArtistAndTitles(ctx, repository.GetReleaseByArtistAndTitlesParams{
+ row, err := d.q.GetReleaseByArtistAndTitles(ctx, repository.GetReleaseByArtistAndTitlesParams{
ArtistID: opts.ArtistID,
Column1: opts.Titles,
})
- } else {
- return nil, errors.New("insufficient information to get album")
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbum: %w", err)
+ }
+ opts.ID = row.ID
}
+ l.Debug().Msgf("Fetching album from DB with id %d", opts.ID)
+ row, err := d.q.GetRelease(ctx, opts.ID)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("GetAlbum: %w", err)
}
count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
ListenedAt: time.Unix(0, 0),
ListenedAt_2: time.Now(),
- ReleaseID: row.ID,
+ ReleaseID: opts.ID,
})
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("GetAlbum: CountListensFromRelease: %w", err)
}
- return &models.Album{
- ID: row.ID,
- MbzID: row.MusicBrainzID,
- Title: row.Title,
- Image: row.Image,
- VariousArtists: row.VariousArtists,
- ListenCount: count,
- }, nil
+ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
+ Timeframe: db.Timeframe{Period: db.PeriodAllTime},
+ AlbumID: opts.ID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
+ }
+
+ firstListen, err := d.q.GetFirstListenFromRelease(ctx, opts.ID)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
+ }
+
+ rank, err := d.q.GetReleaseAllTimeRank(ctx, opts.ID)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("GetAlbum: GetReleaseAllTimeRank: %w", err)
+ }
+
+ ret.ID = row.ID
+ ret.MbzID = row.MusicBrainzID
+ ret.Title = row.Title
+ ret.Image = row.Image
+ ret.VariousArtists = row.VariousArtists
+ err = json.Unmarshal(row.Artists, &ret.Artists)
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbum: json.Unmarshal: %w", err)
+ }
+ ret.AllTimeRank = rank.Rank
+ ret.ListenCount = count
+ ret.TimeListened = seconds
+ ret.FirstListen = firstListen.ListenedAt.Unix()
+
+ return ret, nil
+}
+
+func (d *Psql) GetAlbumWithNoMbzIDByTitles(ctx context.Context, artistId int32, titles []string) (*models.Album, error) {
+ l := logger.FromContext(ctx)
+ ret := new(models.Album)
+
+ if artistId != 0 && len(titles) > 0 {
+ l.Debug().Msgf("GetAlbumWithNoMbzIDByTitles: Fetching release group from DB with artist_id %d and titles %v and no associated MusicBrainz ID", artistId, titles)
+ row, err := d.q.GetReleaseByArtistAndTitlesNoMbzID(ctx, repository.GetReleaseByArtistAndTitlesNoMbzIDParams{
+ ArtistID: artistId,
+ Column1: titles,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbum: %w", err)
+ }
+ ret.ID = row.ID
+ ret.MbzID = row.MusicBrainzID
+ ret.Title = row.Title
+ ret.Image = row.Image
+ ret.VariousArtists = row.VariousArtists
+ } else {
+ return nil, errors.New("GetAlbumWithNoMbzIDByTitles: insufficient information to get album")
+ }
+ count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
+ ListenedAt: time.Unix(0, 0),
+ ListenedAt_2: time.Now(),
+ ReleaseID: ret.ID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbumWithNoMbzIDByTitles: CountListensFromRelease: %w", err)
+ }
+
+ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
+ Timeframe: db.Timeframe{Period: db.PeriodAllTime},
+ AlbumID: ret.ID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetAlbumWithNoMbzIDByTitles: CountTimeListenedToItem: %w", err)
+ }
+
+ firstListen, err := d.q.GetFirstListenFromRelease(ctx, ret.ID)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("GetAlbumWithNoMbzIDByTitles: GetFirstListenFromRelease: %w", err)
+ }
+
+ ret.ListenCount = count
+ ret.TimeListened = seconds
+ ret.FirstListen = firstListen.ListenedAt.Unix()
+
+ return ret, nil
}
func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Album, error) {
@@ -78,17 +163,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)
@@ -100,16 +185,17 @@ 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)
err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{
ArtistID: artistId,
ReleaseID: r.ID,
+ IsPrimary: opts.ArtistIDs[0] == artistId,
})
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)
@@ -121,11 +207,17 @@ 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)
+ }
+
+ err = d.SaveAlbumAliases(ctx, r.ID, opts.Aliases, "MusicBrainz")
+ if err != nil {
+ l.Err(err).Msgf("Failed to save aliases for album %s", opts.Title)
}
return &models.Album{
@@ -142,7 +234,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)
@@ -153,6 +245,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)
@@ -166,7 +259,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)
@@ -177,10 +270,13 @@ 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 {
+ if opts.ImageSrc == "" {
+ return fmt.Errorf("UpdateAlbum: image source must be provided when updating an image")
+ }
l.Debug().Msgf("Updating release with ID %d with image %s", opts.ID, opts.Image)
err := qtx.UpdateReleaseImage(ctx, repository.UpdateReleaseImageParams{
ID: opts.ID,
@@ -188,7 +284,17 @@ 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 {
+ 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 fmt.Errorf("UpdateAlbum: UpdateReleaseVariousArtists: %w", err)
}
}
return tx.Commit(ctx)
@@ -202,13 +308,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)
@@ -216,7 +322,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),
@@ -225,7 +331,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)
@@ -244,7 +350,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 {
@@ -266,14 +372,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
@@ -290,7 +396,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,
@@ -298,7 +404,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,
@@ -306,7 +412,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/album_test.go b/internal/db/psql/album_test.go
index 373abdb..49ebfbb 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{})
@@ -121,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)
@@ -132,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/artist.go b/internal/db/psql/artist.go
index 0368fc6..859a490 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"
@@ -16,112 +17,102 @@ 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 {
- 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
- }
- count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
- ListenedAt: time.Unix(0, 0),
- ListenedAt_2: time.Now(),
- 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,
- }, nil
- } else if opts.MusicBrainzID != uuid.Nil {
+ if opts.MusicBrainzID != uuid.Nil {
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),
- ListenedAt_2: time.Now(),
- 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,
- }, nil
+ opts.ID = row.ID
} else if opts.Name != "" {
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),
- ListenedAt_2: time.Now(),
- 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,
- }, nil
- } else {
- return nil, errors.New("insufficient information to get artist")
+ opts.ID = row.ID
}
+ 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, fmt.Errorf("GetArtist: GetArtist by ID: %w", err)
+ }
+ count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
+ ListenedAt: time.Unix(0, 0),
+ ListenedAt_2: time.Now(),
+ ArtistID: row.ID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
+ }
+ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
+ Timeframe: db.Timeframe{Period: db.PeriodAllTime},
+ ArtistID: row.ID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
+ }
+ firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
+ }
+ rank, err := d.q.GetArtistAllTimeRank(ctx, opts.ID)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("GetArtist: GetArtistAllTimeRank: %w", err)
+ }
+ return &models.Artist{
+ ID: row.ID,
+ MbzID: row.MusicBrainzID,
+ Name: row.Name,
+ Aliases: row.Aliases,
+ Image: row.Image,
+ ListenCount: count,
+ TimeListened: seconds,
+ AllTimeRank: rank.Rank,
+ FirstListen: firstListen.ListenedAt.Unix(),
+ }, nil
}
// Inserts all unique aliases into the DB with specified source
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)
+ l.Debug().Msgf("Fetching existing artist aliases for artist %d...", id)
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)
}
+ l.Debug().Msgf("Ensuring aliases are unique...")
utils.Unique(&aliases)
for _, alias := range aliases {
- if strings.TrimSpace(alias) == "" {
- return errors.New("aliases cannot be blank")
+ l.Debug().Msgf("Inserting alias %s for artist with id %d", alias, id)
+ 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)
@@ -145,13 +136,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{
@@ -160,7 +151,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{
@@ -170,13 +161,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,
@@ -189,7 +180,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
}
@@ -199,12 +190,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)
@@ -215,10 +206,13 @@ 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 {
+ if opts.ImageSrc == "" {
+ return fmt.Errorf("UpdateAlbum: image source must be provided when updating an image")
+ }
l.Debug().Msgf("Updating artist with id %d with image %s", opts.ID, opts.Image)
err = qtx.UpdateArtistImage(ctx, repository.UpdateArtistImageParams{
ID: opts.ID,
@@ -226,10 +220,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 {
@@ -238,10 +237,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 {
@@ -258,19 +258,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
@@ -283,11 +282,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,
@@ -295,7 +293,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,
@@ -303,7 +301,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/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..a1c1cc8 100644
--- a/internal/db/psql/counts.go
+++ b/internal/db/psql/counts.go
@@ -2,69 +2,181 @@ package psql
import (
"context"
- "time"
+ "errors"
+ "fmt"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/repository"
)
-func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error) {
- t2 := time.Now()
- t1 := db.StartTimeFromPeriod(period)
+func (p *Psql) CountListens(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountListens(ctx, repository.CountListensParams{
ListenedAt: t1,
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)
+
+func (p *Psql) CountTracks(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTopTracks(ctx, repository.CountTopTracksParams{
ListenedAt: t1,
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)
+
+func (p *Psql) CountAlbums(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTopReleases(ctx, repository.CountTopReleasesParams{
ListenedAt: t1,
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)
+
+func (p *Psql) CountArtists(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTopArtists(ctx, repository.CountTopArtistsParams{
ListenedAt: t1,
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)
+
+// in seconds
+func (p *Psql) CountTimeListened(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTimeListened(ctx, repository.CountTimeListenedParams{
ListenedAt: t1,
ListenedAt_2: t2,
})
if err != nil {
- return 0, err
+ return 0, fmt.Errorf("CountTimeListened: %w", err)
+ }
+ return count, nil
+}
+
+// in seconds
+func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
+
+ 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, fmt.Errorf("CountTimeListenedToItem (Artist): %w", 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, fmt.Errorf("CountTimeListenedToItem (Album): %w", 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, fmt.Errorf("CountTimeListenedToItem (Track): %w", err)
+ }
+ return count, nil
+ }
+ return 0, errors.New("CountTimeListenedToItem: an id must be provided")
+}
+
+func (p *Psql) CountListensToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
+
+ if opts.ArtistID > 0 {
+ count, err := p.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ ArtistID: opts.ArtistID,
+ })
+ if err != nil {
+ return 0, fmt.Errorf("CountListensToItem (Artist): %w", err)
+ }
+ return count, nil
+ } else if opts.AlbumID > 0 {
+ count, err := p.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ ReleaseID: opts.AlbumID,
+ })
+ if err != nil {
+ return 0, fmt.Errorf("CountListensToItem (Album): %w", err)
+ }
+ return count, nil
+ } else if opts.TrackID > 0 {
+ count, err := p.q.CountListensFromTrack(ctx, repository.CountListensFromTrackParams{
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ TrackID: opts.TrackID,
+ })
+ if err != nil {
+ return 0, fmt.Errorf("CountListensToItem (Track): %w", err)
+ }
+ return count, nil
+ }
+ return 0, errors.New("CountListensToItem: an id must be provided")
+}
+
+func (p *Psql) CountNewTracks(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
+ count, err := p.q.CountNewTracks(ctx, repository.CountNewTracksParams{
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ })
+ if err != nil {
+ return 0, fmt.Errorf("CountNewTracks: %w", err)
+ }
+ return count, nil
+}
+
+func (p *Psql) CountNewAlbums(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
+ count, err := p.q.CountNewReleases(ctx, repository.CountNewReleasesParams{
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ })
+ if err != nil {
+ return 0, fmt.Errorf("CountNewAlbums: %w", err)
+ }
+ return count, nil
+}
+
+func (p *Psql) CountNewArtists(ctx context.Context, timeframe db.Timeframe) (int64, error) {
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
+ count, err := p.q.CountNewArtists(ctx, repository.CountNewArtistsParams{
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ })
+ if err != nil {
+ return 0, fmt.Errorf("CountNewArtists: %w", err)
}
return count, nil
}
diff --git a/internal/db/psql/counts_test.go b/internal/db/psql/counts_test.go
index b6ddd18..d94003e 100644
--- a/internal/db/psql/counts_test.go
+++ b/internal/db/psql/counts_test.go
@@ -3,6 +3,7 @@ package psql_test
import (
"context"
"testing"
+ "time"
"github.com/gabehf/koito/internal/db"
"github.com/stretchr/testify/assert"
@@ -15,7 +16,7 @@ func TestCountListens(t *testing.T) {
// Test CountListens
period := db.PeriodWeek
- count, err := store.CountListens(ctx, period)
+ count, err := store.CountListens(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "expected listens count to match inserted data")
@@ -28,49 +29,160 @@ func TestCountTracks(t *testing.T) {
// Test CountTracks
period := db.PeriodMonth
- count, err := store.CountTracks(ctx, period)
+ count, err := store.CountTracks(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(2), count, "expected tracks count to match inserted data")
truncateTestData(t)
}
+func TestCountNewTracks(t *testing.T) {
+ ctx := context.Background()
+ testDataForTopItems(t)
+ testDataAbsoluteListenTimes(t)
+
+ // Test CountTracks
+ t1, _ := time.Parse(time.DateOnly, "2025-01-01")
+ t1u := t1.Unix()
+ t2, _ := time.Parse(time.DateOnly, "2025-12-31")
+ t2u := t2.Unix()
+ count, err := store.CountNewTracks(ctx, db.Timeframe{FromUnix: t1u, ToUnix: t2u})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), count, "expected tracks count to match inserted data")
+
+ truncateTestData(t)
+}
+
func TestCountAlbums(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
// Test CountAlbums
period := db.PeriodYear
- count, err := store.CountAlbums(ctx, period)
+ count, err := store.CountAlbums(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(3), count, "expected albums count to match inserted data")
truncateTestData(t)
}
+func TestCountNewAlbums(t *testing.T) {
+ ctx := context.Background()
+ testDataForTopItems(t)
+ testDataAbsoluteListenTimes(t)
+
+ // Test CountTracks
+ t1, _ := time.Parse(time.DateOnly, "2025-01-01")
+ t1u := t1.Unix()
+ t2, _ := time.Parse(time.DateOnly, "2025-12-31")
+ t2u := t2.Unix()
+ count, err := store.CountNewAlbums(ctx, db.Timeframe{FromUnix: t1u, ToUnix: t2u})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), count, "expected albums count to match inserted data")
+
+ truncateTestData(t)
+}
+
func TestCountArtists(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
// Test CountArtists
period := db.PeriodAllTime
- count, err := store.CountArtists(ctx, period)
+ count, err := store.CountArtists(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(4), count, "expected artists count to match inserted data")
truncateTestData(t)
}
+func TestCountNewArtists(t *testing.T) {
+ ctx := context.Background()
+ testDataForTopItems(t)
+ testDataAbsoluteListenTimes(t)
+
+ // Test CountTracks
+ t1, _ := time.Parse(time.DateOnly, "2025-01-01")
+ t1u := t1.Unix()
+ t2, _ := time.Parse(time.DateOnly, "2025-12-31")
+ t2u := t2.Unix()
+ count, err := store.CountNewArtists(ctx, db.Timeframe{FromUnix: t1u, ToUnix: t2u})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), count, "expected artists count to match inserted data")
+
+ truncateTestData(t)
+}
+
func TestCountTimeListened(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
// Test CountTimeListened
period := db.PeriodMonth
- count, err := store.CountTimeListened(ctx, period)
+ count, err := store.CountTimeListened(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
// 3 listens in past month, each 100 seconds
assert.Equal(t, int64(300), count, "expected total time listened to match inserted data")
truncateTestData(t)
}
+
+func TestCountTimeListenedToArtist(t *testing.T) {
+ ctx := context.Background()
+ testDataForTopItems(t)
+ period := db.PeriodAllTime
+ count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{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{Timeframe: db.Timeframe{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{Timeframe: db.Timeframe{Period: period}, TrackID: 3})
+ require.NoError(t, err)
+ assert.EqualValues(t, 200, count)
+ truncateTestData(t)
+}
+
+func TestListensToArtist(t *testing.T) {
+ ctx := context.Background()
+ testDataForTopItems(t)
+ period := db.PeriodAllTime
+ count, err := store.CountListensToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, ArtistID: 1})
+ require.NoError(t, err)
+ assert.EqualValues(t, 4, count)
+ truncateTestData(t)
+}
+
+func TestListensToAlbum(t *testing.T) {
+ ctx := context.Background()
+ testDataForTopItems(t)
+ period := db.PeriodAllTime
+ count, err := store.CountListensToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, AlbumID: 2})
+ require.NoError(t, err)
+ assert.EqualValues(t, 3, count)
+ truncateTestData(t)
+}
+
+func TestListensToTrack(t *testing.T) {
+ ctx := context.Background()
+ testDataForTopItems(t)
+ period := db.PeriodAllTime
+ count, err := store.CountListensToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, TrackID: 3})
+ require.NoError(t, err)
+ assert.EqualValues(t, 2, count)
+ truncateTestData(t)
+}
diff --git a/internal/db/psql/exports.go b/internal/db/psql/exports.go
new file mode 100644
index 0000000..13988ce
--- /dev/null
+++ b/internal/db/psql/exports.go
@@ -0,0 +1,59 @@
+package psql
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/models"
+ "github.com/gabehf/koito/internal/repository"
+)
+
+func (d *Psql) GetExportPage(ctx context.Context, opts db.GetExportPageOpts) ([]*db.ExportItem, error) {
+ rows, err := d.q.GetListensExportPage(ctx, repository.GetListensExportPageParams{
+ UserID: opts.UserID,
+ TrackID: opts.TrackID,
+ Limit: opts.Limit,
+ ListenedAt: opts.ListenedAt,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetExportPage: %w", err)
+ }
+ ret := make([]*db.ExportItem, len(rows))
+ for i, row := range rows {
+
+ var trackAliases []models.Alias
+ err = json.Unmarshal(row.TrackAliases, &trackAliases)
+ if err != nil {
+ return nil, fmt.Errorf("GetExportPage: json.Unmarshal trackAliases: %w", err)
+ }
+ var albumAliases []models.Alias
+ err = json.Unmarshal(row.ReleaseAliases, &albumAliases)
+ if err != nil {
+ return nil, fmt.Errorf("GetExportPage: json.Unmarshal albumAliases: %w", err)
+ }
+ var artists []models.ArtistWithFullAliases
+ err = json.Unmarshal(row.Artists, &artists)
+ if err != nil {
+ return nil, fmt.Errorf("GetExportPage: json.Unmarshal artists: %w", err)
+ }
+
+ ret[i] = &db.ExportItem{
+ TrackID: row.TrackID,
+ ListenedAt: row.ListenedAt,
+ UserID: row.UserID,
+ Client: row.Client,
+ TrackMbid: row.TrackMbid,
+ TrackDuration: row.TrackDuration,
+ TrackAliases: trackAliases,
+ ReleaseID: row.ReleaseID,
+ ReleaseMbid: row.ReleaseMbid,
+ ReleaseImageSource: row.ReleaseImageSource.String,
+ VariousArtists: row.VariousArtists,
+ ReleaseAliases: albumAliases,
+ Artists: artists,
+ }
+ }
+ return ret, nil
+}
diff --git a/internal/db/psql/images.go b/internal/db/psql/images.go
index a2b7710..eef0d8f 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{
@@ -72,3 +72,26 @@ func (d *Psql) AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.A
}
return albums, nil
}
+
+// returns nil, nil on no results
+func (d *Psql) ArtistsWithoutImages(ctx context.Context, from int32) ([]*models.Artist, error) {
+ rows, err := d.q.GetArtistsWithoutImages(ctx, repository.GetArtistsWithoutImagesParams{
+ Limit: 20,
+ ID: from,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ } else if err != nil {
+ return nil, fmt.Errorf("ArtistsWithoutImages: %w", err)
+ }
+
+ ret := make([]*models.Artist, len(rows))
+ for i, row := range rows {
+ ret[i] = &models.Artist{
+ ID: row.ID,
+ Name: row.Name,
+ MbzID: row.MusicBrainzID,
+ }
+ }
+ return ret, nil
+}
diff --git a/internal/db/psql/interest.go b/internal/db/psql/interest.go
new file mode 100644
index 0000000..0c8f4eb
--- /dev/null
+++ b/internal/db/psql/interest.go
@@ -0,0 +1,70 @@
+package psql
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/repository"
+)
+
+func (d *Psql) GetInterest(ctx context.Context, opts db.GetInterestOpts) ([]db.InterestBucket, error) {
+ if opts.Buckets == 0 {
+ return nil, errors.New("GetInterest: bucket count must be provided")
+ }
+
+ ret := make([]db.InterestBucket, 0)
+
+ if opts.ArtistID != 0 {
+ resp, err := d.q.GetGroupedListensFromArtist(ctx, repository.GetGroupedListensFromArtistParams{
+ ArtistID: opts.ArtistID,
+ BucketCount: int32(opts.Buckets),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetInterest: GetGroupedListensFromArtist: %w", err)
+ }
+ for _, v := range resp {
+ ret = append(ret, db.InterestBucket{
+ BucketStart: v.BucketStart,
+ BucketEnd: v.BucketEnd,
+ ListenCount: v.ListenCount,
+ })
+ }
+ return ret, nil
+ } else if opts.AlbumID != 0 {
+ resp, err := d.q.GetGroupedListensFromRelease(ctx, repository.GetGroupedListensFromReleaseParams{
+ ReleaseID: opts.AlbumID,
+ BucketCount: int32(opts.Buckets),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetInterest: GetGroupedListensFromRelease: %w", err)
+ }
+ for _, v := range resp {
+ ret = append(ret, db.InterestBucket{
+ BucketStart: v.BucketStart,
+ BucketEnd: v.BucketEnd,
+ ListenCount: v.ListenCount,
+ })
+ }
+ return ret, nil
+ } else if opts.TrackID != 0 {
+ resp, err := d.q.GetGroupedListensFromTrack(ctx, repository.GetGroupedListensFromTrackParams{
+ ID: opts.TrackID,
+ BucketCount: int32(opts.Buckets),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetInterest: GetGroupedListensFromTrack: %w", err)
+ }
+ for _, v := range resp {
+ ret = append(ret, db.InterestBucket{
+ BucketStart: v.BucketStart,
+ BucketEnd: v.BucketEnd,
+ ListenCount: v.ListenCount,
+ })
+ }
+ return ret, nil
+ } else {
+ return nil, errors.New("GetInterest: artist id, album id, or track id must be provided")
+ }
+}
diff --git a/internal/db/psql/interest_test.go b/internal/db/psql/interest_test.go
new file mode 100644
index 0000000..a00e796
--- /dev/null
+++ b/internal/db/psql/interest_test.go
@@ -0,0 +1,112 @@
+package psql_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// an llm wrote this because i didn't feel like it. it looks like it works, although
+// it could stand to be more thorough
+func TestGetInterest(t *testing.T) {
+ truncateTestData(t)
+
+ ctx := context.Background()
+
+ // --- Setup Data ---
+
+ // Insert Artists
+ err := store.Exec(ctx, `
+ INSERT INTO artists (musicbrainz_id)
+ VALUES ('00000000-0000-0000-0000-000000000001'),
+ ('00000000-0000-0000-0000-000000000002')`)
+ require.NoError(t, err)
+
+ // Insert Releases (Albums)
+ err = store.Exec(ctx, `
+ INSERT INTO releases (musicbrainz_id)
+ VALUES ('00000000-0000-0000-0000-000000000011')`)
+ require.NoError(t, err)
+
+ // Insert Tracks (Both on Release 1)
+ err = store.Exec(ctx, `
+ INSERT INTO tracks (musicbrainz_id, release_id)
+ VALUES ('11111111-1111-1111-1111-111111111111', 1),
+ ('22222222-2222-2222-2222-222222222222', 1)`)
+ require.NoError(t, err)
+
+ // Link Artists to Tracks
+ // Artist 1 -> Track 1
+ // Artist 2 -> Track 2
+ err = store.Exec(ctx, `
+ INSERT INTO artist_tracks (artist_id, track_id)
+ VALUES (1, 1), (2, 2)`)
+ require.NoError(t, err)
+
+ // Insert Listens
+ // Track 1 (Artist 1, Release 1): 3 Listens
+ // Track 2 (Artist 2, Release 1): 2 Listens
+ err = store.Exec(ctx, `
+ INSERT INTO listens (user_id, track_id, listened_at) VALUES
+ (1, 1, NOW() - INTERVAL '1 hour'),
+ (1, 1, NOW() - INTERVAL '2 hours'),
+ (1, 1, NOW() - INTERVAL '3 hours'),
+ (1, 2, NOW() - INTERVAL '1 hour'),
+ (1, 2, NOW() - INTERVAL '2 hours')
+ `)
+ require.NoError(t, err)
+
+ // --- Test Validation ---
+
+ t.Run("Validation", func(t *testing.T) {
+ // Error: Missing Buckets
+ _, err := store.GetInterest(ctx, db.GetInterestOpts{ArtistID: 1})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "bucket count must be provided")
+
+ // Error: Missing ID
+ _, err = store.GetInterest(ctx, db.GetInterestOpts{Buckets: 10})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "must be provided")
+ })
+
+ // --- Test Data Retrieval ---
+ // Note: We use Buckets: 1 to ensure all listens are aggregated into a single result
+ // for easier assertion, avoiding complex date/time math in the test.
+
+ t.Run("Artist Interest", func(t *testing.T) {
+ // Artist 1 should have 3 listens (from Track 1)
+ buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
+ ArtistID: 1,
+ Buckets: 1,
+ })
+ require.NoError(t, err)
+ require.Len(t, buckets, 1)
+ assert.EqualValues(t, 3, buckets[0].ListenCount, "Artist 1 should have 3 listens")
+ })
+
+ t.Run("Album Interest", func(t *testing.T) {
+ // Album 1 contains Track 1 (3 listens) and Track 2 (2 listens) = 5 Total
+ buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
+ AlbumID: 1,
+ Buckets: 1,
+ })
+ require.NoError(t, err)
+ require.Len(t, buckets, 1)
+ assert.EqualValues(t, 5, buckets[0].ListenCount, "Album 1 should have 5 listens total")
+ })
+
+ t.Run("Track Interest", func(t *testing.T) {
+ // Track 2 should have 2 listens
+ buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
+ TrackID: 2,
+ Buckets: 1,
+ })
+ require.NoError(t, err)
+ require.Len(t, buckets, 1)
+ assert.EqualValues(t, 2, buckets[0].ListenCount, "Track 2 should have 2 listens")
+ })
+}
diff --git a/internal/db/psql/listen.go b/internal/db/psql/listen.go
index 0864643..add6b33 100644
--- a/internal/db/psql/listen.go
+++ b/internal/db/psql/listen.go
@@ -4,35 +4,27 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/repository"
- "github.com/gabehf/koito/internal/utils"
)
func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*db.PaginatedResponse[*models.Listen], error) {
l := logger.FromContext(ctx)
offset := (opts.Page - 1) * opts.Limit
- t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
- if err != nil {
- return nil, err
- }
- if opts.Month == 0 && opts.Year == 0 {
- // use period, not date range
- t2 = time.Now()
- t1 = db.StartTimeFromPeriod(opts.Period)
- }
+ t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}
var listens []*models.Listen
var count int64
if opts.TrackID > 0 {
- l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching %d listens on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetLastListensFromTrackPaginated(ctx, repository.GetLastListensFromTrackPaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -41,7 +33,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 +46,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,11 +56,11 @@ 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",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching %d listens on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetLastListensFromReleasePaginated(ctx, repository.GetLastListensFromReleasePaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -77,7 +69,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 +82,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,11 +92,11 @@ 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",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching %d listens on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetLastListensFromArtistPaginated(ctx, repository.GetLastListensFromArtistPaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -113,7 +105,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 +118,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,11 +128,11 @@ 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",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching %d listens on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetLastListensPaginated(ctx, repository.GetLastListensPaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -148,7 +140,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 +153,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 +162,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..b2c7990 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"
@@ -22,20 +23,20 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
var listenActivity []db.ListenActivityItem
if opts.AlbumID > 0 {
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for release group %d",
- opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.AlbumID)
+ opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05 MST"), t2.Format("Jan 02, 2006 15:04:05 MST"), opts.AlbumID)
rows, err := d.q.ListenActivityForRelease(ctx, repository.ListenActivityForReleaseParams{
- Column1: t1,
- Column2: t2,
- Column3: stepToInterval(opts.Step),
- ReleaseID: opts.AlbumID,
+ Column1: opts.Timezone.String(),
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ 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 {
t := db.ListenActivityItem{
- Start: row.BucketStart,
+ Start: row.Day.Time,
Listens: row.ListenCount,
}
listenActivity[i] = t
@@ -43,20 +44,20 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
l.Debug().Msgf("Database responded with %d steps", len(rows))
} else if opts.ArtistID > 0 {
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for artist %d",
- opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.ArtistID)
+ opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05 MST"), t2.Format("Jan 02, 2006 15:04:05 MST"), opts.ArtistID)
rows, err := d.q.ListenActivityForArtist(ctx, repository.ListenActivityForArtistParams{
- Column1: t1,
- Column2: t2,
- Column3: stepToInterval(opts.Step),
- ArtistID: opts.ArtistID,
+ Column1: opts.Timezone.String(),
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ 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 {
t := db.ListenActivityItem{
- Start: row.BucketStart,
+ Start: row.Day.Time,
Listens: row.ListenCount,
}
listenActivity[i] = t
@@ -64,20 +65,20 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
l.Debug().Msgf("Database responded with %d steps", len(rows))
} else if opts.TrackID > 0 {
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for track %d",
- opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.TrackID)
+ opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05 MST"), t2.Format("Jan 02, 2006 15:04:05 MST"), opts.TrackID)
rows, err := d.q.ListenActivityForTrack(ctx, repository.ListenActivityForTrackParams{
- Column1: t1,
- Column2: t2,
- Column3: stepToInterval(opts.Step),
- ID: opts.TrackID,
+ Column1: opts.Timezone.String(),
+ ListenedAt: t1,
+ ListenedAt_2: t2,
+ 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 {
t := db.ListenActivityItem{
- Start: row.BucketStart,
+ Start: row.Day.Time,
Listens: row.ListenCount,
}
listenActivity[i] = t
@@ -85,19 +86,19 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
l.Debug().Msgf("Database responded with %d steps", len(rows))
} else {
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v",
- opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"))
+ opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05 MST"), t2.Format("Jan 02, 2006 15:04:05 MST"))
rows, err := d.q.ListenActivity(ctx, repository.ListenActivityParams{
- Column1: t1,
- Column2: t2,
- Column3: stepToInterval(opts.Step),
+ Column1: opts.Timezone.String(),
+ ListenedAt: t1,
+ ListenedAt_2: t2,
})
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 {
t := db.ListenActivityItem{
- Start: row.BucketStart,
+ Start: row.Day.Time,
Listens: row.ListenCount,
}
listenActivity[i] = t
diff --git a/internal/db/psql/listen_activity_test.go b/internal/db/psql/listen_activity_test.go
index 1041823..affc202 100644
--- a/internal/db/psql/listen_activity_test.go
+++ b/internal/db/psql/listen_activity_test.go
@@ -22,55 +22,55 @@ func TestListenActivity(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
- `INSERT INTO artists (musicbrainz_id)
+ `INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000002')`)
require.NoError(t, err)
// Move artist names into artist_aliases
err = store.Exec(context.Background(),
- `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
+ `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 release groups
err = store.Exec(context.Background(),
- `INSERT INTO releases (musicbrainz_id)
+ `INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000011'),
('00000000-0000-0000-0000-000000000022')`)
require.NoError(t, err)
// Move release titles into release_aliases
err = store.Exec(context.Background(),
- `INSERT INTO release_aliases (release_id, alias, source, is_primary)
+ `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'Release One', 'Testing', true),
(2, 'Release Two', 'Testing', true)`)
require.NoError(t, err)
// Insert tracks
err = store.Exec(context.Background(),
- `INSERT INTO tracks (musicbrainz_id, release_id)
+ `INSERT INTO tracks (musicbrainz_id, release_id)
VALUES ('11111111-1111-1111-1111-111111111111', 1),
('22222222-2222-2222-2222-222222222222', 2)`)
require.NoError(t, err)
// Move track titles into track_aliases
err = store.Exec(context.Background(),
- `INSERT INTO track_aliases (track_id, alias, source, is_primary)
+ `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 tracks with artists
err = store.Exec(context.Background(),
- `INSERT INTO artist_tracks (artist_id, track_id)
+ `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)
+ `INSERT INTO listens (user_id, track_id, listened_at)
VALUES (1, 1, NOW() - INTERVAL '1 day'),
(1, 1, NOW() - INTERVAL '2 days'),
(1, 1, NOW() - INTERVAL '1 week 1 day'),
@@ -88,33 +88,35 @@ func TestListenActivity(t *testing.T) {
// Test for opts.Step = db.StepDay
activity, err := store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepDay})
require.NoError(t, err)
- require.Len(t, activity, db.DefaultRange)
- assert.Equal(t, []int64{0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 0}, flattenListenCounts(activity))
+ require.Len(t, activity, 3)
+ assert.Equal(t, []int64{2, 2, 2}, flattenListenCounts(activity))
// Truncate listens table and insert specific dates for testing opts.Step = db.StepMonth
err = store.Exec(context.Background(), `TRUNCATE TABLE listens`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO listens (user_id, track_id, listened_at)
- VALUES (1, 1, NOW() - INTERVAL '1 month'),
- (1, 1, NOW() - INTERVAL '2 months'),
- (1, 1, NOW() - INTERVAL '3 months'),
- (1, 2, NOW() - INTERVAL '1 month'),
- (1, 2, NOW() - INTERVAL '2 months')`)
+ `INSERT INTO listens (user_id, track_id, listened_at)
+ VALUES (1, 1, NOW() - INTERVAL '1 month 1 day'),
+ (1, 1, NOW() - INTERVAL '2 months 1 day'),
+ (1, 1, NOW() - INTERVAL '3 months 1 day'),
+ (1, 2, NOW() - INTERVAL '1 month 1 day'),
+ (1, 2, NOW() - INTERVAL '1 second'),
+ (1, 2, NOW() - INTERVAL '2 seconds'),
+ (1, 2, NOW() - INTERVAL '2 months 1 day')`)
require.NoError(t, err)
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepMonth, Range: 8})
require.NoError(t, err)
- require.Len(t, activity, 8)
- assert.Equal(t, []int64{0, 0, 0, 0, 1, 2, 2, 0}, flattenListenCounts(activity))
+ require.Len(t, activity, 4)
+ assert.Equal(t, []int64{1, 2, 2, 2}, flattenListenCounts(activity))
// Truncate listens table and insert specific dates for testing opts.Step = db.StepYear
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO listens (user_id, track_id, listened_at)
+ `INSERT INTO listens (user_id, track_id, listened_at)
VALUES (1, 1, NOW() - INTERVAL '1 year'),
(1, 1, NOW() - INTERVAL '2 years'),
(1, 2, NOW() - INTERVAL '1 year'),
@@ -123,8 +125,8 @@ func TestListenActivity(t *testing.T) {
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepYear})
require.NoError(t, err)
- require.Len(t, activity, db.DefaultRange)
- assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 0}, flattenListenCounts(activity))
+ require.Len(t, activity, 3)
+ assert.Equal(t, []int64{1, 1, 2}, flattenListenCounts(activity))
// Truncate and insert data for a specific month/year
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
require.NoError(t, err)
@@ -141,10 +143,10 @@ func TestListenActivity(t *testing.T) {
Year: 2024,
})
require.NoError(t, err)
- require.Len(t, activity, 31) // number of days in march
+ require.Len(t, activity, 2) // number of days in march
t.Log(activity)
- assert.EqualValues(t, 1, activity[9].Listens)
- assert.EqualValues(t, 1, activity[19].Listens)
+ assert.EqualValues(t, 1, activity[0].Listens)
+ assert.EqualValues(t, 1, activity[1].Listens)
// Truncate and insert listens associated with two different albums
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
@@ -161,53 +163,29 @@ func TestListenActivity(t *testing.T) {
AlbumID: 1, // Track 1 only
})
require.NoError(t, err)
- require.Len(t, activity, db.DefaultRange)
- assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity))
+ require.Len(t, activity, 2)
+ assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity))
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
Step: db.StepDay,
TrackID: 1, // Track 1 only
})
require.NoError(t, err)
- require.Len(t, activity, db.DefaultRange)
- assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity))
+ require.Len(t, activity, 2)
+ assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity))
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
Step: db.StepDay,
ArtistID: 2, // Should only include listens to Track 2
})
require.NoError(t, err)
- require.Len(t, activity, db.DefaultRange)
- assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}, flattenListenCounts(activity))
+ require.Len(t, activity, 1)
+ assert.Equal(t, []int64{1}, flattenListenCounts(activity))
// month without year is disallowed
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
Step: db.StepDay,
Month: 5,
})
- require.Error(t, err)
-
- // invalid options
- _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
- Year: -10,
- })
- require.Error(t, err)
- _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
- Year: 2025,
- Month: -10,
- })
- require.Error(t, err)
- _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
- Range: -1,
- })
- require.Error(t, err)
- _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
- AlbumID: -1,
- })
- require.Error(t, err)
- _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
- ArtistID: -1,
- })
- require.Error(t, err)
-
+ assert.Error(t, err)
}
diff --git a/internal/db/psql/listen_test.go b/internal/db/psql/listen_test.go
index b0fbd96..a687a43 100644
--- a/internal/db/psql/listen_test.go
+++ b/internal/db/psql/listen_test.go
@@ -14,49 +14,49 @@ func testDataForListens(t *testing.T) {
truncateTestData(t)
// Insert artists
err := store.Exec(context.Background(),
- `INSERT INTO artists (musicbrainz_id)
+ `INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000002')`)
require.NoError(t, err)
// Insert artist aliases
err = store.Exec(context.Background(),
- `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
+ `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 release groups
err = store.Exec(context.Background(),
- `INSERT INTO releases (musicbrainz_id)
+ `INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000011'),
('00000000-0000-0000-0000-000000000022')`)
require.NoError(t, err)
// Insert release aliases
err = store.Exec(context.Background(),
- `INSERT INTO release_aliases (release_id, alias, source, is_primary)
+ `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'Release One', 'Testing', true),
(2, 'Release Two', 'Testing', true)`)
require.NoError(t, err)
// Insert tracks
err = store.Exec(context.Background(),
- `INSERT INTO tracks (musicbrainz_id, release_id)
+ `INSERT INTO tracks (musicbrainz_id, release_id)
VALUES ('11111111-1111-1111-1111-111111111111', 1),
('22222222-2222-2222-2222-222222222222', 2)`)
require.NoError(t, err)
// Insert track aliases
err = store.Exec(context.Background(),
- `INSERT INTO track_aliases (track_id, alias, source, is_primary)
+ `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)
// Insert artist track associations
err = store.Exec(context.Background(),
- `INSERT INTO artist_tracks (track_id, artist_id)
+ `INSERT INTO artist_tracks (track_id, artist_id)
VALUES (1, 1),
(2, 2)`)
require.NoError(t, err)
@@ -67,7 +67,7 @@ func TestGetListens(t *testing.T) {
ctx := context.Background()
// Test valid
- resp, err := store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime})
+ resp, err := store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 10)
assert.Equal(t, int64(10), resp.TotalCount)
@@ -78,7 +78,7 @@ func TestGetListens(t *testing.T) {
assert.Equal(t, "Artist Three", resp.Items[1].Track.Artists[0].Name)
// Test pagination
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Period: db.PeriodAllTime})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
require.Len(t, resp.Items[0].Track.Artists, 1)
@@ -89,7 +89,7 @@ func TestGetListens(t *testing.T) {
assert.Equal(t, "Artist Three", resp.Items[0].Track.Artists[0].Name)
// Test page out of range
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Limit: 10, Page: 10, Period: db.PeriodAllTime})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Limit: 10, Page: 10, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
assert.Empty(t, resp.Items)
assert.False(t, resp.HasNextPage)
@@ -102,7 +102,7 @@ func TestGetListens(t *testing.T) {
assert.Error(t, err)
// Test specify period
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodDay})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodDay}})
require.NoError(t, err)
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
@@ -112,38 +112,38 @@ func TestGetListens(t *testing.T) {
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodWeek})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodWeek}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodMonth})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodMonth}})
require.NoError(t, err)
require.Len(t, resp.Items, 3)
assert.Equal(t, int64(3), resp.TotalCount)
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodYear})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodYear}})
require.NoError(t, err)
require.Len(t, resp.Items, 6)
assert.Equal(t, int64(6), resp.TotalCount)
// Test filter by artists, releases, and tracks
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime, ArtistID: 1})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}, ArtistID: 1})
require.NoError(t, err)
require.Len(t, resp.Items, 4)
assert.Equal(t, int64(4), resp.TotalCount)
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime, AlbumID: 2})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}, AlbumID: 2})
require.NoError(t, err)
require.Len(t, resp.Items, 3)
assert.Equal(t, int64(3), resp.TotalCount)
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime, TrackID: 3})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}, TrackID: 3})
require.NoError(t, err)
require.Len(t, resp.Items, 2)
assert.Equal(t, int64(2), resp.TotalCount)
// when both artistID and albumID are specified, artist id is ignored
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime, AlbumID: 2, ArtistID: 1})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}, AlbumID: 2, ArtistID: 1})
require.NoError(t, err)
require.Len(t, resp.Items, 3)
assert.Equal(t, int64(3), resp.TotalCount)
@@ -152,20 +152,16 @@ func TestGetListens(t *testing.T) {
testDataAbsoluteListenTimes(t)
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Year: 2023})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Year: 2023}})
require.NoError(t, err)
require.Len(t, resp.Items, 4)
assert.Equal(t, int64(4), resp.TotalCount)
- resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Month: 6, Year: 2024})
+ resp, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Month: 6, Year: 2024}})
require.NoError(t, err)
require.Len(t, resp.Items, 3)
assert.Equal(t, int64(3), resp.TotalCount)
- // invalid, year required with month
- _, err = store.GetListensPaginated(ctx, db.GetItemsOpts{Month: 10})
- require.Error(t, err)
-
}
func TestSaveListen(t *testing.T) {
diff --git a/internal/db/psql/merge.go b/internal/db/psql/merge.go
index 91bce1a..dd375c5 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,57 +15,112 @@ 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)
+ 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 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 {
- l.Err(err).Msg("Failed to clean orphaned entries")
+ l.Err(err).Msg("MergeTracks: Failed to clean orphaned entries")
return err
}
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)
+
+ fromArtists, err := qtx.GetReleaseArtists(ctx, fromId)
+ if err != nil {
+ return fmt.Errorf("MergeAlbums: GetReleaseArtists: %w", err)
+ }
+
err = qtx.UpdateReleaseForAll(ctx, repository.UpdateReleaseForAllParams{
ReleaseID: fromId,
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)
+ }
+ }
+
+ 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)
}
-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 +130,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 +138,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 +146,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 +154,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..38e843a 100644
--- a/internal/db/psql/merge_test.go
+++ b/internal/db/psql/merge_test.go
@@ -12,59 +12,67 @@ 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(),
- `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
+ `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)
- 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),
+ (NULL, NULL, NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
- `INSERT INTO release_aliases (release_id, alias, source, is_primary)
+ `INSERT INTO release_aliases (release_id, alias, source, is_primary)
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)
+ `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)
+ `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)`)
+ `INSERT INTO artist_releases (artist_id, release_id)
+ 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)`)
+ `INSERT INTO artist_tracks (artist_id, track_id)
+ 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)
+ `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 old artist is not 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.False(t, exists)
+
+ 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)
}
@@ -90,14 +124,43 @@ 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
+ count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`)
+ require.NoError(t, err)
+ 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
- var count int
- count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`)
+ count, err := store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 3`)
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 3")
truncateTestData(t)
}
@@ -107,18 +170,22 @@ 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")
+ 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)
}
diff --git a/internal/db/psql/psql.go b/internal/db/psql/psql.go
index 2e52d94..3d288c0 100644
--- a/internal/db/psql/psql.go
+++ b/internal/db/psql/psql.go
@@ -5,10 +5,9 @@ import (
"context"
"database/sql"
"fmt"
- "path/filepath"
- "runtime"
"time"
+ "github.com/gabehf/koito/db/migrations"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/repository"
@@ -34,34 +33,30 @@ 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")
- }
- migrationsPath := filepath.Join(filepath.Dir(filename), "..", "..", "..", "db", "migrations")
+ goose.SetBaseFS(migrations.Files)
- if err := goose.Up(sqlDB, migrationsPath); err != nil {
- return nil, fmt.Errorf("goose failed: %w", err)
+ if err := goose.Up(sqlDB, "."); err != nil {
+ 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..652b790 100644
--- a/internal/db/psql/top_albums.go
+++ b/internal/db/psql/top_albums.go
@@ -3,37 +3,28 @@ package psql
import (
"context"
"encoding/json"
- "time"
+ "fmt"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/repository"
- "github.com/gabehf/koito/internal/utils"
)
-func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) (*db.PaginatedResponse[*models.Album], error) {
+func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts) (*db.PaginatedResponse[db.RankedItem[*models.Album]], error) {
l := logger.FromContext(ctx)
offset := (opts.Page - 1) * opts.Limit
- t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
- if err != nil {
- return nil, err
- }
- if opts.Month == 0 && opts.Year == 0 {
- // use period, not date range
- t2 = time.Now()
- t1 = db.StartTimeFromPeriod(opts.Period)
- }
+ t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}
- var rgs []*models.Album
+ var rgs []db.RankedItem[*models.Album]
var count int64
if opts.ArtistID != 0 {
- l.Debug().Msgf("Fetching top %d albums from artist id %d with period %s on page %d from range %v to %v",
- opts.Limit, opts.ArtistID, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching top %d albums from artist id %d on page %d from range %v to %v",
+ opts.Limit, opts.ArtistID, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetTopReleasesFromArtist(ctx, repository.GetTopReleasesFromArtistParams{
ArtistID: int32(opts.ArtistID),
@@ -43,18 +34,18 @@ 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))
+ rgs = make([]db.RankedItem[*models.Album], len(rows))
l.Debug().Msgf("Database responded with %d items", len(rows))
for i, v := range rows {
artists := make([]models.SimpleArtist, 0)
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{
+ rgs[i].Item = &models.Album{
ID: v.ID,
MbzID: v.MusicBrainzID,
Title: v.Title,
@@ -63,14 +54,15 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
VariousArtists: v.VariousArtists,
ListenCount: v.ListenCount,
}
+ rgs[i].Rank = v.Rank
}
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",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching top %d albums on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetTopReleasesPaginated(ctx, repository.GetTopReleasesPaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -78,38 +70,38 @@ 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))
+ rgs = make([]db.RankedItem[*models.Album], len(rows))
l.Debug().Msgf("Database responded with %d items", 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)
- artists = nil
+ return nil, fmt.Errorf("GetTopAlbumsPaginated: Unmarshal: %w", err)
}
- t := &models.Album{
- Title: row.Title,
- MbzID: row.MusicBrainzID,
+ rgs[i].Item = &models.Album{
ID: row.ID,
+ MbzID: row.MusicBrainzID,
+ Title: row.Title,
Image: row.Image,
Artists: artists,
VariousArtists: row.VariousArtists,
ListenCount: row.ListenCount,
}
- rgs[i] = t
+ rgs[i].Rank = row.Rank
}
count, err = d.q.CountTopReleases(ctx, repository.CountTopReleasesParams{
ListenedAt: t1,
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)
}
- return &db.PaginatedResponse[*models.Album]{
+ return &db.PaginatedResponse[db.RankedItem[*models.Album]]{
Items: rgs,
TotalCount: count,
ItemsPerPage: int32(opts.Limit),
diff --git a/internal/db/psql/top_albums_test.go b/internal/db/psql/top_albums_test.go
index d698be6..eb4efde 100644
--- a/internal/db/psql/top_albums_test.go
+++ b/internal/db/psql/top_albums_test.go
@@ -14,23 +14,23 @@ func TestGetTopAlbumsPaginated(t *testing.T) {
ctx := context.Background()
// Test valid
- resp, err := store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime})
+ resp, err := store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 4)
assert.Equal(t, int64(4), resp.TotalCount)
- assert.Equal(t, "Release One", resp.Items[0].Title)
- assert.Equal(t, "Release Two", resp.Items[1].Title)
- assert.Equal(t, "Release Three", resp.Items[2].Title)
- assert.Equal(t, "Release Four", resp.Items[3].Title)
+ assert.Equal(t, "Release One", resp.Items[0].Item.Title)
+ assert.Equal(t, "Release Two", resp.Items[1].Item.Title)
+ assert.Equal(t, "Release Three", resp.Items[2].Item.Title)
+ assert.Equal(t, "Release Four", resp.Items[3].Item.Title)
// Test pagination
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Period: db.PeriodAllTime})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
- assert.Equal(t, "Release Two", resp.Items[0].Title)
+ assert.Equal(t, "Release Two", resp.Items[0].Item.Title)
// Test page out of range
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 10, Period: db.PeriodAllTime})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 10, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Empty(t, resp.Items)
assert.False(t, resp.HasNextPage)
@@ -43,7 +43,7 @@ func TestGetTopAlbumsPaginated(t *testing.T) {
assert.Error(t, err)
// Test specify period
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodDay})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodDay}})
require.NoError(t, err)
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
@@ -53,51 +53,47 @@ func TestGetTopAlbumsPaginated(t *testing.T) {
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodWeek})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodWeek}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Release Four", resp.Items[0].Title)
+ assert.Equal(t, "Release Four", resp.Items[0].Item.Title)
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodMonth})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodMonth}})
require.NoError(t, err)
require.Len(t, resp.Items, 2)
assert.Equal(t, int64(2), resp.TotalCount)
- assert.Equal(t, "Release Three", resp.Items[0].Title)
- assert.Equal(t, "Release Four", resp.Items[1].Title)
+ assert.Equal(t, "Release Three", resp.Items[0].Item.Title)
+ assert.Equal(t, "Release Four", resp.Items[1].Item.Title)
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodYear})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodYear}})
require.NoError(t, err)
require.Len(t, resp.Items, 3)
assert.Equal(t, int64(3), resp.TotalCount)
- assert.Equal(t, "Release Two", resp.Items[0].Title)
- assert.Equal(t, "Release Three", resp.Items[1].Title)
- assert.Equal(t, "Release Four", resp.Items[2].Title)
+ assert.Equal(t, "Release Two", resp.Items[0].Item.Title)
+ assert.Equal(t, "Release Three", resp.Items[1].Item.Title)
+ assert.Equal(t, "Release Four", resp.Items[2].Item.Title)
// test specific artist
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodYear, ArtistID: 2})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodYear}, ArtistID: 2})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Release Two", resp.Items[0].Title)
+ assert.Equal(t, "Release Two", resp.Items[0].Item.Title)
// Test specify dates
testDataAbsoluteListenTimes(t)
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Year: 2023})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Year: 2023}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Release One", resp.Items[0].Title)
+ assert.Equal(t, "Release One", resp.Items[0].Item.Title)
- resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Month: 6, Year: 2024})
+ resp, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Month: 6, Year: 2024}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Release Two", resp.Items[0].Title)
-
- // invalid, year required with month
- _, err = store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Month: 10})
- require.Error(t, err)
+ assert.Equal(t, "Release Two", resp.Items[0].Item.Title)
}
diff --git a/internal/db/psql/top_artists.go b/internal/db/psql/top_artists.go
index 980f89d..497efbd 100644
--- a/internal/db/psql/top_artists.go
+++ b/internal/db/psql/top_artists.go
@@ -2,32 +2,23 @@ package psql
import (
"context"
- "time"
+ "fmt"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/repository"
- "github.com/gabehf/koito/internal/utils"
)
-func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts) (*db.PaginatedResponse[*models.Artist], error) {
+func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts) (*db.PaginatedResponse[db.RankedItem[*models.Artist]], error) {
l := logger.FromContext(ctx)
offset := (opts.Page - 1) * opts.Limit
- t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
- if err != nil {
- return nil, err
- }
- if opts.Month == 0 && opts.Year == 0 {
- // use period, not date range
- t2 = time.Now()
- t1 = db.StartTimeFromPeriod(opts.Period)
- }
+ t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}
- l.Debug().Msgf("Fetching top %d artists with period %s on page %d from range %v to %v",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching top %d artists on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetTopArtistsPaginated(ctx, repository.GetTopArtistsPaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -35,9 +26,9 @@ 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))
+ rgs := make([]db.RankedItem[*models.Artist], len(rows))
for i, row := range rows {
t := &models.Artist{
Name: row.Name,
@@ -46,18 +37,19 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
Image: row.Image,
ListenCount: row.ListenCount,
}
- rgs[i] = t
+ rgs[i].Item = t
+ rgs[i].Rank = row.Rank
}
count, err := d.q.CountTopArtists(ctx, repository.CountTopArtistsParams{
ListenedAt: t1,
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)
- return &db.PaginatedResponse[*models.Artist]{
+ return &db.PaginatedResponse[db.RankedItem[*models.Artist]]{
Items: rgs,
TotalCount: count,
ItemsPerPage: int32(opts.Limit),
diff --git a/internal/db/psql/top_artists_test.go b/internal/db/psql/top_artists_test.go
index 2f261a0..7a69ab5 100644
--- a/internal/db/psql/top_artists_test.go
+++ b/internal/db/psql/top_artists_test.go
@@ -14,23 +14,23 @@ func TestGetTopArtistsPaginated(t *testing.T) {
ctx := context.Background()
// Test valid
- resp, err := store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime})
+ resp, err := store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 4)
assert.Equal(t, int64(4), resp.TotalCount)
- assert.Equal(t, "Artist One", resp.Items[0].Name)
- assert.Equal(t, "Artist Two", resp.Items[1].Name)
- assert.Equal(t, "Artist Three", resp.Items[2].Name)
- assert.Equal(t, "Artist Four", resp.Items[3].Name)
+ assert.Equal(t, "Artist One", resp.Items[0].Item.Name)
+ assert.Equal(t, "Artist Two", resp.Items[1].Item.Name)
+ assert.Equal(t, "Artist Three", resp.Items[2].Item.Name)
+ assert.Equal(t, "Artist Four", resp.Items[3].Item.Name)
// Test pagination
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Period: db.PeriodAllTime})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
- assert.Equal(t, "Artist Two", resp.Items[0].Name)
+ assert.Equal(t, "Artist Two", resp.Items[0].Item.Name)
// Test page out of range
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 10, Period: db.PeriodAllTime})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 10, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
assert.Empty(t, resp.Items)
assert.False(t, resp.HasNextPage)
@@ -43,7 +43,7 @@ func TestGetTopArtistsPaginated(t *testing.T) {
assert.Error(t, err)
// Test specify period
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodDay})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodDay}})
require.NoError(t, err)
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
@@ -53,44 +53,40 @@ func TestGetTopArtistsPaginated(t *testing.T) {
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodWeek})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodWeek}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Artist Four", resp.Items[0].Name)
+ assert.Equal(t, "Artist Four", resp.Items[0].Item.Name)
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodMonth})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodMonth}})
require.NoError(t, err)
require.Len(t, resp.Items, 2)
assert.Equal(t, int64(2), resp.TotalCount)
- assert.Equal(t, "Artist Three", resp.Items[0].Name)
- assert.Equal(t, "Artist Four", resp.Items[1].Name)
+ assert.Equal(t, "Artist Three", resp.Items[0].Item.Name)
+ assert.Equal(t, "Artist Four", resp.Items[1].Item.Name)
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Period: db.PeriodYear})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodYear}})
require.NoError(t, err)
require.Len(t, resp.Items, 3)
assert.Equal(t, int64(3), resp.TotalCount)
- assert.Equal(t, "Artist Two", resp.Items[0].Name)
- assert.Equal(t, "Artist Three", resp.Items[1].Name)
- assert.Equal(t, "Artist Four", resp.Items[2].Name)
+ assert.Equal(t, "Artist Two", resp.Items[0].Item.Name)
+ assert.Equal(t, "Artist Three", resp.Items[1].Item.Name)
+ assert.Equal(t, "Artist Four", resp.Items[2].Item.Name)
// Test specify dates
testDataAbsoluteListenTimes(t)
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Year: 2023})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Year: 2023}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Artist One", resp.Items[0].Name)
+ assert.Equal(t, "Artist One", resp.Items[0].Item.Name)
- resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Month: 6, Year: 2024})
+ resp, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Month: 6, Year: 2024}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Artist Two", resp.Items[0].Name)
-
- // invalid, year required with month
- _, err = store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Month: 10})
- require.Error(t, err)
+ assert.Equal(t, "Artist Two", resp.Items[0].Item.Name)
}
diff --git a/internal/db/psql/top_tracks.go b/internal/db/psql/top_tracks.go
index 765b3a6..89960e8 100644
--- a/internal/db/psql/top_tracks.go
+++ b/internal/db/psql/top_tracks.go
@@ -3,35 +3,26 @@ package psql
import (
"context"
"encoding/json"
- "time"
+ "fmt"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/repository"
- "github.com/gabehf/koito/internal/utils"
)
-func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) (*db.PaginatedResponse[*models.Track], error) {
+func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts) (*db.PaginatedResponse[db.RankedItem[*models.Track]], error) {
l := logger.FromContext(ctx)
offset := (opts.Page - 1) * opts.Limit
- t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
- if err != nil {
- return nil, err
- }
- if opts.Month == 0 && opts.Year == 0 {
- // use period, not date range
- t2 = time.Now()
- t1 = db.StartTimeFromPeriod(opts.Period)
- }
+ t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}
- var tracks []*models.Track
+ var tracks []db.RankedItem[*models.Track]
var count int64
if opts.AlbumID > 0 {
- l.Debug().Msgf("Fetching top %d tracks with period %s on page %d from range %v to %v",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching top %d tracks on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetTopTracksInReleasePaginated(ctx, repository.GetTopTracksInReleasePaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -40,15 +31,15 @@ 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))
+ tracks = make([]db.RankedItem[*models.Track], 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 track with id %d", row.ID)
- artists = nil
+ return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
}
t := &models.Track{
Title: row.Title,
@@ -59,7 +50,8 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
AlbumID: row.ReleaseID,
Artists: artists,
}
- tracks[i] = t
+ tracks[i].Item = t
+ tracks[i].Rank = row.Rank
}
count, err = d.q.CountTopTracksByRelease(ctx, repository.CountTopTracksByReleaseParams{
ListenedAt: t1,
@@ -70,8 +62,8 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
return nil, err
}
} else if opts.ArtistID > 0 {
- l.Debug().Msgf("Fetching top %d tracks with period %s on page %d from range %v to %v",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching top %d tracks on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetTopTracksByArtistPaginated(ctx, repository.GetTopTracksByArtistPaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -80,15 +72,15 @@ 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))
+ tracks = make([]db.RankedItem[*models.Track], 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 track with id %d", row.ID)
- artists = nil
+ return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
}
t := &models.Track{
Title: row.Title,
@@ -99,7 +91,8 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
AlbumID: row.ReleaseID,
Artists: artists,
}
- tracks[i] = t
+ tracks[i].Item = t
+ tracks[i].Rank = row.Rank
}
count, err = d.q.CountTopTracksByArtist(ctx, repository.CountTopTracksByArtistParams{
ListenedAt: t1,
@@ -107,11 +100,11 @@ 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",
- opts.Limit, opts.Period, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
+ l.Debug().Msgf("Fetching top %d tracks on page %d from range %v to %v",
+ opts.Limit, opts.Page, t1.Format("Jan 02, 2006"), t2.Format("Jan 02, 2006"))
rows, err := d.q.GetTopTracksPaginated(ctx, repository.GetTopTracksPaginatedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@@ -119,15 +112,15 @@ 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))
+ tracks = make([]db.RankedItem[*models.Track], 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 track with id %d", row.ID)
- artists = nil
+ return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
}
t := &models.Track{
Title: row.Title,
@@ -138,19 +131,20 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
AlbumID: row.ReleaseID,
Artists: artists,
}
- tracks[i] = t
+ tracks[i].Item = t
+ tracks[i].Rank = row.Rank
}
count, err = d.q.CountTopTracks(ctx, repository.CountTopTracksParams{
ListenedAt: t1,
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)
}
- return &db.PaginatedResponse[*models.Track]{
+ return &db.PaginatedResponse[db.RankedItem[*models.Track]]{
Items: tracks,
TotalCount: count,
ItemsPerPage: int32(opts.Limit),
diff --git a/internal/db/psql/top_tracks_test.go b/internal/db/psql/top_tracks_test.go
index 89e63f1..934d9b7 100644
--- a/internal/db/psql/top_tracks_test.go
+++ b/internal/db/psql/top_tracks_test.go
@@ -14,26 +14,26 @@ func TestGetTopTracksPaginated(t *testing.T) {
ctx := context.Background()
// Test valid
- resp, err := store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime})
+ resp, err := store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 4)
assert.Equal(t, int64(4), resp.TotalCount)
- assert.Equal(t, "Track One", resp.Items[0].Title)
- assert.Equal(t, "Track Two", resp.Items[1].Title)
- assert.Equal(t, "Track Three", resp.Items[2].Title)
- assert.Equal(t, "Track Four", resp.Items[3].Title)
+ assert.Equal(t, "Track One", resp.Items[0].Item.Title)
+ assert.Equal(t, "Track Two", resp.Items[1].Item.Title)
+ assert.Equal(t, "Track Three", resp.Items[2].Item.Title)
+ assert.Equal(t, "Track Four", resp.Items[3].Item.Title)
// ensure artists are included
- require.Len(t, resp.Items[0].Artists, 1)
- assert.Equal(t, "Artist One", resp.Items[0].Artists[0].Name)
+ require.Len(t, resp.Items[0].Item.Artists, 1)
+ assert.Equal(t, "Artist One", resp.Items[0].Item.Artists[0].Name)
// Test pagination
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Period: db.PeriodAllTime})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 2, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
- assert.Equal(t, "Track Two", resp.Items[0].Title)
+ assert.Equal(t, "Track Two", resp.Items[0].Item.Title)
// Test page out of range
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 10, Period: db.PeriodAllTime})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 10, Timeframe: db.Timeframe{Period: db.PeriodAllTime}})
require.NoError(t, err)
assert.Empty(t, resp.Items)
assert.False(t, resp.HasNextPage)
@@ -46,7 +46,7 @@ func TestGetTopTracksPaginated(t *testing.T) {
assert.Error(t, err)
// Test specify period
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodDay})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodDay}})
require.NoError(t, err)
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
@@ -56,63 +56,59 @@ func TestGetTopTracksPaginated(t *testing.T) {
require.Len(t, resp.Items, 0) // empty
assert.Equal(t, int64(0), resp.TotalCount)
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodWeek})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodWeek}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Track Four", resp.Items[0].Title)
+ assert.Equal(t, "Track Four", resp.Items[0].Item.Title)
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodMonth})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodMonth}})
require.NoError(t, err)
require.Len(t, resp.Items, 2)
assert.Equal(t, int64(2), resp.TotalCount)
- assert.Equal(t, "Track Three", resp.Items[0].Title)
- assert.Equal(t, "Track Four", resp.Items[1].Title)
+ assert.Equal(t, "Track Three", resp.Items[0].Item.Title)
+ assert.Equal(t, "Track Four", resp.Items[1].Item.Title)
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodYear})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodYear}})
require.NoError(t, err)
require.Len(t, resp.Items, 3)
assert.Equal(t, int64(3), resp.TotalCount)
- assert.Equal(t, "Track Two", resp.Items[0].Title)
- assert.Equal(t, "Track Three", resp.Items[1].Title)
- assert.Equal(t, "Track Four", resp.Items[2].Title)
+ assert.Equal(t, "Track Two", resp.Items[0].Item.Title)
+ assert.Equal(t, "Track Three", resp.Items[1].Item.Title)
+ assert.Equal(t, "Track Four", resp.Items[2].Item.Title)
// Test filter by artists and releases
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime, ArtistID: 1})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}, ArtistID: 1})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Track One", resp.Items[0].Title)
+ assert.Equal(t, "Track One", resp.Items[0].Item.Title)
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime, AlbumID: 2})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}, AlbumID: 2})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Track Two", resp.Items[0].Title)
+ assert.Equal(t, "Track Two", resp.Items[0].Item.Title)
// when both artistID and albumID are specified, artist id is ignored
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Period: db.PeriodAllTime, AlbumID: 2, ArtistID: 1})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Period: db.PeriodAllTime}, AlbumID: 2, ArtistID: 1})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Track Two", resp.Items[0].Title)
+ assert.Equal(t, "Track Two", resp.Items[0].Item.Title)
// Test specify dates
testDataAbsoluteListenTimes(t)
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Year: 2023})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Year: 2023}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Track One", resp.Items[0].Title)
+ assert.Equal(t, "Track One", resp.Items[0].Item.Title)
- resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Month: 6, Year: 2024})
+ resp, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Timeframe: db.Timeframe{Month: 6, Year: 2024}})
require.NoError(t, err)
require.Len(t, resp.Items, 1)
assert.Equal(t, int64(1), resp.TotalCount)
- assert.Equal(t, "Track Two", resp.Items[0].Title)
-
- // invalid, year required with month
- _, err = store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Month: 10})
- require.Error(t, err)
+ assert.Equal(t, "Track Two", resp.Items[0].Item.Title)
}
diff --git a/internal/db/psql/track.go b/internal/db/psql/track.go
index 0c3c2a4..d4cc616 100644
--- a/internal/db/psql/track.go
+++ b/internal/db/psql/track.go
@@ -2,7 +2,9 @@ package psql
import (
"context"
+ "encoding/json"
"errors"
+ "fmt"
"strings"
"time"
@@ -19,63 +21,74 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
l := logger.FromContext(ctx)
var track models.Track
- if opts.ID != 0 {
- 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
- }
- track = models.Track{
- ID: t.ID,
- MbzID: t.MusicBrainzID,
- Title: t.Title,
- AlbumID: t.ReleaseID,
- Image: t.Image,
- Duration: t.Duration,
- }
- } else if opts.MusicBrainzID != uuid.Nil {
+ if opts.MusicBrainzID != uuid.Nil {
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,
- MbzID: t.MusicBrainzID,
- Title: t.Title,
- AlbumID: t.ReleaseID,
- Duration: t.Duration,
- }
- } else if len(opts.ArtistIDs) > 0 {
- l.Debug().Msgf("Fetching track from DB with title '%s' and artist id(s) '%v'", opts.Title, opts.ArtistIDs)
- t, err := d.q.GetTrackByTitleAndArtists(ctx, repository.GetTrackByTitleAndArtistsParams{
- Title: opts.Title,
- Column2: opts.ArtistIDs,
+ opts.ID = t.ID
+ } else if len(opts.ArtistIDs) > 0 && opts.ReleaseID != 0 {
+ l.Debug().Msgf("Fetching track from DB from release id %d with title '%s' and artist id(s) '%v'", opts.ReleaseID, opts.Title, opts.ArtistIDs)
+ t, err := d.q.GetTrackByTrackInfo(ctx, repository.GetTrackByTrackInfoParams{
+ Title: opts.Title,
+ ReleaseID: opts.ReleaseID,
+ Column3: opts.ArtistIDs,
})
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("GetTrack: GetTrackByTrackInfo: %w", err)
}
- track = models.Track{
- ID: t.ID,
- MbzID: t.MusicBrainzID,
- Title: t.Title,
- AlbumID: t.ReleaseID,
- Duration: t.Duration,
- }
- } else {
- return nil, errors.New("insufficient information to get track")
+ opts.ID = t.ID
+ }
+
+ 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, fmt.Errorf("GetTrack: GetTrack By ID: %w", err)
}
count, err := d.q.CountListensFromTrack(ctx, repository.CountListensFromTrackParams{
ListenedAt: time.Unix(0, 0),
ListenedAt_2: time.Now(),
- TrackID: track.ID,
+ TrackID: opts.ID,
})
if err != nil {
- l.Err(err).Msgf("Failed to get listen count for track with id %d", track.ID)
+ return nil, fmt.Errorf("GetTrack: CountListensFromTrack: %w", err)
}
- track.ListenCount = count
+ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
+ Timeframe: db.Timeframe{Period: db.PeriodAllTime},
+ TrackID: opts.ID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
+ }
+
+ firstListen, err := d.q.GetFirstListenFromTrack(ctx, opts.ID)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
+ }
+ rank, err := d.q.GetTrackAllTimeRank(ctx, opts.ID)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("GetAlbum: GetTrackAllTimeRank: %w", err)
+ }
+
+ track = models.Track{
+ ID: t.ID,
+ MbzID: t.MusicBrainzID,
+ Title: t.Title,
+ AlbumID: t.ReleaseID,
+ Image: t.Image,
+ Duration: t.Duration,
+ AllTimeRank: rank.Rank,
+ ListenCount: count,
+ TimeListened: seconds,
+ FirstListen: firstListen.ListenedAt.Unix(),
+ }
+ err = json.Unmarshal(t.Artists, &track.Artists)
+ if err != nil {
+ return nil, fmt.Errorf("GetTrack: json.Unmarshal: %w", err)
+ }
return &track, nil
}
@@ -88,20 +101,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)
@@ -109,18 +122,27 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
trackRow, err := qtx.InsertTrack(ctx, repository.InsertTrackParams{
MusicBrainzID: insertMbzID,
ReleaseID: opts.AlbumID,
+ Duration: opts.Duration,
})
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("SaveTrack: InsertTrack: %w", err)
}
// insert associated artists
for _, aid := range opts.ArtistIDs {
err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{
- ArtistID: aid,
- TrackID: trackRow.ID,
+ ArtistID: aid,
+ TrackID: trackRow.ID,
+ IsPrimary: opts.ArtistIDs[0] == aid,
})
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)
+ }
+ err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{
+ ArtistID: aid,
+ ReleaseID: trackRow.ReleaseID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)
}
}
// insert primary alias
@@ -131,28 +153,29 @@ 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,
- MbzID: insertMbzID,
- Title: opts.Title,
+ ID: trackRow.ID,
+ MbzID: insertMbzID,
+ Title: opts.Title,
+ Duration: opts.Duration,
}, nil
}
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)
@@ -163,7 +186,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 {
@@ -173,7 +196,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)
@@ -182,18 +205,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)
@@ -210,14 +233,35 @@ 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)
}
func (d *Psql) DeleteTrack(ctx context.Context, id int32) error {
- return d.q.DeleteTrack(ctx, id)
+ l := logger.FromContext(ctx)
+ tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ l.Err(err).Msg("Failed to begin transaction")
+ return fmt.Errorf("DeleteTrack: %w", err)
+ }
+ defer tx.Rollback(ctx)
+ qtx := d.q.WithTx(tx)
+
+ err = qtx.DeleteTrack(ctx, id)
+ if err != nil {
+ return fmt.Errorf("DeleteTrack: DeleteTrack: %w", err)
+ }
+
+ // also clean orphaned entries to ensure artists are disassociated with releases where
+ // they no longer have any tracks on the release
+ err = qtx.CleanOrphanedEntries(ctx)
+ if err != nil {
+ return fmt.Errorf("DeleteTrack: CleanOrphanedEntries: %w", err)
+ }
+
+ return tx.Commit(ctx)
}
func (d *Psql) DeleteTrackAlias(ctx context.Context, id int32, alias string) error {
@@ -230,7 +274,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 {
@@ -252,14 +296,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
@@ -284,7 +328,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,
@@ -292,7 +336,87 @@ 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)
+}
+
+// returns nil, nil when no results
+func (d *Psql) GetTracksWithNoDurationButHaveMbzID(ctx context.Context, from int32) ([]*models.Track, error) {
+ results, err := d.q.GetTracksWithNoDurationButHaveMbzID(ctx, repository.GetTracksWithNoDurationButHaveMbzIDParams{
+ Limit: 20,
+ ID: from,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ } else if err != nil {
+ return nil, fmt.Errorf("GetTracksWithNoDurationButHaveMbzID: %w", err)
+ }
+
+ ret := make([]*models.Track, 0)
+
+ for _, v := range results {
+ ret = append(ret, &models.Track{
+ ID: v.ID,
+ Duration: v.Duration,
+ MbzID: v.MusicBrainzID,
+ Title: v.Title,
+ })
+ }
+
+ return ret, nil
+}
diff --git a/internal/db/psql/track_test.go b/internal/db/psql/track_test.go
index ac79423..f0ecd09 100644
--- a/internal/db/psql/track_test.go
+++ b/internal/db/psql/track_test.go
@@ -16,51 +16,57 @@ func testDataForTracks(t *testing.T) {
// Insert artists
err := store.Exec(context.Background(),
- `INSERT INTO artists (musicbrainz_id)
+ `INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000002')`)
require.NoError(t, err)
// Insert artist aliases
err = store.Exec(context.Background(),
- `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
+ `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 release groups
err = store.Exec(context.Background(),
- `INSERT INTO releases (musicbrainz_id)
+ `INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000011'),
('00000000-0000-0000-0000-000000000022')`)
require.NoError(t, err)
// Insert release aliases
err = store.Exec(context.Background(),
- `INSERT INTO release_aliases (release_id, alias, source, is_primary)
+ `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'Release Group One', 'Testing', true),
(2, 'Release Group Two', 'Testing', true)`)
require.NoError(t, err)
// 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
err = store.Exec(context.Background(),
- `INSERT INTO track_aliases (track_id, alias, source, is_primary)
+ `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 tracks with artists
err = store.Exec(context.Background(),
- `INSERT INTO artist_tracks (artist_id, track_id)
+ `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()), (1, 2, NOW())`)
+ require.NoError(t, err)
}
func TestGetTrack(t *testing.T) {
@@ -73,24 +79,28 @@ 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
+ // Test GetTrack by Title, Release and ArtistIDs
track, err = store.GetTrack(ctx, db.GetTrackOpts{
Title: "Track One",
+ ReleaseID: 1,
ArtistIDs: []int32{1},
})
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{})
+ _, err = store.GetTrack(ctx, db.GetTrackOpts{Title: "Track One"})
assert.Error(t, err)
}
func TestSaveTrack(t *testing.T) {
@@ -218,3 +228,27 @@ func TestDeleteTrack(t *testing.T) {
_, err = store.Count(ctx, `SELECT * FROM tracks WHERE id = 2`)
require.ErrorIs(t, err, pgx.ErrNoRows) // no rows error
}
+
+func TestReleaseAssociations(t *testing.T) {
+ testDataForTracks(t)
+ ctx := context.Background()
+
+ track, err := store.SaveTrack(ctx, db.SaveTrackOpts{
+ Title: "Track Three",
+ AlbumID: 2,
+ ArtistIDs: []int32{2, 1}, // Artist Two feat. Artist One
+ Duration: 100,
+ })
+ require.NoError(t, err)
+ count, err := store.Count(ctx, `SELECT COUNT(*) FROM artist_releases WHERE release_id = 2`)
+ require.NoError(t, err)
+ require.Equal(t, 2, count, "expected release to be associated with artist from inserted track")
+
+ err = store.DeleteTrack(ctx, track.ID)
+ require.NoError(t, err)
+
+ count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_releases WHERE release_id = 2`)
+ require.NoError(t, err)
+ require.Equal(t, 1, count, "expected artist no longer on release to be disassociated from release")
+
+}
diff --git a/internal/db/psql/user.go b/internal/db/psql/user.go
index cfc8dc7..b80ddad 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,
+ Username: strings.ToLower(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/db/timeframe.go b/internal/db/timeframe.go
new file mode 100644
index 0000000..ebc3508
--- /dev/null
+++ b/internal/db/timeframe.go
@@ -0,0 +1,122 @@
+package db
+
+import (
+ "time"
+)
+
+type Timeframe struct {
+ Period Period
+ Year int
+ Month int
+ Week int
+ FromUnix int64
+ ToUnix int64
+ From time.Time
+ To time.Time
+ Timezone *time.Location
+}
+
+func TimeframeToTimeRange(tf Timeframe) (t1, t2 time.Time) {
+ now := time.Now()
+ loc := tf.Timezone
+ if loc == nil {
+ loc, _ = time.LoadLocation("UTC")
+ }
+
+ // ---------------------------------------------------------------------
+ // 1. Explicit From / To (time.Time) — highest precedence
+ // ---------------------------------------------------------------------
+ if !tf.From.IsZero() {
+ if tf.To.IsZero() {
+ return tf.From, now
+ }
+ return tf.From, tf.To
+ }
+
+ // ---------------------------------------------------------------------
+ // 2. Unix timestamps
+ // ---------------------------------------------------------------------
+ if tf.FromUnix != 0 {
+ t1 = time.Unix(tf.FromUnix, 0).In(loc)
+ if tf.ToUnix == 0 {
+ return t1, now
+ }
+ t2 = time.Unix(tf.ToUnix, 0).In(loc)
+ return t1, t2
+ }
+
+ // ---------------------------------------------------------------------
+ // 3. Derived ranges (Year / Month / Week)
+ // ---------------------------------------------------------------------
+
+ // YEAR only
+ if tf.Year != 0 && tf.Month == 0 && tf.Week == 0 {
+ start := time.Date(tf.Year, 1, 1, 0, 0, 0, 0, loc)
+ end := time.Date(tf.Year+1, 1, 1, 0, 0, 0, 0, loc).Add(-time.Second)
+ return start, end
+ }
+
+ // MONTH (+ optional year)
+ if tf.Month != 0 {
+ year := tf.Year
+ if year == 0 {
+ year = now.Year()
+ if int(now.Month()) < tf.Month {
+ year--
+ }
+ }
+
+ start := time.Date(year, time.Month(tf.Month), 1, 0, 0, 0, 0, loc)
+ end := endOfMonth(year, time.Month(tf.Month), loc)
+ return start, end
+ }
+
+ // WEEK (+ optional year)
+ if tf.Week != 0 {
+ year := tf.Year
+ if year == 0 {
+ year = now.Year()
+ _, currentWeek := now.ISOWeek()
+ if currentWeek < tf.Week {
+ year--
+ }
+ }
+
+ // ISO week 1 contains Jan 4
+ jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, loc)
+ week1Start := startOfWeek(jan4)
+
+ start := week1Start.AddDate(0, 0, (tf.Week-1)*7)
+ end := endOfWeek(start)
+ return start, end
+ }
+
+ // ---------------------------------------------------------------------
+ // 4. Period
+ // ---------------------------------------------------------------------
+
+ if !tf.Period.IsZero() {
+ return StartTimeFromPeriod(tf.Period), now
+ }
+
+ // ---------------------------------------------------------------------
+ // 5. Fallback: empty timeframe → zero values
+ // ---------------------------------------------------------------------
+ return time.Time{}, time.Time{}
+}
+
+func startOfWeek(t time.Time) time.Time {
+ // ISO week: Monday = 1
+ weekday := int(t.Weekday())
+ if weekday == 0 { // Sunday
+ weekday = 7
+ }
+ return time.Date(t.Year(), t.Month(), t.Day()-weekday+1, 0, 0, 0, 0, t.Location())
+}
+func endOfWeek(t time.Time) time.Time {
+ return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Second)
+}
+func endOfMonth(year int, month time.Month, loc *time.Location) time.Time {
+ startNextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, loc)
+ return startNextMonth.Add(-time.Second)
+}
diff --git a/internal/db/types.go b/internal/db/types.go
index e5ecb26..46d3c01 100644
--- a/internal/db/types.go
+++ b/internal/db/types.go
@@ -2,6 +2,9 @@ package db
import (
"time"
+
+ "github.com/gabehf/koito/internal/models"
+ "github.com/google/uuid"
)
type InformationSource string
@@ -24,3 +27,31 @@ type PaginatedResponse[T any] struct {
HasNextPage bool `json:"has_next_page"`
CurrentPage int32 `json:"current_page"`
}
+
+type RankedItem[T any] struct {
+ Item T `json:"item"`
+ Rank int64 `json:"rank"`
+}
+
+type ExportItem struct {
+ ListenedAt time.Time
+ UserID int32
+ Client *string
+ TrackID int32
+ TrackMbid *uuid.UUID
+ TrackDuration int32
+ TrackAliases []models.Alias
+ ReleaseID int32
+ ReleaseMbid *uuid.UUID
+ ReleaseImage *uuid.UUID
+ ReleaseImageSource string
+ VariousArtists bool
+ ReleaseAliases []models.Alias
+ Artists []models.ArtistWithFullAliases
+}
+
+type InterestBucket struct {
+ BucketStart time.Time `json:"bucket_start"`
+ BucketEnd time.Time `json:"bucket_end"`
+ ListenCount int64 `json:"listen_count"`
+}
diff --git a/internal/export/export.go b/internal/export/export.go
new file mode 100644
index 0000000..5e7ffb1
--- /dev/null
+++ b/internal/export/export.go
@@ -0,0 +1,145 @@
+package export
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/models"
+ "github.com/google/uuid"
+)
+
+type KoitoExport struct {
+ Version string `json:"version"`
+ ExportedAt time.Time `json:"exported_at"` // RFC3339
+ User string `json:"user"` // username
+ Listens []KoitoListen `json:"listens"`
+}
+type KoitoListen struct {
+ ListenedAt time.Time `json:"listened_at"`
+ Track KoitoTrack `json:"track"`
+ Album KoitoAlbum `json:"album"`
+ Artists []KoitoArtist `json:"artists"`
+}
+type KoitoTrack struct {
+ MBID *uuid.UUID `json:"mbid"`
+ Duration int `json:"duration"`
+ Aliases []models.Alias `json:"aliases"`
+}
+type KoitoAlbum struct {
+ ImageUrl string `json:"image_url"`
+ MBID *uuid.UUID `json:"mbid"`
+ Aliases []models.Alias `json:"aliases"`
+ VariousArtists bool `json:"various_artists"`
+}
+type KoitoArtist struct {
+ ImageUrl string `json:"image_url"`
+ MBID *uuid.UUID `json:"mbid"`
+ IsPrimary bool `json:"is_primary"`
+ Aliases []models.Alias `json:"aliases"`
+}
+
+func ExportData(ctx context.Context, user *models.User, store db.DB, out io.Writer) error {
+ lastTime := time.Unix(0, 0)
+ lastTrackId := int32(0)
+ pageSize := int32(1000)
+
+ l := logger.FromContext(ctx)
+ l.Info().Msg("ExportData: Generating Koito export file...")
+
+ exportedAt := time.Now()
+ // exportFile := path.Join(cfg.ConfigDir(), fmt.Sprintf("koito_export_%d.json", exportedAt.Unix()))
+ // f, err := os.Create(exportFile)
+ // if err != nil {
+ // return fmt.Errorf("ExportData: %w", err)
+ // }
+ // defer f.Close()
+
+ // Write the opening of the JSON manually
+ _, err := fmt.Fprintf(out, "{\n \"version\": \"1\",\n \"exported_at\": \"%s\",\n \"user\": \"%s\",\n \"listens\": [\n", exportedAt.UTC().Format(time.RFC3339), user.Username)
+ if err != nil {
+ return fmt.Errorf("ExportData: %w", err)
+ }
+
+ first := true
+ for {
+ rows, err := store.GetExportPage(ctx, db.GetExportPageOpts{
+ UserID: user.ID,
+ ListenedAt: lastTime,
+ TrackID: lastTrackId,
+ Limit: pageSize,
+ })
+ if err != nil {
+ return fmt.Errorf("ExportData: %w", err)
+ }
+ if len(rows) == 0 {
+ break
+ }
+
+ for _, r := range rows {
+ // Adds a comma after each listen item
+ if !first {
+ _, _ = out.Write([]byte(",\n"))
+ }
+ first = false
+
+ exported := convertToExportFormat(r)
+
+ raw, err := json.MarshalIndent(exported, " ", " ")
+
+ // needed to make the listen item start at the right indent level
+ out.Write([]byte(" "))
+
+ if err != nil {
+ return fmt.Errorf("ExportData: marshal: %w", err)
+ }
+ _, _ = out.Write(raw)
+
+ if r.TrackID > lastTrackId {
+ lastTrackId = r.TrackID
+ }
+ if r.ListenedAt.After(lastTime) {
+ lastTime = r.ListenedAt
+ }
+ }
+ }
+
+ // Write closing of the JSON array and object
+ _, err = out.Write([]byte("\n ]\n}\n"))
+ if err != nil {
+ return fmt.Errorf("ExportData: f.Write: %w", err)
+ }
+
+ l.Info().Msgf("Export successfully created")
+ return nil
+}
+
+func convertToExportFormat(item *db.ExportItem) *KoitoListen {
+ ret := &KoitoListen{
+ ListenedAt: item.ListenedAt.UTC(),
+ Track: KoitoTrack{
+ MBID: item.TrackMbid,
+ Duration: int(item.TrackDuration),
+ Aliases: item.TrackAliases,
+ },
+ Album: KoitoAlbum{
+ MBID: item.ReleaseMbid,
+ ImageUrl: item.ReleaseImageSource,
+ VariousArtists: item.VariousArtists,
+ Aliases: item.ReleaseAliases,
+ },
+ }
+ for i := range item.Artists {
+ ret.Artists = append(ret.Artists, KoitoArtist{
+ IsPrimary: item.Artists[i].IsPrimary,
+ MBID: item.Artists[i].MbzID,
+ Aliases: item.Artists[i].Aliases,
+ ImageUrl: item.Artists[i].ImageSource,
+ })
+ }
+ return ret
+}
diff --git a/internal/images/deezer.go b/internal/images/deezer.go
index f3c7bae..2ced676 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,24 +92,27 @@ 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
}
+// Deezer behavior is that it serves a default image when it can't find one for an artist, so
+// this function will just download the default image thinking that it is an actual artist image.
+// I don't know how to fix this yet.
func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (string, error) {
l := logger.FromContext(ctx)
resp := new(DeezerArtistResponse)
@@ -121,10 +124,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 +142,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 +155,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 +166,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 +182,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 +192,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..46fe87a 100644
--- a/internal/images/imagesrc.go
+++ b/internal/images/imagesrc.go
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"net/http"
+ "strings"
"sync"
"github.com/gabehf/koito/internal/logger"
@@ -12,14 +13,20 @@ import (
)
type ImageSource struct {
- deezerEnabled bool
- deezerC *DeezerClient
- caaEnabled bool
+ deezerEnabled bool
+ deezerC *DeezerClient
+ subsonicEnabled bool
+ subsonicC *SubsonicClient
+ lastfmEnabled bool
+ lastfmC *LastFMClient
+ caaEnabled bool
}
type ImageSourceOpts struct {
- UserAgent string
- EnableCAA bool
- EnableDeezer bool
+ UserAgent string
+ EnableCAA bool
+ EnableDeezer bool
+ EnableSubsonic bool
+ EnableLastFM bool
}
var once sync.Once
@@ -27,6 +34,7 @@ var imgsrc ImageSource
type ArtistImageOpts struct {
Aliases []string
+ MBID *uuid.UUID
}
type AlbumImageOpts struct {
@@ -48,6 +56,14 @@ func Initialize(opts ImageSourceOpts) {
imgsrc.deezerEnabled = true
imgsrc.deezerC = NewDeezerClient()
}
+ if opts.EnableSubsonic {
+ imgsrc.subsonicEnabled = true
+ imgsrc.subsonicC = NewSubsonicClient()
+ }
+ if opts.EnableLastFM {
+ imgsrc.lastfmEnabled = true
+ imgsrc.lastfmC = NewLastFMClient()
+ }
})
}
@@ -57,51 +73,118 @@ func Shutdown() {
func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
l := logger.FromContext(ctx)
- if imgsrc.deezerC != nil {
+ if imgsrc.subsonicEnabled {
+ img, err := imgsrc.subsonicC.GetArtistImage(ctx, opts.MBID, opts.Aliases[0])
+ if err != nil {
+ l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from Subsonic")
+ } else if img != "" {
+ return img, nil
+ }
+ } else {
+ l.Debug().Msg("GetArtistImage: Subsonic image fetching is disabled")
+ }
+ if imgsrc.lastfmEnabled {
+ img, err := imgsrc.lastfmC.GetArtistImage(ctx, opts.MBID, opts.Aliases[0])
+ if err != nil {
+ l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from LastFM")
+ } else if img != "" {
+ return img, nil
+ }
+ } else {
+ l.Debug().Msg("GetArtistImage: LastFM image fetching is disabled")
+ }
+ if imgsrc.deezerEnabled {
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
if err != nil {
+ l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from Deezer")
return "", err
+ } else if img != "" {
+ return img, nil
}
- return img, nil
+ } else {
+ l.Debug().Msg("GetArtistImage: Deezer image fetching is disabled")
}
- 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) {
l := logger.FromContext(ctx)
+ if imgsrc.subsonicEnabled {
+ img, err := imgsrc.subsonicC.GetAlbumImage(ctx, opts.ReleaseMbzID, opts.Artists[0], opts.Album)
+ if err != nil {
+ l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Subsonic")
+ }
+ if img != "" {
+ return img, nil
+ }
+ l.Debug().Msg("Could not find album cover from Subsonic")
+ }
if imgsrc.caaEnabled {
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {
url := fmt.Sprintf(caaBaseUrl+"/release/%s/front", opts.ReleaseMbzID.String())
resp, err := http.DefaultClient.Head(url)
if err != nil {
- return "", err
+ l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release MBID")
+ } else {
+ if resp.StatusCode == 200 {
+ return url, nil
+ } else {
+ l.Debug().Int("status", resp.StatusCode).Msg("GetAlbumImage: Got non-OK response from CoverArtArchive")
+ }
}
- if resp.StatusCode == 200 {
- return url, nil
- }
- l.Debug().Str("url", url).Str("status", resp.Status).Msg("Could not find album cover from CoverArtArchive with MusicBrainz release ID")
}
if opts.ReleaseGroupMbzID != nil && *opts.ReleaseGroupMbzID != uuid.Nil {
url := fmt.Sprintf(caaBaseUrl+"/release-group/%s/front", opts.ReleaseGroupMbzID.String())
resp, err := http.DefaultClient.Head(url)
if err != nil {
- return "", err
+ l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release Group MBID")
}
if resp.StatusCode == 200 {
return url, nil
}
- l.Debug().Str("url", url).Str("status", resp.Status).Msg("Could not find album cover from CoverArtArchive with MusicBrainz release group ID")
}
}
+ if imgsrc.lastfmEnabled {
+ img, err := imgsrc.lastfmC.GetAlbumImage(ctx, opts.ReleaseMbzID, opts.Artists[0], opts.Album)
+ if err != nil {
+ l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Subsonic")
+ }
+ if img != "" {
+ return img, nil
+ }
+ l.Debug().Msg("Could not find album cover from Subsonic")
+ }
if imgsrc.deezerEnabled {
l.Debug().Msg("Attempting to find album image from Deezer")
img, err := imgsrc.deezerC.GetAlbumImages(ctx, opts.Artists, opts.Album)
if err != nil {
+ l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Deezer")
return "", err
}
return img, nil
}
- l.Warn().Msg("No image providers are enabled")
+ l.Warn().Msg("GetAlbumImage: No image providers are enabled")
return "", nil
}
+
+// ValidateImageURL checks if the URL points to a valid image by performing a HEAD request.
+func ValidateImageURL(url string) error {
+ resp, err := http.Head(url)
+ if err != nil {
+ return fmt.Errorf("ValidateImageURL: http.Head: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ 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("ValidateImageURL: URL does not point to an image, content type: %s", contentType)
+ }
+
+ return nil
+}
diff --git a/internal/images/lastfm.go b/internal/images/lastfm.go
new file mode 100644
index 0000000..f35f6a3
--- /dev/null
+++ b/internal/images/lastfm.go
@@ -0,0 +1,298 @@
+package images
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/gabehf/koito/internal/cfg"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/queue"
+ "github.com/google/uuid"
+)
+
+// i told gemini to write this cuz i figured it would be simple enough and
+// it looks like it just works? maybe ai is actually worth one quintillion gallons of water
+
+type LastFMClient struct {
+ apiKey string
+ baseUrl string
+ userAgent string
+ requestQueue *queue.RequestQueue
+}
+
+// LastFM JSON structures use "#text" for the value of XML-mapped fields
+type lastFMImage struct {
+ URL string `json:"#text"`
+ Size string `json:"size"`
+}
+
+type lastFMAlbumResponse struct {
+ Album struct {
+ Name string `json:"name"`
+ Image []lastFMImage `json:"image"`
+ } `json:"album"`
+ Error int `json:"error"`
+ Message string `json:"message"`
+}
+
+type lastFMArtistResponse struct {
+ Artist struct {
+ Name string `json:"name"`
+ Image []lastFMImage `json:"image"`
+ } `json:"artist"`
+ Error int `json:"error"`
+ Message string `json:"message"`
+}
+
+const (
+ lastFMApiBaseUrl = "http://ws.audioscrobbler.com/2.0/"
+)
+
+func NewLastFMClient() *LastFMClient {
+ ret := new(LastFMClient)
+ ret.apiKey = cfg.LastFMApiKey()
+ ret.baseUrl = lastFMApiBaseUrl
+ ret.userAgent = cfg.UserAgent()
+ ret.requestQueue = queue.NewRequestQueue(5, 5)
+ return ret
+}
+
+func (c *LastFMClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
+ l := logger.FromContext(ctx)
+ req.Header.Set("User-Agent", c.userAgent)
+ req.Header.Set("Accept", "application/json")
+
+ resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
+ resp, err := client.Do(req)
+ if err != nil {
+ l.Debug().Err(err).Str("url", req.URL.String()).Msg("Failed to contact LastFM")
+ done <- queue.RequestResult{Err: err}
+ return
+ }
+ defer resp.Body.Close()
+
+ // LastFM might return 200 OK even for API errors (like "Artist not found"),
+ // so we rely on parsing the JSON body for logic errors later,
+ // but we still check for HTTP protocol failures here.
+ if resp.StatusCode >= 500 {
+ err = fmt.Errorf("received server error from LastFM: %s", resp.Status)
+ done <- queue.RequestResult{Body: nil, Err: err}
+ return
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ done <- queue.RequestResult{Body: body, Err: err}
+ })
+
+ result := <-resultChan
+ return result.Body, result.Err
+}
+
+func (c *LastFMClient) getEntity(ctx context.Context, params url.Values, result any) error {
+ l := logger.FromContext(ctx)
+
+ // Add standard parameters
+ params.Set("api_key", c.apiKey)
+ params.Set("format", "json")
+
+ // Construct URL
+ reqUrl, _ := url.Parse(c.baseUrl)
+ reqUrl.RawQuery = params.Encode()
+
+ l.Debug().Msgf("Sending request to LastFM: GET %s", reqUrl.String())
+
+ req, err := http.NewRequest("GET", reqUrl.String(), nil)
+ if err != nil {
+ return fmt.Errorf("getEntity: %w", err)
+ }
+
+ l.Debug().Msg("Adding LastFM request to queue")
+ body, err := c.queue(ctx, req)
+ if err != nil {
+ l.Err(err).Msg("LastFM request failed")
+ return fmt.Errorf("getEntity: %w", err)
+ }
+
+ err = json.Unmarshal(body, result)
+ if err != nil {
+ l.Err(err).Msg("Failed to unmarshal LastFM response")
+ return fmt.Errorf("getEntity: %w", err)
+ }
+
+ return nil
+}
+
+// selectBestImage picks the largest available image from the LastFM slice
+func (c *LastFMClient) selectBestImage(images []lastFMImage) string {
+ // Rank preference: mega > extralarge > large > medium > small
+ // Since LastFM usually returns them in order of size, we could take the last one,
+ // but a map lookup is safer against API changes.
+
+ imgMap := make(map[string]string)
+ for _, img := range images {
+ if img.URL != "" {
+ imgMap[img.Size] = img.URL
+ }
+ }
+
+ if url, ok := imgMap["mega"]; ok {
+ if err := ValidateImageURL(overrideImgSize(url)); err == nil {
+ return overrideImgSize(url)
+ } else {
+ return url
+ }
+ }
+ if url, ok := imgMap["extralarge"]; ok {
+ if err := ValidateImageURL(overrideImgSize(url)); err == nil {
+ return overrideImgSize(url)
+ } else {
+ return url
+ }
+ }
+ if url, ok := imgMap["large"]; ok {
+ if err := ValidateImageURL(overrideImgSize(url)); err == nil {
+ return overrideImgSize(url)
+ } else {
+ return url
+ }
+ }
+ if url, ok := imgMap["medium"]; ok {
+ return url
+ }
+ if url, ok := imgMap["small"]; ok {
+ return url
+ }
+
+ return ""
+}
+
+// lastfm seems to only return a 300x300 image even for "mega" and "extralarge" images, so I'm cheating
+func overrideImgSize(url string) string {
+ return strings.Replace(url, "300x300", "600x600", 1)
+}
+
+func (c *LastFMClient) GetAlbumImage(ctx context.Context, mbid *uuid.UUID, artist, album string) (string, error) {
+ l := logger.FromContext(ctx)
+ resp := new(lastFMAlbumResponse)
+ l.Debug().Msgf("Finding album image for %s from artist %s", album, artist)
+
+ // Helper to run the fetch
+ fetch := func(query paramsBuilder) error {
+ params := url.Values{}
+ params.Set("method", "album.getInfo")
+ query(params)
+ return c.getEntity(ctx, params, resp)
+ }
+
+ // 1. Try MBID search first
+ if mbid != nil {
+ l.Debug().Str("mbid", mbid.String()).Msg("Searching album image by MBID")
+ err := fetch(func(p url.Values) {
+ p.Set("mbid", mbid.String())
+ })
+
+ // If success and no API error code
+ if err == nil && resp.Error == 0 && len(resp.Album.Image) > 0 {
+ best := c.selectBestImage(resp.Album.Image)
+ if best != "" {
+ return best, nil
+ }
+ } else if resp.Error != 0 {
+ l.Debug().Int("api_error", resp.Error).Msg("LastFM MBID lookup failed, falling back to name")
+ }
+ }
+
+ // 2. Fallback to Artist + Album name match
+ l.Debug().Str("title", album).Str("artist", artist).Msg("Searching album image by title and artist")
+
+ // Clear previous response structure just in case
+ resp = new(lastFMAlbumResponse)
+
+ err := fetch(func(p url.Values) {
+ p.Set("artist", artist)
+ p.Set("album", album)
+ // Auto-correct spelling is useful for name lookups
+ p.Set("autocorrect", "1")
+ })
+
+ if err != nil {
+ return "", fmt.Errorf("GetAlbumImage: %v", err)
+ }
+
+ if resp.Error != 0 {
+ return "", fmt.Errorf("GetAlbumImage: LastFM API error %d: %s", resp.Error, resp.Message)
+ }
+
+ best := c.selectBestImage(resp.Album.Image)
+ if best == "" {
+ return "", fmt.Errorf("GetAlbumImage: no suitable image found")
+ }
+
+ return best, nil
+}
+
+func (c *LastFMClient) GetArtistImage(ctx context.Context, mbid *uuid.UUID, artist string) (string, error) {
+ l := logger.FromContext(ctx)
+ resp := new(lastFMArtistResponse)
+ l.Debug().Msgf("Finding artist image for %s", artist)
+
+ fetch := func(query paramsBuilder) error {
+ params := url.Values{}
+ params.Set("method", "artist.getInfo")
+ query(params)
+ return c.getEntity(ctx, params, resp)
+ }
+
+ // 1. Try MBID search
+ if mbid != nil {
+ l.Debug().Str("mbid", mbid.String()).Msg("Searching artist image by MBID")
+ err := fetch(func(p url.Values) {
+ p.Set("mbid", mbid.String())
+ })
+
+ if err == nil && resp.Error == 0 && len(resp.Artist.Image) > 0 {
+ best := c.selectBestImage(resp.Artist.Image)
+ if best != "" {
+ // Validate to match Subsonic implementation behavior
+ if err := ValidateImageURL(best); err == nil {
+ return best, nil
+ }
+ }
+ }
+ }
+
+ // 2. Fallback to Artist name
+ l.Debug().Str("artist", artist).Msg("Searching artist image by name")
+ resp = new(lastFMArtistResponse)
+
+ err := fetch(func(p url.Values) {
+ p.Set("artist", artist)
+ p.Set("autocorrect", "1")
+ })
+
+ if err != nil {
+ return "", fmt.Errorf("GetArtistImage: %v", err)
+ }
+
+ if resp.Error != 0 {
+ return "", fmt.Errorf("GetArtistImage: LastFM API error %d: %s", resp.Error, resp.Message)
+ }
+
+ best := c.selectBestImage(resp.Artist.Image)
+ if best == "" {
+ return "", fmt.Errorf("GetArtistImage: no suitable image found")
+ }
+
+ if err := ValidateImageURL(best); err != nil {
+ return "", fmt.Errorf("GetArtistImage: failed to validate image url")
+ }
+
+ return best, nil
+}
+
+type paramsBuilder func(url.Values)
diff --git a/internal/images/subsonic.go b/internal/images/subsonic.go
new file mode 100644
index 0000000..4fd55c0
--- /dev/null
+++ b/internal/images/subsonic.go
@@ -0,0 +1,180 @@
+package images
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/gabehf/koito/internal/cfg"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/queue"
+ "github.com/google/uuid"
+)
+
+type SubsonicClient struct {
+ url string
+ userAgent string
+ authParams string
+ requestQueue *queue.RequestQueue
+}
+
+type SubsonicAlbumResponse struct {
+ SubsonicResponse struct {
+ Status string `json:"status"`
+ SearchResult3 struct {
+ Album []struct {
+ CoverArt string `json:"coverArt"`
+ Artist string `json:"artist"`
+ MBID string `json:"musicBrainzId"`
+ } `json:"album"`
+ } `json:"searchResult3"`
+ } `json:"subsonic-response"`
+}
+
+type SubsonicArtistResponse struct {
+ SubsonicResponse struct {
+ Status string `json:"status"`
+ SearchResult3 struct {
+ Artist []struct {
+ ArtistImageUrl string `json:"artistImageUrl"`
+ } `json:"artist"`
+ } `json:"searchResult3"`
+ } `json:"subsonic-response"`
+}
+
+const (
+ subsonicAlbumSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=0&songCount=0&albumCount=10"
+ subsonicArtistSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=1&songCount=0&albumCount=0"
+ subsonicCoverArtFmtStr = "/rest/getCoverArt?%s&id=%s&v=1.13.0&c=koito"
+)
+
+func NewSubsonicClient() *SubsonicClient {
+ ret := new(SubsonicClient)
+ ret.url = cfg.SubsonicUrl()
+ ret.userAgent = cfg.UserAgent()
+ ret.authParams = cfg.SubsonicParams()
+ ret.requestQueue = queue.NewRequestQueue(5, 5)
+ return ret
+}
+
+func (c *SubsonicClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
+ l := logger.FromContext(ctx)
+ req.Header.Set("User-Agent", c.userAgent)
+ req.Header.Set("Accept", "application/json")
+
+ resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
+ resp, err := client.Do(req)
+ if err != nil {
+ l.Debug().Err(err).Str("url", req.RequestURI).Msg("Failed to contact ImageSrc")
+ done <- queue.RequestResult{Err: err}
+ return
+ } else if resp.StatusCode >= 300 || resp.StatusCode < 200 {
+ err = fmt.Errorf("recieved non-ok status from Subsonic: %s", resp.Status)
+ done <- queue.RequestResult{Body: nil, Err: err}
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ done <- queue.RequestResult{Body: body, Err: err}
+ })
+
+ result := <-resultChan
+ return result.Body, result.Err
+}
+
+func (c *SubsonicClient) getEntity(ctx context.Context, endpoint string, result any) error {
+ l := logger.FromContext(ctx)
+ url := c.url + endpoint
+ l.Debug().Msgf("Sending request to ImageSrc: GET %s", url)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ 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("Subsonic request failed")
+ return fmt.Errorf("getEntity: %w", err)
+ }
+
+ err = json.Unmarshal(body, result)
+ if err != nil {
+ l.Err(err).Msg("Failed to unmarshal Subsonic response")
+ return fmt.Errorf("getEntity: %w", err)
+ }
+
+ return nil
+}
+
+func (c *SubsonicClient) GetAlbumImage(ctx context.Context, mbid *uuid.UUID, artist, album string) (string, error) {
+ l := logger.FromContext(ctx)
+ resp := new(SubsonicAlbumResponse)
+ l.Debug().Msgf("Finding album image for %s from artist %s", album, artist)
+ // first try mbid search
+ if mbid != nil {
+ l.Debug().Str("mbid", mbid.String()).Msg("Searching album image by MBID")
+ err := c.getEntity(ctx, fmt.Sprintf(subsonicAlbumSearchFmtStr, c.authParams, url.QueryEscape(mbid.String())), resp)
+ if err != nil {
+ return "", fmt.Errorf("GetAlbumImage: %v", err)
+ }
+ l.Debug().Any("subsonic_response", resp).Msg("")
+ if len(resp.SubsonicResponse.SearchResult3.Album) >= 1 {
+ return cfg.SubsonicUrl() + fmt.Sprintf(subsonicCoverArtFmtStr, c.authParams, url.QueryEscape(resp.SubsonicResponse.SearchResult3.Album[0].CoverArt)), nil
+ }
+ }
+ // else do artist match
+ l.Debug().Str("title", album).Str("artist", artist).Msg("Searching album image by title and artist")
+ err := c.getEntity(ctx, fmt.Sprintf(subsonicAlbumSearchFmtStr, c.authParams, url.QueryEscape(album)), resp)
+ if err != nil {
+ return "", fmt.Errorf("GetAlbumImage: %v", err)
+ }
+ l.Debug().Any("subsonic_response", resp).Msg("")
+ if len(resp.SubsonicResponse.SearchResult3.Album) < 1 {
+ return "", fmt.Errorf("GetAlbumImage: failed to get album art from subsonic")
+ }
+ for _, album := range resp.SubsonicResponse.SearchResult3.Album {
+ if album.Artist == artist {
+ return cfg.SubsonicUrl() + fmt.Sprintf(subsonicCoverArtFmtStr, c.authParams, url.QueryEscape(resp.SubsonicResponse.SearchResult3.Album[0].CoverArt)), nil
+ }
+ }
+ return "", fmt.Errorf("GetAlbumImage: failed to get album art from subsonic")
+}
+
+func (c *SubsonicClient) GetArtistImage(ctx context.Context, mbid *uuid.UUID, artist string) (string, error) {
+ l := logger.FromContext(ctx)
+ resp := new(SubsonicArtistResponse)
+ l.Debug().Msgf("Finding artist image for %s", artist)
+ // first try mbid search
+ if mbid != nil {
+ l.Debug().Str("mbid", mbid.String()).Msg("Searching artist image by MBID")
+ err := c.getEntity(ctx, fmt.Sprintf(subsonicArtistSearchFmtStr, c.authParams, url.QueryEscape(mbid.String())), resp)
+ if err != nil {
+ return "", fmt.Errorf("GetArtistImage: %v", err)
+ }
+ l.Debug().Any("subsonic_response", resp).Msg("")
+ if len(resp.SubsonicResponse.SearchResult3.Artist) < 1 || resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl == "" {
+ return "", fmt.Errorf("GetArtistImage: failed to get artist art")
+ }
+ // Subsonic seems to have a tendency to return an artist image even though the url is a 404
+ if err = ValidateImageURL(resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl); err != nil {
+ return "", fmt.Errorf("GetArtistImage: failed to get validate image url")
+ }
+ }
+ l.Debug().Str("artist", artist).Msg("Searching artist image by name")
+ err := c.getEntity(ctx, fmt.Sprintf(subsonicArtistSearchFmtStr, c.authParams, url.QueryEscape(artist)), resp)
+ if err != nil {
+ return "", fmt.Errorf("GetArtistImage: %v", err)
+ }
+ l.Debug().Any("subsonic_response", resp).Msg("")
+ if len(resp.SubsonicResponse.SearchResult3.Artist) < 1 || resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl == "" {
+ return "", fmt.Errorf("GetArtistImage: failed to get artist art")
+ }
+ // Subsonic seems to have a tendency to return an artist image even though the url is a 404
+ if err = ValidateImageURL(resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl); err != nil {
+ return "", fmt.Errorf("GetArtistImage: failed to get validate image url")
+ }
+ return resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl, nil
+}
diff --git a/internal/importer/koito.go b/internal/importer/koito.go
new file mode 100644
index 0000000..0f8df74
--- /dev/null
+++ b/internal/importer/koito.go
@@ -0,0 +1,180 @@
+package importer
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path"
+ "strings"
+
+ "github.com/gabehf/koito/internal/cfg"
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/export"
+ "github.com/gabehf/koito/internal/logger"
+ "github.com/gabehf/koito/internal/models"
+ "github.com/gabehf/koito/internal/utils"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+func ImportKoitoFile(ctx context.Context, store db.DB, filename string) error {
+ l := logger.FromContext(ctx)
+ l.Info().Msgf("Beginning Koito import on file: %s", filename)
+ data := new(export.KoitoExport)
+ f, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
+ if err != nil {
+ return fmt.Errorf("ImportKoitoFile: os.Open: %w", err)
+ }
+ defer f.Close()
+ err = json.NewDecoder(f).Decode(data)
+ if err != nil {
+ return fmt.Errorf("ImportKoitoFile: Decode: %w", err)
+ }
+
+ if data.Version != "1" {
+ return fmt.Errorf("ImportKoitoFile: unupported version: %s", data.Version)
+ }
+
+ l.Info().Msgf("Beginning data import for user: %s", data.User)
+
+ count := 0
+
+ for i := range data.Listens {
+ if !inImportTimeWindow(data.Listens[i].ListenedAt) {
+ l.Debug().Msgf("Skipping import due to import time rules")
+ continue
+ }
+ // use this for save/get mbid for all artist/album/track
+ var mbid uuid.UUID
+
+ artistIds := make([]int32, 0)
+ for _, ia := range data.Listens[i].Artists {
+ mbid = uuid.Nil
+ if ia.MBID != nil {
+ mbid = *ia.MBID
+ }
+ artist, err := store.GetArtist(ctx, db.GetArtistOpts{
+ MusicBrainzID: mbid,
+ Name: getPrimaryAliasFromAliasSlice(ia.Aliases),
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ var imgid = uuid.Nil
+ // not a perfect way to check if the image url is an actual source vs manual upload but
+ // im like 99% sure it will work perfectly
+ if strings.HasPrefix(ia.ImageUrl, "http") {
+ imgid = uuid.New()
+ }
+ // save artist
+ artist, err := store.SaveArtist(ctx, db.SaveArtistOpts{
+ Name: getPrimaryAliasFromAliasSlice(ia.Aliases),
+ Image: imgid,
+ ImageSrc: ia.ImageUrl,
+ MusicBrainzID: mbid,
+ Aliases: utils.FlattenAliases(ia.Aliases),
+ })
+ if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ }
+ artistIds = append(artistIds, artist.ID)
+ } else if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ } else {
+ artistIds = append(artistIds, artist.ID)
+ }
+ }
+ // call associate album
+ albumId := int32(0)
+ mbid = uuid.Nil
+ if data.Listens[i].Album.MBID != nil {
+ mbid = *data.Listens[i].Album.MBID
+ }
+ album, err := store.GetAlbum(ctx, db.GetAlbumOpts{
+ MusicBrainzID: mbid,
+ Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Album.Aliases),
+ ArtistID: artistIds[0],
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ var imgid = uuid.Nil
+ // not a perfect way to check if the image url is an actual source vs manual upload but
+ // im like 99% sure it will work perfectly
+ if strings.HasPrefix(data.Listens[i].Album.ImageUrl, "http") {
+ imgid = uuid.New()
+ }
+ // save album
+ album, err = store.SaveAlbum(ctx, db.SaveAlbumOpts{
+ Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Album.Aliases),
+ Image: imgid,
+ ImageSrc: data.Listens[i].Album.ImageUrl,
+ MusicBrainzID: mbid,
+ Aliases: utils.FlattenAliases(data.Listens[i].Album.Aliases),
+ ArtistIDs: artistIds,
+ VariousArtists: data.Listens[i].Album.VariousArtists,
+ })
+ if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ }
+ albumId = album.ID
+ } else if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ } else {
+ albumId = album.ID
+ }
+
+ // call associate track
+ mbid = uuid.Nil
+ if data.Listens[i].Track.MBID != nil {
+ mbid = *data.Listens[i].Track.MBID
+ }
+ track, err := store.GetTrack(ctx, db.GetTrackOpts{
+ MusicBrainzID: mbid,
+ Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Track.Aliases),
+ ReleaseID: albumId,
+ ArtistIDs: artistIds,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ // save track
+ track, err = store.SaveTrack(ctx, db.SaveTrackOpts{
+ Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Track.Aliases),
+ RecordingMbzID: mbid,
+ Duration: int32(data.Listens[i].Track.Duration),
+ ArtistIDs: artistIds,
+ AlbumID: albumId,
+ })
+ if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ }
+ // save track aliases
+ err = store.SaveTrackAliases(ctx, track.ID, utils.FlattenAliases(data.Listens[i].Track.Aliases), "Import")
+ if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ }
+ } else if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ }
+
+ // save listen
+ err = store.SaveListen(ctx, db.SaveListenOpts{
+ TrackID: track.ID,
+ Time: data.Listens[i].ListenedAt,
+ UserID: 1,
+ })
+ if err != nil {
+ return fmt.Errorf("ImportKoitoFile: %w", err)
+ }
+
+ l.Info().Msgf("ImportKoitoFile: Imported listen for track %s", track.Title)
+ count++
+ }
+
+ return finishImport(ctx, filename, count)
+}
+func getPrimaryAliasFromAliasSlice(aliases []models.Alias) string {
+ for _, a := range aliases {
+ if a.Primary {
+ return a.Alias
+ }
+ }
+ return ""
+}
diff --git a/internal/importer/lastfm.go b/internal/importer/lastfm.go
index f01e4b1..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()
@@ -97,22 +99,31 @@ 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,
+ 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 c9b7355..7c1a8bb 100644
--- a/internal/importer/listenbrainz.go
+++ b/internal/importer/listenbrainz.go
@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
"path"
"strings"
"time"
@@ -34,15 +33,16 @@ func ImportListenBrainzExport(ctx context.Context, store db.DB, mbzc mbz.MusicBr
for _, f := range r.File {
if f.FileInfo().IsDir() {
+ l.Debug().Msgf("File %s is dir, skipping...", f.Name)
continue
}
if strings.HasPrefix(f.Name, "listens/") && strings.HasSuffix(f.Name, ".jsonl") {
- fmt.Println("Found:", f.Name)
+ l.Info().Msgf("Found: %s\n", f.Name)
rc, err := f.Open()
if err != nil {
- log.Printf("Failed to open %s: %v\n", f.Name, err)
+ l.Err(err).Msgf("Failed to open %s\n", f.Name)
continue
}
@@ -75,7 +75,7 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
payload := new(handlers.LbzSubmitListenPayload)
err := json.Unmarshal(line, payload)
if err != nil {
- fmt.Println("Error unmarshaling JSON:", err)
+ l.Err(err).Msg("Error unmarshaling JSON")
continue
}
ts := time.Unix(payload.ListenedAt, 0)
@@ -85,7 +85,14 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
}
artistMbzIDs, err := utils.ParseUUIDSlice(payload.TrackMeta.AdditionalInfo.ArtistMBIDs)
if err != nil {
- l.Debug().Err(err).Msg("Failed to parse one or more uuids")
+ l.Debug().AnErr("error", err).Msg("ImportListenBrainzFile: Failed to parse one or more UUIDs")
+ }
+ if len(artistMbzIDs) < 1 {
+ l.Debug().AnErr("error", err).Msg("ImportListenBrainzFile: Attempting to parse artist UUIDs from mbid_mapping")
+ utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs)
+ if err != nil {
+ l.Debug().AnErr("error", err).Msg("ImportListenBrainzFile: Failed to parse one or more UUIDs")
+ }
}
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
if err != nil {
@@ -93,11 +100,17 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
}
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
@@ -113,25 +126,40 @@ 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,
+ 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..f2e3885 100644
--- a/internal/mbz/track.go
+++ b/internal/mbz/track.go
@@ -2,12 +2,14 @@ package mbz
import (
"context"
+ "fmt"
"github.com/google/uuid"
)
type MusicBrainzTrack struct {
- Title string `json:"title"`
+ Title string `json:"title"`
+ LengthMs int `json:"length"`
}
const recordingFmtStr = "%s/ws/2/recording/%s"
@@ -17,7 +19,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/memkv/memkv.go b/internal/memkv/memkv.go
new file mode 100644
index 0000000..631b646
--- /dev/null
+++ b/internal/memkv/memkv.go
@@ -0,0 +1,110 @@
+package memkv
+
+import (
+ "sync"
+ "time"
+)
+
+type item struct {
+ value interface{}
+ expiresAt time.Time
+}
+
+type InMemoryStore struct {
+ data map[string]item
+ defaultExpiration time.Duration
+ mu sync.RWMutex
+ stopJanitor chan struct{}
+}
+
+var Store *InMemoryStore
+
+func init() {
+ Store = NewStore(10 * time.Minute)
+}
+
+func NewStore(defaultExpiration time.Duration) *InMemoryStore {
+ s := &InMemoryStore{
+ data: make(map[string]item),
+ defaultExpiration: defaultExpiration,
+ stopJanitor: make(chan struct{}),
+ }
+
+ go s.janitor(1 * time.Minute)
+
+ return s
+}
+
+func (s *InMemoryStore) Set(key string, value interface{}, expiration ...time.Duration) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ exp := s.defaultExpiration
+ if len(expiration) > 0 {
+ exp = expiration[0]
+ }
+
+ var expiresAt time.Time
+ if exp > 0 {
+ expiresAt = time.Now().Add(exp)
+ }
+
+ s.data[key] = item{
+ value: value,
+ expiresAt: expiresAt,
+ }
+}
+
+func (s *InMemoryStore) Get(key string) (interface{}, bool) {
+ s.mu.RLock()
+ it, found := s.data[key]
+ s.mu.RUnlock()
+
+ if !found {
+ return nil, false
+ }
+
+ if !it.expiresAt.IsZero() && time.Now().After(it.expiresAt) {
+ s.Delete(key)
+ return nil, false
+ }
+
+ return it.value, true
+}
+
+func (s *InMemoryStore) Delete(key string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.data, key)
+}
+
+func (s *InMemoryStore) janitor(interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ s.cleanup()
+ case <-s.stopJanitor:
+ return
+ }
+ }
+}
+
+func (s *InMemoryStore) cleanup() {
+ now := time.Now()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for k, it := range s.data {
+ if !it.expiresAt.IsZero() && now.After(it.expiresAt) {
+ delete(s.data, k)
+ }
+ }
+}
+
+func (s *InMemoryStore) Close() {
+ close(s.stopJanitor)
+}
diff --git a/internal/models/album.go b/internal/models/album.go
index 90b8cdd..a295fe9 100644
--- a/internal/models/album.go
+++ b/internal/models/album.go
@@ -10,11 +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"`
+ FirstListen int64 `json:"first_listen"`
+ AllTimeRank int64 `json:"all_time_rank"`
}
-
-// type SimpleAlbum struct {
-// ID int32 `json:"id"`
-// Title string `json:"title"`
-// VariousArtists bool `json:"is_various_artists"`
-// Image uuid.UUID `json:"image"`
-// }
diff --git a/internal/models/alias.go b/internal/models/alias.go
index a263af6..d210ed9 100644
--- a/internal/models/alias.go
+++ b/internal/models/alias.go
@@ -1,7 +1,7 @@
package models
type Alias struct {
- ID int32 `json:"id"`
+ ID int32 `json:"id,omitempty"`
Alias string `json:"alias"`
Source string `json:"source"`
Primary bool `json:"is_primary"`
diff --git a/internal/models/artist.go b/internal/models/artist.go
index b240370..07f09e6 100644
--- a/internal/models/artist.go
+++ b/internal/models/artist.go
@@ -3,15 +3,32 @@ 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"`
+ FirstListen int64 `json:"first_listen"`
+ IsPrimary bool `json:"is_primary,omitempty"`
+ AllTimeRank int64 `json:"all_time_rank"`
}
type SimpleArtist struct {
ID int32 `json:"id"`
Name string `json:"name"`
}
+
+type ArtistWithFullAliases struct {
+ ID int32 `json:"id"`
+ MbzID *uuid.UUID `json:"musicbrainz_id"`
+ Name string `json:"name"`
+ Aliases []Alias `json:"aliases"`
+ Image *uuid.UUID `json:"image"`
+ ImageSource string `json:"image_source,omitempty"`
+ ListenCount int64 `json:"listen_count"`
+ TimeListened int64 `json:"time_listened"`
+ FirstListen int64 `json:"first_listen"`
+ IsPrimary bool `json:"is_primary,omitempty"`
+}
diff --git a/internal/models/track.go b/internal/models/track.go
index 386a2fc..4cb5b04 100644
--- a/internal/models/track.go
+++ b/internal/models/track.go
@@ -3,12 +3,15 @@ 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"`
+ FirstListen int64 `json:"first_listen"`
+ AllTimeRank int64 `json:"all_time_rank"`
}
diff --git a/internal/repository/alias.sql.go b/internal/repository/alias.sql.go
index c40ce22..c7d4d4a 100644
--- a/internal/repository/alias.sql.go
+++ b/internal/repository/alias.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: alias.sql
package repository
diff --git a/internal/repository/artist.sql.go b/internal/repository/artist.sql.go
index 3d01e1a..8506975 100644
--- a/internal/repository/artist.sql.go
+++ b/internal/repository/artist.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: artist.sql
package repository
@@ -13,6 +13,30 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
+const countNewArtists = `-- name: CountNewArtists :one
+SELECT COUNT(*) AS total_count
+FROM (
+ SELECT at.artist_id
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN artist_tracks at ON t.id = at.track_id
+ GROUP BY at.artist_id
+ HAVING MIN(l.listened_at) BETWEEN $1 AND $2
+) first_appearances
+`
+
+type CountNewArtistsParams struct {
+ ListenedAt time.Time
+ ListenedAt_2 time.Time
+}
+
+func (q *Queries) CountNewArtists(ctx context.Context, arg CountNewArtistsParams) (int64, error) {
+ row := q.db.QueryRow(ctx, countNewArtists, arg.ListenedAt, arg.ListenedAt_2)
+ var total_count int64
+ err := row.Scan(&total_count)
+ return total_count, err
+}
+
const countTopArtists = `-- name: CountTopArtists :one
SELECT COUNT(DISTINCT at.artist_id) AS total_count
FROM listens l
@@ -78,7 +102,7 @@ func (q *Queries) DeleteConflictingArtistTracks(ctx context.Context, arg DeleteC
}
const getArtist = `-- name: GetArtist :one
-SELECT
+SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@@ -110,6 +134,39 @@ func (q *Queries) GetArtist(ctx context.Context, id int32) (GetArtistRow, error)
return i, err
}
+const getArtistAllTimeRank = `-- name: GetArtistAllTimeRank :one
+SELECT
+ artist_id,
+ rank
+FROM (
+ SELECT
+ x.artist_id,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+ FROM (
+ SELECT
+ at.artist_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN artist_tracks at ON t.id = at.track_id
+ GROUP BY at.artist_id
+ ) x
+ )
+WHERE artist_id = $1
+`
+
+type GetArtistAllTimeRankRow struct {
+ ArtistID int32
+ Rank int64
+}
+
+func (q *Queries) GetArtistAllTimeRank(ctx context.Context, artistID int32) (GetArtistAllTimeRankRow, error) {
+ row := q.db.QueryRow(ctx, getArtistAllTimeRank, artistID)
+ var i GetArtistAllTimeRankRow
+ err := row.Scan(&i.ArtistID, &i.Rank)
+ return i, err
+}
+
const getArtistByImage = `-- name: GetArtistByImage :one
SELECT id, musicbrainz_id, image, image_source FROM artists WHERE image = $1 LIMIT 1
`
@@ -127,7 +184,7 @@ func (q *Queries) GetArtistByImage(ctx context.Context, image *uuid.UUID) (Artis
}
const getArtistByMbzID = `-- name: GetArtistByMbzID :one
-SELECT
+SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@@ -161,7 +218,7 @@ func (q *Queries) GetArtistByMbzID(ctx context.Context, musicbrainzID *uuid.UUID
const getArtistByName = `-- name: GetArtistByName :one
WITH artist_with_aliases AS (
- SELECT
+ SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
COALESCE(array_agg(aa.alias), '{}')::text[] AS aliases
FROM artists_with_name a
@@ -197,17 +254,23 @@ func (q *Queries) GetArtistByName(ctx context.Context, alias string) (GetArtistB
return i, err
}
-const getReleaseArtists = `-- name: GetReleaseArtists :many
-SELECT
- a.id, a.musicbrainz_id, a.image, a.image_source, a.name
-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
+const getArtistsWithoutImages = `-- name: GetArtistsWithoutImages :many
+SELECT
+ id, musicbrainz_id, image, image_source, name
+FROM artists_with_name
+WHERE image IS NULL
+ AND id > $2
+ORDER BY id ASC
+LIMIT $1
`
-func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]ArtistsWithName, error) {
- rows, err := q.db.Query(ctx, getReleaseArtists, releaseID)
+type GetArtistsWithoutImagesParams struct {
+ Limit int32
+ ID int32
+}
+
+func (q *Queries) GetArtistsWithoutImages(ctx context.Context, arg GetArtistsWithoutImagesParams) ([]ArtistsWithName, error) {
+ rows, err := q.db.Query(ctx, getArtistsWithoutImages, arg.Limit, arg.ID)
if err != nil {
return nil, err
}
@@ -232,20 +295,75 @@ func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]Art
return items, nil
}
+const getReleaseArtists = `-- name: GetReleaseArtists :many
+SELECT
+ 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, ar.is_primary
+`
+
+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 []GetReleaseArtistsRow
+ for rows.Next() {
+ var i GetReleaseArtistsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.MusicBrainzID,
+ &i.Image,
+ &i.ImageSource,
+ &i.Name,
+ &i.IsPrimary,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getTopArtistsPaginated = `-- name: GetTopArtistsPaginated :many
SELECT
+ x.id,
+ x.name,
+ x.musicbrainz_id,
+ x.image,
+ x.listen_count,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+FROM (
+ SELECT
a.id,
a.name,
a.musicbrainz_id,
a.image,
COUNT(*) AS listen_count
-FROM listens l
-JOIN tracks t ON l.track_id = t.id
-JOIN artist_tracks at ON at.track_id = t.id
-JOIN artists_with_name a ON a.id = at.artist_id
-WHERE l.listened_at BETWEEN $1 AND $2
-GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name
-ORDER BY listen_count DESC
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN artist_tracks at ON at.track_id = t.id
+ JOIN artists_with_name a ON a.id = at.artist_id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ GROUP BY a.id, a.name, a.musicbrainz_id, a.image
+) x
+ORDER BY x.listen_count DESC, x.id
LIMIT $3 OFFSET $4
`
@@ -262,6 +380,7 @@ type GetTopArtistsPaginatedRow struct {
MusicBrainzID *uuid.UUID
Image *uuid.UUID
ListenCount int64
+ Rank int64
}
func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsPaginatedParams) ([]GetTopArtistsPaginatedRow, error) {
@@ -284,6 +403,7 @@ func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsP
&i.MusicBrainzID,
&i.Image,
&i.ListenCount,
+ &i.Rank,
); err != nil {
return nil, err
}
@@ -296,29 +416,40 @@ 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
+SELECT
+ 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/db.go b/internal/repository/db.go
index 89e33c0..b196af2 100644
--- a/internal/repository/db.go
+++ b/internal/repository/db.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
package repository
diff --git a/internal/repository/etc.sql.go b/internal/repository/etc.sql.go
index 7664959..484f5c4 100644
--- a/internal/repository/etc.sql.go
+++ b/internal/repository/etc.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: etc.sql
package repository
@@ -15,11 +15,17 @@ BEGIN
DELETE FROM tracks WHERE id NOT IN (SELECT l.track_id FROM listens l);
DELETE FROM releases WHERE id NOT IN (SELECT t.release_id FROM tracks t);
DELETE FROM artists WHERE id NOT IN (SELECT at.artist_id FROM artist_tracks at);
+ DELETE FROM artist_releases ar
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM artist_tracks at
+ JOIN tracks t ON at.track_id = t.id
+ WHERE at.artist_id = ar.artist_id
+ AND t.release_id = ar.release_id
+ );
END $$
`
-// DELETE FROM releases WHERE release_group_id NOT IN (SELECT t.release_group_id FROM tracks t);
-// DELETE FROM releases WHERE release_group_id NOT IN (SELECT rg.id FROM release_groups rg);
func (q *Queries) CleanOrphanedEntries(ctx context.Context) error {
_, err := q.db.Exec(ctx, cleanOrphanedEntries)
return err
diff --git a/internal/repository/interest.sql.go b/internal/repository/interest.sql.go
new file mode 100644
index 0000000..ae77764
--- /dev/null
+++ b/internal/repository/interest.sql.go
@@ -0,0 +1,247 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: interest.sql
+
+package repository
+
+import (
+ "context"
+ "time"
+)
+
+const getGroupedListensFromArtist = `-- name: GetGroupedListensFromArtist :many
+WITH bounds AS (
+ SELECT
+ MIN(l.listened_at) AS start_time,
+ NOW() AS end_time
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ JOIN artist_tracks at ON at.track_id = t.id
+ WHERE at.artist_id = $1
+),
+stats AS (
+ SELECT
+ start_time,
+ end_time,
+ EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
+ ((end_time - start_time) / $2::int) AS bucket_interval
+ FROM bounds
+),
+bucket_series AS (
+ SELECT generate_series(0, $2::int - 1) AS idx
+),
+listen_indices AS (
+ SELECT
+ LEAST(
+ $2::int - 1,
+ FLOOR(
+ (EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
+ * $2::int
+ )::int
+ ) AS bucket_idx
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ JOIN artist_tracks at ON at.track_id = t.id
+ CROSS JOIN stats s
+ WHERE at.artist_id = $1
+ AND s.start_time IS NOT NULL
+)
+SELECT
+ (s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
+ (s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
+ COUNT(li.bucket_idx) AS listen_count
+FROM bucket_series bs
+CROSS JOIN stats s
+LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
+WHERE s.start_time IS NOT NULL
+GROUP BY bs.idx, s.start_time, s.bucket_interval
+ORDER BY bs.idx
+`
+
+type GetGroupedListensFromArtistParams struct {
+ ArtistID int32
+ BucketCount int32
+}
+
+type GetGroupedListensFromArtistRow struct {
+ BucketStart time.Time
+ BucketEnd time.Time
+ ListenCount int64
+}
+
+func (q *Queries) GetGroupedListensFromArtist(ctx context.Context, arg GetGroupedListensFromArtistParams) ([]GetGroupedListensFromArtistRow, error) {
+ rows, err := q.db.Query(ctx, getGroupedListensFromArtist, arg.ArtistID, arg.BucketCount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetGroupedListensFromArtistRow
+ for rows.Next() {
+ var i GetGroupedListensFromArtistRow
+ if err := rows.Scan(&i.BucketStart, &i.BucketEnd, &i.ListenCount); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getGroupedListensFromRelease = `-- name: GetGroupedListensFromRelease :many
+WITH bounds AS (
+ SELECT
+ MIN(l.listened_at) AS start_time,
+ NOW() AS end_time
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ WHERE t.release_id = $1
+),
+stats AS (
+ SELECT
+ start_time,
+ end_time,
+ EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
+ ((end_time - start_time) / $2::int) AS bucket_interval
+ FROM bounds
+),
+bucket_series AS (
+ SELECT generate_series(0, $2::int - 1) AS idx
+),
+listen_indices AS (
+ SELECT
+ LEAST(
+ $2::int - 1,
+ FLOOR(
+ (EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
+ * $2::int
+ )::int
+ ) AS bucket_idx
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ CROSS JOIN stats s
+ WHERE t.release_id = $1
+ AND s.start_time IS NOT NULL
+)
+SELECT
+ (s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
+ (s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
+ COUNT(li.bucket_idx) AS listen_count
+FROM bucket_series bs
+CROSS JOIN stats s
+LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
+WHERE s.start_time IS NOT NULL
+GROUP BY bs.idx, s.start_time, s.bucket_interval
+ORDER BY bs.idx
+`
+
+type GetGroupedListensFromReleaseParams struct {
+ ReleaseID int32
+ BucketCount int32
+}
+
+type GetGroupedListensFromReleaseRow struct {
+ BucketStart time.Time
+ BucketEnd time.Time
+ ListenCount int64
+}
+
+func (q *Queries) GetGroupedListensFromRelease(ctx context.Context, arg GetGroupedListensFromReleaseParams) ([]GetGroupedListensFromReleaseRow, error) {
+ rows, err := q.db.Query(ctx, getGroupedListensFromRelease, arg.ReleaseID, arg.BucketCount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetGroupedListensFromReleaseRow
+ for rows.Next() {
+ var i GetGroupedListensFromReleaseRow
+ if err := rows.Scan(&i.BucketStart, &i.BucketEnd, &i.ListenCount); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getGroupedListensFromTrack = `-- name: GetGroupedListensFromTrack :many
+WITH bounds AS (
+ SELECT
+ MIN(l.listened_at) AS start_time,
+ NOW() AS end_time
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ WHERE t.id = $1
+),
+stats AS (
+ SELECT
+ start_time,
+ end_time,
+ EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
+ ((end_time - start_time) / $2::int) AS bucket_interval
+ FROM bounds
+),
+bucket_series AS (
+ SELECT generate_series(0, $2::int - 1) AS idx
+),
+listen_indices AS (
+ SELECT
+ LEAST(
+ $2::int - 1,
+ FLOOR(
+ (EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
+ * $2::int
+ )::int
+ ) AS bucket_idx
+ FROM listens l
+ JOIN tracks t ON t.id = l.track_id
+ CROSS JOIN stats s
+ WHERE t.id = $1
+ AND s.start_time IS NOT NULL
+)
+SELECT
+ (s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
+ (s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
+ COUNT(li.bucket_idx) AS listen_count
+FROM bucket_series bs
+CROSS JOIN stats s
+LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
+WHERE s.start_time IS NOT NULL
+GROUP BY bs.idx, s.start_time, s.bucket_interval
+ORDER BY bs.idx
+`
+
+type GetGroupedListensFromTrackParams struct {
+ ID int32
+ BucketCount int32
+}
+
+type GetGroupedListensFromTrackRow struct {
+ BucketStart time.Time
+ BucketEnd time.Time
+ ListenCount int64
+}
+
+func (q *Queries) GetGroupedListensFromTrack(ctx context.Context, arg GetGroupedListensFromTrackParams) ([]GetGroupedListensFromTrackRow, error) {
+ rows, err := q.db.Query(ctx, getGroupedListensFromTrack, arg.ID, arg.BucketCount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetGroupedListensFromTrackRow
+ for rows.Next() {
+ var i GetGroupedListensFromTrackRow
+ if err := rows.Scan(&i.BucketStart, &i.BucketEnd, &i.ListenCount); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/internal/repository/listen.sql.go b/internal/repository/listen.sql.go
index d3567c3..d3db4bb 100644
--- a/internal/repository/listen.sql.go
+++ b/internal/repository/listen.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: listen.sql
package repository
@@ -9,6 +9,7 @@ import (
"context"
"time"
+ "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -189,20 +190,102 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
return err
}
+const getFirstListen = `-- name: GetFirstListen :one
+SELECT
+ track_id, listened_at, client, user_id
+FROM listens
+ORDER BY listened_at ASC
+LIMIT 1
+`
+
+func (q *Queries) GetFirstListen(ctx context.Context) (Listen, error) {
+ row := q.db.QueryRow(ctx, getFirstListen)
+ var i Listen
+ err := row.Scan(
+ &i.TrackID,
+ &i.ListenedAt,
+ &i.Client,
+ &i.UserID,
+ )
+ return i, err
+}
+
+const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
+SELECT
+ l.track_id, l.listened_at, l.client, l.user_id
+FROM listens l
+JOIN tracks_with_title t ON l.track_id = t.id
+JOIN artist_tracks at ON t.id = at.track_id
+WHERE at.artist_id = $1
+ORDER BY l.listened_at ASC
+LIMIT 1
+`
+
+func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32) (Listen, error) {
+ row := q.db.QueryRow(ctx, getFirstListenFromArtist, artistID)
+ var i Listen
+ err := row.Scan(
+ &i.TrackID,
+ &i.ListenedAt,
+ &i.Client,
+ &i.UserID,
+ )
+ return i, err
+}
+
+const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
+SELECT
+ l.track_id, l.listened_at, l.client, l.user_id
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE t.release_id = $1
+ORDER BY l.listened_at ASC
+LIMIT 1
+`
+
+func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32) (Listen, error) {
+ row := q.db.QueryRow(ctx, getFirstListenFromRelease, releaseID)
+ var i Listen
+ err := row.Scan(
+ &i.TrackID,
+ &i.ListenedAt,
+ &i.Client,
+ &i.UserID,
+ )
+ return i, err
+}
+
+const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
+SELECT
+ l.track_id, l.listened_at, l.client, l.user_id
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE t.id = $1
+ORDER BY l.listened_at ASC
+LIMIT 1
+`
+
+func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen, error) {
+ row := q.db.QueryRow(ctx, getFirstListenFromTrack, id)
+ var i Listen
+ err := row.Scan(
+ &i.TrackID,
+ &i.ListenedAt,
+ &i.Client,
+ &i.UserID,
+ )
+ return i, err
+}
+
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
-SELECT
+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
+JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $5
AND l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
@@ -262,16 +345,11 @@ func (q *Queries) GetLastListensFromArtistPaginated(ctx context.Context, arg Get
}
const getLastListensFromReleasePaginated = `-- name: GetLastListensFromReleasePaginated :many
-SELECT
+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
@@ -333,16 +411,11 @@ func (q *Queries) GetLastListensFromReleasePaginated(ctx context.Context, arg Ge
}
const getLastListensFromTrackPaginated = `-- name: GetLastListensFromTrackPaginated :many
-SELECT
+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
@@ -404,16 +477,11 @@ func (q *Queries) GetLastListensFromTrackPaginated(ctx context.Context, arg GetL
}
const getLastListensPaginated = `-- name: GetLastListensPaginated :many
-SELECT
+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
@@ -471,6 +539,138 @@ func (q *Queries) GetLastListensPaginated(ctx context.Context, arg GetLastListen
return items, nil
}
+const getListensExportPage = `-- name: GetListensExportPage :many
+SELECT
+ l.listened_at,
+ l.user_id,
+ l.client,
+
+ -- Track info
+ t.id AS track_id,
+ t.musicbrainz_id AS track_mbid,
+ t.duration AS track_duration,
+ (
+ SELECT json_agg(json_build_object(
+ 'alias', ta.alias,
+ 'source', ta.source,
+ 'is_primary', ta.is_primary
+ ))
+ FROM track_aliases ta
+ WHERE ta.track_id = t.id
+ ) AS track_aliases,
+
+ -- Release info
+ r.id AS release_id,
+ r.musicbrainz_id AS release_mbid,
+ r.image AS release_image,
+ r.image_source AS release_image_source,
+ r.various_artists,
+ (
+ SELECT json_agg(json_build_object(
+ 'alias', ra.alias,
+ 'source', ra.source,
+ 'is_primary', ra.is_primary
+ ))
+ FROM release_aliases ra
+ WHERE ra.release_id = r.id
+ ) AS release_aliases,
+
+ -- Artists
+ (
+ SELECT json_agg(json_build_object(
+ 'id', a.id,
+ 'musicbrainz_id', a.musicbrainz_id,
+ 'image', a.image,
+ 'image_source', a.image_source,
+ 'aliases', (
+ SELECT json_agg(json_build_object(
+ 'alias', aa.alias,
+ 'source', aa.source,
+ 'is_primary', aa.is_primary
+ ))
+ FROM artist_aliases aa
+ WHERE aa.artist_id = a.id
+ )
+ ))
+ FROM artist_tracks at
+ JOIN artists a ON a.id = at.artist_id
+ WHERE at.track_id = t.id
+ ) AS artists
+
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+JOIN releases r ON t.release_id = r.id
+
+WHERE l.user_id = $2::int
+ AND (l.listened_at, l.track_id) > ($3::timestamptz, $4::int)
+ORDER BY l.listened_at, l.track_id
+LIMIT $1
+`
+
+type GetListensExportPageParams struct {
+ Limit int32
+ UserID int32
+ ListenedAt time.Time
+ TrackID int32
+}
+
+type GetListensExportPageRow struct {
+ ListenedAt time.Time
+ UserID int32
+ Client *string
+ TrackID int32
+ TrackMbid *uuid.UUID
+ TrackDuration int32
+ TrackAliases []byte
+ ReleaseID int32
+ ReleaseMbid *uuid.UUID
+ ReleaseImage *uuid.UUID
+ ReleaseImageSource pgtype.Text
+ VariousArtists bool
+ ReleaseAliases []byte
+ Artists []byte
+}
+
+func (q *Queries) GetListensExportPage(ctx context.Context, arg GetListensExportPageParams) ([]GetListensExportPageRow, error) {
+ rows, err := q.db.Query(ctx, getListensExportPage,
+ arg.Limit,
+ arg.UserID,
+ arg.ListenedAt,
+ arg.TrackID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetListensExportPageRow
+ for rows.Next() {
+ var i GetListensExportPageRow
+ if err := rows.Scan(
+ &i.ListenedAt,
+ &i.UserID,
+ &i.Client,
+ &i.TrackID,
+ &i.TrackMbid,
+ &i.TrackDuration,
+ &i.TrackAliases,
+ &i.ReleaseID,
+ &i.ReleaseMbid,
+ &i.ReleaseImage,
+ &i.ReleaseImageSource,
+ &i.VariousArtists,
+ &i.ReleaseAliases,
+ &i.Artists,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const insertListen = `-- name: InsertListen :exec
INSERT INTO listens (track_id, listened_at, user_id, client)
VALUES ($1, $2, $3, $4)
@@ -495,36 +695,29 @@ func (q *Queries) InsertListen(ctx context.Context, arg InsertListenParams) erro
}
const listenActivity = `-- name: ListenActivity :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT bucket_start, listen_count FROM bucketed_listens
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens
+WHERE listened_at >= $2
+AND listened_at < $3
+GROUP BY day
+ORDER BY day
`
type ListenActivityParams struct {
- Column1 time.Time
- Column2 time.Time
- Column3 pgtype.Interval
+ Column1 string
+ ListenedAt time.Time
+ ListenedAt_2 time.Time
}
type ListenActivityRow struct {
- BucketStart time.Time
+ Day pgtype.Date
ListenCount int64
}
func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams) ([]ListenActivityRow, error) {
- rows, err := q.db.Query(ctx, listenActivity, arg.Column1, arg.Column2, arg.Column3)
+ rows, err := q.db.Query(ctx, listenActivity, arg.Column1, arg.ListenedAt, arg.ListenedAt_2)
if err != nil {
return nil, err
}
@@ -532,7 +725,7 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams)
var items []ListenActivityRow
for rows.Next() {
var i ListenActivityRow
- if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
+ if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
return nil, err
}
items = append(items, i)
@@ -544,46 +737,36 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams)
}
const listenActivityForArtist = `-- name: ListenActivityForArtist :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-filtered_listens AS (
- SELECT l.track_id, l.listened_at, l.client, l.user_id
- FROM listens l
- JOIN artist_tracks t ON l.track_id = t.track_id
- WHERE t.artist_id = $4
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN filtered_listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT bucket_start, listen_count FROM bucketed_listens
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+JOIN artist_tracks at ON t.id = at.track_id
+WHERE l.listened_at >= $2
+AND l.listened_at < $3
+AND at.artist_id = $4
+GROUP BY day
+ORDER BY day
`
type ListenActivityForArtistParams struct {
- Column1 time.Time
- Column2 time.Time
- Column3 pgtype.Interval
- ArtistID int32
+ Column1 string
+ ListenedAt time.Time
+ ListenedAt_2 time.Time
+ ArtistID int32
}
type ListenActivityForArtistRow struct {
- BucketStart time.Time
+ Day pgtype.Date
ListenCount int64
}
func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivityForArtistParams) ([]ListenActivityForArtistRow, error) {
rows, err := q.db.Query(ctx, listenActivityForArtist,
arg.Column1,
- arg.Column2,
- arg.Column3,
+ arg.ListenedAt,
+ arg.ListenedAt_2,
arg.ArtistID,
)
if err != nil {
@@ -593,7 +776,7 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit
var items []ListenActivityForArtistRow
for rows.Next() {
var i ListenActivityForArtistRow
- if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
+ if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
return nil, err
}
items = append(items, i)
@@ -605,46 +788,35 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit
}
const listenActivityForRelease = `-- name: ListenActivityForRelease :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-filtered_listens AS (
- SELECT l.track_id, l.listened_at, l.client, l.user_id
- FROM listens l
- JOIN tracks t ON l.track_id = t.id
- WHERE t.release_id = $4
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN filtered_listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT bucket_start, listen_count FROM bucketed_listens
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE l.listened_at >= $2
+AND l.listened_at < $3
+AND t.release_id = $4
+GROUP BY day
+ORDER BY day
`
type ListenActivityForReleaseParams struct {
- Column1 time.Time
- Column2 time.Time
- Column3 pgtype.Interval
- ReleaseID int32
+ Column1 string
+ ListenedAt time.Time
+ ListenedAt_2 time.Time
+ ReleaseID int32
}
type ListenActivityForReleaseRow struct {
- BucketStart time.Time
+ Day pgtype.Date
ListenCount int64
}
func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivityForReleaseParams) ([]ListenActivityForReleaseRow, error) {
rows, err := q.db.Query(ctx, listenActivityForRelease,
arg.Column1,
- arg.Column2,
- arg.Column3,
+ arg.ListenedAt,
+ arg.ListenedAt_2,
arg.ReleaseID,
)
if err != nil {
@@ -654,7 +826,7 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi
var items []ListenActivityForReleaseRow
for rows.Next() {
var i ListenActivityForReleaseRow
- if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
+ if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
return nil, err
}
items = append(items, i)
@@ -666,46 +838,35 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi
}
const listenActivityForTrack = `-- name: ListenActivityForTrack :many
-WITH buckets AS (
- SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
-),
-filtered_listens AS (
- SELECT l.track_id, l.listened_at, l.client, l.user_id
- FROM listens l
- JOIN tracks t ON l.track_id = t.id
- WHERE t.id = $4
-),
-bucketed_listens AS (
- SELECT
- b.bucket_start,
- COUNT(l.listened_at) AS listen_count
- FROM buckets b
- LEFT JOIN filtered_listens l
- ON l.listened_at >= b.bucket_start
- AND l.listened_at < b.bucket_start + $3::interval
- GROUP BY b.bucket_start
- ORDER BY b.bucket_start
-)
-SELECT bucket_start, listen_count FROM bucketed_listens
+SELECT
+ (listened_at AT TIME ZONE $1::text)::date as day,
+ COUNT(*) AS listen_count
+FROM listens l
+JOIN tracks t ON l.track_id = t.id
+WHERE l.listened_at >= $2
+AND l.listened_at < $3
+AND t.id = $4
+GROUP BY day
+ORDER BY day
`
type ListenActivityForTrackParams struct {
- Column1 time.Time
- Column2 time.Time
- Column3 pgtype.Interval
- ID int32
+ Column1 string
+ ListenedAt time.Time
+ ListenedAt_2 time.Time
+ ID int32
}
type ListenActivityForTrackRow struct {
- BucketStart time.Time
+ Day pgtype.Date
ListenCount int64
}
func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivityForTrackParams) ([]ListenActivityForTrackRow, error) {
rows, err := q.db.Query(ctx, listenActivityForTrack,
arg.Column1,
- arg.Column2,
- arg.Column3,
+ arg.ListenedAt,
+ arg.ListenedAt_2,
arg.ID,
)
if err != nil {
@@ -715,7 +876,7 @@ func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivity
var items []ListenActivityForTrackRow
for rows.Next() {
var i ListenActivityForTrackRow
- if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
+ if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
return nil, err
}
items = append(items, i)
diff --git a/internal/repository/models.go b/internal/repository/models.go
index d1dc41f..ac318e4 100644
--- a/internal/repository/models.go
+++ b/internal/repository/models.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
package repository
@@ -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 06a936e..f62e086 100644
--- a/internal/repository/release.sql.go
+++ b/internal/repository/release.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: release.sql
package repository
@@ -14,24 +14,48 @@ import (
)
const associateArtistToRelease = `-- name: AssociateArtistToRelease :exec
-INSERT INTO artist_releases (artist_id, release_id)
-VALUES ($1, $2)
+INSERT INTO artist_releases (artist_id, release_id, is_primary)
+VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`
type AssociateArtistToReleaseParams struct {
ArtistID int32
ReleaseID int32
+ IsPrimary bool
}
func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArtistToReleaseParams) error {
- _, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID)
+ _, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID, arg.IsPrimary)
return err
}
+const countNewReleases = `-- name: CountNewReleases :one
+SELECT COUNT(*) AS total_count
+FROM (
+ SELECT t.release_id
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ GROUP BY t.release_id
+ HAVING MIN(l.listened_at) BETWEEN $1 AND $2
+) first_appearances
+`
+
+type CountNewReleasesParams struct {
+ ListenedAt time.Time
+ ListenedAt_2 time.Time
+}
+
+func (q *Queries) CountNewReleases(ctx context.Context, arg CountNewReleasesParams) (int64, error) {
+ row := q.db.QueryRow(ctx, countNewReleases, arg.ListenedAt, arg.ListenedAt_2)
+ var total_count int64
+ err := row.Scan(&total_count)
+ return total_count, err
+}
+
const countReleasesFromArtist = `-- name: CountReleasesFromArtist :one
SELECT COUNT(*)
-FROM releases r
+FROM releases r
JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $1
`
@@ -85,13 +109,26 @@ func (q *Queries) DeleteReleasesFromArtist(ctx context.Context, artistID int32)
}
const getRelease = `-- name: GetRelease :one
-SELECT id, musicbrainz_id, image, various_artists, image_source, title FROM releases_with_title
+SELECT
+ id, musicbrainz_id, image, various_artists, image_source, title,
+ get_artists_for_release(id) AS artists
+FROM releases_with_title
WHERE id = $1 LIMIT 1
`
-func (q *Queries) GetRelease(ctx context.Context, id int32) (ReleasesWithTitle, error) {
+type GetReleaseRow struct {
+ ID int32
+ MusicBrainzID *uuid.UUID
+ Image *uuid.UUID
+ VariousArtists bool
+ ImageSource pgtype.Text
+ Title string
+ Artists []byte
+}
+
+func (q *Queries) GetRelease(ctx context.Context, id int32) (GetReleaseRow, error) {
row := q.db.QueryRow(ctx, getRelease, id)
- var i ReleasesWithTitle
+ var i GetReleaseRow
err := row.Scan(
&i.ID,
&i.MusicBrainzID,
@@ -99,10 +136,43 @@ func (q *Queries) GetRelease(ctx context.Context, id int32) (ReleasesWithTitle,
&i.VariousArtists,
&i.ImageSource,
&i.Title,
+ &i.Artists,
)
return i, err
}
+const getReleaseAllTimeRank = `-- name: GetReleaseAllTimeRank :one
+SELECT
+ release_id,
+ rank
+FROM (
+ SELECT
+ x.release_id,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+ FROM (
+ SELECT
+ t.release_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ GROUP BY t.release_id
+ ) x
+ )
+WHERE release_id = $1
+`
+
+type GetReleaseAllTimeRankRow struct {
+ ReleaseID int32
+ Rank int64
+}
+
+func (q *Queries) GetReleaseAllTimeRank(ctx context.Context, releaseID int32) (GetReleaseAllTimeRankRow, error) {
+ row := q.db.QueryRow(ctx, getReleaseAllTimeRank, releaseID)
+ var i GetReleaseAllTimeRankRow
+ err := row.Scan(&i.ReleaseID, &i.Rank)
+ return i, err
+}
+
const getReleaseByArtistAndTitle = `-- name: GetReleaseByArtistAndTitle :one
SELECT r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title
FROM releases_with_title r
@@ -157,6 +227,39 @@ func (q *Queries) GetReleaseByArtistAndTitles(ctx context.Context, arg GetReleas
return i, err
}
+const getReleaseByArtistAndTitlesNoMbzID = `-- name: GetReleaseByArtistAndTitlesNoMbzID :one
+SELECT r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title
+FROM releases_with_title r
+JOIN artist_releases ar ON r.id = ar.release_id
+WHERE r.title = ANY ($1::TEXT[])
+ AND ar.artist_id = $2
+ AND EXISTS (
+ SELECT 1
+ FROM releases r2
+ WHERE r2.id = r.id
+ AND r2.musicbrainz_id IS NULL
+ )
+`
+
+type GetReleaseByArtistAndTitlesNoMbzIDParams struct {
+ Column1 []string
+ ArtistID int32
+}
+
+func (q *Queries) GetReleaseByArtistAndTitlesNoMbzID(ctx context.Context, arg GetReleaseByArtistAndTitlesNoMbzIDParams) (ReleasesWithTitle, error) {
+ row := q.db.QueryRow(ctx, getReleaseByArtistAndTitlesNoMbzID, arg.Column1, arg.ArtistID)
+ var i ReleasesWithTitle
+ err := row.Scan(
+ &i.ID,
+ &i.MusicBrainzID,
+ &i.Image,
+ &i.VariousArtists,
+ &i.ImageSource,
+ &i.Title,
+ )
+ return i, err
+}
+
const getReleaseByImageID = `-- name: GetReleaseByImageID :one
SELECT id, musicbrainz_id, image, various_artists, image_source FROM releases
WHERE image = $1 LIMIT 1
@@ -197,14 +300,9 @@ 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
-FROM releases_with_title r
-WHERE r.image IS NULL
+ get_artists_for_release(r.id) AS artists
+FROM releases_with_title r
+WHERE r.image IS NULL
AND r.id > $2
ORDER BY r.id ASC
LIMIT $1
@@ -255,22 +353,22 @@ func (q *Queries) GetReleasesWithoutImages(ctx context.Context, arg GetReleasesW
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
-FROM listens l
-JOIN tracks t ON l.track_id = t.id
-JOIN releases_with_title r ON t.release_id = r.id
-JOIN artist_releases ar ON r.id = ar.release_id
-WHERE ar.artist_id = $5
- AND l.listened_at BETWEEN $1 AND $2
-GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
-ORDER BY listen_count DESC
+ x.id, x.musicbrainz_id, x.image, x.various_artists, x.image_source, x.title, x.listen_count,
+ get_artists_for_release(x.id) AS artists,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+FROM (
+ SELECT
+ r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN releases_with_title r ON t.release_id = r.id
+ JOIN artist_releases ar ON r.id = ar.release_id
+ WHERE ar.artist_id = $5
+ AND l.listened_at BETWEEN $1 AND $2
+ GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
+) x
+ORDER BY listen_count DESC, x.id
LIMIT $3 OFFSET $4
`
@@ -291,6 +389,7 @@ type GetTopReleasesFromArtistRow struct {
Title string
ListenCount int64
Artists []byte
+ Rank int64
}
func (q *Queries) GetTopReleasesFromArtist(ctx context.Context, arg GetTopReleasesFromArtistParams) ([]GetTopReleasesFromArtistRow, error) {
@@ -317,6 +416,7 @@ func (q *Queries) GetTopReleasesFromArtist(ctx context.Context, arg GetTopReleas
&i.Title,
&i.ListenCount,
&i.Artists,
+ &i.Rank,
); err != nil {
return nil, err
}
@@ -330,20 +430,20 @@ func (q *Queries) GetTopReleasesFromArtist(ctx context.Context, arg GetTopReleas
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
-FROM listens l
-JOIN tracks t ON l.track_id = t.id
-JOIN releases_with_title r ON t.release_id = r.id
-WHERE l.listened_at BETWEEN $1 AND $2
-GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
-ORDER BY listen_count DESC
+ x.id, x.musicbrainz_id, x.image, x.various_artists, x.image_source, x.title, x.listen_count,
+ get_artists_for_release(x.id) AS artists,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+FROM (
+ SELECT
+ r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ JOIN releases_with_title r ON t.release_id = r.id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
+) x
+ORDER BY listen_count DESC, x.id
LIMIT $3 OFFSET $4
`
@@ -363,6 +463,7 @@ type GetTopReleasesPaginatedRow struct {
Title string
ListenCount int64
Artists []byte
+ Rank int64
}
func (q *Queries) GetTopReleasesPaginated(ctx context.Context, arg GetTopReleasesPaginatedParams) ([]GetTopReleasesPaginatedRow, error) {
@@ -388,6 +489,7 @@ func (q *Queries) GetTopReleasesPaginated(ctx context.Context, arg GetTopRelease
&i.Title,
&i.ListenCount,
&i.Artists,
+ &i.Rank,
); err != nil {
return nil, err
}
@@ -460,3 +562,34 @@ func (q *Queries) UpdateReleaseMbzID(ctx context.Context, arg UpdateReleaseMbzID
_, err := q.db.Exec(ctx, updateReleaseMbzID, arg.ID, arg.MusicBrainzID)
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
+`
+
+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
+}
diff --git a/internal/repository/search.sql.go b/internal/repository/search.sql.go
index 5e4e038..e223c5b 100644
--- a/internal/repository/search.sql.go
+++ b/internal/repository/search.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: search.sql
package repository
@@ -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/sessions.sql.go b/internal/repository/sessions.sql.go
index 2985620..e60285b 100644
--- a/internal/repository/sessions.sql.go
+++ b/internal/repository/sessions.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: sessions.sql
package repository
diff --git a/internal/repository/track.sql.go b/internal/repository/track.sql.go
index a31316b..b376198 100644
--- a/internal/repository/track.sql.go
+++ b/internal/repository/track.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: track.sql
package repository
@@ -13,21 +13,44 @@ import (
)
const associateArtistToTrack = `-- name: AssociateArtistToTrack :exec
-INSERT INTO artist_tracks (artist_id, track_id)
-VALUES ($1, $2)
+INSERT INTO artist_tracks (artist_id, track_id, is_primary)
+VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`
type AssociateArtistToTrackParams struct {
- ArtistID int32
- TrackID int32
+ ArtistID int32
+ TrackID int32
+ IsPrimary bool
}
func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtistToTrackParams) error {
- _, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID)
+ _, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID, arg.IsPrimary)
return err
}
+const countNewTracks = `-- name: CountNewTracks :one
+SELECT COUNT(*) AS total_count
+FROM (
+ SELECT track_id
+ FROM listens
+ GROUP BY track_id
+ HAVING MIN(listened_at) BETWEEN $1 AND $2
+) first_appearances
+`
+
+type CountNewTracksParams struct {
+ ListenedAt time.Time
+ ListenedAt_2 time.Time
+}
+
+func (q *Queries) CountNewTracks(ctx context.Context, arg CountNewTracksParams) (int64, error) {
+ row := q.db.QueryRow(ctx, countNewTracks, arg.ListenedAt, arg.ListenedAt_2)
+ var total_count int64
+ err := row.Scan(&total_count)
+ return total_count, err
+}
+
const countTopTracks = `-- name: CountTopTracks :one
SELECT COUNT(DISTINCT l.track_id) AS total_count
FROM listens l
@@ -132,27 +155,30 @@ func (q *Queries) GetAllTracksFromArtist(ctx context.Context, artistID int32) ([
const getTopTracksByArtistPaginated = `-- name: GetTopTracksByArtistPaginated :many
SELECT
- t.id,
+ x.track_id AS id,
t.title,
t.musicbrainz_id,
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
-FROM listens l
-JOIN tracks_with_title t ON l.track_id = t.id
+ x.listen_count,
+ get_artists_for_track(x.track_id) AS artists,
+ x.rank
+FROM (
+ SELECT
+ l.track_id,
+ COUNT(*) AS listen_count,
+ RANK() OVER (ORDER BY COUNT(*) DESC) as rank
+ FROM listens l
+ JOIN artist_tracks at ON l.track_id = at.track_id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ AND at.artist_id = $5
+ GROUP BY l.track_id
+ ORDER BY listen_count DESC
+ LIMIT $3 OFFSET $4
+) x
+JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id
-JOIN artist_tracks at ON at.track_id = t.id
-WHERE l.listened_at BETWEEN $1 AND $2
- AND at.artist_id = $5
-GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
-ORDER BY listen_count DESC
-LIMIT $3 OFFSET $4
+ORDER BY x.listen_count DESC, x.track_id
`
type GetTopTracksByArtistPaginatedParams struct {
@@ -171,6 +197,7 @@ type GetTopTracksByArtistPaginatedRow struct {
Image *uuid.UUID
ListenCount int64
Artists []byte
+ Rank int64
}
func (q *Queries) GetTopTracksByArtistPaginated(ctx context.Context, arg GetTopTracksByArtistPaginatedParams) ([]GetTopTracksByArtistPaginatedRow, error) {
@@ -196,6 +223,7 @@ func (q *Queries) GetTopTracksByArtistPaginated(ctx context.Context, arg GetTopT
&i.Image,
&i.ListenCount,
&i.Artists,
+ &i.Rank,
); err != nil {
return nil, err
}
@@ -209,26 +237,30 @@ func (q *Queries) GetTopTracksByArtistPaginated(ctx context.Context, arg GetTopT
const getTopTracksInReleasePaginated = `-- name: GetTopTracksInReleasePaginated :many
SELECT
- t.id,
+ x.track_id AS id,
t.title,
t.musicbrainz_id,
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
-FROM listens l
-JOIN tracks_with_title t ON l.track_id = t.id
+ x.listen_count,
+ get_artists_for_track(x.track_id) AS artists,
+ x.rank
+FROM (
+ SELECT
+ l.track_id,
+ COUNT(*) AS listen_count,
+ RANK() OVER (ORDER BY COUNT(*) DESC) as rank
+ FROM listens l
+ JOIN tracks t ON l.track_id = t.id
+ WHERE l.listened_at BETWEEN $1 AND $2
+ AND t.release_id = $5
+ GROUP BY l.track_id
+ ORDER BY listen_count DESC
+ LIMIT $3 OFFSET $4
+) x
+JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id
-WHERE l.listened_at BETWEEN $1 AND $2
- AND t.release_id = $5
-GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
-ORDER BY listen_count DESC
-LIMIT $3 OFFSET $4
+ORDER BY x.listen_count DESC, x.track_id
`
type GetTopTracksInReleasePaginatedParams struct {
@@ -247,6 +279,7 @@ type GetTopTracksInReleasePaginatedRow struct {
Image *uuid.UUID
ListenCount int64
Artists []byte
+ Rank int64
}
func (q *Queries) GetTopTracksInReleasePaginated(ctx context.Context, arg GetTopTracksInReleasePaginatedParams) ([]GetTopTracksInReleasePaginatedRow, error) {
@@ -272,6 +305,7 @@ func (q *Queries) GetTopTracksInReleasePaginated(ctx context.Context, arg GetTop
&i.Image,
&i.ListenCount,
&i.Artists,
+ &i.Rank,
); err != nil {
return nil, err
}
@@ -285,25 +319,28 @@ func (q *Queries) GetTopTracksInReleasePaginated(ctx context.Context, arg GetTop
const getTopTracksPaginated = `-- name: GetTopTracksPaginated :many
SELECT
- t.id,
+ x.track_id AS id,
t.title,
t.musicbrainz_id,
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
-FROM listens l
-JOIN tracks_with_title t ON l.track_id = t.id
+ x.listen_count,
+ get_artists_for_track(x.track_id) AS artists,
+ x.rank
+FROM (
+ SELECT
+ track_id,
+ COUNT(*) AS listen_count,
+ RANK() OVER (ORDER BY COUNT(*) DESC) as rank
+ FROM listens
+ WHERE listened_at BETWEEN $1 AND $2
+ GROUP BY track_id
+ ORDER BY listen_count DESC
+ LIMIT $3 OFFSET $4
+) x
+JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id
-WHERE l.listened_at BETWEEN $1 AND $2
-GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
-ORDER BY listen_count DESC
-LIMIT $3 OFFSET $4
+ORDER BY x.listen_count DESC, x.track_id
`
type GetTopTracksPaginatedParams struct {
@@ -321,6 +358,7 @@ type GetTopTracksPaginatedRow struct {
Image *uuid.UUID
ListenCount int64
Artists []byte
+ Rank int64
}
func (q *Queries) GetTopTracksPaginated(ctx context.Context, arg GetTopTracksPaginatedParams) ([]GetTopTracksPaginatedRow, error) {
@@ -345,6 +383,7 @@ func (q *Queries) GetTopTracksPaginated(ctx context.Context, arg GetTopTracksPag
&i.Image,
&i.ListenCount,
&i.Artists,
+ &i.Rank,
); err != nil {
return nil, err
}
@@ -357,8 +396,9 @@ func (q *Queries) GetTopTracksPaginated(ctx context.Context, arg GetTopTracksPag
}
const getTrack = `-- name: GetTrack :one
-SELECT
+SELECT
t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
+ get_artists_for_track(t.id) AS artists,
r.image
FROM tracks_with_title t
JOIN releases r ON t.release_id = r.id
@@ -371,6 +411,7 @@ type GetTrackRow struct {
Duration int32
ReleaseID int32
Title string
+ Artists []byte
Image *uuid.UUID
}
@@ -383,11 +424,43 @@ func (q *Queries) GetTrack(ctx context.Context, id int32) (GetTrackRow, error) {
&i.Duration,
&i.ReleaseID,
&i.Title,
+ &i.Artists,
&i.Image,
)
return i, err
}
+const getTrackAllTimeRank = `-- name: GetTrackAllTimeRank :one
+SELECT
+ id,
+ rank
+FROM (
+ SELECT
+ x.id,
+ RANK() OVER (ORDER BY x.listen_count DESC) AS rank
+ FROM (
+ SELECT
+ t.id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN tracks_with_title t ON l.track_id = t.id
+ GROUP BY t.id) x
+ ) y
+WHERE id = $1
+`
+
+type GetTrackAllTimeRankRow struct {
+ ID int32
+ Rank int64
+}
+
+func (q *Queries) GetTrackAllTimeRank(ctx context.Context, id int32) (GetTrackAllTimeRankRow, error) {
+ row := q.db.QueryRow(ctx, getTrackAllTimeRank, id)
+ var i GetTrackAllTimeRankRow
+ err := row.Scan(&i.ID, &i.Rank)
+ return i, err
+}
+
const getTrackByMbzID = `-- name: GetTrackByMbzID :one
SELECT id, musicbrainz_id, duration, release_id, title FROM tracks_with_title
WHERE musicbrainz_id = $1 LIMIT 1
@@ -406,23 +479,25 @@ func (q *Queries) GetTrackByMbzID(ctx context.Context, musicbrainzID *uuid.UUID)
return i, err
}
-const getTrackByTitleAndArtists = `-- name: GetTrackByTitleAndArtists :one
+const getTrackByTrackInfo = `-- name: GetTrackByTrackInfo :one
SELECT t.id, t.musicbrainz_id, t.duration, t.release_id, t.title
FROM tracks_with_title t
JOIN artist_tracks at ON at.track_id = t.id
WHERE t.title = $1
- AND at.artist_id = ANY($2::int[])
+ AND at.artist_id = ANY($3::int[])
+ AND t.release_id = $2
GROUP BY t.id, t.title, t.musicbrainz_id, t.duration, t.release_id
-HAVING COUNT(DISTINCT at.artist_id) = cardinality($2::int[])
+HAVING COUNT(DISTINCT at.artist_id) = cardinality($3::int[])
`
-type GetTrackByTitleAndArtistsParams struct {
- Title string
- Column2 []int32
+type GetTrackByTrackInfoParams struct {
+ Title string
+ ReleaseID int32
+ Column3 []int32
}
-func (q *Queries) GetTrackByTitleAndArtists(ctx context.Context, arg GetTrackByTitleAndArtistsParams) (TracksWithTitle, error) {
- row := q.db.QueryRow(ctx, getTrackByTitleAndArtists, arg.Title, arg.Column2)
+func (q *Queries) GetTrackByTrackInfo(ctx context.Context, arg GetTrackByTrackInfoParams) (TracksWithTitle, error) {
+ row := q.db.QueryRow(ctx, getTrackByTrackInfo, arg.Title, arg.ReleaseID, arg.Column3)
var i TracksWithTitle
err := row.Scan(
&i.ID,
@@ -434,6 +509,48 @@ func (q *Queries) GetTrackByTitleAndArtists(ctx context.Context, arg GetTrackByT
return i, err
}
+const getTracksWithNoDurationButHaveMbzID = `-- name: GetTracksWithNoDurationButHaveMbzID :many
+SELECT
+ id, musicbrainz_id, duration, release_id, title
+FROM tracks_with_title
+WHERE duration = 0
+ AND musicbrainz_id IS NOT NULL
+ AND id > $2
+ORDER BY id ASC
+LIMIT $1
+`
+
+type GetTracksWithNoDurationButHaveMbzIDParams struct {
+ Limit int32
+ ID int32
+}
+
+func (q *Queries) GetTracksWithNoDurationButHaveMbzID(ctx context.Context, arg GetTracksWithNoDurationButHaveMbzIDParams) ([]TracksWithTitle, error) {
+ rows, err := q.db.Query(ctx, getTracksWithNoDurationButHaveMbzID, arg.Limit, arg.ID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []TracksWithTitle
+ for rows.Next() {
+ var i TracksWithTitle
+ if err := rows.Scan(
+ &i.ID,
+ &i.MusicBrainzID,
+ &i.Duration,
+ &i.ReleaseID,
+ &i.Title,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const insertTrack = `-- name: InsertTrack :one
INSERT INTO tracks (musicbrainz_id, release_id, duration)
VALUES ($1, $2, $3)
@@ -502,3 +619,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/repository/users.sql.go b/internal/repository/users.sql.go
index 8278f41..1d7f0a5 100644
--- a/internal/repository/users.sql.go
+++ b/internal/repository/users.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: users.sql
package repository
diff --git a/internal/repository/year.sql.go b/internal/repository/year.sql.go
new file mode 100644
index 0000000..381e807
--- /dev/null
+++ b/internal/repository/year.sql.go
@@ -0,0 +1,616 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: year.sql
+
+package repository
+
+import (
+ "context"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const artistsOnlyPlayedOnceInYear = `-- name: ArtistsOnlyPlayedOnceInYear :many
+SELECT
+ a.id AS artist_id,
+ a.name,
+ COUNT(l.*) AS listen_count
+FROM listens l
+JOIN artist_tracks at ON at.track_id = l.track_id
+JOIN artists_with_name a ON a.id = at.artist_id
+WHERE EXTRACT(YEAR FROM l.listened_at) = $1::int AND l.user_id = $2::int
+GROUP BY a.id, a.name
+HAVING COUNT(*) = 1
+`
+
+type ArtistsOnlyPlayedOnceInYearParams struct {
+ Year int32
+ UserID int32
+}
+
+type ArtistsOnlyPlayedOnceInYearRow struct {
+ ArtistID int32
+ Name string
+ ListenCount int64
+}
+
+func (q *Queries) ArtistsOnlyPlayedOnceInYear(ctx context.Context, arg ArtistsOnlyPlayedOnceInYearParams) ([]ArtistsOnlyPlayedOnceInYearRow, error) {
+ rows, err := q.db.Query(ctx, artistsOnlyPlayedOnceInYear, arg.Year, arg.UserID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ArtistsOnlyPlayedOnceInYearRow
+ for rows.Next() {
+ var i ArtistsOnlyPlayedOnceInYearRow
+ if err := rows.Scan(&i.ArtistID, &i.Name, &i.ListenCount); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getArtistCountInYear = `-- name: GetArtistCountInYear :one
+SELECT
+ COUNT(DISTINCT at.artist_id) AS artist_count
+FROM listens l
+JOIN artist_tracks at ON at.track_id = l.track_id
+WHERE l.user_id = $1::int
+ AND EXTRACT(YEAR FROM l.listened_at) = $2::int
+`
+
+type GetArtistCountInYearParams struct {
+ UserID int32
+ Year int32
+}
+
+func (q *Queries) GetArtistCountInYear(ctx context.Context, arg GetArtistCountInYearParams) (int64, error) {
+ row := q.db.QueryRow(ctx, getArtistCountInYear, arg.UserID, arg.Year)
+ var artist_count int64
+ err := row.Scan(&artist_count)
+ return artist_count, err
+}
+
+const getArtistWithLongestGapInYear = `-- name: GetArtistWithLongestGapInYear :one
+WITH first_listens AS (
+ SELECT
+ l.user_id,
+ at.artist_id,
+ MIN(l.listened_at::date) AS first_listen_of_year
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE EXTRACT(YEAR FROM l.listened_at) = $1::int
+ GROUP BY l.user_id, at.artist_id
+),
+last_listens AS (
+ SELECT
+ l.user_id,
+ at.artist_id,
+ MAX(l.listened_at::date) AS last_listen
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE l.listened_at < $2::date
+ GROUP BY l.user_id, at.artist_id
+),
+comebacks AS (
+ SELECT
+ f.user_id,
+ f.artist_id,
+ f.first_listen_of_year,
+ p.last_listen,
+ (f.first_listen_of_year - p.last_listen) AS gap_days
+ FROM first_listens f
+ JOIN last_listens p
+ ON f.user_id = p.user_id AND f.artist_id = p.artist_id
+),
+ranked AS (
+ SELECT user_id, artist_id, first_listen_of_year, last_listen, gap_days,
+ RANK() OVER (PARTITION BY user_id ORDER BY gap_days DESC) AS r
+ FROM comebacks
+)
+SELECT
+ c.user_id,
+ c.artist_id,
+ awn.name AS artist_name,
+ c.last_listen,
+ c.first_listen_of_year,
+ c.gap_days
+FROM ranked c
+JOIN artists_with_name awn ON awn.id = c.artist_id
+WHERE r = 1
+`
+
+type GetArtistWithLongestGapInYearParams struct {
+ Year int32
+ FirstDayOfYear pgtype.Date
+}
+
+type GetArtistWithLongestGapInYearRow struct {
+ UserID int32
+ ArtistID int32
+ ArtistName string
+ LastListen interface{}
+ FirstListenOfYear interface{}
+ GapDays int32
+}
+
+func (q *Queries) GetArtistWithLongestGapInYear(ctx context.Context, arg GetArtistWithLongestGapInYearParams) (GetArtistWithLongestGapInYearRow, error) {
+ row := q.db.QueryRow(ctx, getArtistWithLongestGapInYear, arg.Year, arg.FirstDayOfYear)
+ var i GetArtistWithLongestGapInYearRow
+ err := row.Scan(
+ &i.UserID,
+ &i.ArtistID,
+ &i.ArtistName,
+ &i.LastListen,
+ &i.FirstListenOfYear,
+ &i.GapDays,
+ )
+ return i, err
+}
+
+const getArtistsWithOnlyOnePlayInYear = `-- name: GetArtistsWithOnlyOnePlayInYear :many
+WITH first_artist_plays_in_year AS (
+ SELECT
+ l.user_id,
+ at.artist_id,
+ MIN(l.listened_at) AS first_listen
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE EXTRACT(YEAR FROM l.listened_at) = 2024
+ AND NOT EXISTS (
+ SELECT 1
+ FROM listens l2
+ JOIN artist_tracks at2 ON at2.track_id = l2.track_id
+ WHERE l2.user_id = l.user_id
+ AND at2.artist_id = at.artist_id
+ AND l2.listened_at < DATE '2024-01-01'
+ )
+ GROUP BY l.user_id, at.artist_id
+)
+SELECT
+ f.user_id,
+ f.artist_id,
+ f.first_listen, a.name,
+ COUNT(l.*) AS total_plays_in_year
+FROM first_artist_plays_in_year f
+JOIN listens l ON l.user_id = f.user_id
+JOIN artist_tracks at ON at.track_id = l.track_id JOIN artists_with_name a ON at.artist_id = a.id
+WHERE at.artist_id = f.artist_id
+ AND EXTRACT(YEAR FROM l.listened_at) = 2024
+GROUP BY f.user_id, f.artist_id, f.first_listen, a.name HAVING COUNT(*) = 1
+`
+
+type GetArtistsWithOnlyOnePlayInYearRow struct {
+ UserID int32
+ ArtistID int32
+ FirstListen interface{}
+ Name string
+ TotalPlaysInYear int64
+}
+
+func (q *Queries) GetArtistsWithOnlyOnePlayInYear(ctx context.Context) ([]GetArtistsWithOnlyOnePlayInYearRow, error) {
+ rows, err := q.db.Query(ctx, getArtistsWithOnlyOnePlayInYear)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetArtistsWithOnlyOnePlayInYearRow
+ for rows.Next() {
+ var i GetArtistsWithOnlyOnePlayInYearRow
+ if err := rows.Scan(
+ &i.UserID,
+ &i.ArtistID,
+ &i.FirstListen,
+ &i.Name,
+ &i.TotalPlaysInYear,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getFirstListenInYear = `-- name: GetFirstListenInYear :one
+SELECT
+ l.track_id, l.listened_at, l.client, l.user_id,
+ t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
+ get_artists_for_track(t.id) as artists
+FROM listens l
+LEFT JOIN tracks_with_title t ON l.track_id = t.id
+WHERE EXTRACT(YEAR FROM l.listened_at) = 2025
+ORDER BY l.listened_at ASC
+LIMIT 1
+`
+
+type GetFirstListenInYearRow struct {
+ TrackID int32
+ ListenedAt time.Time
+ Client *string
+ UserID int32
+ ID pgtype.Int4
+ MusicBrainzID *uuid.UUID
+ Duration pgtype.Int4
+ ReleaseID pgtype.Int4
+ Title pgtype.Text
+ Artists []byte
+}
+
+func (q *Queries) GetFirstListenInYear(ctx context.Context) (GetFirstListenInYearRow, error) {
+ row := q.db.QueryRow(ctx, getFirstListenInYear)
+ var i GetFirstListenInYearRow
+ err := row.Scan(
+ &i.TrackID,
+ &i.ListenedAt,
+ &i.Client,
+ &i.UserID,
+ &i.ID,
+ &i.MusicBrainzID,
+ &i.Duration,
+ &i.ReleaseID,
+ &i.Title,
+ &i.Artists,
+ )
+ return i, err
+}
+
+const getListenPercentageInTimeWindowInYear = `-- name: GetListenPercentageInTimeWindowInYear :one
+WITH user_listens_in_year AS (
+ SELECT
+ listened_at
+ FROM listens
+ WHERE user_id = $1::int
+ AND EXTRACT(YEAR FROM listened_at) = $2::int
+),
+windowed AS (
+ SELECT
+ COUNT(*) AS in_window
+ FROM user_listens_in_year
+ WHERE EXTRACT(HOUR FROM listened_at) >= $3::int
+ AND EXTRACT(HOUR FROM listened_at) < $4::int
+),
+total AS (
+ SELECT COUNT(*) AS total_listens
+ FROM user_listens_in_year
+)
+SELECT
+ w.in_window,
+ t.total_listens,
+ ROUND((w.in_window::decimal / t.total_listens) * 100, 2) AS percent_of_total
+FROM windowed w, total t
+`
+
+type GetListenPercentageInTimeWindowInYearParams struct {
+ UserID int32
+ Year int32
+ HourWindowStart int32
+ HourWindowEnd int32
+}
+
+type GetListenPercentageInTimeWindowInYearRow struct {
+ InWindow int64
+ TotalListens int64
+ PercentOfTotal pgtype.Numeric
+}
+
+func (q *Queries) GetListenPercentageInTimeWindowInYear(ctx context.Context, arg GetListenPercentageInTimeWindowInYearParams) (GetListenPercentageInTimeWindowInYearRow, error) {
+ row := q.db.QueryRow(ctx, getListenPercentageInTimeWindowInYear,
+ arg.UserID,
+ arg.Year,
+ arg.HourWindowStart,
+ arg.HourWindowEnd,
+ )
+ var i GetListenPercentageInTimeWindowInYearRow
+ err := row.Scan(&i.InWindow, &i.TotalListens, &i.PercentOfTotal)
+ return i, err
+}
+
+const getMostReplayedTrackInYear = `-- name: GetMostReplayedTrackInYear :one
+WITH ordered_listens AS (
+ SELECT
+ user_id,
+ track_id,
+ listened_at,
+ ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY listened_at) AS rn
+ FROM listens
+ WHERE EXTRACT(YEAR FROM listened_at) = $2::int
+),
+streaks AS (
+ SELECT
+ user_id,
+ track_id,
+ listened_at,
+ rn,
+ ROW_NUMBER() OVER (PARTITION BY user_id, track_id ORDER BY listened_at) AS track_rn
+ FROM ordered_listens
+),
+grouped_streaks AS (
+ SELECT
+ user_id,
+ track_id,
+ rn - track_rn AS group_id,
+ COUNT(*) AS streak_length
+ FROM streaks
+ GROUP BY user_id, track_id, rn - track_rn
+),
+ranked_streaks AS (
+ SELECT user_id, track_id, group_id, streak_length,
+ RANK() OVER (PARTITION BY user_id ORDER BY streak_length DESC) AS r
+ FROM grouped_streaks
+)
+SELECT
+ t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
+ get_artists_for_track(t.id) as artists,
+ streak_length
+FROM ranked_streaks rs JOIN tracks_with_title t ON rs.track_id = t.id
+WHERE user_id = $1::int AND r = 1
+`
+
+type GetMostReplayedTrackInYearParams struct {
+ UserID int32
+ Year int32
+}
+
+type GetMostReplayedTrackInYearRow struct {
+ ID int32
+ MusicBrainzID *uuid.UUID
+ Duration int32
+ ReleaseID int32
+ Title string
+ Artists []byte
+ StreakLength int64
+}
+
+func (q *Queries) GetMostReplayedTrackInYear(ctx context.Context, arg GetMostReplayedTrackInYearParams) (GetMostReplayedTrackInYearRow, error) {
+ row := q.db.QueryRow(ctx, getMostReplayedTrackInYear, arg.UserID, arg.Year)
+ var i GetMostReplayedTrackInYearRow
+ err := row.Scan(
+ &i.ID,
+ &i.MusicBrainzID,
+ &i.Duration,
+ &i.ReleaseID,
+ &i.Title,
+ &i.Artists,
+ &i.StreakLength,
+ )
+ return i, err
+}
+
+const getPercentageOfTotalListensFromTopArtistsInYear = `-- name: GetPercentageOfTotalListensFromTopArtistsInYear :one
+WITH user_artist_listens AS (
+ SELECT
+ at.artist_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ JOIN artist_tracks at ON at.track_id = l.track_id
+ WHERE l.user_id = $2::int
+ AND EXTRACT(YEAR FROM l.listened_at) = $3::int
+ GROUP BY at.artist_id
+),
+top_artists AS (
+ SELECT
+ artist_id,
+ listen_count
+ FROM user_artist_listens
+ ORDER BY listen_count DESC
+ LIMIT $1
+),
+totals AS (
+ SELECT
+ (SELECT SUM(listen_count) FROM top_artists) AS top_artist_total,
+ (SELECT SUM(listen_count) FROM user_artist_listens) AS overall_total
+)
+SELECT
+ top_artist_total,
+ overall_total,
+ ROUND((top_artist_total::decimal / overall_total) * 100, 2) AS percent_of_total
+FROM totals
+`
+
+type GetPercentageOfTotalListensFromTopArtistsInYearParams struct {
+ Limit int32
+ UserID int32
+ Year int32
+}
+
+type GetPercentageOfTotalListensFromTopArtistsInYearRow struct {
+ TopArtistTotal int64
+ OverallTotal int64
+ PercentOfTotal pgtype.Numeric
+}
+
+func (q *Queries) GetPercentageOfTotalListensFromTopArtistsInYear(ctx context.Context, arg GetPercentageOfTotalListensFromTopArtistsInYearParams) (GetPercentageOfTotalListensFromTopArtistsInYearRow, error) {
+ row := q.db.QueryRow(ctx, getPercentageOfTotalListensFromTopArtistsInYear, arg.Limit, arg.UserID, arg.Year)
+ var i GetPercentageOfTotalListensFromTopArtistsInYearRow
+ err := row.Scan(&i.TopArtistTotal, &i.OverallTotal, &i.PercentOfTotal)
+ return i, err
+}
+
+const getPercentageOfTotalListensFromTopTracksInYear = `-- name: GetPercentageOfTotalListensFromTopTracksInYear :one
+WITH user_listens AS (
+ SELECT
+ l.track_id,
+ COUNT(*) AS listen_count
+ FROM listens l
+ WHERE l.user_id = $2::int
+ AND EXTRACT(YEAR FROM l.listened_at) = $3::int
+ GROUP BY l.track_id
+),
+top_tracks AS (
+ SELECT
+ track_id,
+ listen_count
+ FROM user_listens
+ ORDER BY listen_count DESC
+ LIMIT $1
+),
+totals AS (
+ SELECT
+ (SELECT SUM(listen_count) FROM top_tracks) AS top_tracks_total,
+ (SELECT SUM(listen_count) FROM user_listens) AS overall_total
+)
+SELECT
+ top_tracks_total,
+ overall_total,
+ ROUND((top_tracks_total::decimal / overall_total) * 100, 2) AS percent_of_total
+FROM totals
+`
+
+type GetPercentageOfTotalListensFromTopTracksInYearParams struct {
+ Limit int32
+ UserID int32
+ Year int32
+}
+
+type GetPercentageOfTotalListensFromTopTracksInYearRow struct {
+ TopTracksTotal int64
+ OverallTotal int64
+ PercentOfTotal pgtype.Numeric
+}
+
+func (q *Queries) GetPercentageOfTotalListensFromTopTracksInYear(ctx context.Context, arg GetPercentageOfTotalListensFromTopTracksInYearParams) (GetPercentageOfTotalListensFromTopTracksInYearRow, error) {
+ row := q.db.QueryRow(ctx, getPercentageOfTotalListensFromTopTracksInYear, arg.Limit, arg.UserID, arg.Year)
+ var i GetPercentageOfTotalListensFromTopTracksInYearRow
+ err := row.Scan(&i.TopTracksTotal, &i.OverallTotal, &i.PercentOfTotal)
+ return i, err
+}
+
+const getTracksPlayedAtLeastOncePerMonthInYear = `-- name: GetTracksPlayedAtLeastOncePerMonthInYear :many
+WITH monthly_plays AS (
+ SELECT
+ l.track_id,
+ EXTRACT(MONTH FROM l.listened_at) AS month
+ FROM listens l
+ WHERE EXTRACT(YEAR FROM l.listened_at) = $1::int
+ GROUP BY l.track_id, EXTRACT(MONTH FROM l.listened_at)
+),
+monthly_counts AS (
+ SELECT
+ track_id,
+ COUNT(DISTINCT month) AS months_played
+ FROM monthly_plays
+ GROUP BY track_id
+)
+SELECT
+ t.id AS track_id,
+ t.title
+FROM monthly_counts mc
+JOIN tracks_with_title t ON t.id = mc.track_id
+WHERE mc.months_played = 12
+`
+
+type GetTracksPlayedAtLeastOncePerMonthInYearRow struct {
+ TrackID int32
+ Title string
+}
+
+func (q *Queries) GetTracksPlayedAtLeastOncePerMonthInYear(ctx context.Context, userID int32) ([]GetTracksPlayedAtLeastOncePerMonthInYearRow, error) {
+ rows, err := q.db.Query(ctx, getTracksPlayedAtLeastOncePerMonthInYear, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetTracksPlayedAtLeastOncePerMonthInYearRow
+ for rows.Next() {
+ var i GetTracksPlayedAtLeastOncePerMonthInYearRow
+ if err := rows.Scan(&i.TrackID, &i.Title); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getWeekWithMostListensInYear = `-- name: GetWeekWithMostListensInYear :one
+SELECT
+ DATE_TRUNC('week', listened_at + INTERVAL '1 day') - INTERVAL '1 day' AS week_start,
+ COUNT(*) AS listen_count
+FROM listens
+WHERE EXTRACT(YEAR FROM listened_at) = $1::int
+ AND user_id = $2::int
+GROUP BY week_start
+ORDER BY listen_count DESC
+LIMIT 1
+`
+
+type GetWeekWithMostListensInYearParams struct {
+ Year int32
+ UserID int32
+}
+
+type GetWeekWithMostListensInYearRow struct {
+ WeekStart int32
+ ListenCount int64
+}
+
+func (q *Queries) GetWeekWithMostListensInYear(ctx context.Context, arg GetWeekWithMostListensInYearParams) (GetWeekWithMostListensInYearRow, error) {
+ row := q.db.QueryRow(ctx, getWeekWithMostListensInYear, arg.Year, arg.UserID)
+ var i GetWeekWithMostListensInYearRow
+ err := row.Scan(&i.WeekStart, &i.ListenCount)
+ return i, err
+}
+
+const tracksOnlyPlayedOnceInYear = `-- name: TracksOnlyPlayedOnceInYear :many
+SELECT
+ t.id AS track_id,
+ t.title,
+ get_artists_for_track(t.id) as artists,
+ COUNT(l.*) AS listen_count
+FROM listens l
+JOIN tracks_with_title t ON t.id = l.track_id
+WHERE EXTRACT(YEAR FROM l.listened_at) = $2::int AND l.user_id = $3::int
+GROUP BY t.id, t.title
+HAVING COUNT(*) = 1
+LIMIT $1
+`
+
+type TracksOnlyPlayedOnceInYearParams struct {
+ Limit int32
+ Year int32
+ UserID int32
+}
+
+type TracksOnlyPlayedOnceInYearRow struct {
+ TrackID int32
+ Title string
+ Artists []byte
+ ListenCount int64
+}
+
+func (q *Queries) TracksOnlyPlayedOnceInYear(ctx context.Context, arg TracksOnlyPlayedOnceInYearParams) ([]TracksOnlyPlayedOnceInYearRow, error) {
+ rows, err := q.db.Query(ctx, tracksOnlyPlayedOnceInYear, arg.Limit, arg.Year, arg.UserID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []TracksOnlyPlayedOnceInYearRow
+ for rows.Next() {
+ var i TracksOnlyPlayedOnceInYearRow
+ if err := rows.Scan(
+ &i.TrackID,
+ &i.Title,
+ &i.Artists,
+ &i.ListenCount,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/internal/summary/image.go b/internal/summary/image.go
new file mode 100644
index 0000000..47f8fea
--- /dev/null
+++ b/internal/summary/image.go
@@ -0,0 +1,186 @@
+package summary
+
+import (
+ "image"
+ "image/color"
+ "image/draw"
+ _ "image/jpeg"
+ "os"
+ "path"
+
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/opentype"
+ "golang.org/x/image/math/fixed"
+ _ "golang.org/x/image/webp"
+)
+
+var (
+ assetPath = path.Join("..", "..", "assets")
+ titleFontPath = path.Join(assetPath, "LeagueSpartan-Medium.ttf")
+ textFontPath = path.Join(assetPath, "Jost-Regular.ttf")
+ paddingLg = 30
+ paddingMd = 20
+ paddingSm = 6
+ featuredImageSize = 180
+ titleFontSize = 48.0
+ textFontSize = 16.0
+ featureTextStart = paddingLg + paddingMd + featuredImageSize
+)
+
+// lots of code borrowed from https://medium.com/@daniel.ruizcamacho/how-to-create-an-image-in-golang-step-by-step-4416affe088f
+// func GenerateImage(summary *Summary) error {
+// base := image.NewRGBA(image.Rect(0, 0, 750, 1100))
+// draw.Draw(base, base.Bounds(), image.NewUniform(color.Black), image.Pt(0, 0), draw.Over)
+
+// file, err := os.Create(path.Join(cfg.ConfigDir(), "summary.png"))
+// if err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// defer file.Close()
+
+// // add title
+// if err := addText(base, summary.Title, "", image.Pt(paddingLg, 60), titleFontPath, titleFontSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// // add images
+// if err := addImage(base, summary.TopArtistImage, image.Pt(-paddingLg, -120), featuredImageSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// if err := addImage(base, summary.TopArtistImage, image.Pt(-paddingLg, -120-(featuredImageSize+paddingLg)), featuredImageSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// if err := addImage(base, summary.TopArtistImage, image.Pt(-paddingLg, -120-(featuredImageSize+paddingLg)*2), featuredImageSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// // top artists text
+// if err := addText(base, "Top Artists", "", image.Pt(featureTextStart, 132), textFontPath, textFontSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// for rank, artist := range summary.TopArtists {
+// if rank == 0 {
+// if err := addText(base, artist.Name, strconv.Itoa(artist.Plays)+" plays", image.Pt(featureTextStart, featuredImageSize+10), titleFontPath, titleFontSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// } else {
+// if err := addText(base, artist.Name, strconv.Itoa(artist.Plays)+" plays", image.Pt(featureTextStart, 210+(rank*(int(textFontSize)+paddingSm))), textFontPath, textFontSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// }
+// }
+// // top albums text
+// if err := addText(base, "Top Albums", "", image.Pt(featureTextStart, 132+featuredImageSize+paddingLg), textFontPath, textFontSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// for rank, album := range summary.TopAlbums {
+// if rank == 0 {
+// if err := addText(base, album.Title, strconv.Itoa(album.Plays)+" plays", image.Pt(featureTextStart, featuredImageSize+10), titleFontPath, titleFontSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// } else {
+// if err := addText(base, album.Title, strconv.Itoa(album.Plays)+" plays", image.Pt(featureTextStart, 210+(rank*(int(textFontSize)+paddingSm))), textFontPath, textFontSize); err != nil {
+// return fmt.Errorf("GenerateImage: %w", err)
+// }
+// }
+// }
+// // top tracks text
+
+// // stats text
+
+// if err := png.Encode(file, base); err != nil {
+// return fmt.Errorf("GenerateImage: png.Encode: %w", err)
+// }
+// return nil
+// }
+
+func addImage(baseImage *image.RGBA, path string, point image.Point, height int) error {
+ templateFile, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+
+ template, _, err := image.Decode(templateFile)
+ if err != nil {
+ return err
+ }
+
+ resized := resize(template, height, height)
+
+ draw.Draw(baseImage, baseImage.Bounds(), resized, point, draw.Over)
+
+ return nil
+}
+
+func addText(baseImage *image.RGBA, text, subtext string, point image.Point, fontFile string, fontSize float64) error {
+ fontBytes, err := os.ReadFile(fontFile)
+ if err != nil {
+ return err
+ }
+
+ ttf, err := opentype.Parse(fontBytes)
+ if err != nil {
+ return err
+ }
+
+ face, err := opentype.NewFace(ttf, &opentype.FaceOptions{
+ Size: fontSize,
+ DPI: 72,
+ Hinting: font.HintingFull,
+ })
+ if err != nil {
+ return err
+ }
+
+ drawer := &font.Drawer{
+ Dst: baseImage,
+ Src: image.NewUniform(color.White),
+ Face: face,
+ Dot: fixed.Point26_6{
+ X: fixed.I(point.X),
+ Y: fixed.I(point.Y),
+ },
+ }
+
+ drawer.DrawString(text)
+ if subtext != "" {
+ face, err = opentype.NewFace(ttf, &opentype.FaceOptions{
+ Size: textFontSize,
+ DPI: 72,
+ Hinting: font.HintingFull,
+ })
+ drawer.Face = face
+ if err != nil {
+ return err
+ }
+ drawer.Src = image.NewUniform(color.RGBA{200, 200, 200, 255})
+ drawer.DrawString(" - ")
+ drawer.DrawString(subtext)
+ }
+
+ return nil
+}
+
+func resize(m image.Image, w, h int) *image.RGBA {
+ if w < 0 || h < 0 {
+ return nil
+ }
+ r := m.Bounds()
+ if w == 0 || h == 0 || r.Dx() <= 0 || r.Dy() <= 0 {
+ return image.NewRGBA(image.Rect(0, 0, w, h))
+ }
+ curw, curh := r.Dx(), r.Dy()
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ for y := range h {
+ for x := range w {
+ // Get a source pixel.
+ subx := x * curw / w
+ suby := y * curh / h
+ r32, g32, b32, a32 := m.At(subx, suby).RGBA()
+ r := uint8(r32 >> 8)
+ g := uint8(g32 >> 8)
+ b := uint8(b32 >> 8)
+ a := uint8(a32 >> 8)
+ img.SetRGBA(x, y, color.RGBA{r, g, b, a})
+ }
+ }
+ return img
+}
diff --git a/internal/summary/summary.go b/internal/summary/summary.go
new file mode 100644
index 0000000..7a2b9d7
--- /dev/null
+++ b/internal/summary/summary.go
@@ -0,0 +1,141 @@
+package summary
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/gabehf/koito/internal/db"
+ "github.com/gabehf/koito/internal/models"
+)
+
+type Summary struct {
+ Title string `json:"title,omitempty"`
+ TopArtists []db.RankedItem[*models.Artist] `json:"top_artists"` // ListenCount and TimeListened are overriden with stats from timeframe
+ TopAlbums []db.RankedItem[*models.Album] `json:"top_albums"` // ListenCount and TimeListened are overriden with stats from timeframe
+ TopTracks []db.RankedItem[*models.Track] `json:"top_tracks"` // ListenCount and TimeListened are overriden with stats from timeframe
+ MinutesListened int `json:"minutes_listened"`
+ AvgMinutesPerDay int `json:"avg_minutes_listened_per_day"`
+ Plays int `json:"plays"`
+ AvgPlaysPerDay float32 `json:"avg_plays_per_day"`
+ UniqueTracks int `json:"unique_tracks"`
+ UniqueAlbums int `json:"unique_albums"`
+ UniqueArtists int `json:"unique_artists"`
+ NewTracks int `json:"new_tracks"`
+ NewAlbums int `json:"new_albums"`
+ NewArtists int `json:"new_artists"`
+}
+
+func GenerateSummary(ctx context.Context, store db.DB, userId int32, timeframe db.Timeframe, title string) (summary *Summary, err error) {
+ // l := logger.FromContext(ctx)
+
+ summary = new(Summary)
+
+ topArtists, err := store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.TopArtists = topArtists.Items
+ // replace ListenCount and TimeListened with stats from timeframe
+ for i, artist := range summary.TopArtists {
+ timelistened, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ArtistID: artist.Item.ID, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{ArtistID: artist.Item.ID, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.TopArtists[i].Item.TimeListened = timelistened
+ summary.TopArtists[i].Item.ListenCount = listens
+ }
+
+ topAlbums, err := store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.TopAlbums = topAlbums.Items
+ // replace ListenCount and TimeListened with stats from timeframe
+ for i, album := range summary.TopAlbums {
+ timelistened, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{AlbumID: album.Item.ID, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{AlbumID: album.Item.ID, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.TopAlbums[i].Item.TimeListened = timelistened
+ summary.TopAlbums[i].Item.ListenCount = listens
+ }
+
+ topTracks, err := store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.TopTracks = topTracks.Items
+ // replace ListenCount and TimeListened with stats from timeframe
+ for i, track := range summary.TopTracks {
+ timelistened, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{TrackID: track.Item.ID, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{TrackID: track.Item.ID, Timeframe: timeframe})
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.TopTracks[i].Item.TimeListened = timelistened
+ summary.TopTracks[i].Item.ListenCount = listens
+ }
+
+ t1, t2 := db.TimeframeToTimeRange(timeframe)
+ daycount := int(t2.Sub(t1).Hours() / 24)
+ // bandaid
+ if daycount == 0 {
+ daycount = 1
+ }
+
+ tmp, err := store.CountTimeListened(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.MinutesListened = int(tmp) / 60
+ summary.AvgMinutesPerDay = summary.MinutesListened / daycount
+ tmp, err = store.CountListens(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.Plays = int(tmp)
+ summary.AvgPlaysPerDay = float32(summary.Plays) / float32(daycount)
+ tmp, err = store.CountTracks(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.UniqueTracks = int(tmp)
+ tmp, err = store.CountAlbums(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.UniqueAlbums = int(tmp)
+ tmp, err = store.CountArtists(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.UniqueArtists = int(tmp)
+ tmp, err = store.CountNewTracks(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.NewTracks = int(tmp)
+ tmp, err = store.CountNewAlbums(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.NewAlbums = int(tmp)
+ tmp, err = store.CountNewArtists(ctx, timeframe)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateSummary: %w", err)
+ }
+ summary.NewArtists = int(tmp)
+
+ return summary, nil
+}
diff --git a/internal/summary/summary_test.go b/internal/summary/summary_test.go
new file mode 100644
index 0000000..5e57819
--- /dev/null
+++ b/internal/summary/summary_test.go
@@ -0,0 +1,84 @@
+package summary_test
+
+import (
+ "testing"
+
+ "github.com/gabehf/koito/internal/cfg"
+)
+
+func TestMain(t *testing.M) {
+ // dir, err := utils.GenerateRandomString(8)
+ // if err != nil {
+ // panic(err)
+ // }
+ cfg.Load(func(env string) string {
+ switch env {
+ case cfg.ENABLE_STRUCTURED_LOGGING_ENV:
+ return "true"
+ case cfg.LOG_LEVEL_ENV:
+ return "debug"
+ case cfg.DATABASE_URL_ENV:
+ return "postgres://postgres:secret@localhost"
+ case cfg.CONFIG_DIR_ENV:
+ return "."
+ case cfg.DISABLE_DEEZER_ENV, cfg.DISABLE_COVER_ART_ARCHIVE_ENV, cfg.DISABLE_MUSICBRAINZ_ENV, cfg.ENABLE_FULL_IMAGE_CACHE_ENV:
+ return "true"
+ default:
+ return ""
+ }
+ }, "test")
+ t.Run()
+}
+
+func TestGenerateSummary(t *testing.T) {
+ // s := summary.Summary{
+ // Title: "20XX Rewind",
+ // TopArtistImage: path.Join("..", "..", "test_assets", "yuu.jpg"),
+ // TopArtists: []struct {
+ // Name string
+ // Plays int
+ // MinutesListened int
+ // }{
+ // {"CHUU", 738, 7321},
+ // {"Paramore", 738, 7321},
+ // {"ano", 738, 7321},
+ // {"NELKE", 738, 7321},
+ // {"ILLIT", 738, 7321},
+ // },
+ // TopAlbumImage: "",
+ // TopAlbums: []struct {
+ // Title string
+ // Plays int
+ // MinutesListened int
+ // }{
+ // {"Only cry in the rain", 738, 7321},
+ // {"Paramore", 738, 7321},
+ // {"ano", 738, 7321},
+ // {"NELKE", 738, 7321},
+ // {"ILLIT", 738, 7321},
+ // },
+ // TopTrackImage: "",
+ // TopTracks: []struct {
+ // Title string
+ // Plays int
+ // MinutesListened int
+ // }{
+ // {"虹の色よ鮮やかであれ (NELKE ver.)", 321, 12351},
+ // {"Paramore", 738, 7321},
+ // {"ano", 738, 7321},
+ // {"NELKE", 738, 7321},
+ // {"ILLIT", 738, 7321},
+ // },
+ // MinutesListened: 0,
+ // Plays: 0,
+ // AvgPlaysPerDay: 0,
+ // UniqueTracks: 0,
+ // UniqueAlbums: 0,
+ // UniqueArtists: 0,
+ // NewTracks: 0,
+ // NewAlbums: 0,
+ // NewArtists: 0,
+ // }
+
+ // assert.NoError(t, summary.GenerateImage(&s))
+}
diff --git a/internal/utils/utils.go b/internal/utils/utils.go
index fdd2b80..eb56425 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)
@@ -127,37 +127,46 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
return start, end, nil
}
+// Returns a time.Time that represents the first moment of the day of t.
+func BeginningOfDay(t time.Time) time.Time {
+ year, month, day := t.Date()
+ return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
+}
+
// CopyFile copies a file from src to dst. If src and dst files exist, and are
// the same, then return success. Otherise, attempt to create a hard link
// between the two files. If that fail, copy the file contents from src to dst.
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 +176,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 +288,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 +318,26 @@ 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
+ }
+}
+
+func FlattenAliases(aliases []models.Alias) []string {
+ ret := make([]string, len(aliases))
+ for i := range aliases {
+ ret[i] = aliases[i].Alias
+ }
+ return ret
+}
diff --git a/test_assets/default_img.webp b/test_assets/default_img.webp
new file mode 100644
index 0000000..30baf52
Binary files /dev/null and b/test_assets/default_img.webp differ
diff --git a/test_assets/koito_export_test.json b/test_assets/koito_export_test.json
new file mode 100644
index 0000000..e2cd8ea
--- /dev/null
+++ b/test_assets/koito_export_test.json
@@ -0,0 +1,706 @@
+{
+ "version": "1",
+ "exported_at": "2025-06-18T12:56:53Z",
+ "user": "koito",
+ "listens": [
+ {
+ "listened_at": "2022-01-08T22:32:08Z",
+ "track": {
+ "mbid": null,
+ "duration": 354,
+ "aliases": [
+ {
+ "alias": "Every Wave To Ever Rise (feat. Elizabeth Powell)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://cdn-images.dzcdn.net/images/cover/1f54d600d0ce5c88a6b2fd75659ec796/1000x1000-000000-80-0-0.jpg",
+ "mbid": "d0ec30bd-7cdc-417c-979d-5a0631b8a161",
+ "aliases": [
+ {
+ "alias": "American Football (LP3)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/ac81a9567a9ae0963274563370eff015/1000x1000-000000-80-0-0.jpg",
+ "mbid": "4ebb5ad3-9018-407d-8c24-c03011ab9ac6",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "American Football",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/4aaff116fd21b5c1bdafd55fbf843053/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "Elizabeth Powell",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2022-01-16T00:43:25Z",
+ "track": {
+ "mbid": null,
+ "duration": 354,
+ "aliases": [
+ {
+ "alias": "Every Wave To Ever Rise (feat. Elizabeth Powell)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://cdn-images.dzcdn.net/images/cover/1f54d600d0ce5c88a6b2fd75659ec796/1000x1000-000000-80-0-0.jpg",
+ "mbid": "d0ec30bd-7cdc-417c-979d-5a0631b8a161",
+ "aliases": [
+ {
+ "alias": "American Football (LP3)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/ac81a9567a9ae0963274563370eff015/1000x1000-000000-80-0-0.jpg",
+ "mbid": "4ebb5ad3-9018-407d-8c24-c03011ab9ac6",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "American Football",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/4aaff116fd21b5c1bdafd55fbf843053/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "Elizabeth Powell",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2022-01-16T04:47:43Z",
+ "track": {
+ "mbid": null,
+ "duration": 354,
+ "aliases": [
+ {
+ "alias": "Every Wave To Ever Rise (feat. Elizabeth Powell)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://cdn-images.dzcdn.net/images/cover/1f54d600d0ce5c88a6b2fd75659ec796/1000x1000-000000-80-0-0.jpg",
+ "mbid": "d0ec30bd-7cdc-417c-979d-5a0631b8a161",
+ "aliases": [
+ {
+ "alias": "American Football (LP3)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/ac81a9567a9ae0963274563370eff015/1000x1000-000000-80-0-0.jpg",
+ "mbid": "4ebb5ad3-9018-407d-8c24-c03011ab9ac6",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "American Football",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/4aaff116fd21b5c1bdafd55fbf843053/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "Elizabeth Powell",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2022-01-18T18:36:21Z",
+ "track": {
+ "mbid": null,
+ "duration": 354,
+ "aliases": [
+ {
+ "alias": "Every Wave To Ever Rise (feat. Elizabeth Powell)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://cdn-images.dzcdn.net/images/cover/1f54d600d0ce5c88a6b2fd75659ec796/1000x1000-000000-80-0-0.jpg",
+ "mbid": "d0ec30bd-7cdc-417c-979d-5a0631b8a161",
+ "aliases": [
+ {
+ "alias": "American Football (LP3)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/ac81a9567a9ae0963274563370eff015/1000x1000-000000-80-0-0.jpg",
+ "mbid": "4ebb5ad3-9018-407d-8c24-c03011ab9ac6",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "American Football",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/4aaff116fd21b5c1bdafd55fbf843053/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "Elizabeth Powell",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2022-01-16T05:28:11Z",
+ "track": {
+ "mbid": null,
+ "duration": 287,
+ "aliases": [
+ {
+ "alias": "I Can't Feel You (feat. Rachel Goswell)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://cdn-images.dzcdn.net/images/cover/1f54d600d0ce5c88a6b2fd75659ec796/1000x1000-000000-80-0-0.jpg",
+ "mbid": "d0ec30bd-7cdc-417c-979d-5a0631b8a161",
+ "aliases": [
+ {
+ "alias": "American Football (LP3)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/ac81a9567a9ae0963274563370eff015/1000x1000-000000-80-0-0.jpg",
+ "mbid": "4ebb5ad3-9018-407d-8c24-c03011ab9ac6",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "American Football",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/36120901230ef2799610483fbfe5402e/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "Rachel Goswell",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2022-01-18T18:59:01Z",
+ "track": {
+ "mbid": null,
+ "duration": 287,
+ "aliases": [
+ {
+ "alias": "I Can't Feel You (feat. Rachel Goswell)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://cdn-images.dzcdn.net/images/cover/1f54d600d0ce5c88a6b2fd75659ec796/1000x1000-000000-80-0-0.jpg",
+ "mbid": "d0ec30bd-7cdc-417c-979d-5a0631b8a161",
+ "aliases": [
+ {
+ "alias": "American Football (LP3)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/ac81a9567a9ae0963274563370eff015/1000x1000-000000-80-0-0.jpg",
+ "mbid": "4ebb5ad3-9018-407d-8c24-c03011ab9ac6",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "American Football",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/36120901230ef2799610483fbfe5402e/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "Rachel Goswell",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2022-01-19T01:31:22Z",
+ "track": {
+ "mbid": null,
+ "duration": 287,
+ "aliases": [
+ {
+ "alias": "I Can't Feel You (feat. Rachel Goswell)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://cdn-images.dzcdn.net/images/cover/1f54d600d0ce5c88a6b2fd75659ec796/1000x1000-000000-80-0-0.jpg",
+ "mbid": "d0ec30bd-7cdc-417c-979d-5a0631b8a161",
+ "aliases": [
+ {
+ "alias": "American Football (LP3)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/ac81a9567a9ae0963274563370eff015/1000x1000-000000-80-0-0.jpg",
+ "mbid": "4ebb5ad3-9018-407d-8c24-c03011ab9ac6",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "American Football",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/36120901230ef2799610483fbfe5402e/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "Rachel Goswell",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2025-03-17T03:16:50Z",
+ "track": {
+ "mbid": null,
+ "duration": 0,
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://coverartarchive.org/release/ac1f8da0-21d7-426e-83b0-befff06f0871/front",
+ "mbid": "ac1f8da0-21d7-426e-83b0-befff06f0871",
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://yt3.googleusercontent.com/DKlRZNy1yaOkBvIH5vaA3RHdLtylEqaoRsnQIfJlSeo8y_Ov1_3fZRebFtm3NgpsB6tvJQzzcQ=s900-c-k-c0x00ffffff-no-rj",
+ "mbid": "30f851bb-dba3-4e9b-811c-5f27f595c86a",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "鈴木雅之",
+ "source": "Canonical",
+ "is_primary": true
+ },
+ {
+ "alias": "Masayuki Suzuki",
+ "source": "MusicBrainz",
+ "is_primary": false
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/827131318ca82724744bc9c2c4b43a6c/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "すぅ",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2025-03-17T22:17:42Z",
+ "track": {
+ "mbid": null,
+ "duration": 0,
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://coverartarchive.org/release/ac1f8da0-21d7-426e-83b0-befff06f0871/front",
+ "mbid": "ac1f8da0-21d7-426e-83b0-befff06f0871",
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://yt3.googleusercontent.com/DKlRZNy1yaOkBvIH5vaA3RHdLtylEqaoRsnQIfJlSeo8y_Ov1_3fZRebFtm3NgpsB6tvJQzzcQ=s900-c-k-c0x00ffffff-no-rj",
+ "mbid": "30f851bb-dba3-4e9b-811c-5f27f595c86a",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "鈴木雅之",
+ "source": "Canonical",
+ "is_primary": true
+ },
+ {
+ "alias": "Masayuki Suzuki",
+ "source": "MusicBrainz",
+ "is_primary": false
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/827131318ca82724744bc9c2c4b43a6c/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "すぅ",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2025-03-18T20:46:02Z",
+ "track": {
+ "mbid": null,
+ "duration": 0,
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://coverartarchive.org/release/ac1f8da0-21d7-426e-83b0-befff06f0871/front",
+ "mbid": "ac1f8da0-21d7-426e-83b0-befff06f0871",
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://yt3.googleusercontent.com/DKlRZNy1yaOkBvIH5vaA3RHdLtylEqaoRsnQIfJlSeo8y_Ov1_3fZRebFtm3NgpsB6tvJQzzcQ=s900-c-k-c0x00ffffff-no-rj",
+ "mbid": "30f851bb-dba3-4e9b-811c-5f27f595c86a",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "鈴木雅之",
+ "source": "Canonical",
+ "is_primary": true
+ },
+ {
+ "alias": "Masayuki Suzuki",
+ "source": "MusicBrainz",
+ "is_primary": false
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/827131318ca82724744bc9c2c4b43a6c/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "すぅ",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2025-03-19T14:15:35Z",
+ "track": {
+ "mbid": null,
+ "duration": 0,
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://coverartarchive.org/release/ac1f8da0-21d7-426e-83b0-befff06f0871/front",
+ "mbid": "ac1f8da0-21d7-426e-83b0-befff06f0871",
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://yt3.googleusercontent.com/DKlRZNy1yaOkBvIH5vaA3RHdLtylEqaoRsnQIfJlSeo8y_Ov1_3fZRebFtm3NgpsB6tvJQzzcQ=s900-c-k-c0x00ffffff-no-rj",
+ "mbid": "30f851bb-dba3-4e9b-811c-5f27f595c86a",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "鈴木雅之",
+ "source": "Canonical",
+ "is_primary": true
+ },
+ {
+ "alias": "Masayuki Suzuki",
+ "source": "MusicBrainz",
+ "is_primary": false
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/827131318ca82724744bc9c2c4b43a6c/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "すぅ",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2025-03-20T00:36:12Z",
+ "track": {
+ "mbid": null,
+ "duration": 0,
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://coverartarchive.org/release/ac1f8da0-21d7-426e-83b0-befff06f0871/front",
+ "mbid": "ac1f8da0-21d7-426e-83b0-befff06f0871",
+ "aliases": [
+ {
+ "alias": "GIRI GIRI",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "https://yt3.googleusercontent.com/DKlRZNy1yaOkBvIH5vaA3RHdLtylEqaoRsnQIfJlSeo8y_Ov1_3fZRebFtm3NgpsB6tvJQzzcQ=s900-c-k-c0x00ffffff-no-rj",
+ "mbid": "30f851bb-dba3-4e9b-811c-5f27f595c86a",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "鈴木雅之",
+ "source": "Canonical",
+ "is_primary": true
+ },
+ {
+ "alias": "Masayuki Suzuki",
+ "source": "MusicBrainz",
+ "is_primary": false
+ }
+ ]
+ },
+ {
+ "image_url": "https://cdn-images.dzcdn.net/images/artist/827131318ca82724744bc9c2c4b43a6c/1000x1000-000000-80-0-0.jpg",
+ "mbid": null,
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "すぅ",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "listened_at": "2025-03-23T17:22:03Z",
+ "track": {
+ "mbid": "a4f26836-3894-46c1-acac-227808308687",
+ "duration": 218,
+ "aliases": [
+ {
+ "alias": "Nijinoiroyo Azayakadeare (NELKE ver.)",
+ "source": "Manual",
+ "is_primary": false
+ },
+ {
+ "alias": "虹の色よ鮮やかであれ (NELKE ver.)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ },
+ "album": {
+ "image_url": "https://coverartarchive.org/release/7114f07c-c1f7-4423-a9c3-585444bef51b/front",
+ "mbid": "7114f07c-c1f7-4423-a9c3-585444bef51b",
+ "aliases": [
+ {
+ "alias": "Nijinoiroyo Azayakadeare (NELKE ver.)",
+ "source": "MusicBrainz",
+ "is_primary": false
+ },
+ {
+ "alias": "虹の色よ鮮やかであれ (NELKE ver.)",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ],
+ "various_artists": false
+ },
+ "artists": [
+ {
+ "image_url": "User Upload",
+ "mbid": "3d202d36-1219-4e31-bfb9-d73355c66a83",
+ "is_primary": false,
+ "aliases": [
+ {
+ "alias": "NELKE",
+ "source": "Canonical",
+ "is_primary": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/test_assets/listenbrainz_shoko1_123456789.zip b/test_assets/listenbrainz_shoko1_123456789.zip
new file mode 100644
index 0000000..14c97a2
Binary files /dev/null and b/test_assets/listenbrainz_shoko1_123456789.zip differ