package dto import ( "path/filepath" "strconv" "strings" "github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/stream" ) // Vellum has two id spaces — media_items and media_files — and Subsonic has // one opaque string id, so every id this layer emits is prefixed to say what it // names. The prefix also lets stream/getCoverArt resolve an id without a // separate lookup table. // // al- an album / directory-grouped item // mf- a song, i.e. one physical media_files row // ar- an artist, from people/item_people // fd- a music folder (a whole audio library) // d-: a browsable directory within a library const ( PrefixAlbum = "al-" PrefixFile = "mf-" PrefixArtist = "ar-" PrefixFolder = "fd-" PrefixDir = "d-" ) // AlbumID renders an album id. func AlbumID(itemID int64) string { return PrefixAlbum + strconv.FormatInt(itemID, 10) } // FileID renders a song id. func FileID(fileID int64) string { return PrefixFile + strconv.FormatInt(fileID, 10) } // ArtistID renders an artist id. func ArtistID(personID int64) string { return PrefixArtist + strconv.FormatInt(personID, 10) } // FolderID renders a music folder id. func FolderID(libID int64) string { return PrefixFolder + strconv.FormatInt(libID, 10) } // DirID renders a browsable directory id. func DirID(libID int64, rel string) string { return PrefixDir + strconv.FormatInt(libID, 10) + ":" + rel } // ParseID splits a prefixed id, returning the numeric part. func ParseID(id, prefix string) (int64, bool) { rest, ok := strings.CutPrefix(id, prefix) if !ok { return 0, false } n, err := strconv.ParseInt(rest, 10, 64) if err != nil || n <= 0 { return 0, false } return n, true } // ParseDirID splits "d-:". An empty relpath is the library root. func ParseDirID(id string) (libID int64, rel string, ok bool) { rest, ok := strings.CutPrefix(id, PrefixDir) if !ok { return 0, "", false } lib, rel, found := strings.Cut(rest, ":") if !found { return 0, "", false } n, err := strconv.ParseInt(lib, 10, 64) if err != nil || n <= 0 { return 0, "", false } return n, rel, true } // ── entities ─────────────────────────────────────────────────────────── // AlbumInfo is one album: a directory-grouped media_items row of an audio kind. type AlbumInfo struct { ID int64 LibraryID int64 Kind string Path string Title string Year int AddedAt string ArtistID int64 Artist string SongCount int DurationMS int64 HasCover bool } // Track is one song: a media_files row together with its album's context. type Track struct { FileID int64 ItemID int64 LibraryID int64 Kind string AlbumPath string AlbumTitle string Year int AddedAt string RelPath string Size int64 DurationMS int64 Container string ArtistID int64 Artist string Ordinal int HasCover bool } // SongNode renders a track as the Subsonic Child/Song element. getSong returns // it bare; getAlbum nests it under . func SongNode(t Track) *Node { ct := stream.ContentType(t.RelPath) suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".") n := El("song", At("id", FileID(t.FileID)), At("parent", AlbumID(t.ItemID)), At("isDir", false), At("title", library.CleanTitle(filepath.Base(t.RelPath))), At("album", t.AlbumTitle), At("albumId", AlbumID(t.ItemID)), At("artist", t.Artist), At("type", "music"), At("isVideo", false), ) if t.ArtistID > 0 { n.Attrs = append(n.Attrs, At("artistId", ArtistID(t.ArtistID))) } if t.HasCover { n.Attrs = append(n.Attrs, At("coverArt", AlbumID(t.ItemID))) } if t.Ordinal > 0 { n.Attrs = append(n.Attrs, At("track", int64(t.Ordinal))) } if t.Year > 0 { n.Attrs = append(n.Attrs, At("year", int64(t.Year))) } n.Attrs = append(n.Attrs, At("size", t.Size), At("contentType", ct), At("suffix", suffix), At("duration", t.DurationMS/1000), At("path", t.RelPath), ) if t.AddedAt != "" { n.Attrs = append(n.Attrs, At("created", t.AddedAt)) } return n } // AlbumNode renders an album as the Subsonic AlbumID3 element. Extra legacy // fields (title, album, isDir) are included because older clients read them and // they cost nothing. func AlbumNode(a AlbumInfo) *Node { parent := FolderID(a.LibraryID) if a.ArtistID > 0 { parent = ArtistID(a.ArtistID) } n := El("album", At("id", AlbumID(a.ID)), At("parent", parent), At("name", a.Title), At("title", a.Title), At("album", a.Title), At("isDir", true), At("artist", a.Artist), At("songCount", int64(a.SongCount)), At("duration", a.DurationMS/1000), At("playCount", int64(0)), ) if a.ArtistID > 0 { n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID))) } if a.HasCover { n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID))) } if a.Year > 0 { n.Attrs = append(n.Attrs, At("year", int64(a.Year))) } if a.AddedAt != "" { n.Attrs = append(n.Attrs, At("created", a.AddedAt)) } return n } // ChildNode renders an album or directory entry for getMusicDirectory / // getIndexes, where the element is a generic Child rather than AlbumID3. func ChildNode(a AlbumInfo) *Node { n := El("child", At("id", AlbumID(a.ID)), At("parent", FolderID(a.LibraryID)), At("isDir", true), At("title", a.Title), At("album", a.Title), At("artist", a.Artist), At("isVideo", false), ) if a.ArtistID > 0 { n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID))) } if a.HasCover { n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID))) } if a.Year > 0 { n.Attrs = append(n.Attrs, At("year", int64(a.Year))) } return n }