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: postgres.remove-scratch:
docker stop koito-scratch && docker rm koito-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 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 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: api.test:

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; album_id: number;
track_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> { async function handleJson<T>(r: Response): Promise<T> {
if (!r.ok) { if (!r.ok) {
@ -281,6 +289,13 @@ function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json()); 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 { export {
getLastListens, getLastListens,
getTopTracks, getTopTracks,
@ -312,6 +327,7 @@ export {
getExport, getExport,
submitListen, submitListen,
getNowPlaying, getNowPlaying,
getRewindStats,
}; };
type Track = { type Track = {
id: number; id: number;
@ -404,6 +420,22 @@ type NowPlaying = {
currently_playing: boolean; currently_playing: boolean;
track: Track; 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 { export type {
getItemsArgs, getItemsArgs,
@ -422,4 +454,5 @@ export type {
Config, Config,
NowPlaying, NowPlaying,
Stats, 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"; @import "tailwindcss";
@theme { @theme {
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif, --font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "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-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards; --animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale { @keyframes fade-in-scale {
0% { 0% {
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
100% {
@keyframes fade-out-scale { opacity: 1;
0% { transform: scale(1);
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.95);
}
} }
}
--animate-fade-in: fade-in 0.1s ease forwards; @keyframes fade-out-scale {
--animate-fade-out: fade-out 0.1s ease forwards; 0% {
opacity: 1;
@keyframes fade-in { transform: scale(1);
0% {
opacity: 0;
}
100% {
opacity: 1;
}
} }
100% {
@keyframes fade-out { opacity: 0;
0% { transform: scale(0.95);
opacity: 1;
}
100% {
opacity: 0;
}
} }
}
--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 { :root {
--header-xl: 36px; --header-xl: 36px;
--header-lg: 28px; --header-lg: 28px;
@ -66,7 +63,7 @@
@media (min-width: 60rem) { @media (min-width: 60rem) {
:root { :root {
--header-xl: 78px; --header-xl: 78px;
--header-lg: 28px; --header-lg: 36px;
--header-md: 22px; --header-md: 22px;
--header-sm: 16px; --header-sm: 16px;
--header-xl-weight: 600; --header-xl-weight: 600;
@ -74,7 +71,6 @@
} }
} }
html, html,
body { body {
background-color: var(--color-bg); background-color: var(--color-bg);
@ -106,16 +102,18 @@ h1 {
h2 { h2 {
font-family: "League Spartan"; font-family: "League Spartan";
font-weight: var(--header-weight); font-weight: var(--header-weight);
font-size: var(--header-md); font-size: var(--header-lg);
margin-bottom: 0.5em;
} }
h3 { h3 {
font-family: "League Spartan"; font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight); font-weight: var(--header-weight);
font-size: var(--header-md);
margin-bottom: 0.5em;
} }
h4 { h4 {
font-size: var(--header-md); font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight);
} }
.header-font { .header-font {
font-family: "League Spartan"; font-family: "League Spartan";

View file

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

View file

@ -2,24 +2,31 @@ import { imageUrl, type Album } from "api/api";
import { Link } from "react-router"; import { Link } from "react-router";
interface Props { interface Props {
album: Album album: Album;
size: number size: number;
} }
export default function AlbumDisplay({ album, size }: Props) { export default function AlbumDisplay({ album, size }: Props) {
return ( return (
<div className="flex gap-3" key={album.id}> <div className="flex gap-3" key={album.id}>
<div> <div>
<Link to={`/album/${album.id}`}> <Link to={`/album/${album.id}`}>
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/> <img
</Link> src={imageUrl(album.image, "large")}
</div> alt={album.title}
<div className="flex flex-col items-start" style={{width: size}}> style={{ width: size }}
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)"> />
<h4>{album.title}</h4> </Link>
</Link> </div>
<p className="color-fg-secondary">{album.listen_count} plays</p> <div className="flex flex-col items-start" style={{ width: size }}>
</div> <Link
</div> 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) { if (isPending) {
return ( return (
<div className="w-[200px]"> <div className="w-[200px]">
<h2>All Time Stats</h2> <h3>All Time Stats</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
@ -18,7 +18,7 @@ export default function AllTimeStats() {
return ( return (
<> <>
<div> <div>
<h2>All Time Stats</h2> <h3>All Time Stats</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
</> </>
@ -29,7 +29,7 @@ export default function AllTimeStats() {
return ( return (
<div> <div>
<h2>All Time Stats</h2> <h3>All Time Stats</h3>
<div> <div>
<span <span
className={numberClasses} className={numberClasses}

View file

@ -1,51 +1,59 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api" import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api";
import { Link } from "react-router" import { Link } from "react-router";
interface Props { interface Props {
artistId: number artistId: number;
name: string name: string;
period: string period: string;
} }
export default function ArtistAlbums({artistId, name, period}: Props) { export default function ArtistAlbums({ artistId, name, period }: Props) {
const { isPending, isError, data, error } = useQuery({
const { isPending, isError, data, error } = useQuery({ queryKey: [
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}], "top-albums",
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), { 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>
)
}
if (isPending) {
return ( return (
<div> <div>
<h2>Albums featuring {name}</h2> <h3>Albums From This Artist</h3>
<div className="flex flex-wrap gap-8"> <p>Loading...</p>
{data.items.map((item) => ( </div>
<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"> if (isError) {
<p>{item.title}</p> return (
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p> <div>
</div> <h3>Albums From This Artist</h3>
</Link> <p className="error">Error:{error.message}</p>
))} </div>
</div> );
</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) { if (isPending) {
return ( return (
<div className="w-[300px] sm:w-[500px]"> <div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2> <h3>Last Played</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) { } else if (isError) {
return ( return (
<div className="w-[300px] sm:w-[500px]"> <div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2> <h3>Last Played</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
); );
@ -85,9 +85,9 @@ export default function LastPlays(props: Props) {
return ( return (
<div className="text-sm sm:text-[16px]"> <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> <Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2> </h3>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{props.showNowPlaying && npData && npData.currently_playing && ( {props.showNowPlaying && npData && npData.currently_playing && (

View file

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

View file

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

View file

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

View file

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

View file

@ -1,106 +1,124 @@
import { logout, updateUser } from "api/api" import { logout, updateUser } from "api/api";
import { useState } from "react" import { useState } from "react";
import { AsyncButton } from "../AsyncButton" import { AsyncButton } from "../AsyncButton";
import { useAppContext } from "~/providers/AppProvider" import { useAppContext } from "~/providers/AppProvider";
export default function Account() { export default function Account() {
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [confirmPw, setConfirmPw] = useState('') const [confirmPw, setConfirmPw] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [success, setSuccess] = useState('') const [success, setSuccess] = useState("");
const { user, setUsername: setCtxUsername } = useAppContext() const { user, setUsername: setCtxUsername } = useAppContext();
const logoutHandler = () => { const logoutHandler = () => {
setLoading(true) setLoading(true);
logout() logout()
.then(r => { .then((r) => {
if (r.ok) { if (r.ok) {
window.location.reload() window.location.reload();
} else { } else {
r.json().then(r => setError(r.error)) 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
} }
setError('') })
setSuccess('') .catch((err) => setError(err));
setLoading(true) setLoading(false);
updateUser(username, password) };
.then(r => { const updateHandler = () => {
if (r.ok) { setError("");
setSuccess("sucessfully updated user") setSuccess("");
if (username != "") { if (password != "" && confirmPw === "") {
setCtxUsername(username) setError("confirm your new password before submitting");
} return;
setUsername('')
setPassword('')
setConfirmPw('')
} else {
r.json().then((r) => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} }
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 ( return (
<> <>
<h2>Account</h2> <h3>Account</h3>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center"> <div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p> <p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton> You're logged in as <strong>{user?.username}</strong>
</div> </p>
<h2>Update User</h2> <AsyncButton loading={loading} onClick={logoutHandler}>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4"> Logout
<div className="flex flex gap-4"> </AsyncButton>
<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> </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"; import { useNavigate } from "react-router";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
trackid: number trackid: number;
} }
export default function AddListenModal({ open, setOpen, trackid }: Props) { export default function AddListenModal({ open, setOpen, trackid }: Props) {
const [ts, setTS] = useState<Date>(new Date); const [ts, setTS] = useState<Date>(new Date());
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const navigate = useNavigate() const navigate = useNavigate();
const close = () => { const close = () => {
setOpen(false) setOpen(false);
} };
const submit = () => { const submit = () => {
setLoading(true) setLoading(true);
submitListen(trackid.toString(), ts) submitListen(trackid.toString(), ts).then((r) => {
.then(r => { if (r.ok) {
if(r.ok) { setLoading(false);
setLoading(false) navigate(0);
navigate(0) } else {
} else { r.json().then((r) => setError(r.error));
r.json().then(r => setError(r.error)) setLoading(false);
setLoading(false) }
} });
}) };
}
const formatForDatetimeLocal = (d: Date) => { const formatForDatetimeLocal = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0"); 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 `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
}; d.getDate()
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
return ( return (
<Modal isOpen={open} onClose={close}> <Modal isOpen={open} onClose={close}>
<h2>Add Listen</h2> <h3>Add Listen</h3>
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<input <input
type="datetime-local" type="datetime-local"
className="w-full mx-auto fg bg rounded p-2" className="w-full mx-auto fg bg rounded p-2"
value={formatForDatetimeLocal(ts)} value={formatForDatetimeLocal(ts)}
onChange={(e) => setTS(new Date(e.target.value))} onChange={(e) => setTS(new Date(e.target.value))}
/> />
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton> <AsyncButton loading={loading} onClick={submit}>
<p className="error">{error}</p> Submit
</div> </AsyncButton>
</Modal> <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"; import { Copy, Trash } from "lucide-react";
type CopiedState = { type CopiedState = {
x: number; x: number;
y: number; y: number;
visible: boolean; visible: boolean;
}; };
export default function ApiKeysModal() { export default function ApiKeysModal() {
const [input, setInput] = useState('') const [input, setInput] = useState("");
const [loading, setLoading ] = useState(false) const [loading, setLoading] = useState(false);
const [err, setError ] = useState<string>() const [err, setError] = useState<string>();
const [displayData, setDisplayData] = useState<ApiKey[]>([]) const [displayData, setDisplayData] = useState<ApiKey[]>([]);
const [copied, setCopied] = useState<CopiedState | null>(null); const [copied, setCopied] = useState<CopiedState | null>(null);
const [expandedKey, setExpandedKey] = useState<string | null>(null); const [expandedKey, setExpandedKey] = useState<string | null>(null);
const textRefs = useRef<Record<string, HTMLDivElement | null>>({}); const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleRevealAndSelect = (key: string) => { const handleRevealAndSelect = (key: string) => {
setExpandedKey(key); setExpandedKey(key);
setTimeout(() => { setTimeout(() => {
const el = textRefs.current[key]; const el = textRefs.current[key];
if (el) { if (el) {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
const sel = window.getSelection(); const sel = window.getSelection();
sel?.removeAllRanges(); sel?.removeAllRanges();
sel?.addRange(range); sel?.addRange(range);
} }
}, 0); }, 0);
}; };
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: ["api-keys"],
'api-keys' queryFn: () => {
], return getApiKeys();
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(() => { setTimeout(() => setCopied(null), 1500);
if (data) { };
setDisplayData(data)
}
}, [data])
if (isError) { const fallbackCopy = (text: string) => {
return ( const textarea = document.createElement("textarea");
<p className="error">Error: {error.message}</p> 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) { document.body.removeChild(textarea);
return ( };
<p>Loading...</p>
) 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) => { const handleDeleteApiKey = (id: number) => {
if (navigator.clipboard && navigator.clipboard.writeText) { setError(undefined);
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); setLoading(true);
} else { deleteApiKey(id).then((r) => {
fallbackCopy(text); if (r.ok) {
} setDisplayData(displayData.filter((v) => v.id != id));
} else {
r.json().then((r) => setError(r.error));
}
});
setLoading(false);
};
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect(); return (
const buttonRect = e.currentTarget.getBoundingClientRect(); <div className="">
<h3>API Keys</h3>
setCopied({ <div className="flex flex-col gap-4 relative">
x: buttonRect.left - parentRect.left + buttonRect.width / 2, {displayData.map((v) => (
y: buttonRect.top - parentRect.top - 8, <div className="flex gap-2">
visible: true, <div
}); key={v.key}
ref={(el) => {
setTimeout(() => setCopied(null), 1500); textRefs.current[v.key] = el;
}; }}
onClick={() => handleRevealAndSelect(v.key)}
const fallbackCopy = (text: string) => { className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
const textarea = document.createElement("textarea"); expandedKey === v.key ? "" : "truncate"
textarea.value = text; }`}
textarea.style.position = "fixed"; // prevent scroll to bottom style={{ whiteSpace: "nowrap" }}
document.body.appendChild(textarea); title={v.key} // optional tooltip
textarea.focus(); >
textarea.select(); {expandedKey === v.key
try { ? v.key
document.execCommand("copy"); : `${v.key.slice(0, 8)}... ${v.label}`}
} 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)
}
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>
</div> </div>
{err && <p className="error">{err}</p>} <button
{copied?.visible && ( onClick={(e) => handleCopy(e, v.key)}
<div className="large-button px-5 rounded-md"
style={{ >
position: "absolute", <Copy size={16} />
top: copied.y, </button>
left: copied.x, <AsyncButton
transform: "translate(-50%, -100%)", loading={loading}
}} onClick={() => handleDeleteApiKey(v.id)}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade" confirm
> >
Copied! <Trash size={16} />
</div> </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>
</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 { deleteItem } from "api/api";
import { AsyncButton } from "../AsyncButton" import { AsyncButton } from "../AsyncButton";
import { Modal } from "./Modal" import { Modal } from "./Modal";
import { useNavigate } from "react-router" import { useNavigate } from "react-router";
import { useState } from "react" import { useState } from "react";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
title: string, title: string;
id: number, id: number;
type: string type: string;
} }
export default function DeleteModal({ open, setOpen, title, id, type }: Props) { export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const navigate = useNavigate() const navigate = useNavigate();
const doDelete = () => { const doDelete = () => {
setLoading(true) setLoading(true);
deleteItem(type.toLowerCase(), id) deleteItem(type.toLowerCase(), id).then((r) => {
.then(r => { if (r.ok) {
if (r.ok) { navigate("/");
navigate('/') } else {
} else { console.log(r);
console.log(r) }
} });
}) };
}
return ( return (
<Modal isOpen={open} onClose={() => setOpen(false)}> <Modal isOpen={open} onClose={() => setOpen(false)}>
<h2>Delete "{title}"?</h2> <h3>Delete "{title}"?</h3>
<p>This action is irreversible!</p> <p>This action is irreversible!</p>
<div className="flex flex-col mt-3 items-center"> <div className="flex flex-col mt-3 items-center">
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton> <AsyncButton loading={loading} onClick={doDelete}>
</div> Yes, Delete It
</Modal> </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}> <Modal maxW={1000} isOpen={open} onClose={handleClose}>
<div className="flex flex-col items-start gap-6 w-full"> <div className="flex flex-col items-start gap-6 w-full">
<div className="w-full"> <div className="w-full">
<h2>Alias Manager</h2> <h3>Alias Manager</h3>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{displayData.map((v) => ( {displayData.map((v) => (
<div className="flex gap-2"> <div className="flex gap-2">

View file

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

View file

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

View file

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

View file

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

View file

@ -1,59 +1,74 @@
import { login } from "api/api" import { login } from "api/api";
import { useEffect, useState } from "react" import { useEffect, useState } from "react";
import { AsyncButton } from "../AsyncButton" import { AsyncButton } from "../AsyncButton";
export default function LoginForm() { export default function LoginForm() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false) const [remember, setRemember] = useState(false);
const loginHandler = () => { const loginHandler = () => {
if (username && password) { if (username && password) {
setLoading(true) setLoading(true);
login(username, password, remember) login(username, password, remember)
.then(r => { .then((r) => {
if (r.status >= 200 && r.status < 300) { if (r.status >= 200 && r.status < 300) {
window.location.reload() window.location.reload();
} else { } else {
r.json().then(r => setError(r.error)) r.json().then((r) => setError(r.error));
} }
}).catch(err => setError(err)) })
setLoading(false) .catch((err) => setError(err));
} else if (username || password) { setLoading(false);
setError("username and password are required") } else if (username || password) {
} setError("username and password are required");
} }
};
return ( return (
<> <>
<h2>Log In</h2> <h3>Log In</h3>
<div className="flex flex-col items-center gap-4 w-full"> <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> <p>
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}> Logging in gives you access to <strong>admin tools</strong>, such as
<input updating images, merging items, deleting items, and more.
name="koito-username" </p>
type="text" <form
placeholder="Username" action="#"
className="w-full mx-auto fg bg rounded p-2" className="flex flex-col items-center gap-4 w-3/4"
onChange={(e) => setUsername(e.target.value)} onSubmit={(e) => e.preventDefault()}
/> >
<input <input
name="koito-password" name="koito-username"
type="password" type="text"
placeholder="Password" placeholder="Username"
className="w-full mx-auto fg bg rounded p-2" className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<div className="flex gap-2"> <input
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} /> name="koito-password"
<label htmlFor="kotio-remember">Remember me</label> type="password"
</div> placeholder="Password"
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton> className="w-full mx-auto fg bg rounded p-2"
</form> onChange={(e) => setPassword(e.target.value)}
<p className="error">{error}</p> />
</div> <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 { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api"; import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults"; 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"; import { useNavigate } from "react-router";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
type: string type: string;
currentId: number currentId: number;
currentTitle: string currentTitle: string;
mergeFunc: MergeFunc mergeFunc: MergeFunc;
mergeCleanerFunc: MergeSearchCleanerFunc mergeCleanerFunc: MergeSearchCleanerFunc;
} }
export default function MergeModal(props: Props) { export default function MergeModal(props: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState("");
const [data, setData] = useState<SearchResponse>(); const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query); const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0}) const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
const [mergeOrderReversed, setMergeOrderReversed] = useState(false) { title: "", id: 0 }
const [replaceImage, setReplaceImage] = useState(false) );
const navigate = useNavigate() 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 = () => { const toggleSelect = ({ title, id }: { title: string; id: number }) => {
props.setOpen(false) setMergeTarget({ title: title, id: id });
setQuery('') };
setData(undefined)
setMergeOrderReversed(false) useEffect(() => {
setMergeTarget({title: '', id: 0}) 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;
} }
props
const toggleSelect = ({title, id}: {title: string, id: number}) => { .mergeFunc(from.id, to.id, replaceImage)
setMergeTarget({title: title, id: id}) .then((r) => {
} if (r.ok) {
if (mergeOrderReversed) {
useEffect(() => { navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
console.log("mergeTarget",mergeTarget) closeMergeModal();
}, [mergeTarget]) } else {
window.location.reload();
const doMerge = () => { }
let from, to
if (!mergeOrderReversed) {
from = mergeTarget
to = {id: props.currentId, title: props.currentTitle}
} else { } else {
from = {id: props.currentId, title: props.currentTitle} // TODO: handle error
to = mergeTarget console.log(r);
} }
props.mergeFunc(from.id, to.id, replaceImage) })
.then(r => { .catch((err) => console.log(err));
if (r.ok) { };
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`) useEffect(() => {
closeMergeModal() const handler = setTimeout(() => {
} else { setDebouncedQuery(query);
window.location.reload() if (query === "") {
} setData(undefined);
} else { }
// TODO: handle error }, 300);
console.log(r)
} return () => {
}) clearTimeout(handler);
.catch((err) => console.log(err)) };
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
r = props.mergeCleanerFunc(r, props.currentId);
setData(r);
});
} }
}, [debouncedQuery]);
useEffect(() => { return (
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);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={props.open} onClose={closeMergeModal}> <Modal isOpen={props.open} onClose={closeMergeModal}>
<h2>Merge {props.type}s</h2> <h3>Merge {props.type}s</h3>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<input <input
type="text" type="text"
autoFocus autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal // 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()}`} placeholder={`Search for a${
className="w-full mx-auto fg bg rounded p-2" props.type.toLowerCase()[0] === "a" ? "n" : ""
onChange={(e) => setQuery(e.target.value)} } ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
/> className="w-full mx-auto fg bg rounded p-2"
<SearchResults selectorMode data={data} onSelect={toggleSelect}/> onChange={(e) => setQuery(e.target.value)}
{ mergeTarget.id !== 0 ? />
<> <SearchResults selectorMode data={data} onSelect={toggleSelect} />
{mergeOrderReversed ? {mergeTarget.id !== 0 ? (
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p> <>
: {mergeOrderReversed ? (
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p> <p className="mt-5">
} <strong>{props.currentTitle}</strong> will be merged into{" "}
<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> <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"> <div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} /> <input
<label htmlFor="reverse-merge-order">Reverse merge order</label> type="checkbox"
name="reverse-merge-order"
checked={mergeOrderReversed}
onChange={() => setMergeOrderReversed(!mergeOrderReversed)}
/>
<label htmlFor="reverse-merge-order">Reverse merge order</label>
</div> </div>
{ {(props.type.toLowerCase() === "album" ||
(props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") && props.type.toLowerCase() === "artist") && (
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<input type="checkbox" name="replace-image" checked={replaceImage} onChange={() => setReplaceImage(!replaceImage)} /> <input
type="checkbox"
name="replace-image"
checked={replaceImage}
onChange={() => setReplaceImage(!replaceImage)}
/>
<label htmlFor="replace-image">Replace image</label> <label htmlFor="replace-image">Replace image</label>
</div> </div>
} )}
</> : </>
''} ) : (
</div> ""
)}
</div>
</Modal> </Modal>
) );
} }

View file

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

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,13 +1,15 @@
import { ExternalLink, Home, Info } from "lucide-react"; import { ExternalLink, History, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch"; import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem"; import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings"; import SidebarSettings from "./SidebarSettings";
import { getRewindYear } from "~/utils/utils";
export default function Sidebar() { export default function Sidebar() {
const iconSize = 20; const iconSize = 20;
return ( return (
<div className=" <div
className="
z-50 z-50
flex flex
sm:flex-col sm:flex-col
@ -28,28 +30,44 @@ export default function Sidebar() {
sm:px-1 sm:px-1
px-4 px-4
bg-(--color-bg) bg-(--color-bg)
"> "
<div className="flex gap-4 sm:flex-col"> >
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}> <div className="flex gap-4 sm:flex-col">
<Home size={iconSize} /> <SidebarItem
</SidebarItem> space={10}
<SidebarSearch size={iconSize} /> to="/"
</div> name="Home"
<div className="flex gap-4 sm:flex-col"> onClick={() => {}}
<SidebarItem modal={<></>}
icon >
keyHint={<ExternalLink size={14} />} <Home size={iconSize} />
space={22} </SidebarItem>
externalLink <SidebarSearch size={iconSize} />
to="https://koito.io" <SidebarItem
name="About" space={10}
onClick={() => {}} to={`/rewind?year=${getRewindYear()}`}
modal={<></>} name="Rewind"
> onClick={() => {}}
<Info size={iconSize} /> modal={<></>}
</SidebarItem> >
<SidebarSettings size={iconSize} /> <History size={iconSize} />
</div> </SidebarItem>
</div> </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 className="flex flex-col gap-10">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2>Select Theme</h2> <h3>Select Theme</h3>
<div className="mb-3"> <div className="mb-3">
<AsyncButton onClick={resetTheme}>Reset</AsyncButton> <AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div> </div>
@ -61,7 +61,7 @@ export function ThemeSwitcher() {
</div> </div>
</div> </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"> <div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea <textarea
name="custom-theme" name="custom-theme"

View file

@ -1,13 +1,14 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes"; import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [ export default [
index("routes/Home.tsx"), index("routes/Home.tsx"),
route("/artist/:id", "routes/MediaItems/Artist.tsx"), route("/artist/:id", "routes/MediaItems/Artist.tsx"),
route("/album/:id", "routes/MediaItems/Album.tsx"), route("/album/:id", "routes/MediaItems/Album.tsx"),
route("/track/:id", "routes/MediaItems/Track.tsx"), route("/track/:id", "routes/MediaItems/Track.tsx"),
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"), route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"), route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"), route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
route("/listens", "routes/Charts/Listens.tsx"), route("/listens", "routes/Charts/Listens.tsx"),
route("/theme-helper", "routes/ThemeHelper.tsx"), route("/rewind", "routes/RewindPage.tsx"),
route("/theme-helper", "routes/ThemeHelper.tsx"),
] satisfies RouteConfig; ] 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) { function timeSince(date: Date) {
const now = new Date(); const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
@ -104,5 +114,5 @@ const timeListenedString = (seconds: number) => {
return `${minutes} minutes listened`; return `${minutes} minutes listened`;
}; };
export { hexToHSL, timeListenedString }; export { hexToHSL, timeListenedString, getRewindYear };
export type { hsl }; export type { hsl };

View file

@ -78,6 +78,17 @@ FROM listens l
JOIN artist_tracks at ON l.track_id = at.track_id JOIN artist_tracks at ON l.track_id = at.track_id
WHERE l.listened_at BETWEEN $1 AND $2; 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 -- name: UpdateArtistMbzID :exec
UPDATE artists SET musicbrainz_id = $2 UPDATE artists SET musicbrainz_id = $2
WHERE id = $1; WHERE id = $1;

View file

@ -73,6 +73,16 @@ FROM releases r
JOIN artist_releases ar ON r.id = ar.release_id JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $1; 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 -- name: AssociateArtistToRelease :exec
INSERT INTO artist_releases (artist_id, release_id, is_primary) INSERT INTO artist_releases (artist_id, release_id, is_primary)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)

View file

@ -109,6 +109,15 @@ JOIN tracks t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
AND t.release_id = $3; 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 -- name: UpdateTrackMbzID :exec
UPDATE tracks SET musicbrainz_id = $2 UPDATE tracks SET musicbrainz_id = $2
WHERE id = $1; 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" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
@ -81,10 +82,93 @@ func OptsFromRequest(r *http.Request) db.GetItemsOpts {
Week: week, Week: week,
Month: month, Month: month,
Year: year, Year: year,
From: from, From: int64(from),
To: to, To: int64(to),
ArtistID: artistId, ArtistID: artistId,
AlbumID: albumId, AlbumID: albumId,
TrackID: trackId, 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) 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 { if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch listen count") l.Err(err).Msg("StatsHandler: Failed to fetch listen count")
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError) utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return return
} }
tracks, err := store.CountTracks(r.Context(), period) tracks, err := store.CountTracks(r.Context(), db.Timeframe{Period: period})
if err != nil { if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch track count") l.Err(err).Msg("StatsHandler: Failed to fetch track count")
utils.WriteError(w, "failed to get tracks: "+err.Error(), http.StatusInternalServerError) utils.WriteError(w, "failed to get tracks: "+err.Error(), http.StatusInternalServerError)
return return
} }
albums, err := store.CountAlbums(r.Context(), period) albums, err := store.CountAlbums(r.Context(), db.Timeframe{Period: period})
if err != nil { if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch album count") l.Err(err).Msg("StatsHandler: Failed to fetch album count")
utils.WriteError(w, "failed to get albums: "+err.Error(), http.StatusInternalServerError) utils.WriteError(w, "failed to get albums: "+err.Error(), http.StatusInternalServerError)
return return
} }
artists, err := store.CountArtists(r.Context(), period) artists, err := store.CountArtists(r.Context(), db.Timeframe{Period: period})
if err != nil { if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch artist count") l.Err(err).Msg("StatsHandler: Failed to fetch artist count")
utils.WriteError(w, "failed to get artists: "+err.Error(), http.StatusInternalServerError) utils.WriteError(w, "failed to get artists: "+err.Error(), http.StatusInternalServerError)
return return
} }
timeListenedS, err := store.CountTimeListened(r.Context(), period) timeListenedS, err := store.CountTimeListened(r.Context(), db.Timeframe{Period: period})
if err != nil { if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch time listened") l.Err(err).Msg("StatsHandler: Failed to fetch time listened")
utils.WriteError(w, "failed to get time listened: "+err.Error(), http.StatusInternalServerError) 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}}) _, err = store.GetTrack(ctx, db.GetTrackOpts{Title: "GIRI GIRI", ArtistIDs: []int32{artist.ID}})
require.NoError(t, err) 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) require.NoError(t, err)
assert.EqualValues(t, 4, count) 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) require.NoError(t, err)
assert.EqualValues(t, 3, count) 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) require.NoError(t, err)
assert.EqualValues(t, 6, count) assert.EqualValues(t, 6, count)

View file

@ -54,6 +54,7 @@ func bindRoutes(
r.Get("/stats", handlers.StatsHandler(db)) r.Get("/stats", handlers.StatsHandler(db))
r.Get("/search", handlers.SearchHandler(db)) r.Get("/search", handlers.SearchHandler(db))
r.Get("/aliases", handlers.GetAliasesHandler(db)) r.Get("/aliases", handlers.GetAliasesHandler(db))
r.Get("/summary", handlers.SummaryHandler(db))
}) })
r.Post("/logout", handlers.LogoutHandler(db)) r.Post("/logout", handlers.LogoutHandler(db))
if !cfg.RateLimitDisabled() { if !cfg.RateLimitDisabled() {

5
go.mod
View file

@ -12,7 +12,7 @@ require (
github.com/pressly/goose/v3 v3.24.3 github.com/pressly/goose/v3 v3.24.3
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.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 golang.org/x/time v0.11.0
) )
@ -60,7 +60,8 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/multierr v1.11.0 // 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/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 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/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 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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= 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.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 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 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.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 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -11,6 +11,7 @@ import (
type DB interface { type DB interface {
// Get // Get
GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error) GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error)
GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error) GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error)
GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, 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) GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*models.User, error)
GetUserByUsername(ctx context.Context, username string) (*models.User, error) GetUserByUsername(ctx context.Context, username string) (*models.User, error)
GetUserByApiKey(ctx context.Context, key string) (*models.User, error) GetUserByApiKey(ctx context.Context, key string) (*models.User, error)
// Save // Save
SaveArtist(ctx context.Context, opts SaveArtistOpts) (*models.Artist, error) SaveArtist(ctx context.Context, opts SaveArtistOpts) (*models.Artist, error)
SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error
SaveAlbum(ctx context.Context, opts SaveAlbumOpts) (*models.Album, 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) SaveUser(ctx context.Context, opts SaveUserOpts) (*models.User, error)
SaveApiKey(ctx context.Context, opts SaveApiKeyOpts) (*models.ApiKey, error) SaveApiKey(ctx context.Context, opts SaveApiKeyOpts) (*models.ApiKey, error)
SaveSession(ctx context.Context, userId int32, expiresAt time.Time, persistent bool) (*models.Session, error) SaveSession(ctx context.Context, userId int32, expiresAt time.Time, persistent bool) (*models.Session, error)
// Update // Update
UpdateArtist(ctx context.Context, opts UpdateArtistOpts) error UpdateArtist(ctx context.Context, opts UpdateArtistOpts) error
UpdateTrack(ctx context.Context, opts UpdateTrackOpts) error UpdateTrack(ctx context.Context, opts UpdateTrackOpts) error
UpdateAlbum(ctx context.Context, opts UpdateAlbumOpts) error UpdateAlbum(ctx context.Context, opts UpdateAlbumOpts) error
@ -52,7 +57,9 @@ type DB interface {
SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) error SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) error
SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error
SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error
// Delete // Delete
DeleteArtist(ctx context.Context, id int32) error DeleteArtist(ctx context.Context, id int32) error
DeleteAlbum(ctx context.Context, id int32) error DeleteAlbum(ctx context.Context, id int32) error
DeleteTrack(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 DeleteTrackAlias(ctx context.Context, id int32, alias string) error
DeleteSession(ctx context.Context, sessionId uuid.UUID) error DeleteSession(ctx context.Context, sessionId uuid.UUID) error
DeleteApiKey(ctx context.Context, id int32) error DeleteApiKey(ctx context.Context, id int32) error
// Count // Count
CountListens(ctx context.Context, period Period) (int64, error)
CountTracks(ctx context.Context, period Period) (int64, error) CountListens(ctx context.Context, timeframe Timeframe) (int64, error)
CountAlbums(ctx context.Context, period Period) (int64, error) CountListensToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
CountArtists(ctx context.Context, period Period) (int64, error) CountTracks(ctx context.Context, timeframe Timeframe) (int64, error)
CountTimeListened(ctx context.Context, period Period) (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) CountTimeListenedToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
CountUsers(ctx context.Context) (int64, error) CountUsers(ctx context.Context) (int64, error)
// Search // Search
SearchArtists(ctx context.Context, q string) ([]*models.Artist, error) SearchArtists(ctx context.Context, q string) ([]*models.Artist, error)
SearchAlbums(ctx context.Context, q string) ([]*models.Album, error) SearchAlbums(ctx context.Context, q string) ([]*models.Album, error)
SearchTracks(ctx context.Context, q string) ([]*models.Track, error) SearchTracks(ctx context.Context, q string) ([]*models.Track, error)
// Merge // Merge
MergeTracks(ctx context.Context, fromId, toId int32) error MergeTracks(ctx context.Context, fromId, toId int32) error
MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error
MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error
// Etc // Etc
ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error)
GetImageSource(ctx context.Context, image uuid.UUID) (string, error) GetImageSource(ctx context.Context, image uuid.UUID) (string, error)
AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.Album, error) AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.Album, error)

View file

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

View file

@ -6,6 +6,23 @@ import (
// should this be in db package ??? // 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 type Period string
const ( 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{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Timeframe: db.Timeframe{Period: db.PeriodAllTime},
AlbumID: ret.ID, AlbumID: ret.ID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err) 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) return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) 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) return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) 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) return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)

View file

@ -4,15 +4,13 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/repository" "github.com/gabehf/koito/internal/repository"
) )
func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountListens(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t2 := time.Now() t1, t2 := db.TimeframeToTimeRange(timeframe)
t1 := db.StartTimeFromPeriod(period)
count, err := p.q.CountListens(ctx, repository.CountListensParams{ count, err := p.q.CountListens(ctx, repository.CountListensParams{
ListenedAt: t1, ListenedAt: t1,
ListenedAt_2: t2, ListenedAt_2: t2,
@ -23,9 +21,8 @@ func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error
return count, nil return count, nil
} }
func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountTracks(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t2 := time.Now() t1, t2 := db.TimeframeToTimeRange(timeframe)
t1 := db.StartTimeFromPeriod(period)
count, err := p.q.CountTopTracks(ctx, repository.CountTopTracksParams{ count, err := p.q.CountTopTracks(ctx, repository.CountTopTracksParams{
ListenedAt: t1, ListenedAt: t1,
ListenedAt_2: t2, ListenedAt_2: t2,
@ -36,9 +33,8 @@ func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error)
return count, nil return count, nil
} }
func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountAlbums(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t2 := time.Now() t1, t2 := db.TimeframeToTimeRange(timeframe)
t1 := db.StartTimeFromPeriod(period)
count, err := p.q.CountTopReleases(ctx, repository.CountTopReleasesParams{ count, err := p.q.CountTopReleases(ctx, repository.CountTopReleasesParams{
ListenedAt: t1, ListenedAt: t1,
ListenedAt_2: t2, ListenedAt_2: t2,
@ -49,9 +45,8 @@ func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error)
return count, nil return count, nil
} }
func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountArtists(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t2 := time.Now() t1, t2 := db.TimeframeToTimeRange(timeframe)
t1 := db.StartTimeFromPeriod(period)
count, err := p.q.CountTopArtists(ctx, repository.CountTopArtistsParams{ count, err := p.q.CountTopArtists(ctx, repository.CountTopArtistsParams{
ListenedAt: t1, ListenedAt: t1,
ListenedAt_2: t2, ListenedAt_2: t2,
@ -62,9 +57,9 @@ func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error
return count, nil return count, nil
} }
func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, error) { // in seconds
t2 := time.Now() func (p *Psql) CountTimeListened(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1 := db.StartTimeFromPeriod(period) t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTimeListened(ctx, repository.CountTimeListenedParams{ count, err := p.q.CountTimeListened(ctx, repository.CountTimeListenedParams{
ListenedAt: t1, ListenedAt: t1,
ListenedAt_2: t2, ListenedAt_2: t2,
@ -75,9 +70,9 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
return count, nil return count, nil
} }
// in seconds
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) { func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t2 := time.Now() t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
t1 := db.StartTimeFromPeriod(opts.Period)
if opts.ArtistID > 0 { if opts.ArtistID > 0 {
count, err := p.q.CountTimeListenedToArtist(ctx, repository.CountTimeListenedToArtistParams{ 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") 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 ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -15,7 +16,7 @@ func TestCountListens(t *testing.T) {
// Test CountListens // Test CountListens
period := db.PeriodWeek period := db.PeriodWeek
count, err := store.CountListens(ctx, period) count, err := store.CountListens(ctx, db.Timeframe{Period: period})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(1), count, "expected listens count to match inserted data") assert.Equal(t, int64(1), count, "expected listens count to match inserted data")
@ -28,46 +29,97 @@ func TestCountTracks(t *testing.T) {
// Test CountTracks // Test CountTracks
period := db.PeriodMonth period := db.PeriodMonth
count, err := store.CountTracks(ctx, period) count, err := store.CountTracks(ctx, db.Timeframe{Period: period})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(2), count, "expected tracks count to match inserted data") assert.Equal(t, int64(2), count, "expected tracks count to match inserted data")
truncateTestData(t) 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) { func TestCountAlbums(t *testing.T) {
ctx := context.Background() ctx := context.Background()
testDataForTopItems(t) testDataForTopItems(t)
// Test CountAlbums // Test CountAlbums
period := db.PeriodYear period := db.PeriodYear
count, err := store.CountAlbums(ctx, period) count, err := store.CountAlbums(ctx, db.Timeframe{Period: period})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(3), count, "expected albums count to match inserted data") assert.Equal(t, int64(3), count, "expected albums count to match inserted data")
truncateTestData(t) 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) { func TestCountArtists(t *testing.T) {
ctx := context.Background() ctx := context.Background()
testDataForTopItems(t) testDataForTopItems(t)
// Test CountArtists // Test CountArtists
period := db.PeriodAllTime period := db.PeriodAllTime
count, err := store.CountArtists(ctx, period) count, err := store.CountArtists(ctx, db.Timeframe{Period: period})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(4), count, "expected artists count to match inserted data") assert.Equal(t, int64(4), count, "expected artists count to match inserted data")
truncateTestData(t) 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) { func TestCountTimeListened(t *testing.T) {
ctx := context.Background() ctx := context.Background()
testDataForTopItems(t) testDataForTopItems(t)
// Test CountTimeListened // Test CountTimeListened
period := db.PeriodMonth period := db.PeriodMonth
count, err := store.CountTimeListened(ctx, period) count, err := store.CountTimeListened(ctx, db.Timeframe{Period: period})
require.NoError(t, err) require.NoError(t, err)
// 3 listens in past month, each 100 seconds // 3 listens in past month, each 100 seconds
assert.Equal(t, int64(300), count, "expected total time listened to match inserted data") 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() ctx := context.Background()
testDataForTopItems(t) testDataForTopItems(t)
period := db.PeriodAllTime 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) require.NoError(t, err)
assert.EqualValues(t, 400, count) assert.EqualValues(t, 400, count)
truncateTestData(t) truncateTestData(t)
@ -89,7 +141,7 @@ func TestCountTimeListenedToAlbum(t *testing.T) {
ctx := context.Background() ctx := context.Background()
testDataForTopItems(t) testDataForTopItems(t)
period := db.PeriodAllTime 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) require.NoError(t, err)
assert.EqualValues(t, 300, count) assert.EqualValues(t, 300, count)
truncateTestData(t) truncateTestData(t)
@ -99,8 +151,38 @@ func TestCountTimeListenedToTrack(t *testing.T) {
ctx := context.Background() ctx := context.Background()
testDataForTopItems(t) testDataForTopItems(t)
period := db.PeriodAllTime 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) require.NoError(t, err)
assert.EqualValues(t, 200, count) assert.EqualValues(t, 200, count)
truncateTestData(t) 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() t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period) 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 { if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage opts.Limit = DefaultItemsPerPage
} }

View file

@ -24,6 +24,10 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now() t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period) 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 { if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage opts.Limit = DefaultItemsPerPage
} }

View file

@ -25,6 +25,10 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now() t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period) 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 { if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage 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{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Timeframe: db.Timeframe{Period: db.PeriodAllTime},
TrackID: track.ID, TrackID: track.ID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)

View file

@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"path" "path"
"strings" "strings"
"time" "time"
@ -34,15 +33,16 @@ func ImportListenBrainzExport(ctx context.Context, store db.DB, mbzc mbz.MusicBr
for _, f := range r.File { for _, f := range r.File {
if f.FileInfo().IsDir() { if f.FileInfo().IsDir() {
l.Debug().Msgf("File %s is dir, skipping...", f.Name)
continue continue
} }
if strings.HasPrefix(f.Name, "listens/") && strings.HasSuffix(f.Name, ".jsonl") { 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() rc, err := f.Open()
if err != nil { 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 continue
} }
@ -75,7 +75,7 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
payload := new(handlers.LbzSubmitListenPayload) payload := new(handlers.LbzSubmitListenPayload)
err := json.Unmarshal(line, payload) err := json.Unmarshal(line, payload)
if err != nil { if err != nil {
fmt.Println("Error unmarshaling JSON:", err) l.Err(err).Msg("Error unmarshaling JSON")
continue continue
} }
ts := time.Unix(payload.ListenedAt, 0) ts := time.Unix(payload.ListenedAt, 0)

View file

@ -13,6 +13,30 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 const countTopArtists = `-- name: CountTopArtists :one
SELECT COUNT(DISTINCT at.artist_id) AS total_count SELECT COUNT(DISTINCT at.artist_id) AS total_count
FROM listens l FROM listens l

View file

@ -30,6 +30,29 @@ func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArt
return err 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 const countReleasesFromArtist = `-- name: CountReleasesFromArtist :one
SELECT COUNT(*) SELECT COUNT(*)
FROM releases r FROM releases r

View file

@ -29,6 +29,28 @@ func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtis
return err 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 const countTopTracks = `-- name: CountTopTracks :one
SELECT COUNT(DISTINCT l.track_id) AS total_count SELECT COUNT(DISTINCT l.track_id) AS total_count
FROM listens l FROM listens l

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