Skip to content

Commit d8146bb

Browse files
committed
fix: align legacy playlist handling with new client
1 parent 27b2b92 commit d8146bb

6 files changed

Lines changed: 148 additions & 34 deletions

File tree

src/backend/provider/musicAssistant/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121

2222
const ROOT_FOLDER_ID = '0';
2323
const DEFAULT_PROVIDER_LABEL = 'Music Assistant';
24-
const DEFAULT_RADIO_SERVICE = 'musicassistant';
24+
const DEFAULT_SERVICE = 'musicassistant';
2525
const CACHE_TTL_MS = 30_000;
2626

2727
// We are faking a NAS origin for all our Music Assistant library items
@@ -68,18 +68,18 @@ export class MusicAssistantProvider implements MediaProvider {
6868
const clientResolver = () => this.getClient();
6969
this.radioController = new RadioController(
7070
clientResolver,
71-
DEFAULT_RADIO_SERVICE,
71+
DEFAULT_SERVICE,
7272
CACHE_TTL_MS,
7373
DEFAULT_PROVIDER_LABEL,
7474
);
7575
this.playlistController = new PlaylistController(
7676
clientResolver,
77-
DEFAULT_RADIO_SERVICE,
77+
DEFAULT_SERVICE,
7878
DEFAULT_PROVIDER_LABEL,
7979
);
8080
this.libraryController = new LibraryController(
8181
clientResolver,
82-
DEFAULT_RADIO_SERVICE,
82+
DEFAULT_SERVICE,
8383
ROOT_FOLDER_ID,
8484
MEDIA_LIBRARY_ROOT_ITEMS,
8585
);

src/backend/provider/musicAssistant/mappers.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
buildLibraryKey,
44
buildLibraryUri,
55
buildPlaylistKey,
6+
buildPlaylistUri,
67
buildRadioKey,
78
extractAlbum,
89
extractArtist,
@@ -12,6 +13,7 @@ import {
1213
extractProvider,
1314
extractUri,
1415
normalizeMediaUri,
16+
toPlaylistCommandUri,
1517
safeNumber,
1618
} from './utils';
1719
import { MediaFolderItem, PlaylistItem, RadioFolderItem } from '../types';
@@ -174,10 +176,10 @@ export function mapTrackToPlaylistItem(
174176
playlistContext?.image,
175177
]);
176178
playlistProviderInstanceId = extractProvider(playlistContext) ?? provider;
177-
playlistCommandUri = normalizeMediaUri(
179+
const rawPlaylistUri =
178180
extractUri(playlistContext, 'playlist', playlistRawId, playlistProviderInstanceId) ??
179-
buildLibraryUri('playlist', playlistRawId, playlistProviderInstanceId),
180-
);
181+
buildPlaylistUri(playlistRawId, playlistProviderInstanceId);
182+
playlistCommandUri = toPlaylistCommandUri(rawPlaylistUri, playlistProviderInstanceId, playlistRawId);
181183
}
182184

183185
const trackCover = resolveArtwork(track, [track?.coverurl, track?.thumbnail, track?.image]);
@@ -216,7 +218,10 @@ export function mapPlaylistToItem(
216218
): PlaylistItem {
217219
const provider = extractProvider(playlist) ?? fallbackProvider;
218220
const rawId = extractItemId(playlist) ?? extractUri(playlist) ?? extractName(playlist) ?? '';
219-
const uri = extractUri(playlist, 'playlist', rawId, provider) ?? buildPlaylistKey(provider, rawId);
221+
const rawUri =
222+
extractUri(playlist, 'playlist', rawId, provider) ??
223+
buildPlaylistUri(rawId, provider);
224+
const uri = toPlaylistCommandUri(rawUri, provider, rawId);
220225
const name = extractName(playlist) ?? rawId;
221226
const cover = resolveArtwork(playlist, [playlist?.playlistCover, playlist?.coverurl, playlist?.thumbnail, playlist?.image]);
222227

src/backend/provider/musicAssistant/playlist.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
logError,
1616
parseIdentifier,
1717
safeNumber,
18+
toPlaylistCommandUri,
1819
} from './utils';
1920

2021
export class PlaylistController {
@@ -73,7 +74,7 @@ export class PlaylistController {
7374
: [];
7475

7576
return {
76-
id: this.fallbackProvider,
77+
id: 0,
7778
name: `${this.providerLabel} Playlists`,
7879
totalitems: totalitems ?? items.length,
7980
start: offset,
@@ -129,9 +130,10 @@ export class PlaylistController {
129130
mapped.length,
130131
);
131132

132-
const uri =
133+
const rawUri =
133134
extractUri(playlist, 'playlist', itemId, provider) ??
134135
`playlist:${itemId}`;
136+
const playlistUri = toPlaylistCommandUri(rawUri, provider, itemId);
135137

136138
const playlistCover = extractImage(playlist) ?? mapped.find((item) => item.coverurl)?.coverurl ?? '';
137139
if (playlistCover) {
@@ -142,7 +144,7 @@ export class PlaylistController {
142144
}
143145

144146
return {
145-
id: uri,
147+
id: playlistUri,
146148
name: extractName(playlist) ?? itemId,
147149
totalitems: totalitems ?? mapped.length,
148150
start: offset,
@@ -178,9 +180,10 @@ export class PlaylistController {
178180

179181
const mapped = mapPlaylistToItem(playlist, this.fallbackProvider);
180182
const rawId = extractItemId(playlist) ?? itemId;
181-
const uri =
183+
const rawUri =
182184
extractUri(playlist, 'playlist', rawId, provider) ??
183185
`playlist:${rawId}`;
186+
const playlistUri = toPlaylistCommandUri(rawUri, provider, rawId);
184187
const totalitems = safeNumber(
185188
playlist?.track_count ??
186189
playlist?.items?.length ??
@@ -189,13 +192,13 @@ export class PlaylistController {
189192

190193
return {
191194
...mapped,
192-
id: uri,
193-
audiopath: uri,
195+
id: playlistUri,
196+
audiopath: playlistUri,
194197
provider: provider ?? mapped.provider,
195198
providerInstanceId: provider ?? mapped.providerInstanceId,
196199
playlistProviderInstanceId: provider ?? mapped.playlistProviderInstanceId,
197-
playlistCommandUri: uri,
198-
playlistId: uri,
200+
playlistCommandUri: playlistUri,
201+
playlistId: playlistUri,
199202
items: totalitems,
200203
coverurl: extractImage(playlist) ?? mapped.coverurl ?? '',
201204
rawId,
@@ -208,7 +211,7 @@ export class PlaylistController {
208211

209212
private buildEmptyResponse(offset: number): PlaylistResponse {
210213
return {
211-
id: this.fallbackProvider,
214+
id: 0,
212215
name: `${this.providerLabel} Playlists`,
213216
totalitems: 0,
214217
start: offset,

src/backend/provider/musicAssistant/utils.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,22 @@ export function parsePlaylistIdentifier(value: string): ParsedKey | undefined {
7979
const [base, query = ''] = value.split('?', 2);
8080
const idPart = base.slice('playlist:'.length);
8181
const params = new URLSearchParams(query);
82-
const provider = params.get('provider') ?? params.get('instance_id') ?? undefined;
83-
const itemId = decodeURIComponent(idPart);
82+
let provider = params.get('provider') ?? params.get('instance_id') ?? undefined;
83+
84+
let itemId: string;
85+
if (!provider) {
86+
const segments = idPart.split(':');
87+
if (segments.length >= 2) {
88+
provider = decodeSegment(segments[0]);
89+
const encodedId = segments.slice(1).join(':');
90+
itemId = decodeURIComponent(encodedId);
91+
} else {
92+
itemId = decodeURIComponent(idPart);
93+
}
94+
} else {
95+
itemId = decodeURIComponent(idPart);
96+
}
97+
8498
return { kind: 'playlist', provider, itemId };
8599
}
86100

@@ -117,6 +131,28 @@ export function parseCompositeIdentifier(value: string): ParsedKey {
117131
return { kind: head, provider, itemId };
118132
}
119133

134+
export function toPlaylistCommandUri(
135+
value: string | undefined,
136+
fallbackProvider?: string,
137+
fallbackId?: string,
138+
): string {
139+
const parsed = value ? parseIdentifier(value) : { kind: '', provider: undefined, itemId: undefined };
140+
141+
if (parsed.kind === 'playlist' && parsed.itemId) {
142+
return buildPlaylistUri(parsed.itemId, parsed.provider ?? fallbackProvider);
143+
}
144+
145+
if (fallbackId) {
146+
return buildPlaylistUri(fallbackId, parsed.provider ?? fallbackProvider);
147+
}
148+
149+
if (value && value.toLowerCase().startsWith('playlist:')) {
150+
return value;
151+
}
152+
153+
return buildPlaylistUri('', fallbackProvider);
154+
}
155+
120156
export function normalizeItemKey(value: string): string {
121157
if (!value) return value;
122158
const trimmed = value.trim();
@@ -140,9 +176,11 @@ export function buildLibraryUri(
140176
export function buildPlaylistUri(id: string, provider?: string): string {
141177
if (!id) return '';
142178
if (id.includes('://')) return id;
143-
if (id.toLowerCase().startsWith('playlist:')) return id;
144-
const base = `playlist:${id}`;
145-
return provider ? `${base}?provider=${encodeURIComponent(provider)}` : base;
179+
const safeId = encodeSegment(id);
180+
if (!provider) {
181+
return `playlist:${safeId}`;
182+
}
183+
return `playlist:${encodeSegment(provider)}:${safeId}`;
146184
}
147185

148186
export function buildLibraryKey(
@@ -308,6 +346,26 @@ export function denormalizeMediaUri(uri: string): string {
308346
return uri;
309347
}
310348

349+
export function denormalizePlaylistUri(value: string): string {
350+
const trimmed = (value ?? '').trim();
351+
if (!trimmed) return '';
352+
353+
if (trimmed.toLowerCase().startsWith('library://')) {
354+
return trimmed;
355+
}
356+
357+
const parsed = parseIdentifier(trimmed);
358+
if (parsed.kind === 'playlist' && parsed.itemId) {
359+
const playlistId = encodeURIComponent(parsed.itemId);
360+
const provider = parsed.provider && parsed.provider.toLowerCase() !== 'library'
361+
? encodeURIComponent(parsed.provider)
362+
: undefined;
363+
return provider ? `library://playlist/${playlistId}?provider=${provider}` : `library://playlist/${playlistId}`;
364+
}
365+
366+
return denormalizeMediaUri(trimmed);
367+
}
368+
311369
export function extractName(item: any): string | undefined {
312370
if (!item || typeof item !== 'object') return undefined;
313371
const candidates = [

src/backend/zone/MusicAssistant/commands.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { updateZoneGroup, getZoneById } from '../zonemanager';
22
import { PlayerStatus, RepeatMode as LoxoneRepeatMode } from '../loxoneTypes';
33
import logger from '../../../utils/troxorlogger';
44
import MusicAssistantClient from './client';
5-
import { denormalizeMediaUri, normalizeMediaUri } from '../../provider/musicAssistant/utils';
5+
import { denormalizeMediaUri, denormalizePlaylistUri, normalizeMediaUri } from '../../provider/musicAssistant/utils';
66

77
type RepeatMapping = {
88
ma: 'off' | 'one' | 'all';
@@ -156,7 +156,7 @@ export async function handleMusicAssistantCommand(
156156
coerceToOptionalString(info.track);
157157
const normalizedPlaylistUri = normalizePlaylistCommandUri(playlistUri, playlistFallback);
158158
const targetUri = normalizedPlaylistUri || playlistUri || playlistFallback;
159-
const maUri = denormalizeMediaUri(targetUri ?? playlistUri ?? '');
159+
const maUri = denormalizePlaylistUri(targetUri ?? playlistUri ?? '') || denormalizeMediaUri(targetUri ?? playlistUri ?? '');
160160

161161
logger.info(`[MusicAssistant][Zone:${ctx.loxoneZoneId}] playlistplay: ${targetUri}`);
162162

@@ -345,6 +345,13 @@ function normalizePlaylistCommandUri(primary: string, fallback?: string): string
345345
return trimmed;
346346
}
347347

348+
if (lower.startsWith('playlist:')) {
349+
const rest = trimmed.slice('playlist:'.length);
350+
if (rest.includes(':') && !rest.includes('://')) {
351+
return trimmed;
352+
}
353+
}
354+
348355
if (lower.startsWith('library://')) resolved = trimmed;
349356
else if (lower.startsWith('playlist://')) resolved = trimmed;
350357
else if (lower.startsWith('http://') || lower.startsWith('https://')) resolved = trimmed;

src/http/handlers/zoneCommands.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CommandResult, emptyCommand, response } from './commandTypes';
22
import { parseNumberPart, splitUrl } from './commandUtils';
33
import { getZoneById, sendCommandToZone } from '../../backend/zone/zonemanager';
44
import { getMediaProvider } from '../../backend/provider/factory';
5+
import { toPlaylistCommandUri } from '../../backend/provider/musicAssistant/utils';
56
import logger from '../../utils/troxorlogger';
67
import { MediaFolderItem, PlaylistItem } from '../../backend/provider/types';
78
import { FileType } from '../../backend/zone/loxoneTypes';
@@ -632,6 +633,18 @@ export async function audioPlayUrl(url: string): Promise<CommandResult> {
632633
};
633634
addPlaylistCandidate(playlistIdFromQuery);
634635
addPlaylistCandidate(decodedBasePath);
636+
// LEGACY(current): older client encodes playlist info under /parentpath or /parentid.
637+
for (let i = 0; i < meta.length; i++) {
638+
const token = meta[i]?.toLowerCase();
639+
if (token === 'parentpath' || token === 'parentid') {
640+
const candidate = meta[i + 1];
641+
if (candidate) {
642+
addPlaylistCandidate(decodeURIComponent(candidate));
643+
}
644+
i++;
645+
continue;
646+
}
647+
}
635648

636649
let resolvedPlaylist: PlaylistItem | undefined;
637650
if (provider.resolvePlaylist) {
@@ -757,21 +770,55 @@ export async function audioPlayUrl(url: string): Promise<CommandResult> {
757770

758771
const providerHint =
759772
resolved?.provider ?? (decodedUri.includes(':') ? decodedUri.split(':')[0] : undefined) ?? 'external';
760-
const sanitizedAudiopath = decodedUri.replace(/\/(?:no)?shuffle$/i, '');
773+
const sanitizedAudiopath = decodedBasePath.replace(/\/(?:no)?shuffle$/i, '');
774+
const localTrackMatch = decodedUri.match(/^library:local:track:([^:]+):/i);
775+
const providerFromPath = localTrackMatch ? localTrackMatch[1] : undefined;
761776
const trackPayload: Record<string, unknown> = {
762777
id: resolved?.id ?? decodedUri,
763778
name: resolved?.title ?? resolved?.name ?? decodedUri,
764779
audiopath: sanitizedAudiopath,
765780
coverurl: resolved?.coverurl ?? '',
766781
type: resolved?.type ?? FileType.File,
767-
provider: resolved?.provider ?? providerHint,
768-
providerInstanceId: resolved?.providerInstanceId,
782+
provider: resolved?.provider ?? providerFromPath ?? providerHint,
783+
providerInstanceId: resolved?.providerInstanceId ?? providerFromPath,
769784
album: resolved?.album,
770785
artist: resolved?.artist,
771786
title: resolved?.title ?? resolved?.name,
772787
uniqueId: resolved?.id ?? decodedUri,
773788
};
774789

790+
const playlistCommandUri =
791+
resolvedPlaylist?.playlistCommandUri ??
792+
playlistContext?.playlistCommandUri ??
793+
playlistRawCommand ??
794+
playlistIdFromQuery ??
795+
undefined;
796+
if (playlistCommandUri) {
797+
// LEGACY(current): queue UI requires playlist ids even when /playurl kicked off playback.
798+
const normalizedPlaylist = toPlaylistCommandUri(playlistCommandUri, playlistContext?.playlistProviderInstanceId, playlistContext?.rawId);
799+
trackPayload.playlistCommandUri = normalizedPlaylist;
800+
trackPayload.playlistId = normalizedPlaylist;
801+
}
802+
if (playlistContext?.playlistProviderInstanceId ?? resolvedPlaylist?.playlistProviderInstanceId ?? resolvedPlaylist?.providerInstanceId) {
803+
trackPayload.playlistProviderInstanceId =
804+
resolvedPlaylist?.playlistProviderInstanceId ??
805+
resolvedPlaylist?.providerInstanceId ??
806+
playlistContext?.playlistProviderInstanceId;
807+
}
808+
if (playlistContext?.playlistCover ?? resolvedPlaylist?.playlistCover) {
809+
trackPayload.playlistCover = resolvedPlaylist?.playlistCover ?? playlistContext?.playlistCover;
810+
}
811+
if (playlistContext?.playlistStartItem ?? resolvedPlaylist?.playlistStartItem ?? startItemParam ?? originalStartItemParam) {
812+
trackPayload.playlistStartItem =
813+
resolvedPlaylist?.playlistStartItem ??
814+
playlistContext?.playlistStartItem ??
815+
startItemParam ??
816+
originalStartItemParam;
817+
}
818+
if (playlistContext?.playlistName ?? resolvedPlaylist?.playlistName) {
819+
trackPayload.playlistName = resolvedPlaylist?.playlistName ?? playlistContext?.playlistName;
820+
}
821+
775822
if (shuffle !== undefined) {
776823
trackPayload.shuffle = shuffle ? 1 : 0;
777824
}
@@ -834,13 +881,7 @@ function buildEmptyQueue(url: string, zoneId: number): CommandResult {
834881
}
835882

836883
function ensurePlaylistCommandUri(value: string): string {
837-
const trimmed = (value || '').trim();
838-
if (!trimmed) return '';
839-
const lower = trimmed.toLowerCase();
840-
if (lower.includes('://') || lower.startsWith('playlist:')) {
841-
return trimmed;
842-
}
843-
return `playlist:${trimmed}`;
884+
return toPlaylistCommandUri(value);
844885
}
845886

846887
function buildFriendlyPlaylistPath(rawId: string): string {

0 commit comments

Comments
 (0)