mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
Pre-release version v0.0.13 (#52)
* feat: search/merge items by id * feat: update track duration using musicbrainz * chore: changelog * fix: make username updates case insensitive * feat: add minutes listened to ui and fix image drop * chore: changelog * fix: embed db migrations (#37) * feat: Add support for ARM in publish workflow (#51) * chore: changelog * docs: search by id and custom theme support --------- Co-authored-by: potatoattack <lvl70nub@gmail.com> Co-authored-by: Benjamin Jonard <benjaminjonard@users.noreply.github.com>
This commit is contained in:
parent
5537b6fb89
commit
5419178012
32 changed files with 1252 additions and 100 deletions
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
|
|
@ -83,6 +83,7 @@ jobs:
|
||||||
gabehf/koito:${{ env.KOITO_VERSION }}
|
gabehf/koito:${{ env.KOITO_VERSION }}
|
||||||
build-args: |
|
build-args: |
|
||||||
KOITO_VERSION=${{ env.KOITO_VERSION }}
|
KOITO_VERSION=${{ env.KOITO_VERSION }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Generate artifact attestation
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
|
|
|
||||||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,22 +1,16 @@
|
||||||
# v0.0.10
|
# v0.0.13
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Support for custom themes added! You can find the custom theme input in the Appearance menu.
|
|
||||||
- Allow loading environment variables from files using the _FILE suffix (#20)
|
|
||||||
- All activity grids (calendar heatmaps) are now configurable
|
|
||||||
- Native import and export
|
|
||||||
|
|
||||||
## Enhancements
|
## Enhancements
|
||||||
- The activity grid on the home page is now configurable
|
- Track durations will now be updated using MusicBrainz data where possible, if the duration was not provided by the request. (#27)
|
||||||
|
- You can now search and merge items by their ID! Just preface the id with `id:`. E.g. `id:123` (#26)
|
||||||
|
- Hovering over any "hours listened" statistic will now also show the minutes listened.
|
||||||
|
- An experiemental ARM docker image has been added. (#51)
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
- Sub-second precision is stripped from incoming listens to ensure they can be deleted reliably
|
- Navigating from one page directly to another and then changing the image via drag-and-drop now works as expected. (#25)
|
||||||
- Top items are now sorted by id for stability
|
- Fixed a bug that caused updated usernames with uppercase letters to create login failures.
|
||||||
- Clear input when closing edit modal
|
|
||||||
- Use correct request body for create and delete alias requests
|
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
- Adjusted colors for the "Yuu" theme
|
- Migrations are now embedded to allow for a community AUR package. (#37)
|
||||||
- Themes now have a single source of truth in themes.css.ts
|
|
||||||
- Configurable activity grids now have a re-styled, collapsible menu
|
|
||||||
- The year option for activity grids has been removed
|
|
||||||
9
Makefile
9
Makefile
|
|
@ -12,6 +12,9 @@ postgres.schemadump:
|
||||||
postgres.run:
|
postgres.run:
|
||||||
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
||||||
|
|
||||||
|
postgres.run-scratch:
|
||||||
|
docker run --name koito-scratch -p 5433:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
||||||
|
|
||||||
postgres.start:
|
postgres.start:
|
||||||
docker start koito-db
|
docker start koito-db
|
||||||
|
|
||||||
|
|
@ -21,9 +24,15 @@ postgres.stop:
|
||||||
postgres.remove:
|
postgres.remove:
|
||||||
docker stop koito-db && docker rm koito-db
|
docker stop koito-db && docker rm koito-db
|
||||||
|
|
||||||
|
postgres.remove-scratch:
|
||||||
|
docker stop koito-scratch && docker rm koito-scratch
|
||||||
|
|
||||||
api.debug:
|
api.debug:
|
||||||
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:
|
||||||
|
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:
|
||||||
go test ./... -timeout 60s
|
go test ./... -timeout 60s
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ type Stats = {
|
||||||
track_count: number
|
track_count: number
|
||||||
album_count: number
|
album_count: number
|
||||||
artist_count: number
|
artist_count: number
|
||||||
hours_listened: number
|
minutes_listened: number
|
||||||
}
|
}
|
||||||
type SearchResponse = {
|
type SearchResponse = {
|
||||||
albums: Album[]
|
albums: Album[]
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default function AllTimeStats() {
|
||||||
<div>
|
<div>
|
||||||
<h2>All Time Stats</h2>
|
<h2>All Time Stats</h2>
|
||||||
<div>
|
<div>
|
||||||
<span className={numberClasses}>{data.hours_listened}</span> Hours Listened
|
<span className={numberClasses} title={data.minutes_listened + " minutes"}>{Math.floor(data.minutes_listened / 60)}</span> Hours Listened
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className={numberClasses}>{data.listen_count}</span> Plays
|
<span className={numberClasses}>{data.listen_count}</span> Plays
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@ import { useEffect } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
itemType: string,
|
itemType: string,
|
||||||
id: number,
|
|
||||||
onComplete: Function
|
onComplete: Function
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
export default function ImageDropHandler({ itemType, onComplete }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDragOver = (e: DragEvent) => {
|
const handleDragOver = (e: DragEvent) => {
|
||||||
console.log('dragover!!')
|
console.log('dragover!!')
|
||||||
|
|
@ -25,7 +24,11 @@ export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', imageFile);
|
formData.append('image', imageFile);
|
||||||
formData.append(itemType.toLowerCase()+'_id', String(id))
|
const pathname = window.location.pathname;
|
||||||
|
const segments = pathname.split('/');
|
||||||
|
const filteredSegments = segments.filter(segment => segment !== '');
|
||||||
|
const lastSegment = filteredSegments[filteredSegments.length - 1];
|
||||||
|
formData.append(itemType.toLowerCase()+'_id', lastSegment)
|
||||||
replaceImage(formData).then((r) => {
|
replaceImage(formData).then((r) => {
|
||||||
if (r.status >= 200 && r.status < 300) {
|
if (r.status >= 200 && r.status < 300) {
|
||||||
onComplete()
|
onComplete()
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,12 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data === undefined) {
|
if (!data) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{ data.artists.length > 0 &&
|
{ data.artists && data.artists.length > 0 &&
|
||||||
<>
|
<>
|
||||||
<h3 className={hClasses}>Artists</h3>
|
<h3 className={hClasses}>Artists</h3>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
|
|
@ -52,7 +52,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{ data.albums.length > 0 &&
|
{ data.albums && data.albums.length > 0 &&
|
||||||
<>
|
<>
|
||||||
<h3 className={hClasses}>Albums</h3>
|
<h3 className={hClasses}>Albums</h3>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
|
|
@ -77,7 +77,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{ data.tracks.length > 0 &&
|
{ data.tracks && data.tracks.length > 0 &&
|
||||||
<>
|
<>
|
||||||
<h3 className={hClasses}>Tracks</h3>
|
<h3 className={hClasses}>Tracks</h3>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export default function Album() {
|
||||||
}}
|
}}
|
||||||
subContent={<div className="flex flex-col gap-2 items-start">
|
subContent={<div className="flex flex-col gap-2 items-start">
|
||||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
||||||
{<p>{timeListenedString(album.time_listened)}</p>}
|
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export default function Artist() {
|
||||||
}}
|
}}
|
||||||
subContent={<div className="flex flex-col gap-2 items-start">
|
subContent={<div className="flex flex-col gap-2 items-start">
|
||||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
||||||
{<p>{timeListenedString(artist.time_listened)}</p>}
|
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export default function MediaLayout(props: Props) {
|
||||||
transition: '1000',
|
transition: '1000',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
|
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} onComplete={replaceImageCallback} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta
|
<meta
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export default function Track() {
|
||||||
subContent={<div className="flex flex-col gap-2 items-start">
|
subContent={<div className="flex flex-col gap-2 items-start">
|
||||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
||||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
||||||
{<p>{timeListenedString(track.time_listened)}</p>}
|
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|
|
||||||
3
db/migrations/000004_fix_usernames.sql
Normal file
3
db/migrations/000004_fix_usernames.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- +goose Up
|
||||||
|
UPDATE users
|
||||||
|
SET username = LOWER(username);
|
||||||
6
db/migrations/migrations.go
Normal file
6
db/migrations/migrations.go
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var Files embed.FS
|
||||||
|
|
@ -4,7 +4,10 @@ VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetRelease :one
|
-- name: GetRelease :one
|
||||||
SELECT * FROM releases_with_title
|
SELECT
|
||||||
|
*,
|
||||||
|
get_artists_for_release(id) AS artists
|
||||||
|
FROM releases_with_title
|
||||||
WHERE id = $1 LIMIT 1;
|
WHERE id = $1 LIMIT 1;
|
||||||
|
|
||||||
-- name: GetReleaseByMbzID :one
|
-- name: GetReleaseByMbzID :one
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ ON CONFLICT DO NOTHING;
|
||||||
-- name: GetTrack :one
|
-- name: GetTrack :one
|
||||||
SELECT
|
SELECT
|
||||||
t.*,
|
t.*,
|
||||||
|
get_artists_for_track(t.id) AS artists,
|
||||||
r.image
|
r.image
|
||||||
FROM tracks_with_title t
|
FROM tracks_with_title t
|
||||||
JOIN releases r ON t.release_id = r.id
|
JOIN releases r ON t.release_id = r.id
|
||||||
|
|
|
||||||
374
db/queries/year.sql
Normal file
374
db/queries/year.sql
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
-- name: GetMostReplayedTrackInYear :one
|
||||||
|
WITH ordered_listens AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
track_id,
|
||||||
|
listened_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY listened_at) AS rn
|
||||||
|
FROM listens
|
||||||
|
WHERE EXTRACT(YEAR FROM listened_at) = @year::int
|
||||||
|
),
|
||||||
|
streaks AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
track_id,
|
||||||
|
listened_at,
|
||||||
|
rn,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id, track_id ORDER BY listened_at) AS track_rn
|
||||||
|
FROM ordered_listens
|
||||||
|
),
|
||||||
|
grouped_streaks AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
track_id,
|
||||||
|
rn - track_rn AS group_id,
|
||||||
|
COUNT(*) AS streak_length
|
||||||
|
FROM streaks
|
||||||
|
GROUP BY user_id, track_id, rn - track_rn
|
||||||
|
),
|
||||||
|
ranked_streaks AS (
|
||||||
|
SELECT *,
|
||||||
|
RANK() OVER (PARTITION BY user_id ORDER BY streak_length DESC) AS r
|
||||||
|
FROM grouped_streaks
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.*,
|
||||||
|
get_artists_for_track(t.id) as artists,
|
||||||
|
streak_length
|
||||||
|
FROM ranked_streaks rs JOIN tracks_with_title t ON rs.track_id = t.id
|
||||||
|
WHERE user_id = @user_id::int AND r = 1;
|
||||||
|
|
||||||
|
-- name: TracksOnlyPlayedOnceInYear :many
|
||||||
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
|
t.title,
|
||||||
|
get_artists_for_track(t.id) as artists,
|
||||||
|
COUNT(l.*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks_with_title t ON t.id = l.track_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int AND l.user_id = @user_id::int
|
||||||
|
GROUP BY t.id, t.title
|
||||||
|
HAVING COUNT(*) = 1
|
||||||
|
LIMIT $1;
|
||||||
|
|
||||||
|
-- name: ArtistsOnlyPlayedOnceInYear :many
|
||||||
|
SELECT
|
||||||
|
a.id AS artist_id,
|
||||||
|
a.name,
|
||||||
|
COUNT(l.*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
JOIN artists_with_name a ON a.id = at.artist_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int AND l.user_id = @user_id::int
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
HAVING COUNT(*) = 1;
|
||||||
|
|
||||||
|
-- GetNewTrackWithMostListensInYear :one
|
||||||
|
WITH first_plays_in_year AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
l.track_id,
|
||||||
|
MIN(l.listened_at) AS first_listen
|
||||||
|
FROM listens l
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM listens l2
|
||||||
|
WHERE l2.user_id = l.user_id
|
||||||
|
AND l2.track_id = l.track_id
|
||||||
|
AND l2.listened_at < @first_day_of_year::date
|
||||||
|
)
|
||||||
|
GROUP BY l.user_id, l.track_id
|
||||||
|
),
|
||||||
|
seven_day_window AS (
|
||||||
|
SELECT
|
||||||
|
f.user_id,
|
||||||
|
f.track_id,
|
||||||
|
f.first_listen,
|
||||||
|
COUNT(l.*) AS plays_in_7_days
|
||||||
|
FROM first_plays_in_year f
|
||||||
|
JOIN listens l
|
||||||
|
ON l.user_id = f.user_id
|
||||||
|
AND l.track_id = f.track_id
|
||||||
|
AND l.listened_at >= f.first_listen
|
||||||
|
AND l.listened_at < f.first_listen + INTERVAL '7 days'
|
||||||
|
GROUP BY f.user_id, f.track_id, f.first_listen
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
RANK() OVER (PARTITION BY user_id ORDER BY plays_in_7_days DESC) AS r
|
||||||
|
FROM seven_day_window
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.user_id,
|
||||||
|
s.track_id,
|
||||||
|
t.title,
|
||||||
|
get_artists_for_track(t.id) as artists,
|
||||||
|
s.first_listen,
|
||||||
|
s.plays_in_7_days
|
||||||
|
FROM ranked s
|
||||||
|
JOIN tracks_with_title t ON t.id = s.track_id
|
||||||
|
WHERE r = 1;
|
||||||
|
|
||||||
|
-- GetTopThreeNewArtistsInYear :many
|
||||||
|
WITH first_artist_plays_in_year AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
at.artist_id,
|
||||||
|
MIN(l.listened_at) AS first_listen
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM listens l2
|
||||||
|
JOIN artist_tracks at2 ON at2.track_id = l2.track_id
|
||||||
|
WHERE l2.user_id = l.user_id
|
||||||
|
AND at2.artist_id = at.artist_id
|
||||||
|
AND l2.listened_at < @first_day_of_year::date
|
||||||
|
)
|
||||||
|
GROUP BY l.user_id, at.artist_id
|
||||||
|
),
|
||||||
|
artist_plays_in_year AS (
|
||||||
|
SELECT
|
||||||
|
f.user_id,
|
||||||
|
f.artist_id,
|
||||||
|
f.first_listen,
|
||||||
|
COUNT(l.*) AS total_plays_in_year
|
||||||
|
FROM first_artist_plays_in_year f
|
||||||
|
JOIN listens l ON l.user_id = f.user_id
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE at.artist_id = f.artist_id
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = @year::int
|
||||||
|
GROUP BY f.user_id, f.artist_id, f.first_listen
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
RANK() OVER (PARTITION BY user_id ORDER BY total_plays_in_year DESC) AS r
|
||||||
|
FROM artist_plays_in_year
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
a.user_id,
|
||||||
|
a.artist_id,
|
||||||
|
awn.name AS artist_name,
|
||||||
|
a.first_listen,
|
||||||
|
a.total_plays_in_year
|
||||||
|
FROM ranked a
|
||||||
|
JOIN artists_with_name awn ON awn.id = a.artist_id
|
||||||
|
WHERE r <= 3;
|
||||||
|
|
||||||
|
-- name: GetArtistWithLongestGapInYear :one
|
||||||
|
WITH first_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
at.artist_id,
|
||||||
|
MIN(l.listened_at::date) AS first_listen_of_year
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
|
||||||
|
GROUP BY l.user_id, at.artist_id
|
||||||
|
),
|
||||||
|
last_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
at.artist_id,
|
||||||
|
MAX(l.listened_at::date) AS last_listen
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE l.listened_at < @first_day_of_year::date
|
||||||
|
GROUP BY l.user_id, at.artist_id
|
||||||
|
),
|
||||||
|
comebacks AS (
|
||||||
|
SELECT
|
||||||
|
f.user_id,
|
||||||
|
f.artist_id,
|
||||||
|
f.first_listen_of_year,
|
||||||
|
p.last_listen,
|
||||||
|
(f.first_listen_of_year - p.last_listen) AS gap_days
|
||||||
|
FROM first_listens f
|
||||||
|
JOIN last_listens p
|
||||||
|
ON f.user_id = p.user_id AND f.artist_id = p.artist_id
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
RANK() OVER (PARTITION BY user_id ORDER BY gap_days DESC) AS r
|
||||||
|
FROM comebacks
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.user_id,
|
||||||
|
c.artist_id,
|
||||||
|
awn.name AS artist_name,
|
||||||
|
c.last_listen,
|
||||||
|
c.first_listen_of_year,
|
||||||
|
c.gap_days
|
||||||
|
FROM ranked c
|
||||||
|
JOIN artists_with_name awn ON awn.id = c.artist_id
|
||||||
|
WHERE r = 1;
|
||||||
|
|
||||||
|
-- name: GetFirstListenInYear :one
|
||||||
|
SELECT
|
||||||
|
l.*,
|
||||||
|
t.*,
|
||||||
|
get_artists_for_track(t.id) as artists
|
||||||
|
FROM listens l
|
||||||
|
LEFT JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = 2025
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetTracksPlayedAtLeastOncePerMonthInYear :many
|
||||||
|
WITH monthly_plays AS (
|
||||||
|
SELECT
|
||||||
|
l.track_id,
|
||||||
|
EXTRACT(MONTH FROM l.listened_at) AS month
|
||||||
|
FROM listens l
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = @user_id::int
|
||||||
|
GROUP BY l.track_id, EXTRACT(MONTH FROM l.listened_at)
|
||||||
|
),
|
||||||
|
monthly_counts AS (
|
||||||
|
SELECT
|
||||||
|
track_id,
|
||||||
|
COUNT(DISTINCT month) AS months_played
|
||||||
|
FROM monthly_plays
|
||||||
|
GROUP BY track_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
|
t.title
|
||||||
|
FROM monthly_counts mc
|
||||||
|
JOIN tracks_with_title t ON t.id = mc.track_id
|
||||||
|
WHERE mc.months_played = 12;
|
||||||
|
|
||||||
|
-- name: GetWeekWithMostListensInYear :one
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('week', listened_at + INTERVAL '1 day') - INTERVAL '1 day' AS week_start,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens
|
||||||
|
WHERE EXTRACT(YEAR FROM listened_at) = @year::int
|
||||||
|
AND user_id = @user_id::int
|
||||||
|
GROUP BY week_start
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetPercentageOfTotalListensFromTopTracksInYear :one
|
||||||
|
WITH user_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.track_id,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
WHERE l.user_id = @user_id::int
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = @year::int
|
||||||
|
GROUP BY l.track_id
|
||||||
|
),
|
||||||
|
top_tracks AS (
|
||||||
|
SELECT
|
||||||
|
track_id,
|
||||||
|
listen_count
|
||||||
|
FROM user_listens
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT $1
|
||||||
|
),
|
||||||
|
totals AS (
|
||||||
|
SELECT
|
||||||
|
(SELECT SUM(listen_count) FROM top_tracks) AS top_tracks_total,
|
||||||
|
(SELECT SUM(listen_count) FROM user_listens) AS overall_total
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
top_tracks_total,
|
||||||
|
overall_total,
|
||||||
|
ROUND((top_tracks_total::decimal / overall_total) * 100, 2) AS percent_of_total
|
||||||
|
FROM totals;
|
||||||
|
|
||||||
|
-- name: GetPercentageOfTotalListensFromTopArtistsInYear :one
|
||||||
|
WITH user_artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
at.artist_id,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE l.user_id = @user_id::int
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = @year::int
|
||||||
|
GROUP BY at.artist_id
|
||||||
|
),
|
||||||
|
top_artists AS (
|
||||||
|
SELECT
|
||||||
|
artist_id,
|
||||||
|
listen_count
|
||||||
|
FROM user_artist_listens
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT $1
|
||||||
|
),
|
||||||
|
totals AS (
|
||||||
|
SELECT
|
||||||
|
(SELECT SUM(listen_count) FROM top_artists) AS top_artist_total,
|
||||||
|
(SELECT SUM(listen_count) FROM user_artist_listens) AS overall_total
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
top_artist_total,
|
||||||
|
overall_total,
|
||||||
|
ROUND((top_artist_total::decimal / overall_total) * 100, 2) AS percent_of_total
|
||||||
|
FROM totals;
|
||||||
|
|
||||||
|
-- name: GetArtistsWithOnlyOnePlayInYear :many
|
||||||
|
WITH first_artist_plays_in_year AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
at.artist_id,
|
||||||
|
MIN(l.listened_at) AS first_listen
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = 2024
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM listens l2
|
||||||
|
JOIN artist_tracks at2 ON at2.track_id = l2.track_id
|
||||||
|
WHERE l2.user_id = l.user_id
|
||||||
|
AND at2.artist_id = at.artist_id
|
||||||
|
AND l2.listened_at < DATE '2024-01-01'
|
||||||
|
)
|
||||||
|
GROUP BY l.user_id, at.artist_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.user_id,
|
||||||
|
f.artist_id,
|
||||||
|
f.first_listen, a.name,
|
||||||
|
COUNT(l.*) AS total_plays_in_year
|
||||||
|
FROM first_artist_plays_in_year f
|
||||||
|
JOIN listens l ON l.user_id = f.user_id
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id JOIN artists_with_name a ON at.artist_id = a.id
|
||||||
|
WHERE at.artist_id = f.artist_id
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = 2024
|
||||||
|
GROUP BY f.user_id, f.artist_id, f.first_listen, a.name HAVING COUNT(*) = 1;
|
||||||
|
|
||||||
|
-- name: GetArtistCountInYear :one
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT at.artist_id) AS artist_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE l.user_id = @user_id::int
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = @year::int;
|
||||||
|
|
||||||
|
-- name: GetListenPercentageInTimeWindowInYear :one
|
||||||
|
WITH user_listens_in_year AS (
|
||||||
|
SELECT
|
||||||
|
listened_at
|
||||||
|
FROM listens
|
||||||
|
WHERE user_id = @user_id::int
|
||||||
|
AND EXTRACT(YEAR FROM listened_at) = @year::int
|
||||||
|
),
|
||||||
|
windowed AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS in_window
|
||||||
|
FROM user_listens_in_year
|
||||||
|
WHERE EXTRACT(HOUR FROM listened_at) >= @hour_window_start::int
|
||||||
|
AND EXTRACT(HOUR FROM listened_at) < @hour_window_end::int
|
||||||
|
),
|
||||||
|
total AS (
|
||||||
|
SELECT COUNT(*) AS total_listens
|
||||||
|
FROM user_listens_in_year
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
w.in_window,
|
||||||
|
t.total_listens,
|
||||||
|
ROUND((w.in_window::decimal / t.total_listens) * 100, 2) AS percent_of_total
|
||||||
|
FROM windowed w, total t;
|
||||||
|
|
@ -60,6 +60,8 @@ Once merged, we can see that all of the listen activity for Tsumugu has been asi
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
You can also search for items when merging by their ID using the format `id:1234`.
|
||||||
|
|
||||||
#### Deleting Items
|
#### Deleting Items
|
||||||
|
|
||||||
To delete at item, just click the trash icon, which is the fourth and final icon in the editing options. Doing so will open a confirmation dialogue. Once confirmed, the item you delete, as well as all of its children
|
To delete at item, just click the trash icon, which is the fourth and final icon in the editing options. Doing so will open a confirmation dialogue. Once confirmed, the item you delete, as well as all of its children
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,6 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||||
Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server.
|
Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server.
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Themeable" icon="seti:css">
|
<Card title="Themeable" icon="seti:css">
|
||||||
Koito ships with twelve different themes, with custom theme options to be added soon™.
|
Koito ships with twelve different themes, now with support for custom themes!
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/db"
|
"github.com/gabehf/koito/internal/db"
|
||||||
"github.com/gabehf/koito/internal/logger"
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
|
@ -20,27 +22,62 @@ func SearchHandler(store db.DB) http.HandlerFunc {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
q := r.URL.Query().Get("q")
|
q := r.URL.Query().Get("q")
|
||||||
artists, err := store.SearchArtists(ctx, q)
|
|
||||||
|
|
||||||
l.Debug().Msgf("SearchHandler: Received search with query: %s", r.URL.Query().Encode())
|
l.Debug().Msgf("SearchHandler: Received search with query: %s", r.URL.Query().Encode())
|
||||||
|
|
||||||
if err != nil {
|
var artists []*models.Artist
|
||||||
l.Err(err).Msg("Failed to search for artists")
|
var albums []*models.Album
|
||||||
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
|
var tracks []*models.Track
|
||||||
return
|
|
||||||
}
|
if strings.HasPrefix(q, "id:") {
|
||||||
albums, err := store.SearchAlbums(ctx, q)
|
idStr := strings.TrimPrefix(q, "id:")
|
||||||
if err != nil {
|
id, _ := strconv.Atoi(idStr)
|
||||||
l.Err(err).Msg("Failed to search for albums")
|
|
||||||
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
|
artist, err := store.GetArtist(ctx, db.GetArtistOpts{ID: int32(id)})
|
||||||
return
|
if err != nil {
|
||||||
}
|
l.Debug().Msg("No artists found with id")
|
||||||
tracks, err := store.SearchTracks(ctx, q)
|
}
|
||||||
if err != nil {
|
if artist != nil {
|
||||||
l.Err(err).Msg("Failed to search for tracks")
|
artists = append(artists, artist)
|
||||||
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
|
}
|
||||||
return
|
|
||||||
|
album, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: int32(id)})
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().Msg("No albums found with id")
|
||||||
|
}
|
||||||
|
if album != nil {
|
||||||
|
albums = append(albums, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := store.GetTrack(ctx, db.GetTrackOpts{ID: int32(id)})
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().Msg("No tracks found with id")
|
||||||
|
}
|
||||||
|
if track != nil {
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
artists, err = store.SearchArtists(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Failed to search for artists")
|
||||||
|
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albums, err = store.SearchAlbums(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Failed to search for albums")
|
||||||
|
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tracks, err = store.SearchTracks(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Failed to search for tracks")
|
||||||
|
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.WriteJSON(w, http.StatusOK, SearchResults{
|
utils.WriteJSON(w, http.StatusOK, SearchResults{
|
||||||
Artists: artists,
|
Artists: artists,
|
||||||
Albums: albums,
|
Albums: albums,
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatsResponse struct {
|
type StatsResponse struct {
|
||||||
ListenCount int64 `json:"listen_count"`
|
ListenCount int64 `json:"listen_count"`
|
||||||
TrackCount int64 `json:"track_count"`
|
TrackCount int64 `json:"track_count"`
|
||||||
AlbumCount int64 `json:"album_count"`
|
AlbumCount int64 `json:"album_count"`
|
||||||
ArtistCount int64 `json:"artist_count"`
|
ArtistCount int64 `json:"artist_count"`
|
||||||
HoursListened int64 `json:"hours_listened"`
|
MinutesListened int64 `json:"minutes_listened"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func StatsHandler(store db.DB) http.HandlerFunc {
|
func StatsHandler(store db.DB) http.HandlerFunc {
|
||||||
|
|
@ -79,11 +79,11 @@ func StatsHandler(store db.DB) http.HandlerFunc {
|
||||||
|
|
||||||
l.Debug().Msg("StatsHandler: Successfully fetched statistics")
|
l.Debug().Msg("StatsHandler: Successfully fetched statistics")
|
||||||
utils.WriteJSON(w, http.StatusOK, StatsResponse{
|
utils.WriteJSON(w, http.StatusOK, StatsResponse{
|
||||||
ListenCount: listens,
|
ListenCount: listens,
|
||||||
TrackCount: tracks,
|
TrackCount: tracks,
|
||||||
AlbumCount: albums,
|
AlbumCount: albums,
|
||||||
ArtistCount: artists,
|
ArtistCount: artists,
|
||||||
HoursListened: timeListenedS / 60 / 60,
|
MinutesListened: timeListenedS / 60,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -447,7 +447,7 @@ func TestStats(t *testing.T) {
|
||||||
assert.EqualValues(t, 3, actual.TrackCount)
|
assert.EqualValues(t, 3, actual.TrackCount)
|
||||||
assert.EqualValues(t, 3, actual.AlbumCount)
|
assert.EqualValues(t, 3, actual.AlbumCount)
|
||||||
assert.EqualValues(t, 3, actual.ArtistCount)
|
assert.EqualValues(t, 3, actual.ArtistCount)
|
||||||
assert.EqualValues(t, 0, actual.HoursListened)
|
assert.EqualValues(t, 11, actual.MinutesListened)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenActivity(t *testing.T) {
|
func TestListenActivity(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -134,15 +134,35 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
|
||||||
}
|
}
|
||||||
l.Debug().Any("track", track).Msg("Matched listen to track")
|
l.Debug().Any("track", track).Msg("Matched listen to track")
|
||||||
|
|
||||||
if track.Duration == 0 && opts.Duration != 0 {
|
if track.Duration == 0 {
|
||||||
err := store.UpdateTrack(ctx, db.UpdateTrackOpts{
|
if opts.Duration != 0 {
|
||||||
ID: track.ID,
|
l.Debug().Msg("Updating duration using request information")
|
||||||
Duration: opts.Duration,
|
err := store.UpdateTrack(ctx, db.UpdateTrackOpts{
|
||||||
})
|
ID: track.ID,
|
||||||
if err != nil {
|
Duration: opts.Duration,
|
||||||
l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
|
||||||
|
} else {
|
||||||
|
l.Info().Msgf("Duration updated to %d for track '%s'", opts.Duration, track.Title)
|
||||||
|
}
|
||||||
|
} else if track.MbzID != nil && *track.MbzID != uuid.Nil {
|
||||||
|
l.Debug().Msg("Attempting to update duration using MusicBrainz ID")
|
||||||
|
mbztrack, err := opts.MbzCaller.GetTrack(ctx, *track.MbzID)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Failed to make request to MusicBrainz")
|
||||||
|
} else {
|
||||||
|
err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
|
||||||
|
ID: track.ID,
|
||||||
|
Duration: int32(mbztrack.LengthMs / 1000),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
|
||||||
|
} else {
|
||||||
|
l.Info().Msgf("Duration updated to %d for track '%s'", mbztrack.LengthMs/1000, track.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
l.Info().Msgf("Duration updated to %d for track '%s'", opts.Duration, track.Title)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.SkipSaveListen {
|
if opts.SkipSaveListen {
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,8 @@ var (
|
||||||
}
|
}
|
||||||
mbzTrackData = map[uuid.UUID]*mbz.MusicBrainzTrack{
|
mbzTrackData = map[uuid.UUID]*mbz.MusicBrainzTrack{
|
||||||
uuid.MustParse("00000000-0000-0000-0000-000000001001"): {
|
uuid.MustParse("00000000-0000-0000-0000-000000001001"): {
|
||||||
Title: "Tokyo Calling",
|
Title: "Tokyo Calling",
|
||||||
|
LengthMs: 191000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,43 @@ func TestSubmitListen_UpdateTrackDuration(t *testing.T) {
|
||||||
assert.Equal(t, 1, count, "expected duration to be updated")
|
assert.Equal(t, 1, count, "expected duration to be updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubmitListen_UpdateTrackDurationWithMbz(t *testing.T) {
|
||||||
|
setupTestDataSansMbzIDs(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
mbzc := &mbz.MbzMockCaller{
|
||||||
|
Tracks: mbzTrackData,
|
||||||
|
}
|
||||||
|
opts := catalog.SubmitListenOpts{
|
||||||
|
MbzCaller: mbzc,
|
||||||
|
ArtistNames: []string{"ATARASHII GAKKO!"},
|
||||||
|
Artist: "ATARASHII GAKKO!",
|
||||||
|
TrackTitle: "Tokyo Calling",
|
||||||
|
RecordingMbzID: uuid.MustParse("00000000-0000-0000-0000-000000001001"),
|
||||||
|
ReleaseTitle: "AG! Calling",
|
||||||
|
Time: time.Now(),
|
||||||
|
UserID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := catalog.SubmitListen(ctx, store, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify that the listen was saved
|
||||||
|
exists, err := store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM listens
|
||||||
|
WHERE track_id = $1
|
||||||
|
)`, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "expected listen row to exist")
|
||||||
|
|
||||||
|
count, err := store.Count(ctx, `
|
||||||
|
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1 AND duration = 191
|
||||||
|
`, "Tokyo Calling")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, count, "expected duration to be updated")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubmitListen_MatchFromTrackTitleNoMbzIDs(t *testing.T) {
|
func TestSubmitListen_MatchFromTrackTitleNoMbzIDs(t *testing.T) {
|
||||||
setupTestDataSansMbzIDs(t)
|
setupTestDataSansMbzIDs(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package psql
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -19,40 +20,71 @@ import (
|
||||||
|
|
||||||
func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Album, error) {
|
func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Album, error) {
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
var row repository.ReleasesWithTitle
|
|
||||||
var err error
|
var err error
|
||||||
|
var ret = new(models.Album)
|
||||||
|
|
||||||
if opts.ID != 0 {
|
if opts.ID != 0 {
|
||||||
l.Debug().Msgf("Fetching album from DB with id %d", opts.ID)
|
l.Debug().Msgf("Fetching album from DB with id %d", opts.ID)
|
||||||
row, err = d.q.GetRelease(ctx, opts.ID)
|
row, err := d.q.GetRelease(ctx, opts.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: %w", err)
|
||||||
|
}
|
||||||
|
ret.ID = row.ID
|
||||||
|
ret.MbzID = row.MusicBrainzID
|
||||||
|
ret.Title = row.Title
|
||||||
|
ret.Image = row.Image
|
||||||
|
ret.VariousArtists = row.VariousArtists
|
||||||
|
err = json.Unmarshal(row.Artists, &ret.Artists)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: json.Unmarshal: %w", err)
|
||||||
|
}
|
||||||
} else if opts.MusicBrainzID != uuid.Nil {
|
} else if opts.MusicBrainzID != uuid.Nil {
|
||||||
l.Debug().Msgf("Fetching album from DB with MusicBrainz Release ID %s", opts.MusicBrainzID)
|
l.Debug().Msgf("Fetching album from DB with MusicBrainz Release ID %s", opts.MusicBrainzID)
|
||||||
row, err = d.q.GetReleaseByMbzID(ctx, &opts.MusicBrainzID)
|
row, err := d.q.GetReleaseByMbzID(ctx, &opts.MusicBrainzID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: %w", err)
|
||||||
|
}
|
||||||
|
ret.ID = row.ID
|
||||||
|
ret.MbzID = row.MusicBrainzID
|
||||||
|
ret.Title = row.Title
|
||||||
|
ret.Image = row.Image
|
||||||
|
ret.VariousArtists = row.VariousArtists
|
||||||
} else if opts.ArtistID != 0 && opts.Title != "" {
|
} else if opts.ArtistID != 0 && opts.Title != "" {
|
||||||
l.Debug().Msgf("Fetching album from DB with artist_id %d and title %s", opts.ArtistID, opts.Title)
|
l.Debug().Msgf("Fetching album from DB with artist_id %d and title %s", opts.ArtistID, opts.Title)
|
||||||
row, err = d.q.GetReleaseByArtistAndTitle(ctx, repository.GetReleaseByArtistAndTitleParams{
|
row, err := d.q.GetReleaseByArtistAndTitle(ctx, repository.GetReleaseByArtistAndTitleParams{
|
||||||
ArtistID: opts.ArtistID,
|
ArtistID: opts.ArtistID,
|
||||||
Title: opts.Title,
|
Title: opts.Title,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: %w", err)
|
||||||
|
}
|
||||||
|
ret.ID = row.ID
|
||||||
|
ret.MbzID = row.MusicBrainzID
|
||||||
|
ret.Title = row.Title
|
||||||
|
ret.Image = row.Image
|
||||||
|
ret.VariousArtists = row.VariousArtists
|
||||||
} else if opts.ArtistID != 0 && len(opts.Titles) > 0 {
|
} else if opts.ArtistID != 0 && len(opts.Titles) > 0 {
|
||||||
l.Debug().Msgf("Fetching release group from DB with artist_id %d and titles %v", opts.ArtistID, opts.Titles)
|
l.Debug().Msgf("Fetching release group from DB with artist_id %d and titles %v", opts.ArtistID, opts.Titles)
|
||||||
row, err = d.q.GetReleaseByArtistAndTitles(ctx, repository.GetReleaseByArtistAndTitlesParams{
|
row, err := d.q.GetReleaseByArtistAndTitles(ctx, repository.GetReleaseByArtistAndTitlesParams{
|
||||||
ArtistID: opts.ArtistID,
|
ArtistID: opts.ArtistID,
|
||||||
Column1: opts.Titles,
|
Column1: opts.Titles,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: %w", err)
|
||||||
|
}
|
||||||
|
ret.ID = row.ID
|
||||||
|
ret.MbzID = row.MusicBrainzID
|
||||||
|
ret.Title = row.Title
|
||||||
|
ret.Image = row.Image
|
||||||
|
ret.VariousArtists = row.VariousArtists
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("GetAlbum: insufficient information to get album")
|
return nil, errors.New("GetAlbum: insufficient information to get album")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetAlbum: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
|
count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
|
||||||
ListenedAt: time.Unix(0, 0),
|
ListenedAt: time.Unix(0, 0),
|
||||||
ListenedAt_2: time.Now(),
|
ListenedAt_2: time.Now(),
|
||||||
ReleaseID: row.ID,
|
ReleaseID: ret.ID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetAlbum: CountListensFromRelease: %w", err)
|
return nil, fmt.Errorf("GetAlbum: CountListensFromRelease: %w", err)
|
||||||
|
|
@ -60,21 +92,16 @@ 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,
|
Period: db.PeriodAllTime,
|
||||||
AlbumID: row.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &models.Album{
|
ret.ListenCount = count
|
||||||
ID: row.ID,
|
ret.TimeListened = seconds
|
||||||
MbzID: row.MusicBrainzID,
|
|
||||||
Title: row.Title,
|
return ret, nil
|
||||||
Image: row.Image,
|
|
||||||
VariousArtists: row.VariousArtists,
|
|
||||||
ListenCount: count,
|
|
||||||
TimeListened: seconds,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Album, error) {
|
func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Album, error) {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/db/migrations"
|
||||||
"github.com/gabehf/koito/internal/cfg"
|
"github.com/gabehf/koito/internal/cfg"
|
||||||
"github.com/gabehf/koito/internal/db"
|
"github.com/gabehf/koito/internal/db"
|
||||||
"github.com/gabehf/koito/internal/repository"
|
"github.com/gabehf/koito/internal/repository"
|
||||||
|
|
@ -54,13 +53,9 @@ func New() (*Psql, error) {
|
||||||
return nil, fmt.Errorf("psql.New: failed to open db for migrations: %w", err)
|
return nil, fmt.Errorf("psql.New: failed to open db for migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, filename, _, ok := runtime.Caller(0)
|
goose.SetBaseFS(migrations.Files)
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("psql.New: unable to get caller info")
|
|
||||||
}
|
|
||||||
migrationsPath := filepath.Join(filepath.Dir(filename), "..", "..", "..", "db", "migrations")
|
|
||||||
|
|
||||||
if err := goose.Up(sqlDB, migrationsPath); err != nil {
|
if err := goose.Up(sqlDB, "."); err != nil {
|
||||||
return nil, fmt.Errorf("psql.New: goose failed: %w", err)
|
return nil, fmt.Errorf("psql.New: goose failed: %w", err)
|
||||||
}
|
}
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package psql
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -34,6 +35,10 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
||||||
Image: t.Image,
|
Image: t.Image,
|
||||||
Duration: t.Duration,
|
Duration: t.Duration,
|
||||||
}
|
}
|
||||||
|
err = json.Unmarshal(t.Artists, &track.Artists)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetTrack: json.Unmarshal: %w", err)
|
||||||
|
}
|
||||||
} else if opts.MusicBrainzID != uuid.Nil {
|
} else if opts.MusicBrainzID != uuid.Nil {
|
||||||
l.Debug().Msgf("Fetching track from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
l.Debug().Msgf("Fetching track from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
||||||
t, err := d.q.GetTrackByMbzID(ctx, &opts.MusicBrainzID)
|
t, err := d.q.GetTrackByMbzID(ctx, &opts.MusicBrainzID)
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
|
||||||
}
|
}
|
||||||
err = qtx.UpdateUserUsername(ctx, repository.UpdateUserUsernameParams{
|
err = qtx.UpdateUserUsername(ctx, repository.UpdateUserUsernameParams{
|
||||||
ID: opts.ID,
|
ID: opts.ID,
|
||||||
Username: opts.Username,
|
Username: strings.ToLower(opts.Username),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("UpdateUser: UpdateUserUsername: %w", err)
|
return fmt.Errorf("UpdateUser: UpdateUserUsername: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type MusicBrainzTrack struct {
|
type MusicBrainzTrack struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
LengthMs int `json:"length"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordingFmtStr = "%s/ws/2/recording/%s"
|
const recordingFmtStr = "%s/ws/2/recording/%s"
|
||||||
|
|
|
||||||
|
|
@ -85,13 +85,26 @@ func (q *Queries) DeleteReleasesFromArtist(ctx context.Context, artistID int32)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRelease = `-- name: GetRelease :one
|
const getRelease = `-- name: GetRelease :one
|
||||||
SELECT id, musicbrainz_id, image, various_artists, image_source, title FROM releases_with_title
|
SELECT
|
||||||
|
id, musicbrainz_id, image, various_artists, image_source, title,
|
||||||
|
get_artists_for_release(id) AS artists
|
||||||
|
FROM releases_with_title
|
||||||
WHERE id = $1 LIMIT 1
|
WHERE id = $1 LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetRelease(ctx context.Context, id int32) (ReleasesWithTitle, error) {
|
type GetReleaseRow struct {
|
||||||
|
ID int32
|
||||||
|
MusicBrainzID *uuid.UUID
|
||||||
|
Image *uuid.UUID
|
||||||
|
VariousArtists bool
|
||||||
|
ImageSource pgtype.Text
|
||||||
|
Title string
|
||||||
|
Artists []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetRelease(ctx context.Context, id int32) (GetReleaseRow, error) {
|
||||||
row := q.db.QueryRow(ctx, getRelease, id)
|
row := q.db.QueryRow(ctx, getRelease, id)
|
||||||
var i ReleasesWithTitle
|
var i GetReleaseRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.MusicBrainzID,
|
&i.MusicBrainzID,
|
||||||
|
|
@ -99,6 +112,7 @@ func (q *Queries) GetRelease(ctx context.Context, id int32) (ReleasesWithTitle,
|
||||||
&i.VariousArtists,
|
&i.VariousArtists,
|
||||||
&i.ImageSource,
|
&i.ImageSource,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
|
&i.Artists,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,7 @@ func (q *Queries) GetTopTracksPaginated(ctx context.Context, arg GetTopTracksPag
|
||||||
const getTrack = `-- name: GetTrack :one
|
const getTrack = `-- name: GetTrack :one
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
|
t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
|
||||||
|
get_artists_for_track(t.id) AS artists,
|
||||||
r.image
|
r.image
|
||||||
FROM tracks_with_title t
|
FROM tracks_with_title t
|
||||||
JOIN releases r ON t.release_id = r.id
|
JOIN releases r ON t.release_id = r.id
|
||||||
|
|
@ -356,6 +357,7 @@ type GetTrackRow struct {
|
||||||
Duration int32
|
Duration int32
|
||||||
ReleaseID int32
|
ReleaseID int32
|
||||||
Title string
|
Title string
|
||||||
|
Artists []byte
|
||||||
Image *uuid.UUID
|
Image *uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,6 +370,7 @@ func (q *Queries) GetTrack(ctx context.Context, id int32) (GetTrackRow, error) {
|
||||||
&i.Duration,
|
&i.Duration,
|
||||||
&i.ReleaseID,
|
&i.ReleaseID,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
|
&i.Artists,
|
||||||
&i.Image,
|
&i.Image,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
|
|
|
||||||
616
internal/repository/year.sql.go
Normal file
616
internal/repository/year.sql.go
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: year.sql
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const artistsOnlyPlayedOnceInYear = `-- name: ArtistsOnlyPlayedOnceInYear :many
|
||||||
|
SELECT
|
||||||
|
a.id AS artist_id,
|
||||||
|
a.name,
|
||||||
|
COUNT(l.*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
JOIN artists_with_name a ON a.id = at.artist_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = $1::int AND l.user_id = $2::int
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
HAVING COUNT(*) = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type ArtistsOnlyPlayedOnceInYearParams struct {
|
||||||
|
Year int32
|
||||||
|
UserID int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistsOnlyPlayedOnceInYearRow struct {
|
||||||
|
ArtistID int32
|
||||||
|
Name string
|
||||||
|
ListenCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ArtistsOnlyPlayedOnceInYear(ctx context.Context, arg ArtistsOnlyPlayedOnceInYearParams) ([]ArtistsOnlyPlayedOnceInYearRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, artistsOnlyPlayedOnceInYear, arg.Year, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ArtistsOnlyPlayedOnceInYearRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ArtistsOnlyPlayedOnceInYearRow
|
||||||
|
if err := rows.Scan(&i.ArtistID, &i.Name, &i.ListenCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getArtistCountInYear = `-- name: GetArtistCountInYear :one
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT at.artist_id) AS artist_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE l.user_id = $1::int
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = $2::int
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetArtistCountInYearParams struct {
|
||||||
|
UserID int32
|
||||||
|
Year int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetArtistCountInYear(ctx context.Context, arg GetArtistCountInYearParams) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getArtistCountInYear, arg.UserID, arg.Year)
|
||||||
|
var artist_count int64
|
||||||
|
err := row.Scan(&artist_count)
|
||||||
|
return artist_count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getArtistWithLongestGapInYear = `-- name: GetArtistWithLongestGapInYear :one
|
||||||
|
WITH first_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
at.artist_id,
|
||||||
|
MIN(l.listened_at::date) AS first_listen_of_year
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = $1::int
|
||||||
|
GROUP BY l.user_id, at.artist_id
|
||||||
|
),
|
||||||
|
last_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
at.artist_id,
|
||||||
|
MAX(l.listened_at::date) AS last_listen
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE l.listened_at < $2::date
|
||||||
|
GROUP BY l.user_id, at.artist_id
|
||||||
|
),
|
||||||
|
comebacks AS (
|
||||||
|
SELECT
|
||||||
|
f.user_id,
|
||||||
|
f.artist_id,
|
||||||
|
f.first_listen_of_year,
|
||||||
|
p.last_listen,
|
||||||
|
(f.first_listen_of_year - p.last_listen) AS gap_days
|
||||||
|
FROM first_listens f
|
||||||
|
JOIN last_listens p
|
||||||
|
ON f.user_id = p.user_id AND f.artist_id = p.artist_id
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT user_id, artist_id, first_listen_of_year, last_listen, gap_days,
|
||||||
|
RANK() OVER (PARTITION BY user_id ORDER BY gap_days DESC) AS r
|
||||||
|
FROM comebacks
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.user_id,
|
||||||
|
c.artist_id,
|
||||||
|
awn.name AS artist_name,
|
||||||
|
c.last_listen,
|
||||||
|
c.first_listen_of_year,
|
||||||
|
c.gap_days
|
||||||
|
FROM ranked c
|
||||||
|
JOIN artists_with_name awn ON awn.id = c.artist_id
|
||||||
|
WHERE r = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetArtistWithLongestGapInYearParams struct {
|
||||||
|
Year int32
|
||||||
|
FirstDayOfYear pgtype.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetArtistWithLongestGapInYearRow struct {
|
||||||
|
UserID int32
|
||||||
|
ArtistID int32
|
||||||
|
ArtistName string
|
||||||
|
LastListen interface{}
|
||||||
|
FirstListenOfYear interface{}
|
||||||
|
GapDays int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetArtistWithLongestGapInYear(ctx context.Context, arg GetArtistWithLongestGapInYearParams) (GetArtistWithLongestGapInYearRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getArtistWithLongestGapInYear, arg.Year, arg.FirstDayOfYear)
|
||||||
|
var i GetArtistWithLongestGapInYearRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.LastListen,
|
||||||
|
&i.FirstListenOfYear,
|
||||||
|
&i.GapDays,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getArtistsWithOnlyOnePlayInYear = `-- name: GetArtistsWithOnlyOnePlayInYear :many
|
||||||
|
WITH first_artist_plays_in_year AS (
|
||||||
|
SELECT
|
||||||
|
l.user_id,
|
||||||
|
at.artist_id,
|
||||||
|
MIN(l.listened_at) AS first_listen
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = 2024
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM listens l2
|
||||||
|
JOIN artist_tracks at2 ON at2.track_id = l2.track_id
|
||||||
|
WHERE l2.user_id = l.user_id
|
||||||
|
AND at2.artist_id = at.artist_id
|
||||||
|
AND l2.listened_at < DATE '2024-01-01'
|
||||||
|
)
|
||||||
|
GROUP BY l.user_id, at.artist_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.user_id,
|
||||||
|
f.artist_id,
|
||||||
|
f.first_listen, a.name,
|
||||||
|
COUNT(l.*) AS total_plays_in_year
|
||||||
|
FROM first_artist_plays_in_year f
|
||||||
|
JOIN listens l ON l.user_id = f.user_id
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id JOIN artists_with_name a ON at.artist_id = a.id
|
||||||
|
WHERE at.artist_id = f.artist_id
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = 2024
|
||||||
|
GROUP BY f.user_id, f.artist_id, f.first_listen, a.name HAVING COUNT(*) = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetArtistsWithOnlyOnePlayInYearRow struct {
|
||||||
|
UserID int32
|
||||||
|
ArtistID int32
|
||||||
|
FirstListen interface{}
|
||||||
|
Name string
|
||||||
|
TotalPlaysInYear int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetArtistsWithOnlyOnePlayInYear(ctx context.Context) ([]GetArtistsWithOnlyOnePlayInYearRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getArtistsWithOnlyOnePlayInYear)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetArtistsWithOnlyOnePlayInYearRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetArtistsWithOnlyOnePlayInYearRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.FirstListen,
|
||||||
|
&i.Name,
|
||||||
|
&i.TotalPlaysInYear,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstListenInYear = `-- name: GetFirstListenInYear :one
|
||||||
|
SELECT
|
||||||
|
l.track_id, l.listened_at, l.client, l.user_id,
|
||||||
|
t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
|
||||||
|
get_artists_for_track(t.id) as artists
|
||||||
|
FROM listens l
|
||||||
|
LEFT JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = 2025
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFirstListenInYearRow struct {
|
||||||
|
TrackID int32
|
||||||
|
ListenedAt time.Time
|
||||||
|
Client *string
|
||||||
|
UserID int32
|
||||||
|
ID pgtype.Int4
|
||||||
|
MusicBrainzID *uuid.UUID
|
||||||
|
Duration pgtype.Int4
|
||||||
|
ReleaseID pgtype.Int4
|
||||||
|
Title pgtype.Text
|
||||||
|
Artists []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFirstListenInYear(ctx context.Context) (GetFirstListenInYearRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFirstListenInYear)
|
||||||
|
var i GetFirstListenInYearRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.ListenedAt,
|
||||||
|
&i.Client,
|
||||||
|
&i.UserID,
|
||||||
|
&i.ID,
|
||||||
|
&i.MusicBrainzID,
|
||||||
|
&i.Duration,
|
||||||
|
&i.ReleaseID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Artists,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getListenPercentageInTimeWindowInYear = `-- name: GetListenPercentageInTimeWindowInYear :one
|
||||||
|
WITH user_listens_in_year AS (
|
||||||
|
SELECT
|
||||||
|
listened_at
|
||||||
|
FROM listens
|
||||||
|
WHERE user_id = $1::int
|
||||||
|
AND EXTRACT(YEAR FROM listened_at) = $2::int
|
||||||
|
),
|
||||||
|
windowed AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS in_window
|
||||||
|
FROM user_listens_in_year
|
||||||
|
WHERE EXTRACT(HOUR FROM listened_at) >= $3::int
|
||||||
|
AND EXTRACT(HOUR FROM listened_at) < $4::int
|
||||||
|
),
|
||||||
|
total AS (
|
||||||
|
SELECT COUNT(*) AS total_listens
|
||||||
|
FROM user_listens_in_year
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
w.in_window,
|
||||||
|
t.total_listens,
|
||||||
|
ROUND((w.in_window::decimal / t.total_listens) * 100, 2) AS percent_of_total
|
||||||
|
FROM windowed w, total t
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetListenPercentageInTimeWindowInYearParams struct {
|
||||||
|
UserID int32
|
||||||
|
Year int32
|
||||||
|
HourWindowStart int32
|
||||||
|
HourWindowEnd int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetListenPercentageInTimeWindowInYearRow struct {
|
||||||
|
InWindow int64
|
||||||
|
TotalListens int64
|
||||||
|
PercentOfTotal pgtype.Numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetListenPercentageInTimeWindowInYear(ctx context.Context, arg GetListenPercentageInTimeWindowInYearParams) (GetListenPercentageInTimeWindowInYearRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getListenPercentageInTimeWindowInYear,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Year,
|
||||||
|
arg.HourWindowStart,
|
||||||
|
arg.HourWindowEnd,
|
||||||
|
)
|
||||||
|
var i GetListenPercentageInTimeWindowInYearRow
|
||||||
|
err := row.Scan(&i.InWindow, &i.TotalListens, &i.PercentOfTotal)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMostReplayedTrackInYear = `-- name: GetMostReplayedTrackInYear :one
|
||||||
|
WITH ordered_listens AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
track_id,
|
||||||
|
listened_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY listened_at) AS rn
|
||||||
|
FROM listens
|
||||||
|
WHERE EXTRACT(YEAR FROM listened_at) = $2::int
|
||||||
|
),
|
||||||
|
streaks AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
track_id,
|
||||||
|
listened_at,
|
||||||
|
rn,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id, track_id ORDER BY listened_at) AS track_rn
|
||||||
|
FROM ordered_listens
|
||||||
|
),
|
||||||
|
grouped_streaks AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
track_id,
|
||||||
|
rn - track_rn AS group_id,
|
||||||
|
COUNT(*) AS streak_length
|
||||||
|
FROM streaks
|
||||||
|
GROUP BY user_id, track_id, rn - track_rn
|
||||||
|
),
|
||||||
|
ranked_streaks AS (
|
||||||
|
SELECT user_id, track_id, group_id, streak_length,
|
||||||
|
RANK() OVER (PARTITION BY user_id ORDER BY streak_length DESC) AS r
|
||||||
|
FROM grouped_streaks
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
|
||||||
|
get_artists_for_track(t.id) as artists,
|
||||||
|
streak_length
|
||||||
|
FROM ranked_streaks rs JOIN tracks_with_title t ON rs.track_id = t.id
|
||||||
|
WHERE user_id = $1::int AND r = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetMostReplayedTrackInYearParams struct {
|
||||||
|
UserID int32
|
||||||
|
Year int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetMostReplayedTrackInYearRow struct {
|
||||||
|
ID int32
|
||||||
|
MusicBrainzID *uuid.UUID
|
||||||
|
Duration int32
|
||||||
|
ReleaseID int32
|
||||||
|
Title string
|
||||||
|
Artists []byte
|
||||||
|
StreakLength int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetMostReplayedTrackInYear(ctx context.Context, arg GetMostReplayedTrackInYearParams) (GetMostReplayedTrackInYearRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getMostReplayedTrackInYear, arg.UserID, arg.Year)
|
||||||
|
var i GetMostReplayedTrackInYearRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.MusicBrainzID,
|
||||||
|
&i.Duration,
|
||||||
|
&i.ReleaseID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Artists,
|
||||||
|
&i.StreakLength,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPercentageOfTotalListensFromTopArtistsInYear = `-- name: GetPercentageOfTotalListensFromTopArtistsInYear :one
|
||||||
|
WITH user_artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
at.artist_id,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON at.track_id = l.track_id
|
||||||
|
WHERE l.user_id = $2::int
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = $3::int
|
||||||
|
GROUP BY at.artist_id
|
||||||
|
),
|
||||||
|
top_artists AS (
|
||||||
|
SELECT
|
||||||
|
artist_id,
|
||||||
|
listen_count
|
||||||
|
FROM user_artist_listens
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT $1
|
||||||
|
),
|
||||||
|
totals AS (
|
||||||
|
SELECT
|
||||||
|
(SELECT SUM(listen_count) FROM top_artists) AS top_artist_total,
|
||||||
|
(SELECT SUM(listen_count) FROM user_artist_listens) AS overall_total
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
top_artist_total,
|
||||||
|
overall_total,
|
||||||
|
ROUND((top_artist_total::decimal / overall_total) * 100, 2) AS percent_of_total
|
||||||
|
FROM totals
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetPercentageOfTotalListensFromTopArtistsInYearParams struct {
|
||||||
|
Limit int32
|
||||||
|
UserID int32
|
||||||
|
Year int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetPercentageOfTotalListensFromTopArtistsInYearRow struct {
|
||||||
|
TopArtistTotal int64
|
||||||
|
OverallTotal int64
|
||||||
|
PercentOfTotal pgtype.Numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPercentageOfTotalListensFromTopArtistsInYear(ctx context.Context, arg GetPercentageOfTotalListensFromTopArtistsInYearParams) (GetPercentageOfTotalListensFromTopArtistsInYearRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getPercentageOfTotalListensFromTopArtistsInYear, arg.Limit, arg.UserID, arg.Year)
|
||||||
|
var i GetPercentageOfTotalListensFromTopArtistsInYearRow
|
||||||
|
err := row.Scan(&i.TopArtistTotal, &i.OverallTotal, &i.PercentOfTotal)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPercentageOfTotalListensFromTopTracksInYear = `-- name: GetPercentageOfTotalListensFromTopTracksInYear :one
|
||||||
|
WITH user_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.track_id,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
WHERE l.user_id = $2::int
|
||||||
|
AND EXTRACT(YEAR FROM l.listened_at) = $3::int
|
||||||
|
GROUP BY l.track_id
|
||||||
|
),
|
||||||
|
top_tracks AS (
|
||||||
|
SELECT
|
||||||
|
track_id,
|
||||||
|
listen_count
|
||||||
|
FROM user_listens
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT $1
|
||||||
|
),
|
||||||
|
totals AS (
|
||||||
|
SELECT
|
||||||
|
(SELECT SUM(listen_count) FROM top_tracks) AS top_tracks_total,
|
||||||
|
(SELECT SUM(listen_count) FROM user_listens) AS overall_total
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
top_tracks_total,
|
||||||
|
overall_total,
|
||||||
|
ROUND((top_tracks_total::decimal / overall_total) * 100, 2) AS percent_of_total
|
||||||
|
FROM totals
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetPercentageOfTotalListensFromTopTracksInYearParams struct {
|
||||||
|
Limit int32
|
||||||
|
UserID int32
|
||||||
|
Year int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetPercentageOfTotalListensFromTopTracksInYearRow struct {
|
||||||
|
TopTracksTotal int64
|
||||||
|
OverallTotal int64
|
||||||
|
PercentOfTotal pgtype.Numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPercentageOfTotalListensFromTopTracksInYear(ctx context.Context, arg GetPercentageOfTotalListensFromTopTracksInYearParams) (GetPercentageOfTotalListensFromTopTracksInYearRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getPercentageOfTotalListensFromTopTracksInYear, arg.Limit, arg.UserID, arg.Year)
|
||||||
|
var i GetPercentageOfTotalListensFromTopTracksInYearRow
|
||||||
|
err := row.Scan(&i.TopTracksTotal, &i.OverallTotal, &i.PercentOfTotal)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTracksPlayedAtLeastOncePerMonthInYear = `-- name: GetTracksPlayedAtLeastOncePerMonthInYear :many
|
||||||
|
WITH monthly_plays AS (
|
||||||
|
SELECT
|
||||||
|
l.track_id,
|
||||||
|
EXTRACT(MONTH FROM l.listened_at) AS month
|
||||||
|
FROM listens l
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = $1::int
|
||||||
|
GROUP BY l.track_id, EXTRACT(MONTH FROM l.listened_at)
|
||||||
|
),
|
||||||
|
monthly_counts AS (
|
||||||
|
SELECT
|
||||||
|
track_id,
|
||||||
|
COUNT(DISTINCT month) AS months_played
|
||||||
|
FROM monthly_plays
|
||||||
|
GROUP BY track_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
|
t.title
|
||||||
|
FROM monthly_counts mc
|
||||||
|
JOIN tracks_with_title t ON t.id = mc.track_id
|
||||||
|
WHERE mc.months_played = 12
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTracksPlayedAtLeastOncePerMonthInYearRow struct {
|
||||||
|
TrackID int32
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetTracksPlayedAtLeastOncePerMonthInYear(ctx context.Context, userID int32) ([]GetTracksPlayedAtLeastOncePerMonthInYearRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getTracksPlayedAtLeastOncePerMonthInYear, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetTracksPlayedAtLeastOncePerMonthInYearRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetTracksPlayedAtLeastOncePerMonthInYearRow
|
||||||
|
if err := rows.Scan(&i.TrackID, &i.Title); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWeekWithMostListensInYear = `-- name: GetWeekWithMostListensInYear :one
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('week', listened_at + INTERVAL '1 day') - INTERVAL '1 day' AS week_start,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens
|
||||||
|
WHERE EXTRACT(YEAR FROM listened_at) = $1::int
|
||||||
|
AND user_id = $2::int
|
||||||
|
GROUP BY week_start
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetWeekWithMostListensInYearParams struct {
|
||||||
|
Year int32
|
||||||
|
UserID int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetWeekWithMostListensInYearRow struct {
|
||||||
|
WeekStart int32
|
||||||
|
ListenCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetWeekWithMostListensInYear(ctx context.Context, arg GetWeekWithMostListensInYearParams) (GetWeekWithMostListensInYearRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getWeekWithMostListensInYear, arg.Year, arg.UserID)
|
||||||
|
var i GetWeekWithMostListensInYearRow
|
||||||
|
err := row.Scan(&i.WeekStart, &i.ListenCount)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracksOnlyPlayedOnceInYear = `-- name: TracksOnlyPlayedOnceInYear :many
|
||||||
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
|
t.title,
|
||||||
|
get_artists_for_track(t.id) as artists,
|
||||||
|
COUNT(l.*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks_with_title t ON t.id = l.track_id
|
||||||
|
WHERE EXTRACT(YEAR FROM l.listened_at) = $2::int AND l.user_id = $3::int
|
||||||
|
GROUP BY t.id, t.title
|
||||||
|
HAVING COUNT(*) = 1
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type TracksOnlyPlayedOnceInYearParams struct {
|
||||||
|
Limit int32
|
||||||
|
Year int32
|
||||||
|
UserID int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type TracksOnlyPlayedOnceInYearRow struct {
|
||||||
|
TrackID int32
|
||||||
|
Title string
|
||||||
|
Artists []byte
|
||||||
|
ListenCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) TracksOnlyPlayedOnceInYear(ctx context.Context, arg TracksOnlyPlayedOnceInYearParams) ([]TracksOnlyPlayedOnceInYearRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, tracksOnlyPlayedOnceInYear, arg.Limit, arg.Year, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TracksOnlyPlayedOnceInYearRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i TracksOnlyPlayedOnceInYearRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Artists,
|
||||||
|
&i.ListenCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue