mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
feat: v0.0.5
This commit is contained in:
parent
4c4ebc593d
commit
242a82ad8c
36 changed files with 694 additions and 174 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,3 +1,14 @@
|
||||||
# v0.0.4
|
# v0.0.5
|
||||||
|
## Features
|
||||||
|
- Artist MusicBrainz IDs will now be mapped during ListenBrainz and LastFM imports, even when MusicBrainz is disabled
|
||||||
|
- Merges now support replacing images for artists and albums
|
||||||
|
- Time listened per item is now displayed on the item page, below the total play count
|
||||||
|
|
||||||
## Enhancements
|
## Enhancements
|
||||||
- Re-download images missing from cache on request
|
- More reliable artist MusicBrainz ID mapping when scrobbling
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
- Token validation now correctly validates case-insensitive authorization scheme
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
- Removed the portion that mentions not being able to map MusicBrainz IDs when it is disabled, as that is no longer true
|
||||||
|
|
@ -74,13 +74,13 @@ function mergeTracks(from: number, to: number): Promise<Response> {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function mergeAlbums(from: number, to: number): Promise<Response> {
|
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, {
|
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function mergeArtists(from: number, to: number): Promise<Response> {
|
function mergeArtists(from: number, to: number, replaceImage: boolean): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, {
|
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +200,7 @@ type Track = {
|
||||||
image: string
|
image: string
|
||||||
album_id: number
|
album_id: number
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string
|
||||||
|
time_listened: number
|
||||||
}
|
}
|
||||||
type Artist = {
|
type Artist = {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -208,6 +209,7 @@ type Artist = {
|
||||||
aliases: string[]
|
aliases: string[]
|
||||||
listen_count: number
|
listen_count: number
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string
|
||||||
|
time_listened: number
|
||||||
}
|
}
|
||||||
type Album = {
|
type Album = {
|
||||||
id: number,
|
id: number,
|
||||||
|
|
@ -217,6 +219,7 @@ type Album = {
|
||||||
is_various_artists: boolean
|
is_various_artists: boolean
|
||||||
artists: SimpleArtists[]
|
artists: SimpleArtists[]
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string
|
||||||
|
time_listened: number
|
||||||
}
|
}
|
||||||
type Alias = {
|
type Alias = {
|
||||||
id: number
|
id: number
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
|
||||||
<span className="color-fg-secondary">Various Artists</span>
|
<span className="color-fg-secondary">Various Artists</span>
|
||||||
:
|
:
|
||||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||||
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export default function MergeModal(props: Props) {
|
||||||
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}>({title: '', id: 0})
|
||||||
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
|
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
|
||||||
|
const [replaceImage, setReplaceImage] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,7 +54,7 @@ export default function MergeModal(props: Props) {
|
||||||
from = {id: props.currentId, title: props.currentTitle}
|
from = {id: props.currentId, title: props.currentTitle}
|
||||||
to = mergeTarget
|
to = mergeTarget
|
||||||
}
|
}
|
||||||
props.mergeFunc(from.id, to.id)
|
props.mergeFunc(from.id, to.id, replaceImage)
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
if (mergeOrderReversed) {
|
if (mergeOrderReversed) {
|
||||||
|
|
@ -117,6 +118,13 @@ export default function MergeModal(props: Props) {
|
||||||
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
|
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
|
||||||
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
||||||
</div>
|
</div>
|
||||||
|
{
|
||||||
|
(props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") &&
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<input type="checkbox" name="replace-image" checked={replaceImage} onChange={() => setReplaceImage(!replaceImage)} />
|
||||||
|
<label htmlFor="replace-image">Replace image</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</> :
|
</> :
|
||||||
''}
|
''}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import LastPlays from "~/components/LastPlays";
|
||||||
import PeriodSelector from "~/components/PeriodSelector";
|
import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
||||||
|
|
@ -40,9 +41,10 @@ export default function Album() {
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}}
|
}}
|
||||||
subContent={<>
|
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>}
|
||||||
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ArtistAlbums from "~/components/ArtistAlbums";
|
import ArtistAlbums from "~/components/ArtistAlbums";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
||||||
|
|
@ -46,9 +47,10 @@ export default function Artist() {
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}}
|
}}
|
||||||
subContent={<>
|
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>}
|
||||||
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||||
import DeleteModal from "~/components/modals/DeleteModal";
|
import DeleteModal from "~/components/modals/DeleteModal";
|
||||||
import RenameModal from "~/components/modals/RenameModal";
|
import RenameModal from "~/components/modals/RenameModal";
|
||||||
|
|
||||||
export type MergeFunc = (from: number, to: number) => Promise<Response>
|
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
|
||||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import LastPlays from "~/components/LastPlays";
|
||||||
import PeriodSelector from "~/components/PeriodSelector";
|
import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||||
|
|
@ -42,9 +43,10 @@ export default function Track() {
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}}
|
}}
|
||||||
subContent={<div className="flex flex-col gap-4 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>}
|
||||||
</div>}
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|
|
||||||
|
|
@ -86,5 +86,17 @@ const hexToHSL = (hex: string): hsl => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export {hexToHSL}
|
const timeListenedString = (seconds: number) => {
|
||||||
|
if (!seconds) return ""
|
||||||
|
|
||||||
|
if (seconds > (120 * 60) - 1) {
|
||||||
|
let hours = Math.floor(seconds / 60 / 60)
|
||||||
|
return `${hours} hours listened`
|
||||||
|
} else {
|
||||||
|
let minutes = Math.floor(seconds / 60)
|
||||||
|
return `${minutes} minutes listened`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {hexToHSL, timeListenedString}
|
||||||
export type {hsl}
|
export type {hsl}
|
||||||
|
|
@ -12,8 +12,7 @@ Koito currently supports the following sources to import data from:
|
||||||
:::note
|
:::note
|
||||||
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
|
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
|
||||||
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
|
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
|
||||||
means that artist aliases will not be automatically fetched for imported artists. This also means that artists will not be associated with their MusicBrainz IDs internally,
|
means that artist aliases will not be automatically fetched for imported artists. You can also use
|
||||||
which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use
|
|
||||||
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
|
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
|
||||||
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
|
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,19 @@ type LbzTrackMeta struct {
|
||||||
ArtistName string `json:"artist_name"` // required
|
ArtistName string `json:"artist_name"` // required
|
||||||
TrackName string `json:"track_name"` // required
|
TrackName string `json:"track_name"` // required
|
||||||
ReleaseName string `json:"release_name,omitempty"`
|
ReleaseName string `json:"release_name,omitempty"`
|
||||||
|
MBIDMapping LbzMBIDMapping `json:"mbid_mapping"`
|
||||||
AdditionalInfo LbzAdditionalInfo `json:"additional_info,omitempty"`
|
AdditionalInfo LbzAdditionalInfo `json:"additional_info,omitempty"`
|
||||||
}
|
}
|
||||||
|
type LbzArtist struct {
|
||||||
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
|
ArtistName string `json:"artist_credit_name"`
|
||||||
|
}
|
||||||
|
type LbzMBIDMapping struct {
|
||||||
|
ReleaseMBID string `json:"release_mbid"`
|
||||||
|
RecordingMBID string `json:"recording_mbid"`
|
||||||
|
ArtistMBIDs []string `json:"artist_mbids"`
|
||||||
|
Artists []LbzArtist `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
type LbzAdditionalInfo struct {
|
type LbzAdditionalInfo struct {
|
||||||
MediaPlayer string `json:"media_player,omitempty"`
|
MediaPlayer string `json:"media_player,omitempty"`
|
||||||
|
|
@ -128,17 +139,30 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
|
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
|
||||||
}
|
}
|
||||||
|
if len(artistMbzIDs) < 1 {
|
||||||
|
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping")
|
||||||
|
utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
|
||||||
|
}
|
||||||
|
}
|
||||||
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
|
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rgMbzID = uuid.Nil
|
rgMbzID = uuid.Nil
|
||||||
}
|
}
|
||||||
releaseMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseMBID)
|
releaseMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
releaseMbzID = uuid.Nil
|
releaseMbzID, err = uuid.Parse(payload.TrackMeta.MBIDMapping.ReleaseMBID)
|
||||||
|
if err != nil {
|
||||||
|
releaseMbzID = uuid.Nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
recordingMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.RecordingMBID)
|
recordingMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.RecordingMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
recordingMbzID = uuid.Nil
|
recordingMbzID, err = uuid.Parse(payload.TrackMeta.MBIDMapping.RecordingMBID)
|
||||||
|
if err != nil {
|
||||||
|
recordingMbzID = uuid.Nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var client string
|
var client string
|
||||||
|
|
@ -160,20 +184,33 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
|
||||||
listenedAt = time.Unix(payload.ListenedAt, 0)
|
listenedAt = time.Unix(payload.ListenedAt, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var artistMbidMap []catalog.ArtistMbidMap
|
||||||
|
for _, a := range payload.TrackMeta.MBIDMapping.Artists {
|
||||||
|
if a.ArtistMBID == "" || a.ArtistName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mbid, err := uuid.Parse(a.ArtistMBID)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName)
|
||||||
|
}
|
||||||
|
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid})
|
||||||
|
}
|
||||||
|
|
||||||
opts := catalog.SubmitListenOpts{
|
opts := catalog.SubmitListenOpts{
|
||||||
MbzCaller: mbzc,
|
MbzCaller: mbzc,
|
||||||
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
|
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
|
||||||
Artist: payload.TrackMeta.ArtistName,
|
Artist: payload.TrackMeta.ArtistName,
|
||||||
ArtistMbzIDs: artistMbzIDs,
|
ArtistMbzIDs: artistMbzIDs,
|
||||||
TrackTitle: payload.TrackMeta.TrackName,
|
TrackTitle: payload.TrackMeta.TrackName,
|
||||||
RecordingMbzID: recordingMbzID,
|
RecordingMbzID: recordingMbzID,
|
||||||
ReleaseTitle: payload.TrackMeta.ReleaseName,
|
ReleaseTitle: payload.TrackMeta.ReleaseName,
|
||||||
ReleaseMbzID: releaseMbzID,
|
ReleaseMbzID: releaseMbzID,
|
||||||
ReleaseGroupMbzID: rgMbzID,
|
ReleaseGroupMbzID: rgMbzID,
|
||||||
Duration: duration,
|
ArtistMbidMappings: artistMbidMap,
|
||||||
Time: listenedAt,
|
Duration: duration,
|
||||||
UserID: u.ID,
|
Time: listenedAt,
|
||||||
Client: client,
|
UserID: u.ID,
|
||||||
|
Client: client,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ListenType == ListenTypePlayingNow {
|
if req.ListenType == ListenTypePlayingNow {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"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"
|
||||||
|
|
@ -67,9 +68,16 @@ func MergeReleaseGroupsHandler(store db.DB) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var replaceImage bool
|
||||||
|
replaceImgStr := r.URL.Query().Get("replace_image")
|
||||||
|
if strings.ToLower(replaceImgStr) == "true" {
|
||||||
|
l.Debug().Msg("MergeReleaseGroupsHandler: Merge will replace image")
|
||||||
|
replaceImage = true
|
||||||
|
}
|
||||||
|
|
||||||
l.Debug().Msgf("MergeReleaseGroupsHandler: Merging release groups from ID %d to ID %d", fromId, toId)
|
l.Debug().Msgf("MergeReleaseGroupsHandler: Merging release groups from ID %d to ID %d", fromId, toId)
|
||||||
|
|
||||||
err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId))
|
err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId), replaceImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("MergeReleaseGroupsHandler: Failed to merge release groups")
|
l.Err(err).Msg("MergeReleaseGroupsHandler: Failed to merge release groups")
|
||||||
utils.WriteError(w, "Failed to merge release groups: "+err.Error(), http.StatusInternalServerError)
|
utils.WriteError(w, "Failed to merge release groups: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
|
@ -103,9 +111,16 @@ func MergeArtistsHandler(store db.DB) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var replaceImage bool
|
||||||
|
replaceImgStr := r.URL.Query().Get("replace_image")
|
||||||
|
if strings.ToLower(replaceImgStr) == "true" {
|
||||||
|
l.Debug().Msg("MergeReleaseGroupsHandler: Merge will replace image")
|
||||||
|
replaceImage = true
|
||||||
|
}
|
||||||
|
|
||||||
l.Debug().Msgf("MergeArtistsHandler: Merging artists from ID %d to ID %d", fromId, toId)
|
l.Debug().Msgf("MergeArtistsHandler: Merging artists from ID %d to ID %d", fromId, toId)
|
||||||
|
|
||||||
err = store.MergeArtists(r.Context(), int32(fromId), int32(toId))
|
err = store.MergeArtists(r.Context(), int32(fromId), int32(toId), replaceImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("MergeArtistsHandler: Failed to merge artists")
|
l.Err(err).Msg("MergeArtistsHandler: Failed to merge artists")
|
||||||
utils.WriteError(w, "Failed to merge artists: "+err.Error(), http.StatusInternalServerError)
|
utils.WriteError(w, "Failed to merge artists: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,40 @@ func TestImportLastFM(t *testing.T) {
|
||||||
truncateTestData(t)
|
truncateTestData(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportLastFM_MbzDisabled(t *testing.T) {
|
||||||
|
|
||||||
|
src := path.Join("..", "test_assets", "recenttracks-shoko2-1749776100.json")
|
||||||
|
destDir := filepath.Join(cfg.ConfigDir(), "import")
|
||||||
|
dest := filepath.Join(destDir, "recenttracks-shoko2-1749776100.json")
|
||||||
|
|
||||||
|
// not going to make the dest dir because engine should make it already
|
||||||
|
|
||||||
|
input, err := os.ReadFile(src)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(dest, input, os.ModePerm))
|
||||||
|
|
||||||
|
engine.RunImporter(logger.Get(), store, &mbz.MbzErrorCaller{})
|
||||||
|
|
||||||
|
album, err := store.GetAlbum(context.Background(), db.GetAlbumOpts{MusicBrainzID: uuid.MustParse("e9e78802-0bf8-4ca3-9655-1d943d2d2fa0")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ZOO!!", album.Title)
|
||||||
|
artist, err := store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("4b00640f-3be6-43f8-9b34-ff81bd89320a")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "OurR", artist.Name)
|
||||||
|
artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{Name: "CHUU"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "because I'm stupid?", ArtistIDs: []int32{artist.ID}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Log(track)
|
||||||
|
listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Period: db.PeriodAllTime})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, listens.Items, 1)
|
||||||
|
assert.WithinDuration(t, time.Unix(1749776100, 0), listens.Items[0].Time, 1*time.Second)
|
||||||
|
|
||||||
|
truncateTestData(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImportListenBrainz(t *testing.T) {
|
func TestImportListenBrainz(t *testing.T) {
|
||||||
|
|
||||||
src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip")
|
src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip")
|
||||||
|
|
@ -188,3 +222,41 @@ func TestImportListenBrainz(t *testing.T) {
|
||||||
|
|
||||||
truncateTestData(t)
|
truncateTestData(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportListenBrainz_MbzDisabled(t *testing.T) {
|
||||||
|
|
||||||
|
src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip")
|
||||||
|
destDir := filepath.Join(cfg.ConfigDir(), "import")
|
||||||
|
dest := filepath.Join(destDir, "listenbrainz_shoko1_1749780844.zip")
|
||||||
|
|
||||||
|
// not going to make the dest dir because engine should make it already
|
||||||
|
|
||||||
|
input, err := os.ReadFile(src)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(dest, input, os.ModePerm))
|
||||||
|
|
||||||
|
engine.RunImporter(logger.Get(), store, &mbz.MbzErrorCaller{})
|
||||||
|
|
||||||
|
album, err := store.GetAlbum(context.Background(), db.GetAlbumOpts{MusicBrainzID: uuid.MustParse("ce330d67-9c46-4a3b-9d62-08406370f234")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "酸欠少女", album.Title)
|
||||||
|
artist, err := store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("4b00640f-3be6-43f8-9b34-ff81bd89320a")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "OurR", artist.Name)
|
||||||
|
artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("09887aa7-226e-4ecc-9a0c-02d2ae5777e1")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Carly Rae Jepsen", artist.Name)
|
||||||
|
artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("78e46ae5-9bfd-433b-be3f-19e993d67ecc")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Rufus Wainwright", artist.Name)
|
||||||
|
track, err := store.GetTrack(context.Background(), db.GetTrackOpts{MusicBrainzID: uuid.MustParse("08e8f55b-f1a4-46b8-b2d1-fab4c592165c")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Desert", track.Title)
|
||||||
|
listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Period: db.PeriodAllTime})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, listens.Items, 1)
|
||||||
|
assert.WithinDuration(t, time.Unix(1749780612, 0), listens.Items[0].Time, 1*time.Second)
|
||||||
|
|
||||||
|
truncateTestData(t)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,16 +87,16 @@ func ValidateApiKey(store db.DB) func(next http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
authh := r.Header.Get("Authorization")
|
authh := r.Header.Get("Authorization")
|
||||||
s := strings.Split(authh, "Token ")
|
var token string
|
||||||
if len(s) < 2 {
|
if strings.HasPrefix(strings.ToLower(authh), "token ") {
|
||||||
l.Debug().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'")
|
token = strings.TrimSpace(authh[6:]) // strip "Token "
|
||||||
|
} else {
|
||||||
|
l.Error().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'")
|
||||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key := s[1]
|
u, err := store.GetUserByApiKey(ctx, token)
|
||||||
|
|
||||||
u, err := store.GetUserByApiKey(ctx, key)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to get user from database using api key")
|
l.Err(err).Msg("Failed to get user from database using api key")
|
||||||
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
|
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module github.com/gabehf/koito
|
module github.com/gabehf/koito
|
||||||
|
|
||||||
go 1.23.7
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package catalog
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -17,11 +18,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type AssociateArtistsOpts struct {
|
type AssociateArtistsOpts struct {
|
||||||
ArtistMbzIDs []uuid.UUID
|
ArtistMbzIDs []uuid.UUID
|
||||||
ArtistNames []string
|
ArtistNames []string
|
||||||
ArtistName string
|
ArtistMbidMap []ArtistMbidMap
|
||||||
TrackTitle string
|
ArtistName string
|
||||||
Mbzc mbz.MusicBrainzCaller
|
TrackTitle string
|
||||||
|
Mbzc mbz.MusicBrainzCaller
|
||||||
}
|
}
|
||||||
|
|
||||||
func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
|
func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
|
||||||
|
|
@ -29,9 +31,19 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
|
||||||
|
|
||||||
var result []*models.Artist
|
var result []*models.Artist
|
||||||
|
|
||||||
if len(opts.ArtistMbzIDs) > 0 {
|
// use mbid map first, as it is the most reliable way to get mbid for artists
|
||||||
l.Debug().Msg("Associating artists by MusicBrainz ID(s)")
|
if len(opts.ArtistMbidMap) > 0 {
|
||||||
mbzMatches, err := matchArtistsByMBID(ctx, d, opts)
|
l.Debug().Msg("Associating artists by MusicBrainz ID(s) mappings")
|
||||||
|
mbzMatches, err := matchArtistsByMBIDMappings(ctx, d, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, mbzMatches...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.ArtistMbzIDs) > len(result) {
|
||||||
|
l.Debug().Msg("Associating artists by list of MusicBrainz ID(s)")
|
||||||
|
mbzMatches, err := matchArtistsByMBID(ctx, d, opts, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -60,11 +72,82 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
|
func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
var result []*models.Artist
|
||||||
|
|
||||||
|
for _, a := range opts.ArtistMbidMap {
|
||||||
|
// first, try to get by mbid
|
||||||
|
artist, err := d.GetArtist(ctx, db.GetArtistOpts{
|
||||||
|
MusicBrainzID: a.Mbid,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
l.Debug().Msgf("Artist '%s' found by MusicBrainz ID", artist.Name)
|
||||||
|
result = append(result, artist)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)
|
||||||
|
}
|
||||||
|
// then, try to get by mbz name
|
||||||
|
artist, err = d.GetArtist(ctx, db.GetArtistOpts{
|
||||||
|
Name: a.Artist,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
l.Debug().Msgf("Artist '%s' found by Name", a.Artist)
|
||||||
|
// ...associate with mbzid if found
|
||||||
|
err = d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: artist.ID, MusicBrainzID: a.Mbid})
|
||||||
|
if err != nil {
|
||||||
|
l.Err(fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)).Msgf("Failed to associate artist '%s' with MusicBrainz ID", artist.Name)
|
||||||
|
} else {
|
||||||
|
artist.MbzID = &a.Mbid
|
||||||
|
}
|
||||||
|
result = append(result, artist)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// then, try to get by aliases, or create
|
||||||
|
artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts.Mbzc)
|
||||||
|
if err != nil {
|
||||||
|
// if mbz unreachable, just create a new artist with provided name and mbid
|
||||||
|
l.Warn().Msg("MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping")
|
||||||
|
var imgid uuid.UUID
|
||||||
|
imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{
|
||||||
|
Aliases: []string{a.Artist},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
imgid = uuid.New()
|
||||||
|
err = DownloadAndCacheImage(ctx, imgid, imgUrl, ImageSourceSize())
|
||||||
|
if err != nil {
|
||||||
|
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to download artist image for artist '%s'", a.Artist)
|
||||||
|
imgid = uuid.Nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to get artist image for artist '%s'", a.Artist)
|
||||||
|
}
|
||||||
|
artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: a.Artist, MusicBrainzID: a.Mbid, Image: imgid, ImageSrc: imgUrl})
|
||||||
|
if err != nil {
|
||||||
|
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to create artist '%s' in database", a.Artist)
|
||||||
|
return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, artist)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts, existing []*models.Artist) ([]*models.Artist, error) {
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
var result []*models.Artist
|
var result []*models.Artist
|
||||||
|
|
||||||
for _, id := range opts.ArtistMbzIDs {
|
for _, id := range opts.ArtistMbzIDs {
|
||||||
|
if artistExistsByMbzID(id, existing) || artistExistsByMbzID(id, result) {
|
||||||
|
l.Debug().Msgf("Artist with MusicBrainz ID %s already found, skipping...", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if id == uuid.Nil {
|
if id == uuid.Nil {
|
||||||
l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID")
|
l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID")
|
||||||
return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
|
return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
|
||||||
|
|
@ -229,3 +312,11 @@ func artistExists(name string, artists []*models.Artist) bool {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
func artistExistsByMbzID(id uuid.UUID, artists []*models.Artist) bool {
|
||||||
|
for _, a := range artists {
|
||||||
|
if a.MbzID != nil && *a.MbzID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,24 +29,30 @@ type SaveListenOpts struct {
|
||||||
Time time.Time
|
Time time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArtistMbidMap struct {
|
||||||
|
Artist string
|
||||||
|
Mbid uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
type SubmitListenOpts struct {
|
type SubmitListenOpts struct {
|
||||||
// When true, skips registering the listen and only associates or creates the
|
// When true, skips registering the listen and only associates or creates the
|
||||||
// artist, release, release group, and track in DB
|
// artist, release, release group, and track in DB
|
||||||
SkipSaveListen bool
|
SkipSaveListen bool
|
||||||
|
|
||||||
MbzCaller mbz.MusicBrainzCaller
|
MbzCaller mbz.MusicBrainzCaller
|
||||||
ArtistNames []string
|
ArtistNames []string
|
||||||
Artist string
|
Artist string
|
||||||
ArtistMbzIDs []uuid.UUID
|
ArtistMbzIDs []uuid.UUID
|
||||||
TrackTitle string
|
ArtistMbidMappings []ArtistMbidMap
|
||||||
RecordingMbzID uuid.UUID
|
TrackTitle string
|
||||||
Duration int32 // in seconds
|
RecordingMbzID uuid.UUID
|
||||||
ReleaseTitle string
|
Duration int32 // in seconds
|
||||||
ReleaseMbzID uuid.UUID
|
ReleaseTitle string
|
||||||
ReleaseGroupMbzID uuid.UUID
|
ReleaseMbzID uuid.UUID
|
||||||
Time time.Time
|
ReleaseGroupMbzID uuid.UUID
|
||||||
UserID int32
|
Time time.Time
|
||||||
Client string
|
UserID int32
|
||||||
|
Client string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -64,11 +70,12 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
|
||||||
ctx,
|
ctx,
|
||||||
store,
|
store,
|
||||||
AssociateArtistsOpts{
|
AssociateArtistsOpts{
|
||||||
ArtistMbzIDs: opts.ArtistMbzIDs,
|
ArtistMbzIDs: opts.ArtistMbzIDs,
|
||||||
ArtistNames: opts.ArtistNames,
|
ArtistNames: opts.ArtistNames,
|
||||||
ArtistName: opts.Artist,
|
ArtistName: opts.Artist,
|
||||||
Mbzc: opts.MbzCaller,
|
ArtistMbidMap: opts.ArtistMbidMappings,
|
||||||
TrackTitle: opts.TrackTitle,
|
Mbzc: opts.MbzCaller,
|
||||||
|
TrackTitle: opts.TrackTitle,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Error().Err(err).Msg("Failed to associate artists to listen")
|
l.Error().Err(err).Msg("Failed to associate artists to listen")
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,15 @@ const (
|
||||||
ImageCacheDir = "image_cache"
|
ImageCacheDir = "image_cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func ImageSourceSize() (size ImageSize) {
|
||||||
|
if cfg.FullImageCacheEnabled() {
|
||||||
|
size = ImageSizeFull
|
||||||
|
} else {
|
||||||
|
size = ImageSizeLarge
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func ParseImageSize(size string) (ImageSize, error) {
|
func ParseImageSize(size string) (ImageSize, error) {
|
||||||
switch strings.ToLower(size) {
|
switch strings.ToLower(size) {
|
||||||
case "small":
|
case "small":
|
||||||
|
|
|
||||||
|
|
@ -856,3 +856,64 @@ func TestSubmitListen_MusicBrainzUnreachable(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, exists, "expected listen row to exist")
|
assert.True(t, exists, "expected listen row to exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubmitListen_MusicBrainzUnreachableMBIDMappings(t *testing.T) {
|
||||||
|
truncateTestData(t)
|
||||||
|
|
||||||
|
// correctly associate MBID when musicbrainz unreachable, but map provided
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
mbzc := &mbz.MbzErrorCaller{}
|
||||||
|
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||||
|
artist2MbzID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||||
|
releaseGroupMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000011")
|
||||||
|
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
|
||||||
|
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
|
||||||
|
artistMbzIdMap := []catalog.ArtistMbidMap{{Artist: "ATARASHII GAKKO!", Mbid: artistMbzID}, {Artist: "Featured Artist", Mbid: artist2MbzID}}
|
||||||
|
opts := catalog.SubmitListenOpts{
|
||||||
|
MbzCaller: mbzc,
|
||||||
|
ArtistNames: []string{"ATARASHII GAKKO!", "Featured Artist"},
|
||||||
|
Artist: "ATARASHII GAKKO! feat. Featured Artist",
|
||||||
|
ArtistMbzIDs: []uuid.UUID{
|
||||||
|
artistMbzID,
|
||||||
|
},
|
||||||
|
TrackTitle: "Tokyo Calling",
|
||||||
|
RecordingMbzID: trackMbzID,
|
||||||
|
ReleaseTitle: "AG! Calling",
|
||||||
|
ReleaseMbzID: releaseMbzID,
|
||||||
|
ReleaseGroupMbzID: releaseGroupMbzID,
|
||||||
|
ArtistMbidMappings: artistMbzIdMap,
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Verify that the artist has the mbid saved
|
||||||
|
exists, err = store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM artists
|
||||||
|
WHERE musicbrainz_id = $1
|
||||||
|
)`, artistMbzID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "expected artist to have correct musicbrainz id")
|
||||||
|
|
||||||
|
// Verify that the artist has the mbid saved
|
||||||
|
exists, err = store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM artists
|
||||||
|
WHERE musicbrainz_id = $1
|
||||||
|
)`, artist2MbzID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "expected artist to have correct musicbrainz id")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ type DB interface {
|
||||||
CountAlbums(ctx context.Context, period Period) (int64, error)
|
CountAlbums(ctx context.Context, period Period) (int64, error)
|
||||||
CountArtists(ctx context.Context, period Period) (int64, error)
|
CountArtists(ctx context.Context, period Period) (int64, error)
|
||||||
CountTimeListened(ctx context.Context, period Period) (int64, error)
|
CountTimeListened(ctx context.Context, period Period) (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)
|
||||||
|
|
@ -71,8 +72,8 @@ type DB interface {
|
||||||
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) error
|
MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error
|
||||||
MergeArtists(ctx context.Context, fromId, toId int32) 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)
|
||||||
|
|
|
||||||
|
|
@ -138,3 +138,10 @@ type ListenActivityOpts struct {
|
||||||
ArtistID int32
|
ArtistID int32
|
||||||
TrackID int32
|
TrackID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimeListenedOpts struct {
|
||||||
|
Period Period
|
||||||
|
AlbumID int32
|
||||||
|
ArtistID int32
|
||||||
|
TrackID int32
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||||
|
Period: db.PeriodAllTime,
|
||||||
|
AlbumID: row.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &models.Album{
|
return &models.Album{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MbzID: row.MusicBrainzID,
|
MbzID: row.MusicBrainzID,
|
||||||
|
|
@ -64,6 +72,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
|
||||||
Image: row.Image,
|
Image: row.Image,
|
||||||
VariousArtists: row.VariousArtists,
|
VariousArtists: row.VariousArtists,
|
||||||
ListenCount: count,
|
ListenCount: count,
|
||||||
|
TimeListened: seconds,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,21 +47,16 @@ func testDataForRelease(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAlbum(t *testing.T) {
|
func TestGetAlbum(t *testing.T) {
|
||||||
testDataForRelease(t)
|
testDataForTopItems(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Insert test data
|
|
||||||
rg, err := store.SaveAlbum(ctx, db.SaveAlbumOpts{
|
|
||||||
Title: "Test Release Group",
|
|
||||||
ArtistIDs: []int32{1},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Test GetAlbum by ID
|
// Test GetAlbum by ID
|
||||||
result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: rg.ID})
|
result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: 1})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, rg.ID, result.ID)
|
assert.EqualValues(t, 1, result.ID)
|
||||||
assert.Equal(t, "Test Release Group", result.Title)
|
assert.Equal(t, "Release One", result.Title)
|
||||||
|
assert.EqualValues(t, 4, result.ListenCount)
|
||||||
|
assert.EqualValues(t, 400, result.TimeListened)
|
||||||
|
|
||||||
// Test GetAlbum with insufficient information
|
// Test GetAlbum with insufficient information
|
||||||
_, err = store.GetAlbum(ctx, db.GetAlbumOpts{})
|
_, err = store.GetAlbum(ctx, db.GetAlbumOpts{})
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// this function sucks because sqlc keeps making new types for rows that are the same
|
||||||
func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error) {
|
func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error) {
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
if opts.ID != 0 {
|
if opts.ID != 0 {
|
||||||
|
|
@ -32,13 +33,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||||
|
Period: db.PeriodAllTime,
|
||||||
|
ArtistID: row.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &models.Artist{
|
return &models.Artist{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MbzID: row.MusicBrainzID,
|
MbzID: row.MusicBrainzID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Aliases: row.Aliases,
|
Aliases: row.Aliases,
|
||||||
Image: row.Image,
|
Image: row.Image,
|
||||||
ListenCount: count,
|
ListenCount: count,
|
||||||
|
TimeListened: seconds,
|
||||||
}, nil
|
}, nil
|
||||||
} else if opts.MusicBrainzID != uuid.Nil {
|
} else if opts.MusicBrainzID != uuid.Nil {
|
||||||
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
||||||
|
|
@ -54,13 +63,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||||
|
Period: db.PeriodAllTime,
|
||||||
|
ArtistID: row.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &models.Artist{
|
return &models.Artist{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MbzID: row.MusicBrainzID,
|
MbzID: row.MusicBrainzID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Aliases: row.Aliases,
|
Aliases: row.Aliases,
|
||||||
Image: row.Image,
|
Image: row.Image,
|
||||||
ListenCount: count,
|
TimeListened: seconds,
|
||||||
|
ListenCount: count,
|
||||||
}, nil
|
}, nil
|
||||||
} else if opts.Name != "" {
|
} else if opts.Name != "" {
|
||||||
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
|
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
|
||||||
|
|
@ -76,13 +93,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||||
|
Period: db.PeriodAllTime,
|
||||||
|
ArtistID: row.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &models.Artist{
|
return &models.Artist{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MbzID: row.MusicBrainzID,
|
MbzID: row.MusicBrainzID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Aliases: row.Aliases,
|
Aliases: row.Aliases,
|
||||||
Image: row.Image,
|
Image: row.Image,
|
||||||
ListenCount: count,
|
ListenCount: count,
|
||||||
|
TimeListened: seconds,
|
||||||
}, nil
|
}, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("insufficient information to get artist")
|
return nil, errors.New("insufficient information to get artist")
|
||||||
|
|
|
||||||
|
|
@ -13,30 +13,33 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetArtist(t *testing.T) {
|
func TestGetArtist(t *testing.T) {
|
||||||
|
testDataForTopItems(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
mbzId := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
mbzId := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||||
// Insert test data
|
|
||||||
artist, err := store.SaveArtist(ctx, db.SaveArtistOpts{
|
|
||||||
Name: "Test Artist",
|
|
||||||
MusicBrainzID: mbzId,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Test GetArtist by ID
|
// Test GetArtist by ID
|
||||||
result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: artist.ID})
|
result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: 1})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, artist.ID, result.ID)
|
assert.EqualValues(t, 1, result.ID)
|
||||||
assert.Equal(t, "Test Artist", result.Name)
|
assert.Equal(t, "Artist One", result.Name)
|
||||||
|
assert.EqualValues(t, 4, result.ListenCount)
|
||||||
|
assert.EqualValues(t, 400, result.TimeListened)
|
||||||
|
|
||||||
// Test GetArtist by Name
|
// Test GetArtist by Name
|
||||||
result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: artist.Name})
|
result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "Artist One"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, artist.ID, result.ID)
|
assert.EqualValues(t, 1, result.ID)
|
||||||
|
assert.Equal(t, "Artist One", result.Name)
|
||||||
|
assert.EqualValues(t, 4, result.ListenCount)
|
||||||
|
assert.EqualValues(t, 400, result.TimeListened)
|
||||||
|
|
||||||
// Test GetArtist by MusicBrainzID
|
// Test GetArtist by MusicBrainzID
|
||||||
result, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: mbzId})
|
result, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: mbzId})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, artist.ID, result.ID)
|
assert.EqualValues(t, 1, result.ID)
|
||||||
|
assert.Equal(t, "Artist One", result.Name)
|
||||||
|
assert.EqualValues(t, 4, result.ListenCount)
|
||||||
|
assert.EqualValues(t, 400, result.TimeListened)
|
||||||
|
|
||||||
// Test GetArtist with insufficient information
|
// Test GetArtist with insufficient information
|
||||||
_, err = store.GetArtist(ctx, db.GetArtistOpts{})
|
_, err = store.GetArtist(ctx, db.GetArtistOpts{})
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package psql
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/db"
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
|
@ -68,3 +69,41 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
|
||||||
|
t2 := time.Now()
|
||||||
|
t1 := db.StartTimeFromPeriod(opts.Period)
|
||||||
|
|
||||||
|
if opts.ArtistID > 0 {
|
||||||
|
count, err := p.q.CountTimeListenedToArtist(ctx, repository.CountTimeListenedToArtistParams{
|
||||||
|
ListenedAt: t1,
|
||||||
|
ListenedAt_2: t2,
|
||||||
|
ArtistID: opts.ArtistID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
} else if opts.AlbumID > 0 {
|
||||||
|
count, err := p.q.CountTimeListenedToRelease(ctx, repository.CountTimeListenedToReleaseParams{
|
||||||
|
ListenedAt: t1,
|
||||||
|
ListenedAt_2: t2,
|
||||||
|
ReleaseID: opts.AlbumID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
|
||||||
|
} else if opts.TrackID > 0 {
|
||||||
|
count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{
|
||||||
|
ListenedAt: t1,
|
||||||
|
ListenedAt_2: t2,
|
||||||
|
ID: opts.TrackID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
return 0, errors.New("an id must be provided")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,33 @@ func TestCountTimeListened(t *testing.T) {
|
||||||
|
|
||||||
truncateTestData(t)
|
truncateTestData(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCountTimeListenedToArtist(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
testDataForTopItems(t)
|
||||||
|
period := db.PeriodAllTime
|
||||||
|
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, ArtistID: 1})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 400, count)
|
||||||
|
truncateTestData(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountTimeListenedToAlbum(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
testDataForTopItems(t)
|
||||||
|
period := db.PeriodAllTime
|
||||||
|
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, AlbumID: 2})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 300, count)
|
||||||
|
truncateTestData(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountTimeListenedToTrack(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
testDataForTopItems(t)
|
||||||
|
period := db.PeriodAllTime
|
||||||
|
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, TrackID: 3})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 200, count)
|
||||||
|
truncateTestData(t)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package psql
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/logger"
|
"github.com/gabehf/koito/internal/logger"
|
||||||
"github.com/gabehf/koito/internal/repository"
|
"github.com/gabehf/koito/internal/repository"
|
||||||
|
|
@ -14,7 +15,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
|
||||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to begin transaction")
|
l.Err(err).Msg("Failed to begin transaction")
|
||||||
return err
|
return fmt.Errorf("MergeTracks: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
qtx := d.q.WithTx(tx)
|
qtx := d.q.WithTx(tx)
|
||||||
|
|
@ -23,7 +24,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
|
||||||
TrackID_2: toId,
|
TrackID_2: toId,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("MergeTracks: %w", err)
|
||||||
}
|
}
|
||||||
err = qtx.CleanOrphanedEntries(ctx)
|
err = qtx.CleanOrphanedEntries(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -33,13 +34,13 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
|
func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error {
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
l.Info().Msgf("Merging album %d into album %d", fromId, toId)
|
l.Info().Msgf("Merging album %d into album %d", fromId, toId)
|
||||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to begin transaction")
|
l.Err(err).Msg("Failed to begin transaction")
|
||||||
return err
|
return fmt.Errorf("MergeAlbums: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
qtx := d.q.WithTx(tx)
|
qtx := d.q.WithTx(tx)
|
||||||
|
|
@ -48,7 +49,21 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
|
||||||
ReleaseID_2: toId,
|
ReleaseID_2: toId,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("MergeAlbums: %w", err)
|
||||||
|
}
|
||||||
|
if replaceImage {
|
||||||
|
old, err := qtx.GetRelease(ctx, fromId)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MergeAlbums: %w", err)
|
||||||
|
}
|
||||||
|
err = qtx.UpdateReleaseImage(ctx, repository.UpdateReleaseImageParams{
|
||||||
|
ID: toId,
|
||||||
|
Image: old.Image,
|
||||||
|
ImageSource: old.ImageSource,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MergeAlbums: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = qtx.CleanOrphanedEntries(ctx)
|
err = qtx.CleanOrphanedEntries(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -58,13 +73,13 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
|
func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error {
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
l.Info().Msgf("Merging artist %d into artist %d", fromId, toId)
|
l.Info().Msgf("Merging artist %d into artist %d", fromId, toId)
|
||||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to begin transaction")
|
l.Err(err).Msg("Failed to begin transaction")
|
||||||
return err
|
return fmt.Errorf("MergeArtists: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
qtx := d.q.WithTx(tx)
|
qtx := d.q.WithTx(tx)
|
||||||
|
|
@ -74,7 +89,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to delete conflicting artist tracks")
|
l.Err(err).Msg("Failed to delete conflicting artist tracks")
|
||||||
return err
|
return fmt.Errorf("MergeArtists: %w", err)
|
||||||
}
|
}
|
||||||
err = qtx.DeleteConflictingArtistReleases(ctx, repository.DeleteConflictingArtistReleasesParams{
|
err = qtx.DeleteConflictingArtistReleases(ctx, repository.DeleteConflictingArtistReleasesParams{
|
||||||
ArtistID: fromId,
|
ArtistID: fromId,
|
||||||
|
|
@ -82,7 +97,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to delete conflicting artist releases")
|
l.Err(err).Msg("Failed to delete conflicting artist releases")
|
||||||
return err
|
return fmt.Errorf("MergeArtists: %w", err)
|
||||||
}
|
}
|
||||||
err = qtx.UpdateArtistTracks(ctx, repository.UpdateArtistTracksParams{
|
err = qtx.UpdateArtistTracks(ctx, repository.UpdateArtistTracksParams{
|
||||||
ArtistID: fromId,
|
ArtistID: fromId,
|
||||||
|
|
@ -90,7 +105,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to update artist tracks")
|
l.Err(err).Msg("Failed to update artist tracks")
|
||||||
return err
|
return fmt.Errorf("MergeArtists: %w", err)
|
||||||
}
|
}
|
||||||
err = qtx.UpdateArtistReleases(ctx, repository.UpdateArtistReleasesParams{
|
err = qtx.UpdateArtistReleases(ctx, repository.UpdateArtistReleasesParams{
|
||||||
ArtistID: fromId,
|
ArtistID: fromId,
|
||||||
|
|
@ -98,12 +113,26 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to update artist releases")
|
l.Err(err).Msg("Failed to update artist releases")
|
||||||
return err
|
return fmt.Errorf("MergeArtists: %w", err)
|
||||||
|
}
|
||||||
|
if replaceImage {
|
||||||
|
old, err := qtx.GetArtist(ctx, fromId)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MergeAlbums: %w", err)
|
||||||
|
}
|
||||||
|
err = qtx.UpdateArtistImage(ctx, repository.UpdateArtistImageParams{
|
||||||
|
ID: toId,
|
||||||
|
Image: old.Image,
|
||||||
|
ImageSource: old.ImageSource,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MergeAlbums: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = qtx.CleanOrphanedEntries(ctx)
|
err = qtx.CleanOrphanedEntries(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to clean orphaned entries")
|
l.Err(err).Msg("Failed to clean orphaned entries")
|
||||||
return err
|
return fmt.Errorf("MergeArtists: %w", err)
|
||||||
}
|
}
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ func setupTestDataForMerge(t *testing.T) {
|
||||||
truncateTestData(t)
|
truncateTestData(t)
|
||||||
// Insert artists
|
// Insert artists
|
||||||
err := store.Exec(context.Background(),
|
err := store.Exec(context.Background(),
|
||||||
`INSERT INTO artists (musicbrainz_id)
|
`INSERT INTO artists (musicbrainz_id, image, image_source)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000001'),
|
VALUES ('00000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000000', 'source.com'),
|
||||||
('00000000-0000-0000-0000-000000000002')`)
|
('00000000-0000-0000-0000-000000000002', NULL, NULL)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
|
|
@ -25,9 +25,9 @@ func setupTestDataForMerge(t *testing.T) {
|
||||||
|
|
||||||
// Insert albums
|
// Insert albums
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO releases (musicbrainz_id)
|
`INSERT INTO releases (musicbrainz_id, image, image_source)
|
||||||
VALUES ('11111111-1111-1111-1111-111111111111'),
|
VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com'),
|
||||||
('22222222-2222-2222-2222-222222222222')`)
|
('22222222-2222-2222-2222-222222222222', NULL, NULL)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
|
|
@ -90,11 +90,15 @@ func TestMergeAlbums(t *testing.T) {
|
||||||
setupTestDataForMerge(t)
|
setupTestDataForMerge(t)
|
||||||
|
|
||||||
// Merge Album 1 into Album 2
|
// Merge Album 1 into Album 2
|
||||||
err := store.MergeAlbums(ctx, 1, 2)
|
err := store.MergeAlbums(ctx, 1, 2, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify image was replaced
|
||||||
|
count, err := store.Count(ctx, `SELECT COUNT(*) FROM releases WHERE image = '20000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, count, "expected merged release to contain image information")
|
||||||
|
|
||||||
// Verify tracks are updated
|
// Verify tracks are updated
|
||||||
var count int
|
|
||||||
count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`)
|
count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 2, count, "expected all tracks to be merged into Album 2")
|
assert.Equal(t, 2, count, "expected all tracks to be merged into Album 2")
|
||||||
|
|
@ -107,11 +111,15 @@ func TestMergeArtists(t *testing.T) {
|
||||||
setupTestDataForMerge(t)
|
setupTestDataForMerge(t)
|
||||||
|
|
||||||
// Merge Artist 1 into Artist 2
|
// Merge Artist 1 into Artist 2
|
||||||
err := store.MergeArtists(ctx, 1, 2)
|
err := store.MergeArtists(ctx, 1, 2, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify image was replaced
|
||||||
|
count, err := store.Count(ctx, `SELECT COUNT(*) FROM artists WHERE image = '10000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, count, "expected merged artist to contain image information")
|
||||||
|
|
||||||
// Verify artist associations are updated
|
// Verify artist associations are updated
|
||||||
var count int
|
|
||||||
count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`)
|
count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 2, count, "expected all tracks to be associated with Artist 2")
|
assert.Equal(t, 2, count, "expected all tracks to be associated with Artist 2")
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,19 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
||||||
TrackID: track.ID,
|
TrackID: track.ID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msgf("Failed to get listen count for track with id %d", track.ID)
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||||
|
Period: db.PeriodAllTime,
|
||||||
|
TrackID: track.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
track.ListenCount = count
|
track.ListenCount = count
|
||||||
|
track.TimeListened = seconds
|
||||||
|
|
||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ func testDataForTracks(t *testing.T) {
|
||||||
|
|
||||||
// Insert tracks
|
// Insert tracks
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO tracks (musicbrainz_id, release_id)
|
`INSERT INTO tracks (musicbrainz_id, release_id, duration)
|
||||||
VALUES ('11111111-1111-1111-1111-111111111111', 1),
|
VALUES ('11111111-1111-1111-1111-111111111111', 1, 100),
|
||||||
('22222222-2222-2222-2222-222222222222', 2)`)
|
('22222222-2222-2222-2222-222222222222', 2, 100)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Insert track aliases
|
// Insert track aliases
|
||||||
|
|
@ -61,6 +61,12 @@ func testDataForTracks(t *testing.T) {
|
||||||
`INSERT INTO artist_tracks (artist_id, track_id)
|
`INSERT INTO artist_tracks (artist_id, track_id)
|
||||||
VALUES (1, 1), (2, 2)`)
|
VALUES (1, 1), (2, 2)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Associate tracks with artists
|
||||||
|
err = store.Exec(context.Background(),
|
||||||
|
`INSERT INTO listens (user_id, track_id, listened_at)
|
||||||
|
VALUES (1, 1, NOW()), (1, 2, NOW())`)
|
||||||
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTrack(t *testing.T) {
|
func TestGetTrack(t *testing.T) {
|
||||||
|
|
@ -73,12 +79,14 @@ func TestGetTrack(t *testing.T) {
|
||||||
assert.Equal(t, int32(1), track.ID)
|
assert.Equal(t, int32(1), track.ID)
|
||||||
assert.Equal(t, "Track One", track.Title)
|
assert.Equal(t, "Track One", track.Title)
|
||||||
assert.Equal(t, uuid.MustParse("11111111-1111-1111-1111-111111111111"), *track.MbzID)
|
assert.Equal(t, uuid.MustParse("11111111-1111-1111-1111-111111111111"), *track.MbzID)
|
||||||
|
assert.EqualValues(t, 100, track.TimeListened)
|
||||||
|
|
||||||
// Test GetTrack by MusicBrainzID
|
// Test GetTrack by MusicBrainzID
|
||||||
track, err = store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: uuid.MustParse("22222222-2222-2222-2222-222222222222")})
|
track, err = store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: uuid.MustParse("22222222-2222-2222-2222-222222222222")})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int32(2), track.ID)
|
assert.Equal(t, int32(2), track.ID)
|
||||||
assert.Equal(t, "Track Two", track.Title)
|
assert.Equal(t, "Track Two", track.Title)
|
||||||
|
assert.EqualValues(t, 100, track.TimeListened)
|
||||||
|
|
||||||
// Test GetTrack by Title and ArtistIDs
|
// Test GetTrack by Title and ArtistIDs
|
||||||
track, err = store.GetTrack(ctx, db.GetTrackOpts{
|
track, err = store.GetTrack(ctx, db.GetTrackOpts{
|
||||||
|
|
@ -88,6 +96,7 @@ func TestGetTrack(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int32(1), track.ID)
|
assert.Equal(t, int32(1), track.ID)
|
||||||
assert.Equal(t, "Track One", track.Title)
|
assert.Equal(t, "Track One", track.Title)
|
||||||
|
assert.EqualValues(t, 100, track.TimeListened)
|
||||||
|
|
||||||
// Test GetTrack with insufficient information
|
// Test GetTrack with insufficient information
|
||||||
_, err = store.GetTrack(ctx, db.GetTrackOpts{})
|
_, err = store.GetTrack(ctx, db.GetTrackOpts{})
|
||||||
|
|
|
||||||
|
|
@ -97,17 +97,25 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
|
||||||
l.Debug().Msgf("Skipping import due to import time rules")
|
l.Debug().Msgf("Skipping import due to import time rules")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var artistMbidMap []catalog.ArtistMbidMap
|
||||||
|
if artistMbzID != uuid.Nil {
|
||||||
|
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: track.Artist.Text, Mbid: artistMbzID})
|
||||||
|
}
|
||||||
|
|
||||||
opts := catalog.SubmitListenOpts{
|
opts := catalog.SubmitListenOpts{
|
||||||
MbzCaller: mbzc,
|
MbzCaller: mbzc,
|
||||||
Artist: track.Artist.Text,
|
Artist: track.Artist.Text,
|
||||||
ArtistMbzIDs: []uuid.UUID{artistMbzID},
|
ArtistNames: []string{track.Artist.Text},
|
||||||
TrackTitle: track.Name,
|
ArtistMbzIDs: []uuid.UUID{artistMbzID},
|
||||||
RecordingMbzID: trackMbzID,
|
TrackTitle: track.Name,
|
||||||
ReleaseTitle: album,
|
RecordingMbzID: trackMbzID,
|
||||||
ReleaseMbzID: albumMbzID,
|
ReleaseTitle: album,
|
||||||
Client: "lastfm",
|
ReleaseMbzID: albumMbzID,
|
||||||
Time: ts,
|
ArtistMbidMappings: artistMbidMap,
|
||||||
UserID: 1,
|
Client: "lastfm",
|
||||||
|
Time: ts,
|
||||||
|
UserID: 1,
|
||||||
}
|
}
|
||||||
err = catalog.SubmitListen(ctx, store, opts)
|
err = catalog.SubmitListen(ctx, store, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -113,20 +113,34 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
|
||||||
} else if payload.TrackMeta.AdditionalInfo.DurationMs != 0 {
|
} else if payload.TrackMeta.AdditionalInfo.DurationMs != 0 {
|
||||||
duration = payload.TrackMeta.AdditionalInfo.DurationMs / 1000
|
duration = payload.TrackMeta.AdditionalInfo.DurationMs / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var artistMbidMap []catalog.ArtistMbidMap
|
||||||
|
for _, a := range payload.TrackMeta.MBIDMapping.Artists {
|
||||||
|
if a.ArtistMBID == "" || a.ArtistName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mbid, err := uuid.Parse(a.ArtistMBID)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName)
|
||||||
|
}
|
||||||
|
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid})
|
||||||
|
}
|
||||||
|
|
||||||
opts := catalog.SubmitListenOpts{
|
opts := catalog.SubmitListenOpts{
|
||||||
MbzCaller: mbzc,
|
MbzCaller: mbzc,
|
||||||
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
|
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
|
||||||
Artist: payload.TrackMeta.ArtistName,
|
Artist: payload.TrackMeta.ArtistName,
|
||||||
ArtistMbzIDs: artistMbzIDs,
|
ArtistMbzIDs: artistMbzIDs,
|
||||||
TrackTitle: payload.TrackMeta.TrackName,
|
TrackTitle: payload.TrackMeta.TrackName,
|
||||||
RecordingMbzID: recordingMbzID,
|
RecordingMbzID: recordingMbzID,
|
||||||
ReleaseTitle: payload.TrackMeta.ReleaseName,
|
ReleaseTitle: payload.TrackMeta.ReleaseName,
|
||||||
ReleaseMbzID: releaseMbzID,
|
ReleaseMbzID: releaseMbzID,
|
||||||
ReleaseGroupMbzID: rgMbzID,
|
ReleaseGroupMbzID: rgMbzID,
|
||||||
Duration: duration,
|
ArtistMbidMappings: artistMbidMap,
|
||||||
Time: ts,
|
Duration: duration,
|
||||||
UserID: 1,
|
Time: ts,
|
||||||
Client: client,
|
UserID: 1,
|
||||||
|
Client: client,
|
||||||
}
|
}
|
||||||
err = catalog.SubmitListen(ctx, store, opts)
|
err = catalog.SubmitListen(ctx, store, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type Album struct {
|
||||||
Artists []SimpleArtist `json:"artists"`
|
Artists []SimpleArtist `json:"artists"`
|
||||||
VariousArtists bool `json:"is_various_artists"`
|
VariousArtists bool `json:"is_various_artists"`
|
||||||
ListenCount int64 `json:"listen_count"`
|
ListenCount int64 `json:"listen_count"`
|
||||||
|
TimeListened int64 `json:"time_listened"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// type SimpleAlbum struct {
|
// type SimpleAlbum struct {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ package models
|
||||||
import "github.com/google/uuid"
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
MbzID *uuid.UUID `json:"musicbrainz_id"`
|
MbzID *uuid.UUID `json:"musicbrainz_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Aliases []string `json:"aliases"`
|
Aliases []string `json:"aliases"`
|
||||||
Image *uuid.UUID `json:"image"`
|
Image *uuid.UUID `json:"image"`
|
||||||
ListenCount int64 `json:"listen_count"`
|
ListenCount int64 `json:"listen_count"`
|
||||||
|
TimeListened int64 `json:"time_listened"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SimpleArtist struct {
|
type SimpleArtist struct {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ package models
|
||||||
import "github.com/google/uuid"
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Artists []SimpleArtist `json:"artists"`
|
Artists []SimpleArtist `json:"artists"`
|
||||||
MbzID *uuid.UUID `json:"musicbrainz_id"`
|
MbzID *uuid.UUID `json:"musicbrainz_id"`
|
||||||
ListenCount int64 `json:"listen_count"`
|
ListenCount int64 `json:"listen_count"`
|
||||||
Duration int32 `json:"duration"`
|
Duration int32 `json:"duration"`
|
||||||
Image *uuid.UUID `json:"image"`
|
Image *uuid.UUID `json:"image"`
|
||||||
AlbumID int32 `json:"album_id"`
|
AlbumID int32 `json:"album_id"`
|
||||||
|
TimeListened int64 `json:"time_listened"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue