feat: Rewind (#116)

* wip

* chore: update counts to allow unix timeframe

* feat: add db functions for counting new items

* wip: endpoint working

* wip

* wip: initial ui done

* add header, adjust ui

* add time listened toggle

* fix layout, year param

* param fixes
This commit is contained in:
Gabe Farrell 2025-12-31 18:44:55 -05:00 committed by GitHub
parent c0a8c64243
commit d4ac96f780
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 2252 additions and 1055 deletions

View file

@ -27,10 +27,10 @@ postgres.remove:
postgres.remove-scratch:
docker stop koito-scratch && docker rm koito-scratch
api.debug:
api.debug: postgres.start
KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go
api.scratch:
api.scratch: postgres.run-scratch
KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir/scratch KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5433?sslmode=disable go run cmd/api/main.go
api.test:
@ -45,7 +45,7 @@ client.dev:
docs.dev:
cd docs && yarn dev
client.deps:
client.deps:
cd client && yarn install
client.build: client.deps
@ -53,4 +53,4 @@ client.build: client.deps
test: api.test
build: api.build client.build
build: api.build client.build

BIN
assets/Jost-Regular.ttf Normal file

Binary file not shown.

Binary file not shown.

View file

@ -15,6 +15,14 @@ interface getActivityArgs {
album_id: number;
track_id: number;
}
interface timeframe {
week?: number;
month?: number;
year?: number;
from?: number;
to?: number;
period?: string;
}
async function handleJson<T>(r: Response): Promise<T> {
if (!r.ok) {
@ -281,6 +289,13 @@ function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
}
async function getRewindStats(args: timeframe): Promise<RewindStats> {
const r = await fetch(
`/apis/web/v1/summary?week=${args.week}&month=${args.month}&year=${args.year}&from=${args.from}&to=${args.to}`
);
return handleJson<RewindStats>(r);
}
export {
getLastListens,
getTopTracks,
@ -312,6 +327,7 @@ export {
getExport,
submitListen,
getNowPlaying,
getRewindStats,
};
type Track = {
id: number;
@ -404,6 +420,22 @@ type NowPlaying = {
currently_playing: boolean;
track: Track;
};
type RewindStats = {
title: string;
top_artists: Artist[];
top_albums: Album[];
top_tracks: Track[];
minutes_listened: number;
avg_minutes_listened_per_day: number;
plays: number;
avg_plays_per_day: number;
unique_tracks: number;
unique_albums: number;
unique_artists: number;
new_tracks: number;
new_albums: number;
new_artists: number;
};
export type {
getItemsArgs,
@ -422,4 +454,5 @@ export type {
Config,
NowPlaying,
Stats,
RewindStats,
};

View file

@ -1,59 +1,56 @@
@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap");
@import "tailwindcss";
@theme {
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale {
0% {
opacity: 0;
transform: scale(0.95);
}
@keyframes fade-out-scale {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
--animate-fade-in: fade-in 0.1s ease forwards;
--animate-fade-out: fade-out 0.1s ease forwards;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out-scale {
0% {
opacity: 1;
transform: scale(1);
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
100% {
opacity: 0;
transform: scale(0.95);
}
}
--animate-fade-in: fade-in 0.1s ease forwards;
--animate-fade-out: fade-out 0.1s ease forwards;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}
:root {
--header-xl: 36px;
--header-lg: 28px;
@ -66,7 +63,7 @@
@media (min-width: 60rem) {
:root {
--header-xl: 78px;
--header-lg: 28px;
--header-lg: 36px;
--header-md: 22px;
--header-sm: 16px;
--header-xl-weight: 600;
@ -74,7 +71,6 @@
}
}
html,
body {
background-color: var(--color-bg);
@ -106,16 +102,18 @@ h1 {
h2 {
font-family: "League Spartan";
font-weight: var(--header-weight);
font-size: var(--header-md);
margin-bottom: 0.5em;
font-size: var(--header-lg);
}
h3 {
font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight);
font-size: var(--header-md);
margin-bottom: 0.5em;
}
h4 {
font-size: var(--header-md);
font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight);
}
.header-font {
font-family: "League Spartan";
@ -197,4 +195,4 @@ button.default[disabled]:hover {
}
button.default:hover {
color: var(--color-fg-secondary);
}
}

View file

@ -69,14 +69,14 @@ export default function ActivityGrid({
if (isPending) {
return (
<div className="w-[500px]">
<h2>Activity</h2>
<h3>Activity</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[500px]">
<h2>Activity</h2>
<h3>Activity</h3>
<p className="error">Error: {error.message}</p>
</div>
);
@ -148,7 +148,7 @@ export default function ActivityGrid({
return (
<div className="flex flex-col items-start">
<h2>Activity</h2>
<h3>Activity</h3>
{configurable ? (
<ActivityOptsSelector
rangeSetter={setRange}

View file

@ -2,24 +2,31 @@ import { imageUrl, type Album } from "api/api";
import { Link } from "react-router";
interface Props {
album: Album
size: number
album: Album;
size: number;
}
export default function AlbumDisplay({ album, size }: Props) {
return (
<div className="flex gap-3" key={album.id}>
<div>
<Link to={`/album/${album.id}`}>
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/>
</Link>
</div>
<div className="flex flex-col items-start" style={{width: size}}>
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
<h4>{album.title}</h4>
</Link>
<p className="color-fg-secondary">{album.listen_count} plays</p>
</div>
</div>
)
}
return (
<div className="flex gap-3" key={album.id}>
<div>
<Link to={`/album/${album.id}`}>
<img
src={imageUrl(album.image, "large")}
alt={album.title}
style={{ width: size }}
/>
</Link>
</div>
<div className="flex flex-col items-start" style={{ width: size }}>
<Link
to={`/album/${album.id}`}
className="hover:text-(--color-fg-secondary)"
>
<h4>{album.title}</h4>
</Link>
<p className="color-fg-secondary">{album.listen_count} plays</p>
</div>
</div>
);
}

View file

@ -10,7 +10,7 @@ export default function AllTimeStats() {
if (isPending) {
return (
<div className="w-[200px]">
<h2>All Time Stats</h2>
<h3>All Time Stats</h3>
<p>Loading...</p>
</div>
);
@ -18,7 +18,7 @@ export default function AllTimeStats() {
return (
<>
<div>
<h2>All Time Stats</h2>
<h3>All Time Stats</h3>
<p className="error">Error: {error.message}</p>
</div>
</>
@ -29,7 +29,7 @@ export default function AllTimeStats() {
return (
<div>
<h2>All Time Stats</h2>
<h3>All Time Stats</h3>
<div>
<span
className={numberClasses}

View file

@ -1,51 +1,59 @@
import { useQuery } from "@tanstack/react-query"
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import { useQuery } from "@tanstack/react-query";
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api";
import { Link } from "react-router";
interface Props {
artistId: number
name: string
period: string
artistId: number;
name: string;
period: string;
}
export default function ArtistAlbums({artistId, name, period}: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div>
<h2>Albums From This Artist</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return (
<div>
<h2>Albums From This Artist</h2>
<p className="error">Error:{error.message}</p>
</div>
)
}
export default function ArtistAlbums({ artistId, name, period }: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"top-albums",
{ limit: 99, period: "all_time", artist_id: artistId, page: 0 },
],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
});
if (isPending) {
return (
<div>
<h2>Albums featuring {name}</h2>
<div className="flex flex-wrap gap-8">
{data.items.map((item) => (
<Link to={`/album/${item.id}`}className="flex gap-2 items-start">
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} />
<div className="w-[180px] flex flex-col items-start gap-1">
<p>{item.title}</p>
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p>
</div>
</Link>
))}
</div>
</div>
)
}
<div>
<h3>Albums From This Artist</h3>
<p>Loading...</p>
</div>
);
}
if (isError) {
return (
<div>
<h3>Albums From This Artist</h3>
<p className="error">Error:{error.message}</p>
</div>
);
}
return (
<div>
<h3>Albums featuring {name}</h3>
<div className="flex flex-wrap gap-8">
{data.items.map((item) => (
<Link to={`/album/${item.id}`} className="flex gap-2 items-start">
<img
src={imageUrl(item.image, "medium")}
alt={item.title}
style={{ width: 130 }}
/>
<div className="w-[180px] flex flex-col items-start gap-1">
<p>{item.title}</p>
<p className="text-sm color-fg-secondary">
{item.listen_count} play{item.listen_count > 1 ? "s" : ""}
</p>
</div>
</Link>
))}
</div>
</div>
);
}

View file

@ -63,14 +63,14 @@ export default function LastPlays(props: Props) {
if (isPending) {
return (
<div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2>
<h3>Last Played</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2>
<h3>Last Played</h3>
<p className="error">Error: {error.message}</p>
</div>
);
@ -85,9 +85,9 @@ export default function LastPlays(props: Props) {
return (
<div className="text-sm sm:text-[16px]">
<h2 className="hover:underline">
<h3 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2>
</h3>
<table className="-ml-4">
<tbody>
{props.showNowPlaying && npData && npData.currently_playing && (

View file

@ -33,14 +33,14 @@ export default function TopAlbums(props: Props) {
if (isPending) {
return (
<div className="w-[300px]">
<h2>Top Albums</h2>
<h3>Top Albums</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[300px]">
<h2>Top Albums</h2>
<h3>Top Albums</h3>
<p className="error">Error: {error.message}</p>
</div>
);
@ -48,7 +48,7 @@ export default function TopAlbums(props: Props) {
return (
<div>
<h2 className="hover:underline">
<h3 className="hover:underline">
<Link
to={`/chart/top-albums?period=${props.period}${
props.artistId ? `&artist_id=${props.artistId}` : ""
@ -56,7 +56,7 @@ export default function TopAlbums(props: Props) {
>
Top Albums
</Link>
</h2>
</h3>
<div className="max-w-[300px]">
<TopItemList type="album" data={data} />
{data.items.length < 1 ? "Nothing to show" : ""}

View file

@ -24,14 +24,14 @@ export default function TopArtists(props: Props) {
if (isPending) {
return (
<div className="w-[300px]">
<h2>Top Artists</h2>
<h3>Top Artists</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[300px]">
<h2>Top Artists</h2>
<h3>Top Artists</h3>
<p className="error">Error: {error.message}</p>
</div>
);
@ -39,11 +39,11 @@ export default function TopArtists(props: Props) {
return (
<div>
<h2 className="hover:underline">
<h3 className="hover:underline">
<Link to={`/chart/top-artists?period=${props.period}`}>
Top Artists
</Link>
</h2>
</h3>
<div className="max-w-[300px]">
<TopItemList type="artist" data={data} />
{data.items.length < 1 ? "Nothing to show" : ""}

View file

@ -1,38 +1,43 @@
import { useQuery } from "@tanstack/react-query"
import { getTopAlbums, type getItemsArgs } from "api/api"
import AlbumDisplay from "./AlbumDisplay"
import { useQuery } from "@tanstack/react-query";
import { getTopAlbums, type getItemsArgs } from "api/api";
import AlbumDisplay from "./AlbumDisplay";
interface Props {
period: string
artistId?: Number
vert?: boolean
hideTitle?: boolean
period: string;
artistId?: Number;
vert?: boolean;
hideTitle?: boolean;
}
export default function TopThreeAlbums(props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"top-albums",
{ limit: 3, period: props.period, artist_id: props.artistId, page: 0 },
],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
});
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
})
if (isPending) {
return <p>Loading...</p>;
}
if (isError) {
return <p className="error">Error:{error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
console.log(data);
console.log(data)
return (
<div>
{!props.hideTitle && <h2>Top Three Albums</h2>}
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
{data.items.map((item, index) => (
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
))}
</div>
</div>
)
}
return (
<div>
{!props.hideTitle && <h3>Top Three Albums</h3>}
<div
className={`flex ${props.vert ? "flex-col" : ""}`}
style={{ gap: 15 }}
>
{data.items.map((item, index) => (
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
))}
</div>
</div>
);
}

View file

@ -31,14 +31,14 @@ const TopTracks = (props: Props) => {
if (isPending) {
return (
<div className="w-[300px]">
<h2>Top Tracks</h2>
<h3>Top Tracks</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[300px]">
<h2>Top Tracks</h2>
<h3>Top Tracks</h3>
<p className="error">Error: {error.message}</p>
</div>
);
@ -51,11 +51,11 @@ const TopTracks = (props: Props) => {
return (
<div>
<h2 className="hover:underline">
<h3 className="hover:underline">
<Link to={`/chart/top-tracks?period=${props.period}${params}`}>
Top Tracks
</Link>
</h2>
</h3>
<div className="max-w-[300px]">
<TopItemList type="track" data={data} />
{data.items.length < 1 ? "Nothing to show" : ""}

View file

@ -1,106 +1,124 @@
import { logout, updateUser } from "api/api"
import { useState } from "react"
import { AsyncButton } from "../AsyncButton"
import { useAppContext } from "~/providers/AppProvider"
import { logout, updateUser } from "api/api";
import { useState } from "react";
import { AsyncButton } from "../AsyncButton";
import { useAppContext } from "~/providers/AppProvider";
export default function Account() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { user, setUsername: setCtxUsername } = useAppContext()
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const { user, setUsername: setCtxUsername } = useAppContext();
const logoutHandler = () => {
setLoading(true)
logout()
.then(r => {
if (r.ok) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
}
const updateHandler = () => {
setError('')
setSuccess('')
if (password != "" && confirmPw === "") {
setError("confirm your new password before submitting")
return
const logoutHandler = () => {
setLoading(true);
logout()
.then((r) => {
if (r.ok) {
window.location.reload();
} else {
r.json().then((r) => setError(r.error));
}
setError('')
setSuccess('')
setLoading(true)
updateUser(username, password)
.then(r => {
if (r.ok) {
setSuccess("sucessfully updated user")
if (username != "") {
setCtxUsername(username)
}
setUsername('')
setPassword('')
setConfirmPw('')
} else {
r.json().then((r) => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
})
.catch((err) => setError(err));
setLoading(false);
};
const updateHandler = () => {
setError("");
setSuccess("");
if (password != "" && confirmPw === "") {
setError("confirm your new password before submitting");
return;
}
setError("");
setSuccess("");
setLoading(true);
updateUser(username, password)
.then((r) => {
if (r.ok) {
setSuccess("sucessfully updated user");
if (username != "") {
setCtxUsername(username);
}
setUsername("");
setPassword("");
setConfirmPw("");
} else {
r.json().then((r) => setError(r.error));
}
})
.catch((err) => setError(err));
setLoading(false);
};
return (
<>
<h2>Account</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
</div>
<h2>Update User</h2>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm new password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
return (
<>
<h3>Account</h3>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center">
<p>
You're logged in as <strong>{user?.username}</strong>
</p>
<AsyncButton loading={loading} onClick={logoutHandler}>
Logout
</AsyncButton>
</div>
</>
)
}
<h3>Update User</h3>
<form
action="#"
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>
Submit
</AsyncButton>
</div>
</form>
<form
action="#"
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm new password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>
Submit
</AsyncButton>
</div>
</form>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div>
</>
);
}

View file

@ -5,53 +5,56 @@ import { submitListen } from "api/api";
import { useNavigate } from "react-router";
interface Props {
open: boolean
setOpen: Function
trackid: number
open: boolean;
setOpen: Function;
trackid: number;
}
export default function AddListenModal({ open, setOpen, trackid }: Props) {
const [ts, setTS] = useState<Date>(new Date);
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const [ts, setTS] = useState<Date>(new Date());
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate();
const close = () => {
setOpen(false)
}
const close = () => {
setOpen(false);
};
const submit = () => {
setLoading(true)
submitListen(trackid.toString(), ts)
.then(r => {
if(r.ok) {
setLoading(false)
navigate(0)
} else {
r.json().then(r => setError(r.error))
setLoading(false)
}
})
}
const submit = () => {
setLoading(true);
submitListen(trackid.toString(), ts).then((r) => {
if (r.ok) {
setLoading(false);
navigate(0);
} else {
r.json().then((r) => setError(r.error));
setLoading(false);
}
});
};
const formatForDatetimeLocal = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const formatForDatetimeLocal = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
d.getDate()
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
return (
<Modal isOpen={open} onClose={close}>
<h2>Add Listen</h2>
<div className="flex flex-col items-center gap-4">
<input
type="datetime-local"
className="w-full mx-auto fg bg rounded p-2"
value={formatForDatetimeLocal(ts)}
onChange={(e) => setTS(new Date(e.target.value))}
/>
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton>
<p className="error">{error}</p>
</div>
</Modal>
)
return (
<Modal isOpen={open} onClose={close}>
<h3>Add Listen</h3>
<div className="flex flex-col items-center gap-4">
<input
type="datetime-local"
className="w-full mx-auto fg bg rounded p-2"
value={formatForDatetimeLocal(ts)}
onChange={(e) => setTS(new Date(e.target.value))}
/>
<AsyncButton loading={loading} onClick={submit}>
Submit
</AsyncButton>
<p className="error">{error}</p>
</div>
</Modal>
);
}

View file

@ -5,172 +5,183 @@ import { useEffect, useRef, useState } from "react";
import { Copy, Trash } from "lucide-react";
type CopiedState = {
x: number;
y: number;
visible: boolean;
x: number;
y: number;
visible: boolean;
};
export default function ApiKeysModal() {
const [input, setInput] = useState('')
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<ApiKey[]>([])
const [copied, setCopied] = useState<CopiedState | null>(null);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleRevealAndSelect = (key: string) => {
setExpandedKey(key);
setTimeout(() => {
const el = textRefs.current[key];
if (el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}, 0);
};
const { isPending, isError, data, error } = useQuery({
queryKey: [
'api-keys'
],
queryFn: () => {
return getApiKeys();
},
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [err, setError] = useState<string>();
const [displayData, setDisplayData] = useState<ApiKey[]>([]);
const [copied, setCopied] = useState<CopiedState | null>(null);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleRevealAndSelect = (key: string) => {
setExpandedKey(key);
setTimeout(() => {
const el = textRefs.current[key];
if (el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}, 0);
};
const { isPending, isError, data, error } = useQuery({
queryKey: ["api-keys"],
queryFn: () => {
return getApiKeys();
},
});
useEffect(() => {
if (data) {
setDisplayData(data);
}
}, [data]);
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
const parentRect = (
e.currentTarget.closest(".relative") as HTMLElement
).getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
setCopied({
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
y: buttonRect.top - parentRect.top - 8,
visible: true,
});
useEffect(() => {
if (data) {
setDisplayData(data)
}
}, [data])
setTimeout(() => setCopied(null), 1500);
};
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
const fallbackCopy = (text: string) => {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed"; // prevent scroll to bottom
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} catch (err) {
console.error("Fallback: Copy failed", err);
}
if (isPending) {
return (
<p>Loading...</p>
)
document.body.removeChild(textarea);
};
const handleCreateApiKey = () => {
setError(undefined);
if (input === "") {
setError("a label must be provided");
return;
}
setLoading(true);
createApiKey(input)
.then((r) => {
setDisplayData([r, ...displayData]);
setInput("");
})
.catch((err) => setError(err.message));
setLoading(false);
};
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
setCopied({
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
y: buttonRect.top - parentRect.top - 8,
visible: true,
});
setTimeout(() => setCopied(null), 1500);
};
const fallbackCopy = (text: string) => {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed"; // prevent scroll to bottom
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} catch (err) {
console.error("Fallback: Copy failed", err);
}
document.body.removeChild(textarea);
};
const handleCreateApiKey = () => {
setError(undefined)
if (input === "") {
setError("a label must be provided")
return
}
setLoading(true)
createApiKey(input)
.then(r => {
setDisplayData([r, ...displayData])
setInput('')
}).catch((err) => setError(err.message))
setLoading(false)
}
const handleDeleteApiKey = (id: number) => {
setError(undefined);
setLoading(true);
deleteApiKey(id).then((r) => {
if (r.ok) {
setDisplayData(displayData.filter((v) => v.id != id));
} else {
r.json().then((r) => setError(r.error));
}
});
setLoading(false);
};
const handleDeleteApiKey = (id: number) => {
setError(undefined)
setLoading(true)
deleteApiKey(id)
.then(r => {
if (r.ok) {
setDisplayData(displayData.filter((v) => v.id != id))
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
return (
<div className="">
<h2>API Keys</h2>
<div className="flex flex-col gap-4 relative">
{displayData.map((v) => (
<div className="flex gap-2"><div
key={v.key}
ref={el => {
textRefs.current[v.key] = el;
}}
onClick={() => handleRevealAndSelect(v.key)}
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
expandedKey === v.key ? '' : 'truncate'
}`}
style={{ whiteSpace: 'nowrap' }}
title={v.key} // optional tooltip
>
{expandedKey === v.key ? v.key : `${v.key.slice(0, 8)}... ${v.label}`}
</div>
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
</div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a label for a new API key"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
return (
<div className="">
<h3>API Keys</h3>
<div className="flex flex-col gap-4 relative">
{displayData.map((v) => (
<div className="flex gap-2">
<div
key={v.key}
ref={(el) => {
textRefs.current[v.key] = el;
}}
onClick={() => handleRevealAndSelect(v.key)}
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
expandedKey === v.key ? "" : "truncate"
}`}
style={{ whiteSpace: "nowrap" }}
title={v.key} // optional tooltip
>
{expandedKey === v.key
? v.key
: `${v.key.slice(0, 8)}... ${v.label}`}
</div>
{err && <p className="error">{err}</p>}
{copied?.visible && (
<div
style={{
position: "absolute",
top: copied.y,
left: copied.x,
transform: "translate(-50%, -100%)",
}}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
>
Copied!
</div>
)}
<button
onClick={(e) => handleCopy(e, v.key)}
className="large-button px-5 rounded-md"
>
<Copy size={16} />
</button>
<AsyncButton
loading={loading}
onClick={() => handleDeleteApiKey(v.id)}
confirm
>
<Trash size={16} />
</AsyncButton>
</div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a label for a new API key"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleCreateApiKey}>
Create
</AsyncButton>
</div>
</div>
)
}
{err && <p className="error">{err}</p>}
{copied?.visible && (
<div
style={{
position: "absolute",
top: copied.y,
left: copied.x,
transform: "translate(-50%, -100%)",
}}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
>
Copied!
</div>
)}
</div>
</div>
);
}

View file

@ -1,40 +1,41 @@
import { deleteItem } from "api/api"
import { AsyncButton } from "../AsyncButton"
import { Modal } from "./Modal"
import { useNavigate } from "react-router"
import { useState } from "react"
import { deleteItem } from "api/api";
import { AsyncButton } from "../AsyncButton";
import { Modal } from "./Modal";
import { useNavigate } from "react-router";
import { useState } from "react";
interface Props {
open: boolean
setOpen: Function
title: string,
id: number,
type: string
open: boolean;
setOpen: Function;
title: string;
id: number;
type: string;
}
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const doDelete = () => {
setLoading(true)
deleteItem(type.toLowerCase(), id)
.then(r => {
if (r.ok) {
navigate('/')
} else {
console.log(r)
}
})
}
const doDelete = () => {
setLoading(true);
deleteItem(type.toLowerCase(), id).then((r) => {
if (r.ok) {
navigate("/");
} else {
console.log(r);
}
});
};
return (
<Modal isOpen={open} onClose={() => setOpen(false)}>
<h2>Delete "{title}"?</h2>
<p>This action is irreversible!</p>
<div className="flex flex-col mt-3 items-center">
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton>
</div>
</Modal>
)
}
return (
<Modal isOpen={open} onClose={() => setOpen(false)}>
<h3>Delete "{title}"?</h3>
<p>This action is irreversible!</p>
<div className="flex flex-col mt-3 items-center">
<AsyncButton loading={loading} onClick={doDelete}>
Yes, Delete It
</AsyncButton>
</div>
</Modal>
);
}

View file

@ -108,7 +108,7 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
<Modal maxW={1000} isOpen={open} onClose={handleClose}>
<div className="flex flex-col items-start gap-6 w-full">
<div className="w-full">
<h2>Alias Manager</h2>
<h3>Alias Manager</h3>
<div className="flex flex-col gap-4">
{displayData.map((v) => (
<div className="flex gap-2">

View file

@ -1,99 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { getAlbum, type Artist } from "api/api";
import { useEffect, useState } from "react"
import { useEffect, useState } from "react";
interface Props {
id: number
type: string
id: number;
type: string;
}
export default function SetPrimaryArtist({ id, type }: Props) {
const [err, setErr] = useState('')
const [primary, setPrimary] = useState<Artist>()
const [success, setSuccess] = useState('')
const { isPending, isError, data, error } = useQuery({
queryKey: [
'get-artists-'+type.toLowerCase(),
{
id: id
},
],
queryFn: () => {
return fetch('/apis/web/v1/artists?'+type.toLowerCase()+'_id='+id).then(r => r.json()) as Promise<Artist[]>;
},
});
const [err, setErr] = useState("");
const [primary, setPrimary] = useState<Artist>();
const [success, setSuccess] = useState("");
useEffect(() => {
if (data) {
for (let a of data) {
if (a.is_primary) {
setPrimary(a)
break
}
}
const { isPending, isError, data, error } = useQuery({
queryKey: [
"get-artists-" + type.toLowerCase(),
{
id: id,
},
],
queryFn: () => {
return fetch(
"/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
).then((r) => r.json()) as Promise<Artist[]>;
},
});
useEffect(() => {
if (data) {
for (let a of data) {
if (a.is_primary) {
setPrimary(a);
break;
}
}, [data])
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
}
}
}, [data]);
const updatePrimary = (artist: number, val: boolean) => {
setErr('');
setSuccess('');
fetch(`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`, {
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const updatePrimary = (artist: number, val: boolean) => {
setErr("");
setSuccess("");
fetch(
`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
).then((r) => {
if (r.ok) {
setSuccess("successfully updated primary artists");
} else {
r.json().then((r) => setErr(r.error));
}
});
};
return (
<div className="w-full">
<h3>Set Primary Artist</h3>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-60 px-3 py-2 rounded-md"
value={primary?.name || ""}
onChange={(e) => {
for (let a of data) {
if (a.name === e.target.value) {
setPrimary(a);
updatePrimary(a.id, true);
}
}
})
.then(r => {
if (r.ok) {
setSuccess('successfully updated primary artists');
} else {
r.json().then(r => setErr(r.error));
}
});
}
return (
<div className="w-full">
<h2>Set Primary Artist</h2>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-60 px-3 py-2 rounded-md"
value={primary?.name || ""}
onChange={(e) => {
for (let a of data) {
if (a.name === e.target.value) {
setPrimary(a);
updatePrimary(a.id, true);
}
}
}}
>
<option value="" disabled>
Select an artist
</option>
{data.map((a) => (
<option key={a.id} value={a.name}>
{a.name}
</option>
))}
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
);
}
}}
>
<option value="" disabled>
Select an artist
</option>
{data.map((a) => (
<option key={a.id} value={a.name}>
{a.name}
</option>
))}
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
);
}

View file

@ -1,80 +1,77 @@
import { useQuery } from "@tanstack/react-query";
import { getAlbum } from "api/api";
import { useEffect, useState } from "react"
import { useEffect, useState } from "react";
interface Props {
id: number
id: number;
}
export default function SetVariousArtists({ id }: Props) {
const [err, setErr] = useState('')
const [va, setVA] = useState(false)
const [success, setSuccess] = useState('')
const { isPending, isError, data, error } = useQuery({
queryKey: [
'get-album',
{
id: id
},
],
queryFn: ({ queryKey }) => {
const params = queryKey[1] as { id: number };
return getAlbum(params.id);
},
const [err, setErr] = useState("");
const [va, setVA] = useState(false);
const [success, setSuccess] = useState("");
const { isPending, isError, data, error } = useQuery({
queryKey: [
"get-album",
{
id: id,
},
],
queryFn: ({ queryKey }) => {
const params = queryKey[1] as { id: number };
return getAlbum(params.id);
},
});
useEffect(() => {
if (data) {
setVA(data.is_various_artists);
}
}, [data]);
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const updateVA = (val: boolean) => {
setErr("");
setSuccess("");
fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, {
method: "PATCH",
}).then((r) => {
if (r.ok) {
setSuccess("Successfully updated album");
} else {
r.json().then((r) => setErr(r.error));
}
});
};
useEffect(() => {
if (data) {
setVA(data.is_various_artists)
}
}, [data])
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
}
const updateVA = (val: boolean) => {
setErr('');
setSuccess('');
fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, { method: 'PATCH' })
.then(r => {
if (r.ok) {
setSuccess('Successfully updated album');
} else {
r.json().then(r => setErr(r.error));
}
});
}
return (
<div className="w-full">
<h2>Mark as Various Artists</h2>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-30 px-3 py-2 rounded-md"
value={va.toString()}
onChange={(e) => {
const val = e.target.value === 'true';
setVA(val);
updateVA(val);
}}
>
<option value="true">True</option>
<option value="false">False</option>
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
)
}
return (
<div className="w-full">
<h3>Mark as Various Artists</h3>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-30 px-3 py-2 rounded-md"
value={va.toString()}
onChange={(e) => {
const val = e.target.value === "true";
setVA(val);
updateVA(val);
}}
>
<option value="true">True</option>
<option value="false">False</option>
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
);
}

View file

@ -3,43 +3,45 @@ import { AsyncButton } from "../AsyncButton";
import { getExport } from "api/api";
export default function ExportModal() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleExport = () => {
setLoading(true)
fetch(`/apis/web/v1/export`, {
method: "GET"
})
.then(res => {
if (res.ok) {
res.blob()
.then(blob => {
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "koito_export.json"
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
setLoading(false)
})
} else {
res.json().then(r => setError(r.error))
setLoading(false)
}
}).catch(err => {
setError(err)
setLoading(false)
})
}
const handleExport = () => {
setLoading(true);
fetch(`/apis/web/v1/export`, {
method: "GET",
})
.then((res) => {
if (res.ok) {
res.blob().then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "koito_export.json";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
setLoading(false);
});
} else {
res.json().then((r) => setError(r.error));
setLoading(false);
}
})
.catch((err) => {
setError(err);
setLoading(false);
});
};
return (
<div>
<h2>Export</h2>
<AsyncButton loading={loading} onClick={handleExport}>Export Data</AsyncButton>
{error && <p className="error">{error}</p>}
</div>
)
}
return (
<div>
<h3>Export</h3>
<AsyncButton loading={loading} onClick={handleExport}>
Export Data
</AsyncButton>
{error && <p className="error">{error}</p>}
</div>
);
}

View file

@ -50,7 +50,7 @@ export default function ImageReplaceModal({
return (
<Modal isOpen={open} onClose={closeModal}>
<h2>Replace Image</h2>
<h3>Replace Image</h3>
<div className="flex flex-col items-center">
<input
type="text"

View file

@ -1,59 +1,74 @@
import { login } from "api/api"
import { useEffect, useState } from "react"
import { AsyncButton } from "../AsyncButton"
import { login } from "api/api";
import { useEffect, useState } from "react";
import { AsyncButton } from "../AsyncButton";
export default function LoginForm() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const loginHandler = () => {
if (username && password) {
setLoading(true)
login(username, password, remember)
.then(r => {
if (r.status >= 200 && r.status < 300) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} else if (username || password) {
setError("username and password are required")
}
const loginHandler = () => {
if (username && password) {
setLoading(true);
login(username, password, remember)
.then((r) => {
if (r.status >= 200 && r.status < 300) {
window.location.reload();
} else {
r.json().then((r) => setError(r.error));
}
})
.catch((err) => setError(err));
setLoading(false);
} else if (username || password) {
setError("username and password are required");
}
};
return (
<>
<h2>Log In</h2>
<div className="flex flex-col items-center gap-4 w-full">
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
<input
name="koito-username"
type="text"
placeholder="Username"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)}
/>
<input
name="koito-password"
type="password"
placeholder="Password"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)}
/>
<div className="flex gap-2">
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
<label htmlFor="kotio-remember">Remember me</label>
</div>
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
</form>
<p className="error">{error}</p>
</div>
</>
)
}
return (
<>
<h3>Log In</h3>
<div className="flex flex-col items-center gap-4 w-full">
<p>
Logging in gives you access to <strong>admin tools</strong>, such as
updating images, merging items, deleting items, and more.
</p>
<form
action="#"
className="flex flex-col items-center gap-4 w-3/4"
onSubmit={(e) => e.preventDefault()}
>
<input
name="koito-username"
type="text"
placeholder="Username"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)}
/>
<input
name="koito-password"
type="password"
placeholder="Password"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)}
/>
<div className="flex gap-2">
<input
type="checkbox"
name="koito-remember"
id="koito-remember"
onChange={() => setRemember(!remember)}
/>
<label htmlFor="kotio-remember">Remember me</label>
</div>
<AsyncButton loading={loading} onClick={loginHandler}>
Login
</AsyncButton>
</form>
<p className="error">{error}</p>
</div>
</>
);
}

View file

@ -2,128 +2,158 @@ import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
import type {
MergeFunc,
MergeSearchCleanerFunc,
} from "~/routes/MediaItems/MediaLayout";
import { useNavigate } from "react-router";
interface Props {
open: boolean
setOpen: Function
type: string
currentId: number
currentTitle: string
mergeFunc: MergeFunc
mergeCleanerFunc: MergeSearchCleanerFunc
open: boolean;
setOpen: Function;
type: string;
currentId: number;
currentTitle: string;
mergeFunc: MergeFunc;
mergeCleanerFunc: MergeSearchCleanerFunc;
}
export default function MergeModal(props: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
const [replaceImage, setReplaceImage] = useState(false)
const navigate = useNavigate()
const [query, setQuery] = useState("");
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
{ title: "", id: 0 }
);
const [mergeOrderReversed, setMergeOrderReversed] = useState(false);
const [replaceImage, setReplaceImage] = useState(false);
const navigate = useNavigate();
const closeMergeModal = () => {
props.setOpen(false);
setQuery("");
setData(undefined);
setMergeOrderReversed(false);
setMergeTarget({ title: "", id: 0 });
};
const closeMergeModal = () => {
props.setOpen(false)
setQuery('')
setData(undefined)
setMergeOrderReversed(false)
setMergeTarget({title: '', id: 0})
const toggleSelect = ({ title, id }: { title: string; id: number }) => {
setMergeTarget({ title: title, id: id });
};
useEffect(() => {
console.log("mergeTarget", mergeTarget);
}, [mergeTarget]);
const doMerge = () => {
let from, to;
if (!mergeOrderReversed) {
from = mergeTarget;
to = { id: props.currentId, title: props.currentTitle };
} else {
from = { id: props.currentId, title: props.currentTitle };
to = mergeTarget;
}
const toggleSelect = ({title, id}: {title: string, id: number}) => {
setMergeTarget({title: title, id: id})
}
useEffect(() => {
console.log("mergeTarget",mergeTarget)
}, [mergeTarget])
const doMerge = () => {
let from, to
if (!mergeOrderReversed) {
from = mergeTarget
to = {id: props.currentId, title: props.currentTitle}
props
.mergeFunc(from.id, to.id, replaceImage)
.then((r) => {
if (r.ok) {
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
closeMergeModal();
} else {
window.location.reload();
}
} else {
from = {id: props.currentId, title: props.currentTitle}
to = mergeTarget
// TODO: handle error
console.log(r);
}
props.mergeFunc(from.id, to.id, replaceImage)
.then(r => {
if (r.ok) {
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`)
closeMergeModal()
} else {
window.location.reload()
}
} else {
// TODO: handle error
console.log(r)
}
})
.catch((err) => console.log(err))
})
.catch((err) => console.log(err));
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === "") {
setData(undefined);
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
r = props.mergeCleanerFunc(r, props.currentId);
setData(r);
});
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
}, [debouncedQuery]);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
r = props.mergeCleanerFunc(r, props.currentId)
setData(r);
});
}
}, [debouncedQuery]);
return (
return (
<Modal isOpen={props.open} onClose={closeMergeModal}>
<h2>Merge {props.type}s</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
{ mergeTarget.id !== 0 ?
<>
{mergeOrderReversed ?
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
:
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
}
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
<h3>Merge {props.type}s</h3>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Search for a${
props.type.toLowerCase()[0] === "a" ? "n" : ""
} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults selectorMode data={data} onSelect={toggleSelect} />
{mergeTarget.id !== 0 ? (
<>
{mergeOrderReversed ? (
<p className="mt-5">
<strong>{props.currentTitle}</strong> will be merged into{" "}
<strong>{mergeTarget.title}</strong>
</p>
) : (
<p className="mt-5">
<strong>{mergeTarget.title}</strong> will be merged into{" "}
<strong>{props.currentTitle}</strong>
</p>
)}
<button
className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)"
onClick={doMerge}
>
Merge Items
</button>
<div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
<label htmlFor="reverse-merge-order">Reverse merge order</label>
<input
type="checkbox"
name="reverse-merge-order"
checked={mergeOrderReversed}
onChange={() => setMergeOrderReversed(!mergeOrderReversed)}
/>
<label htmlFor="reverse-merge-order">Reverse merge order</label>
</div>
{
(props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") &&
<div className="flex gap-2 mt-3">
<input type="checkbox" name="replace-image" checked={replaceImage} onChange={() => setReplaceImage(!replaceImage)} />
{(props.type.toLowerCase() === "album" ||
props.type.toLowerCase() === "artist") && (
<div className="flex gap-2 mt-3">
<input
type="checkbox"
name="replace-image"
checked={replaceImage}
onChange={() => setReplaceImage(!replaceImage)}
/>
<label htmlFor="replace-image">Replace image</label>
</div>
}
</> :
''}
</div>
</div>
)}
</>
) : (
""
)}
</div>
</Modal>
)
);
}

View file

@ -4,57 +4,57 @@ import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
interface Props {
open: boolean
setOpen: Function
open: boolean;
setOpen: Function;
}
export default function SearchModal({ open, setOpen }: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [query, setQuery] = useState("");
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const closeSearchModal = () => {
setOpen(false)
setQuery('')
setData(undefined)
const closeSearchModal = () => {
setOpen(false);
setQuery("");
setData(undefined);
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === "") {
setData(undefined);
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
setData(r);
});
}
}, [debouncedQuery]);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
setData(r);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={open} onClose={closeSearchModal}>
<h2>Search</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
placeholder="Search for an artist, album, or track"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="h-3/4 w-full">
<SearchResults data={data} onSelect={closeSearchModal}/>
</div>
</div>
</Modal>
)
return (
<Modal isOpen={open} onClose={closeSearchModal}>
<h3>Search</h3>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
placeholder="Search for an artist, album, or track"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="h-3/4 w-full">
<SearchResults data={data} onSelect={closeSearchModal} />
</div>
</div>
</Modal>
);
}

View file

@ -0,0 +1,72 @@
import { imageUrl, type RewindStats } from "api/api";
import RewindStatText from "./RewindStatText";
import { RewindTopItem } from "./RewindTopItem";
interface Props {
stats: RewindStats;
includeTime?: boolean;
}
export default function Rewind(props: Props) {
const artistimg = props.stats.top_artists[0].image;
const albumimg = props.stats.top_albums[0].image;
const trackimg = props.stats.top_tracks[0].image;
return (
<div className="flex flex-col gap-7">
<h2>{props.stats.title}</h2>
<RewindTopItem
title="Top Artist"
imageSrc={imageUrl(artistimg, "medium")}
items={props.stats.top_artists}
getLabel={(a) => a.name}
includeTime={props.includeTime}
/>
<RewindTopItem
title="Top Album"
imageSrc={imageUrl(albumimg, "medium")}
items={props.stats.top_albums}
getLabel={(a) => a.title}
includeTime={props.includeTime}
/>
<RewindTopItem
title="Top Track"
imageSrc={imageUrl(trackimg, "medium")}
items={props.stats.top_tracks}
getLabel={(t) => t.title}
includeTime={props.includeTime}
/>
<div className="grid grid-cols-3 gap-y-5">
<RewindStatText
figure={`${props.stats.minutes_listened}`}
text="Minutes listened"
/>
<RewindStatText figure={`${props.stats.unique_tracks}`} text="Tracks" />
<RewindStatText
figure={`${props.stats.new_tracks}`}
text="New tracks"
/>
<RewindStatText figure={`${props.stats.plays}`} text="Plays" />
<RewindStatText figure={`${props.stats.unique_albums}`} text="Albums" />
<RewindStatText
figure={`${props.stats.new_albums}`}
text="New albums"
/>
<RewindStatText
figure={`${props.stats.avg_plays_per_day.toFixed(1)}`}
text="Plays per day"
/>
<RewindStatText
figure={`${props.stats.unique_artists}`}
text="Artists"
/>
<RewindStatText
figure={`${props.stats.new_artists}`}
text="New artists"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
interface Props {
figure: string;
text: string;
}
export default function RewindStatText(props: Props) {
return (
<div className="flex items-baseline gap-1.5">
<div className="w-23 text-end shrink-0">
<span
className="
relative inline-block
text-2xl font-semibold
"
>
<span
className="
absolute inset-0
-translate-x-2 translate-y-8
bg-(--color-primary)
z-0
h-1
"
aria-hidden
/>
<span className="relative z-1">{props.figure}</span>
</span>
</div>
<span className="text-sm">{props.text}</span>
</div>
);
}

View file

@ -0,0 +1,55 @@
type TopItemProps<T> = {
title: string;
imageSrc: string;
items: T[];
getLabel: (item: T) => string;
includeTime?: boolean;
};
export function RewindTopItem<
T extends {
id: string | number;
listen_count: number;
time_listened: number;
}
>({ title, imageSrc, items, getLabel, includeTime }: TopItemProps<T>) {
const [top, ...rest] = items;
if (!top) return null;
return (
<div className="flex gap-5">
<div className="rewind-top-item-image">
<img className="max-w-48 max-h-48" src={imageSrc} />
</div>
<div className="flex flex-col gap-1">
<h4 className="-mb-1">{title}</h4>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start mb-2">
<h2>{getLabel(top)}</h2>
<span className="text-(--color-fg-tertiary) -mt-3 text-sm">
{`${top.listen_count} plays`}
{includeTime
? ` (${Math.floor(top.time_listened / 60)} minutes)`
: ``}
</span>
</div>
</div>
{rest.map((e) => (
<div key={e.id} className="text-sm">
{getLabel(e)}
<span className="text-(--color-fg-tertiary)">
{` - ${e.listen_count} plays`}
{includeTime
? ` (${Math.floor(e.time_listened / 60)} minutes)`
: ``}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -1,55 +1,73 @@
import { ExternalLink, Home, Info } from "lucide-react";
import { ExternalLink, History, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings";
import { getRewindYear } from "~/utils/utils";
export default function Sidebar() {
const iconSize = 20;
const iconSize = 20;
return (
<div className="
z-50
flex
sm:flex-col
justify-between
sm:fixed
sm:top-0
sm:left-0
sm:h-screen
h-auto
sm:w-auto
w-full
border-b
sm:border-b-0
sm:border-r
border-(--color-bg-tertiary)
pt-2
sm:py-10
sm:px-1
px-4
return (
<div
className="
z-50
flex
sm:flex-col
justify-between
sm:fixed
sm:top-0
sm:left-0
sm:h-screen
h-auto
sm:w-auto
w-full
border-b
sm:border-b-0
sm:border-r
border-(--color-bg-tertiary)
pt-2
sm:py-10
sm:px-1
px-4
bg-(--color-bg)
">
<div className="flex gap-4 sm:flex-col">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
</div>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div>
);
"
>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
space={10}
to="/"
name="Home"
onClick={() => {}}
modal={<></>}
>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
<SidebarItem
space={10}
to={`/rewind?year=${getRewindYear()}`}
name="Rewind"
onClick={() => {}}
modal={<></>}
>
<History size={iconSize} />
</SidebarItem>
</div>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div>
);
}

View file

@ -44,7 +44,7 @@ export function ThemeSwitcher() {
<div className="flex flex-col gap-10">
<div>
<div className="flex items-center gap-3">
<h2>Select Theme</h2>
<h3>Select Theme</h3>
<div className="mb-3">
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div>
@ -61,7 +61,7 @@ export function ThemeSwitcher() {
</div>
</div>
<div>
<h2>Use Custom Theme</h2>
<h3>Use Custom Theme</h3>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea
name="custom-theme"

View file

@ -1,13 +1,14 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/Home.tsx"),
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
route("/album/:id", "routes/MediaItems/Album.tsx"),
route("/track/:id", "routes/MediaItems/Track.tsx"),
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
route("/listens", "routes/Charts/Listens.tsx"),
route("/theme-helper", "routes/ThemeHelper.tsx"),
] satisfies RouteConfig;
index("routes/Home.tsx"),
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
route("/album/:id", "routes/MediaItems/Album.tsx"),
route("/track/:id", "routes/MediaItems/Track.tsx"),
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
route("/listens", "routes/Charts/Listens.tsx"),
route("/rewind", "routes/RewindPage.tsx"),
route("/theme-helper", "routes/ThemeHelper.tsx"),
] satisfies RouteConfig;

View file

@ -0,0 +1,52 @@
import Rewind from "~/components/rewind/Rewind";
import type { Route } from "./+types/Home";
import { type RewindStats } from "api/api";
import { useState } from "react";
import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { getRewindYear } from "~/utils/utils";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const year = url.searchParams.get("year") || getRewindYear();
const res = await fetch(`/apis/web/v1/summary?year=${year}`);
if (!res.ok) {
throw new Response("Failed to load summary", { status: 500 });
}
const stats: RewindStats = await res.json();
stats.title = `Your ${year} Rewind`;
return { stats };
}
export function meta({}: Route.MetaArgs) {
return [
{ title: `Rewind - Koito` },
{ name: "description", content: "Rewind - Koito" },
];
}
export default function RewindPage() {
const [showTime, setShowTime] = useState(false);
const { stats: stats } = useLoaderData<{ stats: RewindStats }>();
return (
<main className="w-18/20">
<title>{stats.title} - Koito</title>
<meta property="og:title" content={`${stats.title} - Koito`} />
<meta name="description" content={`${stats.title} - Koito`} />
<div className="flex flex-col items-start mt-20 gap-10">
<div className="flex items-center gap-3">
<label htmlFor="show-time-checkbox">Show time listened?</label>
<input
type="checkbox"
name="show-time-checkbox"
checked={showTime}
onChange={(e) => setShowTime(!showTime)}
></input>
</div>
{stats !== undefined && <Rewind stats={stats} includeTime={showTime} />}
</div>
</main>
);
}

View file

@ -15,6 +15,16 @@ const timeframeToInterval = (timeframe: Timeframe): string => {
}
};
const getRewindYear = (): number => {
const today = new Date();
if (today.getMonth() > 10 && today.getDate() >= 30) {
// if we are in december 30/31, just serve current year
return today.getFullYear();
} else {
return today.getFullYear() - 1;
}
};
function timeSince(date: Date) {
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
@ -104,5 +114,5 @@ const timeListenedString = (seconds: number) => {
return `${minutes} minutes listened`;
};
export { hexToHSL, timeListenedString };
export { hexToHSL, timeListenedString, getRewindYear };
export type { hsl };

View file

@ -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,7 +13,7 @@ WHERE a.id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
-- name: GetTrackArtists :many
SELECT
SELECT
a.*,
at.is_primary as is_primary
FROM artists_with_name a
@ -25,7 +25,7 @@ GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary;
SELECT * FROM artists WHERE image = $1 LIMIT 1;
-- name: GetReleaseArtists :many
SELECT
SELECT
a.*,
ar.is_primary as is_primary
FROM artists_with_name a
@ -35,7 +35,7 @@ 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
@ -48,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
@ -78,6 +78,17 @@ 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;
@ -111,4 +122,4 @@ SET artist_id = $2
WHERE artist_id = $1;
-- name: DeleteArtist :exec
DELETE FROM artists WHERE id = $1;
DELETE FROM artists WHERE id = $1;

View file

@ -4,7 +4,7 @@ VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetRelease :one
SELECT
SELECT
*,
get_artists_for_release(id) AS artists
FROM releases_with_title
@ -69,10 +69,20 @@ 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, is_primary)
VALUES ($1, $2, $3)
@ -82,8 +92,8 @@ ON CONFLICT DO NOTHING;
SELECT
r.*,
get_artists_for_release(r.id) AS artists
FROM releases_with_title r
WHERE r.image IS NULL
FROM releases_with_title r
WHERE r.image IS NULL
AND r.id > $2
ORDER BY r.id ASC
LIMIT $1;
@ -107,8 +117,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;
AND ar.artist_id = $1;

View file

@ -9,7 +9,7 @@ VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING;
-- name: GetTrack :one
SELECT
SELECT
t.*,
get_artists_for_track(t.id) AS artists,
r.image
@ -109,6 +109,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;
@ -126,4 +135,4 @@ UPDATE artist_tracks SET is_primary = $3
WHERE artist_id = $1 AND track_id = $2;
-- name: DeleteTrack :exec
DELETE FROM tracks WHERE id = $1;
DELETE FROM tracks WHERE id = $1;

View file

@ -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("GetTopAlbumsHandler: Received request to retrieve top albums")
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)
}
}

View file

@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
@ -81,10 +82,93 @@ func OptsFromRequest(r *http.Request) db.GetItemsOpts {
Week: week,
Month: month,
Year: year,
From: from,
To: to,
From: int64(from),
To: int64(to),
ArtistID: artistId,
AlbumID: albumId,
TrackID: trackId,
}
}
// Takes a request and returns a db.Timeframe representing the week, month, year, period, or unix
// time range specified by the request parameters
func TimeframeFromRequest(r *http.Request) db.Timeframe {
opts := OptsFromRequest(r)
now := time.Now()
loc := now.Location()
// if 'from' is set, but 'to' is not set, assume 'to' should be now
if opts.From != 0 && opts.To == 0 {
opts.To = now.Unix()
}
// YEAR
if opts.Year != 0 && opts.Month == 0 && opts.Week == 0 {
start := time.Date(opts.Year, 1, 1, 0, 0, 0, 0, loc)
end := time.Date(opts.Year+1, 1, 1, 0, 0, 0, 0, loc).Add(-time.Second)
opts.From = start.Unix()
opts.To = end.Unix()
}
// MONTH (+ optional year)
if opts.Month != 0 {
year := opts.Year
if year == 0 {
year = now.Year()
if int(now.Month()) < opts.Month {
year--
}
}
start := time.Date(year, time.Month(opts.Month), 1, 0, 0, 0, 0, loc)
end := endOfMonth(year, time.Month(opts.Month), loc)
opts.From = start.Unix()
opts.To = end.Unix()
}
// WEEK (+ optional year)
if opts.Week != 0 {
year := opts.Year
if year == 0 {
year = now.Year()
_, currentWeek := now.ISOWeek()
if currentWeek < opts.Week {
year--
}
}
// ISO week 1 is defined as the week with Jan 4 in it
jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, loc)
week1Start := startOfWeek(jan4)
start := week1Start.AddDate(0, 0, (opts.Week-1)*7)
end := endOfWeek(start)
opts.From = start.Unix()
opts.To = end.Unix()
}
return db.Timeframe{
Period: opts.Period,
T1u: opts.From,
T2u: opts.To,
}
}
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)
}

View file

@ -42,35 +42,35 @@ func StatsHandler(store db.DB) http.HandlerFunc {
l.Debug().Msgf("StatsHandler: Fetching statistics for period '%s'", period)
listens, err := store.CountListens(r.Context(), period)
listens, err := store.CountListens(r.Context(), db.Timeframe{Period: period})
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(), db.Timeframe{Period: period})
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(), db.Timeframe{Period: period})
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(), db.Timeframe{Period: period})
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(), db.Timeframe{Period: period})
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)

View file

@ -326,13 +326,13 @@ func TestImportKoito(t *testing.T) {
_, err = store.GetTrack(ctx, db.GetTrackOpts{Title: "GIRI GIRI", ArtistIDs: []int32{artist.ID}})
require.NoError(t, err)
count, err := store.CountTracks(ctx, db.PeriodAllTime)
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.PeriodAllTime)
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.PeriodAllTime)
count, err = store.CountArtists(ctx, db.Timeframe{Period: db.PeriodAllTime})
require.NoError(t, err)
assert.EqualValues(t, 6, count)

View file

@ -54,6 +54,7 @@ func bindRoutes(
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.Post("/logout", handlers.LogoutHandler(db))
if !cfg.RateLimitDisabled() {

5
go.mod
View file

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

6
go.sum
View file

@ -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=

View file

@ -11,6 +11,7 @@ import (
type DB interface {
// Get
GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error)
GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error)
GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, error)
@ -28,7 +29,9 @@ 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)
// 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)
@ -39,7 +42,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
@ -52,7 +57,9 @@ type DB interface {
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
@ -62,23 +69,36 @@ 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, 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)

View file

@ -122,8 +122,8 @@ type GetItemsOpts struct {
Week int // 1-52
Month int // 1-12
Year int
From int // unix timestamp
To int // unix timestamp
From int64 // unix timestamp
To int64 // unix timestamp
// Used only for getting top tracks
ArtistID int
@ -144,10 +144,10 @@ type ListenActivityOpts struct {
}
type TimeListenedOpts struct {
Period Period
AlbumID int32
ArtistID int32
TrackID int32
Timeframe Timeframe
AlbumID int32
ArtistID int32
TrackID int32
}
type GetExportPageOpts struct {

View file

@ -6,6 +6,23 @@ import (
// should this be in db package ???
type Timeframe struct {
Period Period
T1u int64
T2u int64
}
func TimeframeToTimeRange(timeframe Timeframe) (t1, t2 time.Time) {
if timeframe.T1u == 0 && timeframe.T2u == 0 {
t2 = time.Now()
t1 = StartTimeFromPeriod(timeframe.Period)
} else {
t1 = time.Unix(timeframe.T1u, 0)
t2 = time.Unix(timeframe.T2u, 0)
}
return
}
type Period string
const (

View file

@ -91,8 +91,8 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
AlbumID: ret.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
AlbumID: ret.ID,
})
if err != nil {
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)

View file

@ -35,8 +35,8 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID,
})
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
@ -70,8 +70,8 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID,
})
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
@ -105,8 +105,8 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID,
})
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)

View file

@ -4,15 +4,13 @@ import (
"context"
"errors"
"fmt"
"time"
"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,
@ -23,9 +21,8 @@ func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error
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,
@ -36,9 +33,8 @@ func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error)
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,
@ -49,9 +45,8 @@ func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error)
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,
@ -62,9 +57,9 @@ func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error
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,
@ -75,9 +70,9 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
return count, nil
}
// in seconds
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(opts.Period)
t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
if opts.ArtistID > 0 {
count, err := p.q.CountTimeListenedToArtist(ctx, repository.CountTimeListenedToArtistParams{
@ -112,3 +107,76 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
}
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
}

View file

@ -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,46 +29,97 @@ 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{T1u: t1u, T2u: 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{T1u: t1u, T2u: 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{T1u: t1u, T2u: 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")
@ -79,7 +131,7 @@ func TestCountTimeListenedToArtist(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, ArtistID: 1})
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)
@ -89,7 +141,7 @@ func TestCountTimeListenedToAlbum(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, AlbumID: 2})
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)
@ -99,8 +151,38 @@ func TestCountTimeListenedToTrack(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, TrackID: 3})
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)
}

View file

@ -25,6 +25,10 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period)
}
if opts.From != 0 || opts.To != 0 {
t1 = time.Unix(opts.From, 0)
t2 = time.Unix(opts.To, 0)
}
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}

View file

@ -24,6 +24,10 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period)
}
if opts.From != 0 || opts.To != 0 {
t1 = time.Unix(opts.From, 0)
t2 = time.Unix(opts.To, 0)
}
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}

View file

@ -25,6 +25,10 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period)
}
if opts.From != 0 || opts.To != 0 {
t1 = time.Unix(opts.From, 0)
t2 = time.Unix(opts.To, 0)
}
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}

View file

@ -82,8 +82,8 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
TrackID: track.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
TrackID: track.ID,
})
if err != nil {
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)

View file

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

View file

@ -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
@ -127,7 +151,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 +185,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
@ -198,7 +222,7 @@ func (q *Queries) GetArtistByName(ctx context.Context, alias string) (GetArtistB
}
const getReleaseArtists = `-- name: GetReleaseArtists :many
SELECT
SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
ar.is_primary as is_primary
FROM artists_with_name a
@ -307,7 +331,7 @@ func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsP
}
const getTrackArtists = `-- name: GetTrackArtists :many
SELECT
SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
at.is_primary as is_primary
FROM artists_with_name a

View file

@ -30,9 +30,32 @@ func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArt
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
`
@ -86,7 +109,7 @@ func (q *Queries) DeleteReleasesFromArtist(ctx context.Context, artistID int32)
}
const getRelease = `-- name: GetRelease :one
SELECT
SELECT
id, musicbrainz_id, image, various_artists, image_source, title,
get_artists_for_release(id) AS artists
FROM releases_with_title
@ -213,8 +236,8 @@ const getReleasesWithoutImages = `-- name: GetReleasesWithoutImages :many
SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
get_artists_for_release(r.id) AS artists
FROM releases_with_title r
WHERE r.image IS NULL
FROM releases_with_title r
WHERE r.image IS NULL
AND r.id > $2
ORDER BY r.id ASC
LIMIT $1

View file

@ -29,6 +29,28 @@ func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtis
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
@ -343,7 +365,7 @@ 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

186
internal/summary/image.go Normal file
View file

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

141
internal/summary/summary.go Normal file
View file

@ -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 []*models.Artist `json:"top_artists"` // ListenCount and TimeListened are overriden with stats from timeframe
TopAlbums []*models.Album `json:"top_albums"` // ListenCount and TimeListened are overriden with stats from timeframe
TopTracks []*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, From: timeframe.T1u, To: timeframe.T2u, Period: timeframe.Period})
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.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{ArtistID: artist.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopArtists[i].TimeListened = timelistened
summary.TopArtists[i].ListenCount = listens
}
topAlbums, err := store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, From: timeframe.T1u, To: timeframe.T2u, Period: timeframe.Period})
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.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{AlbumID: album.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopAlbums[i].TimeListened = timelistened
summary.TopAlbums[i].ListenCount = listens
}
topTracks, err := store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, From: timeframe.T1u, To: timeframe.T2u, Period: timeframe.Period})
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.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{TrackID: track.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopTracks[i].TimeListened = timelistened
summary.TopTracks[i].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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB