package subsonic 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-" ) func albumID(itemID int64) string { return prefixAlbum + strconv.FormatInt(itemID, 10) } func fileID(fileID int64) string { return prefixFile + strconv.FormatInt(fileID, 10) } func artistID(personID int64) string { return prefixArtist + strconv.FormatInt(personID, 10) } func folderID(libID int64) string { return prefixFolder + strconv.FormatInt(libID, 10) } 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 }