diff --git a/client/api/api.ts b/client/api/api.ts index c48ac85..afca716 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -256,6 +256,7 @@ type Track = { album_id: number musicbrainz_id: string time_listened: number + first_listen: number } type Artist = { id: number @@ -265,6 +266,7 @@ type Artist = { listen_count: number musicbrainz_id: string time_listened: number + first_listen: number is_primary: boolean } type Album = { @@ -276,6 +278,7 @@ type Album = { artists: SimpleArtists[] musicbrainz_id: string time_listened: number + first_listen: number } type Alias = { id: number diff --git a/client/app/routes/MediaItems/Album.tsx b/client/app/routes/MediaItems/Album.tsx index a262ac3..d6ae430 100644 --- a/client/app/routes/MediaItems/Album.tsx +++ b/client/app/routes/MediaItems/Album.tsx @@ -44,6 +44,7 @@ export default function Album() { subContent={
{album.listen_count &&

{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}

} {

{timeListenedString(album.time_listened)}

} + {

Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}

}
} >
diff --git a/client/app/routes/MediaItems/Artist.tsx b/client/app/routes/MediaItems/Artist.tsx index b698a27..20b4f88 100644 --- a/client/app/routes/MediaItems/Artist.tsx +++ b/client/app/routes/MediaItems/Artist.tsx @@ -50,6 +50,7 @@ export default function Artist() { subContent={
{artist.listen_count &&

{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}

} {

{timeListenedString(artist.time_listened)}

} + {

Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}

}
} >
diff --git a/client/app/routes/MediaItems/Track.tsx b/client/app/routes/MediaItems/Track.tsx index 44ac3aa..5f61dcd 100644 --- a/client/app/routes/MediaItems/Track.tsx +++ b/client/app/routes/MediaItems/Track.tsx @@ -46,7 +46,8 @@ export default function Track() { subContent={
appears on {album.title} {track.listen_count &&

{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}

} - {

{timeListenedString(track.time_listened)}

} + {

{timeListenedString(track.time_listened)}

} + {

Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}

}
} >
diff --git a/db/queries/listen.sql b/db/queries/listen.sql index 6a1d9a6..fc8c502 100644 --- a/db/queries/listen.sql +++ b/db/queries/listen.sql @@ -29,6 +29,16 @@ WHERE at.artist_id = $5 ORDER BY l.listened_at DESC LIMIT $3 OFFSET $4; +-- name: GetFirstListenFromArtist :one +SELECT + l.* +FROM listens l +JOIN tracks_with_title t ON l.track_id = t.id +JOIN artist_tracks at ON t.id = at.track_id +WHERE at.artist_id = $1 +ORDER BY l.listened_at ASC +LIMIT 1; + -- name: GetLastListensFromReleasePaginated :many SELECT l.*, @@ -42,6 +52,15 @@ WHERE l.listened_at BETWEEN $1 AND $2 ORDER BY l.listened_at DESC LIMIT $3 OFFSET $4; +-- name: GetFirstListenFromRelease :one +SELECT + l.* +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE t.release_id = $1 +ORDER BY l.listened_at ASC +LIMIT 1; + -- name: GetLastListensFromTrackPaginated :many SELECT l.*, @@ -55,6 +74,15 @@ WHERE l.listened_at BETWEEN $1 AND $2 ORDER BY l.listened_at DESC LIMIT $3 OFFSET $4; +-- name: GetFirstListenFromTrack :one +SELECT + l.* +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE t.id = $1 +ORDER BY l.listened_at ASC +LIMIT 1; + -- name: CountListens :one SELECT COUNT(*) AS total_count FROM listens l diff --git a/internal/db/psql/album.go b/internal/db/psql/album.go index 2ac2fe0..eb0dcad 100644 --- a/internal/db/psql/album.go +++ b/internal/db/psql/album.go @@ -98,8 +98,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err) } + firstListen, err := d.q.GetFirstListenFromRelease(ctx, ret.ID) + if err != nil { + return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err) + } + ret.ListenCount = count ret.TimeListened = seconds + ret.FirstListen = firstListen.ListenedAt.Unix() return ret, nil } diff --git a/internal/db/psql/artist.go b/internal/db/psql/artist.go index bfa31fc..9158d15 100644 --- a/internal/db/psql/artist.go +++ b/internal/db/psql/artist.go @@ -41,6 +41,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar if err != nil { return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) } + firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID) + if err != nil { + return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err) + } return &models.Artist{ ID: row.ID, MbzID: row.MusicBrainzID, @@ -49,6 +53,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar Image: row.Image, ListenCount: count, TimeListened: seconds, + FirstListen: firstListen.ListenedAt.Unix(), }, nil } else if opts.MusicBrainzID != uuid.Nil { l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID) @@ -71,14 +76,19 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar if err != nil { return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) } + firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID) + if err != nil { + return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err) + } return &models.Artist{ ID: row.ID, MbzID: row.MusicBrainzID, Name: row.Name, Aliases: row.Aliases, Image: row.Image, - TimeListened: seconds, ListenCount: count, + TimeListened: seconds, + FirstListen: firstListen.ListenedAt.Unix(), }, nil } else if opts.Name != "" { l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name) @@ -101,6 +111,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar if err != nil { return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) } + firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID) + if err != nil { + return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err) + } return &models.Artist{ ID: row.ID, MbzID: row.MusicBrainzID, @@ -109,6 +123,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar Image: row.Image, ListenCount: count, TimeListened: seconds, + FirstListen: firstListen.ListenedAt.Unix(), }, nil } else { return nil, errors.New("insufficient information to get artist") diff --git a/internal/db/psql/track.go b/internal/db/psql/track.go index 1614448..0660702 100644 --- a/internal/db/psql/track.go +++ b/internal/db/psql/track.go @@ -89,8 +89,14 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err) } + firstListen, err := d.q.GetFirstListenFromTrack(ctx, track.ID) + if err != nil { + return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err) + } + track.ListenCount = count track.TimeListened = seconds + track.FirstListen = firstListen.ListenedAt.Unix() return &track, nil } diff --git a/internal/models/album.go b/internal/models/album.go index a92a3aa..24948f9 100644 --- a/internal/models/album.go +++ b/internal/models/album.go @@ -11,6 +11,7 @@ type Album struct { VariousArtists bool `json:"is_various_artists"` ListenCount int64 `json:"listen_count"` TimeListened int64 `json:"time_listened"` + FirstListen int64 `json:"first_listen"` } // type SimpleAlbum struct { diff --git a/internal/models/artist.go b/internal/models/artist.go index d9f62f0..7784e51 100644 --- a/internal/models/artist.go +++ b/internal/models/artist.go @@ -10,6 +10,7 @@ type Artist struct { Image *uuid.UUID `json:"image"` ListenCount int64 `json:"listen_count"` TimeListened int64 `json:"time_listened"` + FirstListen int64 `json:"first_listen"` IsPrimary bool `json:"is_primary,omitempty"` } @@ -27,5 +28,6 @@ type ArtistWithFullAliases struct { ImageSource string `json:"image_source,omitempty"` ListenCount int64 `json:"listen_count"` TimeListened int64 `json:"time_listened"` + FirstListen int64 `json:"first_listen"` IsPrimary bool `json:"is_primary,omitempty"` } diff --git a/internal/models/track.go b/internal/models/track.go index 086813f..8eb802c 100644 --- a/internal/models/track.go +++ b/internal/models/track.go @@ -12,4 +12,5 @@ type Track struct { Image *uuid.UUID `json:"image"` AlbumID int32 `json:"album_id"` TimeListened int64 `json:"time_listened"` + FirstListen int64 `json:"first_listen"` } diff --git a/internal/repository/listen.sql.go b/internal/repository/listen.sql.go index 583dca6..027873a 100644 --- a/internal/repository/listen.sql.go +++ b/internal/repository/listen.sql.go @@ -190,6 +190,73 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro return err } +const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one +SELECT + l.track_id, l.listened_at, l.client, l.user_id +FROM listens l +JOIN tracks_with_title t ON l.track_id = t.id +JOIN artist_tracks at ON t.id = at.track_id +WHERE at.artist_id = $1 +ORDER BY l.listened_at ASC +LIMIT 1 +` + +func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32) (Listen, error) { + row := q.db.QueryRow(ctx, getFirstListenFromArtist, artistID) + var i Listen + err := row.Scan( + &i.TrackID, + &i.ListenedAt, + &i.Client, + &i.UserID, + ) + return i, err +} + +const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one +SELECT + l.track_id, l.listened_at, l.client, l.user_id +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE t.release_id = $1 +ORDER BY l.listened_at ASC +LIMIT 1 +` + +func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32) (Listen, error) { + row := q.db.QueryRow(ctx, getFirstListenFromRelease, releaseID) + var i Listen + err := row.Scan( + &i.TrackID, + &i.ListenedAt, + &i.Client, + &i.UserID, + ) + return i, err +} + +const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one +SELECT + l.track_id, l.listened_at, l.client, l.user_id +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE t.id = $1 +ORDER BY l.listened_at ASC +LIMIT 1 +` + +func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen, error) { + row := q.db.QueryRow(ctx, getFirstListenFromTrack, id) + var i Listen + err := row.Scan( + &i.TrackID, + &i.ListenedAt, + &i.Client, + &i.UserID, + ) + return i, err +} + const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many SELECT l.track_id, l.listened_at, l.client, l.user_id,