diff --git a/client/api/api.ts b/client/api/api.ts index 989202c..2b0b665 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -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(r: Response): Promise { if (!r.ok) { @@ -79,6 +85,13 @@ async function getActivity( return handleJson(r); } +async function getInterest(args: getInterestArgs): Promise { + 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(r); +} + async function getStats(period: string): Promise { 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, diff --git a/client/app/components/InterestGraph.tsx b/client/app/components/InterestGraph.tsx new file mode 100644 index 0000000..02b704c --- /dev/null +++ b/client/app/components/InterestGraph.tsx @@ -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 ( +
+

Interest over time

+

Loading...

+
+ ); + } else if (isError) { + return ( +
+

Interest over time

+

Error: {error.message}

+
+ ); + } + + return ( +
+

Interest over time

+ + + + + + + + + + +
+ ); +} diff --git a/client/app/routes/MediaItems/Album.tsx b/client/app/routes/MediaItems/Album.tsx index 4806384..b300422 100644 --- a/client/app/routes/MediaItems/Album.tsx +++ b/client/app/routes/MediaItems/Album.tsx @@ -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() {
- +
+ + +
); diff --git a/client/app/routes/MediaItems/Artist.tsx b/client/app/routes/MediaItems/Artist.tsx index 3f9485c..00334c1 100644 --- a/client/app/routes/MediaItems/Artist.tsx +++ b/client/app/routes/MediaItems/Artist.tsx @@ -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() {
- +
+ + +
diff --git a/client/app/routes/MediaItems/Track.tsx b/client/app/routes/MediaItems/Track.tsx index 87ce4ea..2a45e2f 100644 --- a/client/app/routes/MediaItems/Track.tsx +++ b/client/app/routes/MediaItems/Track.tsx @@ -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() {
- +
+ + +
); diff --git a/client/package.json b/client/package.json index ce0614f..eee0653 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/yarn.lock b/client/yarn.lock index 552e8f9..48a33db 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -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" diff --git a/db/queries/interest.sql b/db/queries/interest.sql new file mode 100644 index 0000000..389c75b --- /dev/null +++ b/db/queries/interest.sql @@ -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; 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/routes.go b/engine/routes.go index 54100ed..e1c5fda 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -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() { diff --git a/internal/db/db.go b/internal/db/db.go index f2364be..e725bc8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 diff --git a/internal/db/opts.go b/internal/db/opts.go index 65834f2..cb23bd3 100644 --- a/internal/db/opts.go +++ b/internal/db/opts.go @@ -153,3 +153,10 @@ type GetExportPageOpts struct { TrackID int32 Limit int32 } + +type GetInterestOpts struct { + Buckets int + AlbumID int32 + ArtistID int32 + TrackID int32 +} diff --git a/internal/db/psql/interest.go b/internal/db/psql/interest.go new file mode 100644 index 0000000..9e8a623 --- /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, 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") + } +} 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/types.go b/internal/db/types.go index 421832f..93ff031 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -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"` +} diff --git a/internal/repository/interest.sql.go b/internal/repository/interest.sql.go new file mode 100644 index 0000000..27c1920 --- /dev/null +++ b/internal/repository/interest.sql.go @@ -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 +}