mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
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:
parent
c0a8c64243
commit
d4ac96f780
64 changed files with 2252 additions and 1055 deletions
4
Makefile
4
Makefile
|
|
@ -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
BIN
assets/Jost-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/LeagueSpartan-Medium.ttf
Normal file
BIN
assets/LeagueSpartan-Medium.ttf
Normal file
Binary file not shown.
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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" : ""}
|
||||||
|
|
|
||||||
|
|
@ -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" : ""}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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" : ""}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
client/app/components/rewind/Rewind.tsx
Normal file
72
client/app/components/rewind/Rewind.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
client/app/components/rewind/RewindStatText.tsx
Normal file
32
client/app/components/rewind/RewindStatText.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
client/app/components/rewind/RewindTopItem.tsx
Normal file
55
client/app/components/rewind/RewindTopItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
52
client/app/routes/RewindPage.tsx
Normal file
52
client/app/routes/RewindPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
28
engine/handlers/get_summary.go
Normal file
28
engine/handlers/get_summary.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
5
go.mod
|
|
@ -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
6
go.sum
|
|
@ -136,6 +136,8 @@ golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
186
internal/summary/image.go
Normal 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
141
internal/summary/summary.go
Normal 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
|
||||||
|
}
|
||||||
BIN
internal/summary/summary.png
Normal file
BIN
internal/summary/summary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
84
internal/summary/summary_test.go
Normal file
84
internal/summary/summary_test.go
Normal 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))
|
||||||
|
}
|
||||||
BIN
test_assets/default_img.webp
Normal file
BIN
test_assets/default_img.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Loading…
Add table
Add a link
Reference in a new issue