feat: interest over time graph (#127)

* api

* ui

* test

* add margin to prevent clipping
This commit is contained in:
Gabe Farrell 2026-01-12 16:20:31 -05:00 committed by GitHub
parent e45099c71a
commit 231eb1b0fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1097 additions and 4 deletions

View file

@ -23,6 +23,12 @@ interface timeframe {
to?: number;
period?: string;
}
interface getInterestArgs {
buckets: number;
artist_id: number;
album_id: number;
track_id: number;
}
async function handleJson<T>(r: Response): Promise<T> {
if (!r.ok) {
@ -79,6 +85,13 @@ async function getActivity(
return handleJson<ListenActivityItem[]>(r);
}
async function getInterest(args: getInterestArgs): Promise<InterestBucket[]> {
const r = await fetch(
`/apis/web/v1/interest?buckets=${args.buckets}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
);
return handleJson<InterestBucket[]>(r);
}
async function getStats(period: string): Promise<Stats> {
const r = await fetch(`/apis/web/v1/stats?period=${period}`);
@ -315,6 +328,7 @@ export {
getTopAlbums,
getTopArtists,
getActivity,
getInterest,
getStats,
search,
replaceImage,
@ -397,6 +411,11 @@ type ListenActivityItem = {
start_time: Date;
listens: number;
};
type InterestBucket = {
bucket_start: Date;
bucket_end: Date;
listen_count: number;
};
type SimpleArtists = {
name: string;
id: number;
@ -454,6 +473,7 @@ type RewindStats = {
export type {
getItemsArgs,
getActivityArgs,
getInterestArgs,
Track,
Artist,
Album,
@ -461,6 +481,7 @@ export type {
SearchResponse,
PaginatedResponse,
ListenActivityItem,
InterestBucket,
User,
Alias,
ApiKey,

View file

@ -0,0 +1,117 @@
import { useQuery } from "@tanstack/react-query";
import {
getActivity,
getInterest,
type getActivityArgs,
type getInterestArgs,
type ListenActivityItem,
} from "api/api";
import Popup from "./Popup";
import { useState } from "react";
import { useTheme } from "~/hooks/useTheme";
import ActivityOptsSelector from "./ActivityOptsSelector";
import type { Theme } from "~/styles/themes.css";
import { Area, AreaChart, Line, LineChart, Tooltip } from "recharts";
import { RechartsDevtools } from "@recharts/devtools";
function getPrimaryColor(theme: Theme): string {
const value = theme.primary;
const rgbMatch = value.match(
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
}
return value;
}
interface Props {
buckets?: number;
artistId?: number;
albumId?: number;
trackId?: number;
}
export default function InterestGraph({
buckets = 14,
artistId = 0,
albumId = 0,
trackId = 0,
}: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"interest",
{
buckets: buckets,
artist_id: artistId,
album_id: albumId,
track_id: trackId,
},
],
queryFn: ({ queryKey }) => getInterest(queryKey[1] as getInterestArgs),
});
const { theme } = useTheme();
const color = getPrimaryColor(theme);
if (isPending) {
return (
<div className="w-[500px]">
<h3>Interest over time</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[500px]">
<h3>Interest over time</h3>
<p className="error">Error: {error.message}</p>
</div>
);
}
return (
<div className="flex flex-col items-start w-full max-w-[500px]">
<h3>Interest over time</h3>
<AreaChart
style={{
width: "100%",
aspectRatio: 3.5,
maxWidth: 440,
overflow: "visible",
}}
margin={{ top: 5, right: 0, left: 0, bottom: 10 }}
data={data}
>
<defs>
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.5} />
<stop offset="85%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
dataKey="listen_count"
type="natural"
stroke="none"
fill="url(#colorGradient)"
animationDuration={750}
animationEasing="ease-in-out"
activeDot={false}
/>
<Area
dataKey="listen_count"
type="natural"
stroke={color}
fill="none"
strokeWidth={2}
animationDuration={750}
animationEasing="ease-in-out"
dot={false}
activeDot={false}
style={{ filter: `drop-shadow(0px 0px 5px ${color})` }}
/>
</AreaChart>
</div>
);
}

View file

@ -7,6 +7,7 @@ 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}`);
@ -69,7 +70,10 @@ export default function Album() {
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} />
<div className="flex flex-col items-start gap-4">
<ActivityGrid configurable albumId={album.id} />
<InterestGraph albumId={album.id} />
</div>
</div>
</MediaLayout>
);

View file

@ -8,6 +8,7 @@ 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}`);
@ -76,7 +77,10 @@ export default function Artist() {
<div className="flex gap-15 mt-10 flex-wrap">
<LastPlays limit={20} artistId={artist.id} />
<TopTracks limit={8} period={period} artistId={artist.id} />
<div className="flex flex-col items-start gap-4">
<ActivityGrid configurable artistId={artist.id} />
<InterestGraph artistId={artist.id} />
</div>
</div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
</div>

View file

@ -6,6 +6,7 @@ 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}`);
@ -73,7 +74,10 @@ export default function Track() {
</div>
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={20} trackId={track.id} />
<ActivityGrid trackId={track.id} configurable />
<div className="flex flex-col items-start gap-4">
<ActivityGrid configurable trackId={track.id} />
<InterestGraph trackId={track.id} />
</div>
</div>
</MediaLayout>
);

View file

@ -13,6 +13,7 @@
"@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",
@ -20,7 +21,9 @@
"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",

View file

@ -689,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"
@ -789,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"
@ -918,6 +945,57 @@
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"
@ -949,6 +1027,11 @@
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"
@ -1163,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"
@ -1261,6 +1349,77 @@ csstype@^3.0.2, csstype@^3.0.7:
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"
@ -1275,6 +1434,11 @@ 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"
@ -1384,6 +1548,11 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
dependencies:
es-errors "^1.3.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"
@ -1438,6 +1607,11 @@ eval@0.1.8:
"@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"
@ -1646,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"
@ -2180,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"
@ -2203,11 +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"
@ -2492,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"
@ -2566,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"
@ -2594,6 +2838,26 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
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"

162
db/queries/interest.sql Normal file
View file

@ -0,0 +1,162 @@
-- name: GetGroupedListensFromArtist :many
WITH artist_listens AS (
SELECT
l.listened_at
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
),
bounds AS (
SELECT
MIN(listened_at) AS start_time,
MAX(listened_at) AS end_time
FROM artist_listens
),
bucketed AS (
SELECT
LEAST(
sqlc.arg(bucket_count) - 1,
FLOOR(
(
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
/
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
) * sqlc.arg(bucket_count)
)::int
) AS bucket_idx,
b.start_time,
b.end_time
FROM artist_listens al
CROSS JOIN bounds b
),
aggregated AS (
SELECT
start_time
+ (
bucket_idx * (end_time - start_time)
/ sqlc.arg(bucket_count)
) AS bucket_start,
start_time
+ (
(bucket_idx + 1) * (end_time - start_time)
/ sqlc.arg(bucket_count)
) AS bucket_end,
COUNT(*) AS listen_count
FROM bucketed
GROUP BY bucket_idx, start_time, end_time
)
SELECT
bucket_start::timestamptz,
bucket_end::timestamptz,
listen_count
FROM aggregated
ORDER BY bucket_start;
-- name: GetGroupedListensFromRelease :many
WITH artist_listens AS (
SELECT
l.listened_at
FROM listens l
JOIN tracks t ON t.id = l.track_id
WHERE t.release_id = $1
),
bounds AS (
SELECT
MIN(listened_at) AS start_time,
MAX(listened_at) AS end_time
FROM artist_listens
),
bucketed AS (
SELECT
LEAST(
sqlc.arg(bucket_count) - 1,
FLOOR(
(
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
/
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
) * sqlc.arg(bucket_count)
)::int
) AS bucket_idx,
b.start_time,
b.end_time
FROM artist_listens al
CROSS JOIN bounds b
),
aggregated AS (
SELECT
start_time
+ (
bucket_idx * (end_time - start_time)
/ sqlc.arg(bucket_count)
) AS bucket_start,
start_time
+ (
(bucket_idx + 1) * (end_time - start_time)
/ sqlc.arg(bucket_count)
) AS bucket_end,
COUNT(*) AS listen_count
FROM bucketed
GROUP BY bucket_idx, start_time, end_time
)
SELECT
bucket_start::timestamptz,
bucket_end::timestamptz,
listen_count
FROM aggregated
ORDER BY bucket_start;
-- name: GetGroupedListensFromTrack :many
WITH artist_listens AS (
SELECT
l.listened_at
FROM listens l
JOIN tracks t ON t.id = l.track_id
WHERE t.id = $1
),
bounds AS (
SELECT
MIN(listened_at) AS start_time,
MAX(listened_at) AS end_time
FROM artist_listens
),
bucketed AS (
SELECT
LEAST(
sqlc.arg(bucket_count) - 1,
FLOOR(
(
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
/
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
) * sqlc.arg(bucket_count)
)::int
) AS bucket_idx,
b.start_time,
b.end_time
FROM artist_listens al
CROSS JOIN bounds b
),
aggregated AS (
SELECT
start_time
+ (
bucket_idx * (end_time - start_time)
/ sqlc.arg(bucket_count)
) AS bucket_start,
start_time
+ (
(bucket_idx + 1) * (end_time - start_time)
/ sqlc.arg(bucket_count)
) AS bucket_end,
COUNT(*) AS listen_count
FROM bucketed
GROUP BY bucket_idx, start_time, end_time
)
SELECT
bucket_start::timestamptz,
bucket_end::timestamptz,
listen_count
FROM aggregated
ORDER BY bucket_start;

View file

@ -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)
}
}

