package dto import ( "bytes" "encoding/hex" "strconv" "strings" "github.com/navidrome/navidrome/model/id" ) // guidLen is the length of a Jellyfin GUID on the wire: 16 bytes as lowercase hex, no dashes // (what Guid.ToString("N") produces). const guidLen = 32 // Reserved GUIDs stand in for ids that aren't 128-bit values: 12 zero bytes, a non-zero kind tag, // then a 24-bit payload. The tag is never zero because Jellyfin serializes the all-zero GUID as null. const ( kindIdx = 12 maxPayload = 1<<24 - 1 ) const ( kindLibrary byte = iota + 1 kindPlaylistsFolder kindPlaylistEntry ) var zeroPrefix [kindIdx]byte // PlaylistsFolderID is the internal id of the synthetic "playlists library" folder. It can't be // mistaken for a real id, which is always 22-char base62. const PlaylistsFolderID = "playlists" // PlaylistsFolderGUID is the wire form of PlaylistsFolderID. var PlaylistsFolderGUID = encodeReserved(kindPlaylistsFolder, 0) // EncodeID renders a canonical Navidrome id as a Jellyfin GUID. Anything that isn't one encodes // to "" rather than to a shape clients can't parse. func EncodeID(ndID string) string { b, err := id.Decode(ndID) if err != nil { return "" } return hex.EncodeToString(b) } // EncodeLibraryID renders a library's integer id in the reserved GUID space. func EncodeLibraryID(libID int) string { return encodeReserved(kindLibrary, libID) } // EncodePlaylistEntryID renders a playlist entry's position (model.PlaylistTrack.ID, an integer // column) in the reserved GUID space. Clients echo it back to remove one occurrence of a song. func EncodePlaylistEntryID(entryID string) string { n, err := strconv.Atoi(entryID) if err != nil { return "" } return encodeReserved(kindPlaylistEntry, n) } func encodeReserved(kind byte, payload int) string { if payload < 0 || payload > maxPayload { return "" } var b [16]byte b[kindIdx] = kind b[13], b[14], b[15] = byte(payload>>16), byte(payload>>8), byte(payload) return hex.EncodeToString(b[:]) } // DecodeID maps an inbound GUID back to the identifier the rest of the API uses: a canonical id, // a decimal library id, or PlaylistsFolderID. ok is false for anything that isn't a well-formed // GUID — including the empty string — so an undecodable id can't reach a caller as "no filter". // Dashed and uppercase forms are accepted, as Jellyfin's Guid.Parse accepts them. Playlist entries // decode through DecodePlaylistEntryID instead, so a position can't reach a caller expecting an // entity id. func DecodeID(guid string) (string, bool) { b, ok := decodeGUID(guid) if !ok { return "", false } kind, payload, reserved := reservedFields(b) if !reserved { return id.Encode(b), true } switch kind { case kindLibrary: return strconv.Itoa(payload), true case kindPlaylistsFolder: return PlaylistsFolderID, true } return "", false } // DecodeIDs decodes a list of GUIDs, all-or-nothing: ok is false if any entry is malformed, so a // caller can't mistake "every entry failed" for "no filter" (see DecodeID). func DecodeIDs(guids []string) ([]string, bool) { out := make([]string, len(guids)) for i, guid := range guids { decoded, ok := DecodeID(guid) if !ok { return nil, false } out[i] = decoded } return out, true } // DecodePlaylistEntryID decodes a playlist entry GUID to its position. It rejects every other kind, // so an entity id can't be taken for a playlist_tracks row. func DecodePlaylistEntryID(guid string) (string, bool) { b, ok := decodeGUID(guid) if !ok { return "", false } if kind, payload, reserved := reservedFields(b); reserved && kind == kindPlaylistEntry { return strconv.Itoa(payload), true } return "", false } func decodeGUID(guid string) ([16]byte, bool) { guid = strings.ToLower(strings.ReplaceAll(guid, "-", "")) if len(guid) != guidLen { return [16]byte{}, false } bs, err := hex.DecodeString(guid) if err != nil { return [16]byte{}, false } return [16]byte(bs), true } // reservedFields reports the kind tag and payload of a reserved GUID; reserved is false for the // entity GUIDs that make up almost all traffic. func reservedFields(b [16]byte) (kind byte, payload int, reserved bool) { if !bytes.Equal(b[:kindIdx], zeroPrefix[:]) { return 0, 0, false } return b[kindIdx], int(b[13])<<16 | int(b[14])<<8 | int(b[15]), true }