View file

@ -55,6 +55,7 @@ func bindRoutes(
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() {

View file

@ -30,6 +30,7 @@ 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

View file

@ -153,3 +153,10 @@ type GetExportPageOpts struct {
TrackID int32
Limit int32
}
type GetInterestOpts struct {
Buckets int
AlbumID int32
ArtistID int32
TrackID int32
}

View file

@ -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, opts.Buckets)
if opts.ArtistID != 0 {
resp, err := d.q.GetGroupedListensFromArtist(ctx, repository.GetGroupedListensFromArtistParams{
ArtistID: opts.ArtistID,
BucketCount: opts.Buckets,
})
if err != nil {
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromArtist: %w", err)
}
for i, v := range resp {
ret[i] = 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: opts.Buckets,
})
if err != nil {
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromRelease: %w", err)
}
for i, v := range resp {
ret[i] = 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: opts.Buckets,
})
if err != nil {
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromTrack: %w", err)
}
for i, v := range resp {
ret[i] = 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")
}
}

View file

@ -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")
})
}

View file

@ -44,3 +44,9 @@ type ExportItem struct {
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"`
}

View file

@ -0,0 +1,270 @@
// 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 artist_listens AS (
SELECT
l.listened_at
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
),
bounds AS (
SELECT
MIN(listened_at) AS start_time,
MAX(listened_at) AS end_time
FROM artist_listens
),
bucketed AS (
SELECT
LEAST(
$2 - 1,
FLOOR(
(
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
/
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
) * $2
)::int
) AS bucket_idx,
b.start_time,
b.end_time
FROM artist_listens al
CROSS JOIN bounds b
),
aggregated AS (
SELECT
start_time
+ (
bucket_idx * (end_time - start_time)
/ $2
) AS bucket_start,
start_time
+ (
(bucket_idx + 1) * (end_time - start_time)
/ $2
) AS bucket_end,
COUNT(*) AS listen_count
FROM bucketed
GROUP BY bucket_idx, start_time, end_time
)
SELECT
bucket_start::timestamptz,
bucket_end::timestamptz,
listen_count
FROM aggregated
ORDER BY bucket_start
`
type GetGroupedListensFromArtistParams struct {
ArtistID int32
BucketCount interface{}
}
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 artist_listens AS (
SELECT
l.listened_at
FROM listens l
JOIN tracks t ON t.id = l.track_id
WHERE t.release_id = $1
),
bounds AS (
SELECT
MIN(listened_at) AS start_time,
MAX(listened_at) AS end_time
FROM artist_listens
),
bucketed AS (
SELECT
LEAST(
$2 - 1,
FLOOR(
(
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
/
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
) * $2
)::int
) AS bucket_idx,
b.start_time,
b.end_time
FROM artist_listens al
CROSS JOIN bounds b
),
aggregated AS (
SELECT
start_time
+ (
bucket_idx * (end_time - start_time)
/ $2
) AS bucket_start,
start_time
+ (
(bucket_idx + 1) * (end_time - start_time)
/ $2
) AS bucket_end,
COUNT(*) AS listen_count
FROM bucketed
GROUP BY bucket_idx, start_time, end_time
)
SELECT
bucket_start::timestamptz,
bucket_end::timestamptz,
listen_count
FROM aggregated
ORDER BY bucket_start
`
type GetGroupedListensFromReleaseParams struct {
ReleaseID int32
BucketCount interface{}
}
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 artist_listens AS (
SELECT
l.listened_at
FROM listens l
JOIN tracks t ON t.id = l.track_id
WHERE t.id = $1
),
bounds AS (
SELECT
MIN(listened_at) AS start_time,
MAX(listened_at) AS end_time
FROM artist_listens
),
bucketed AS (
SELECT
LEAST(
$2 - 1,
FLOOR(
(
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
/
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
) * $2
)::int
) AS bucket_idx,
b.start_time,
b.end_time
FROM artist_listens al
CROSS JOIN bounds b
),
aggregated AS (
SELECT
start_time
+ (
bucket_idx * (end_time - start_time)
/ $2
) AS bucket_start,
start_time
+ (
(bucket_idx + 1) * (end_time - start_time)
/ $2
) AS bucket_end,
COUNT(*) AS listen_count
FROM bucketed
GROUP BY bucket_idx, start_time, end_time
)
SELECT
bucket_start::timestamptz,
bucket_end::timestamptz,
listen_count
FROM aggregated
ORDER BY bucket_start
`
type GetGroupedListensFromTrackParams struct {
ID int32
BucketCount interface{}
}
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
}