This commit is contained in:
simojenki
2025-02-17 05:47:19 +00:00
parent b0dc11abcb
commit b97590dd36
5 changed files with 181 additions and 164 deletions

View File

@@ -60,7 +60,7 @@ export type Encoding = {
mimeType: string mimeType: string
} }
export type Track = { export type TrackSummary = {
id: string; id: string;
name: string; name: string;
encoding: Encoding, encoding: Encoding,
@@ -68,9 +68,12 @@ export type Track = {
number: number | undefined; number: number | undefined;
genre: Genre | undefined; genre: Genre | undefined;
coverArt: BUrn | undefined; coverArt: BUrn | undefined;
album: AlbumSummary;
artist: ArtistSummary; artist: ArtistSummary;
rating: Rating; rating: Rating;
}
export type Track = TrackSummary & {
album: AlbumSummary;
}; };
export type RadioStation = { export type RadioStation = {
@@ -129,6 +132,18 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
coverArt: it.coverArt coverArt: it.coverArt
}); });
export const trackToTrackSummary = (it: Track): TrackSummary => ({
id: it.id,
name: it.name,
encoding: it.encoding,
duration: it.duration,
number: it.number,
genre: it.genre,
coverArt: it.coverArt,
artist: it.artist,
rating: it.rating
});
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({ export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
id: it.id, id: it.id,
name: it.name, name: it.name,
@@ -199,8 +214,8 @@ export interface MusicLibrary {
deletePlaylist(id: string): Promise<boolean> deletePlaylist(id: string): Promise<boolean>
addToPlaylist(playlistId: string, trackId: string): Promise<boolean> addToPlaylist(playlistId: string, trackId: string): Promise<boolean>
removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean> removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean>
similarSongs(id: string): Promise<Track[]>; similarSongs(id: string): Promise<TrackSummary[]>;
topSongs(artistId: string): Promise<Track[]>; topSongs(artistId: string): Promise<TrackSummary[]>;
radioStation(id: string): Promise<RadioStation> radioStation(id: string): Promise<RadioStation>
radioStations(): Promise<RadioStation[]> radioStations(): Promise<RadioStation[]>
} }

View File

@@ -12,9 +12,9 @@ import {
Track, Track,
CoverArt, CoverArt,
AlbumQueryType, AlbumQueryType,
PlaylistSummary,
Encoding, Encoding,
albumToAlbumSummary, albumToAlbumSummary,
TrackSummary,
} from "./music_library"; } from "./music_library";
import sharp from "sharp"; import sharp from "sharp";
import _ from "underscore"; import _ from "underscore";
@@ -170,24 +170,34 @@ export type GetAlbumResponse = {
}; };
}; };
type playlist = {
id: string;
name: string;
coverArt: string | undefined;
};
export type GetPlaylistResponse = { export type GetPlaylistResponse = {
// todo: isnt the type here a composite? playlistSummary && { entry: song[]; } // todo: isnt the type here a composite? playlistSummary && { entry: song[]; }
playlist: { playlist: {
id: string; id: string;
name: string; name: string;
coverArt: string | undefined;
entry: song[]; entry: song[];
// todo: this is an ND specific field?
coverArt: string | undefined;
}; };
}; };
export type GetPlaylistsResponse = { export type GetPlaylistsResponse = {
playlists: { playlist: playlist[] }; playlists: {
playlist: {
id: string;
name: string;
//owner: string,
//public: boolean,
//created: string,
//changed: string,
//songCount: int,
//duration: int,
// todo: this is an ND specific field.
coverArt: string | undefined;
}[]
};
}; };
export type GetSimilarSongsResponse = { export type GetSimilarSongsResponse = {
@@ -280,11 +290,10 @@ export const artistImageURN = (
} }
}; };
export const asTrack = ( export const asTrackSummary = (
album: AlbumSummary,
song: song, song: song,
customPlayers: CustomPlayers customPlayers: CustomPlayers
): Track => ({ ): TrackSummary => ({
id: song.id, id: song.id,
name: song.title, name: song.title,
encoding: pipe( encoding: pipe(
@@ -300,7 +309,6 @@ export const asTrack = (
number: song.track || 0, number: song.track || 0,
genre: maybeAsGenre(song.genre), genre: maybeAsGenre(song.genre),
coverArt: coverArtURN(song.coverArt), coverArt: coverArtURN(song.coverArt),
album: album,
artist: { artist: {
id: song.artistId, id: song.artistId,
name: song.artist ? song.artist : "?", name: song.artist ? song.artist : "?",
@@ -317,6 +325,15 @@ export const asTrack = (
}, },
}); });
export const asTrack = (
album: AlbumSummary,
song: song,
customPlayers: CustomPlayers
): Track => ({
...asTrackSummary(song, customPlayers),
album: album,
});
export const asAlbumSummary = (album: album): AlbumSummary => ({ export const asAlbumSummary = (album: album): AlbumSummary => ({
id: album.id, id: album.id,
name: album.name, name: album.name,
@@ -327,13 +344,6 @@ export const asAlbumSummary = (album: album): AlbumSummary => ({
coverArt: coverArtURN(album.coverArt), coverArt: coverArtURN(album.coverArt),
}); });
// coverArtURN
export const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
});
export const asGenre = (genreName: string) => ({ export const asGenre = (genreName: string) => ({
id: b64Encode(genreName), id: b64Encode(genreName),
name: genreName, name: genreName,
@@ -612,7 +622,6 @@ export class Subsonic {
}; };
}); });
getArtist = ( getArtist = (
credentials: Credentials, credentials: Credentials,
id: string id: string
@@ -764,5 +773,93 @@ export class Subsonic {
"accept-ranges": stream.headers["accept-ranges"], "accept-ranges": stream.headers["accept-ranges"],
}, },
stream: stream.data, stream: stream.data,
})) }));
playlists = (credentials: Credentials) =>
this.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
.then(({ playlists }) => (playlists.playlist || []).map( it => ({
id: it.id,
name: it.name,
coverArt: coverArtURN(it.coverArt),
}))
);
playlist = (credentials: Credentials, id: string) =>
this.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
id,
})
.then(({ playlist }) => {
let trackNumber = 1;
return {
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
entries: (playlist.entry || []).map((entry) => ({
...asTrack(
{
id: entry.albumId!,
name: entry.album!,
year: entry.year,
genre: maybeAsGenre(entry.genre),
artistName: entry.artist,
artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt),
},
entry,
this.customPlayers
),
number: trackNumber++,
})),
};
});
createPlayList = (credentials: Credentials, name: string) =>
this.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
name,
})
.then(({ playlist }) => ({
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
}));
deletePlayList = (credentials: Credentials, id: string) =>
this.getJSON<SubsonicResponse>(credentials, "/rest/deletePlaylist", {
id,
})
.then(it => it.status == "ok");
updatePlaylist = (
credentials: Credentials,
playlistId: string,
changes : Partial<{ songIdToAdd: string | undefined, songIndexToRemove: number[] | undefined }> = {}
) =>
this.getJSON<SubsonicResponse>(credentials, "/rest/updatePlaylist", {
playlistId,
...changes
})
.then(it => it.status == "ok");
getSimilarSongs2 = (credentials: Credentials, id: string) =>
this.getJSON<GetSimilarSongsResponse>(
credentials,
"/rest/getSimilarSongs2",
//todo: remove this hard coded 50?
{ id, count: 50 }
)
.then((it) =>
(it.similarSongs2.song || []).map(it => asTrackSummary(it, this.customPlayers))
);
getTopSongs = (credentials: Credentials, artist: string) =>
this.getJSON<GetTopSongsResponse>(
credentials,
"/rest/getTopSongs",
//todo: remove this hard coded 50?
{ artist, count: 50 }
)
.then((it) =>
(it.topSongs.song || []).map(it => asTrackSummary(it, this.customPlayers))
);
} }

View File

@@ -15,24 +15,15 @@ import {
Artist, Artist,
AuthFailure, AuthFailure,
AuthSuccess, AuthSuccess,
albumToAlbumSummary,
} from "./music_library"; } from "./music_library";
import { import {
Subsonic, Subsonic,
CustomPlayers, CustomPlayers,
asTrack,
PingResponse, PingResponse,
NO_CUSTOM_PLAYERS, NO_CUSTOM_PLAYERS,
asToken, asToken,
parseToken, parseToken,
artistImageURN, artistImageURN,
GetPlaylistsResponse,
GetPlaylistResponse,
asPlayListSummary,
coverArtURN,
maybeAsGenre,
GetSimilarSongsResponse,
GetTopSongsResponse,
GetInternetRadioStationsResponse, GetInternetRadioStationsResponse,
asYear, asYear,
isValidImage isValidImage
@@ -292,112 +283,31 @@ export class SubsonicMusicLibrary implements MusicLibrary {
); );
playlists = async () => playlists = async () =>
this.subsonic this.subsonic.playlists(this.credentials);
.getJSON<GetPlaylistsResponse>(this.credentials, "/rest/getPlaylists")
.then(({ playlists }) =>
(playlists.playlist || []).map(asPlayListSummary)
);
playlist = async (id: string) => playlist = async (id: string) =>
this.subsonic this.subsonic.playlist(this.credentials, id);
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/getPlaylist", {
id,
})
.then(({ playlist }) => {
let trackNumber = 1;
return {
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
entries: (playlist.entry || []).map((entry) => ({
...asTrack(
{
id: entry.albumId!,
name: entry.album!,
year: entry.year,
genre: maybeAsGenre(entry.genre),
artistName: entry.artist,
artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt),
},
entry,
this.customPlayers
),
number: trackNumber++,
})),
};
});
createPlaylist = async (name: string) => createPlaylist = async (name: string) =>
this.subsonic this.subsonic.createPlayList(this.credentials, name);
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/createPlaylist", {
name,
})
.then(({ playlist }) => ({
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
}));
deletePlaylist = async (id: string) => deletePlaylist = async (id: string) =>
this.subsonic this.subsonic.deletePlayList(this.credentials, id);
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/deletePlaylist", {
id,
})
.then((_) => true);
addToPlaylist = async (playlistId: string, trackId: string) => addToPlaylist = async (playlistId: string, trackId: string) =>
this.subsonic this.subsonic.updatePlaylist(this.credentials, playlistId, { songIdToAdd: trackId });
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
playlistId,
songIdToAdd: trackId,
})
.then((_) => true);
removeFromPlaylist = async (playlistId: string, indicies: number[]) => removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
this.subsonic this.subsonic.updatePlaylist(this.credentials, playlistId, { songIndexToRemove: indicies });
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
playlistId,
songIndexToRemove: indicies,
})
.then((_) => true);
similarSongs = async (id: string) => similarSongs = async (id: string) =>
this.subsonic this.subsonic.getSimilarSongs2(this.credentials, id)
.getJSON<GetSimilarSongsResponse>(
this.credentials,
"/rest/getSimilarSongs2",
{ id, count: 50 }
)
.then((it) => it.similarSongs2.song || [])
.then((songs) =>
Promise.all(
songs.map((song) =>
this.subsonic
.getAlbum(this.credentials, song.albumId!)
.then((album) => asTrack(albumToAlbumSummary(album), song, this.customPlayers))
)
)
);
topSongs = async (artistId: string) => topSongs = async (artistId: string) =>
this.subsonic.getArtist(this.credentials, artistId).then(({ name }) => this.subsonic.getArtist(this.credentials, artistId)
this.subsonic .then(({ name }) =>
.getJSON<GetTopSongsResponse>(this.credentials, "/rest/getTopSongs", { this.subsonic.getTopSongs(this.credentials, name)
artist: name, );
count: 50,
})
.then((it) => it.topSongs.song || [])
.then((songs) =>
Promise.all(
songs.map((song) =>
this.subsonic
.getAlbum(this.credentials, song.albumId!)
.then((album) => asTrack(albumToAlbumSummary(album), song, this.customPlayers))
)
)
)
);
radioStations = async () => radioStations = async () =>
this.subsonic this.subsonic

View File

@@ -13,7 +13,8 @@ import {
SimilarArtist, SimilarArtist,
AlbumSummary, AlbumSummary,
RadioStation, RadioStation,
ArtistSummary ArtistSummary,
TrackSummary
} from "../src/music_library"; } from "../src/music_library";
import { b64Encode } from "../src/b64"; import { b64Encode } from "../src/b64";
@@ -178,12 +179,11 @@ export const SAMPLE_GENRES = [
]; ];
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)]; export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
export function aTrack(fields: Partial<Track> = {}): Track { export function aTrackSummary(fields: Partial<TrackSummary> = {}): TrackSummary {
const id = uuid(); const id = uuid();
const artist = fields.artist || anArtistSummary(); const artist = fields.artist || anArtistSummary();
const genre = fields.genre || randomGenre(); const genre = fields.genre || randomGenre();
const rating = { love: false, stars: Math.floor(Math.random() * 5) }; const rating = { love: false, stars: Math.floor(Math.random() * 5) };
const album = fields.album || anAlbumSummary({ artistId: artist.id, artistName: artist.name, genre })
return { return {
id, id,
name: `Track ${id}`, name: `Track ${id}`,
@@ -195,12 +195,21 @@ export function aTrack(fields: Partial<Track> = {}): Track {
number: randomInt(100), number: randomInt(100),
genre, genre,
artist, artist,
album,
coverArt: { system: "subsonic", resource: `art:${uuid()}`}, coverArt: { system: "subsonic", resource: `art:${uuid()}`},
rating, rating,
...fields, ...fields,
}; };
} };
export function aTrack(fields: Partial<Track> = {}): Track {
const summary = aTrackSummary(fields);
const album = fields.album || anAlbumSummary({ artistId: summary.artist.id, artistName: summary.artist.name, genre: summary.genre })
return {
...summary,
album,
...fields
};
};
export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary { export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary {
const id = uuid(); const id = uuid();
@@ -213,7 +222,7 @@ export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary
artistId: `Artist ${uuid()}`, artistId: `Artist ${uuid()}`,
artistName: `Artist ${randomstring.generate()}`, artistName: `Artist ${randomstring.generate()}`,
...fields ...fields
} };
}; };
export function anAlbum(fields: Partial<Album> = {}): Album { export function anAlbum(fields: Partial<Album> = {}): Album {

View File

@@ -19,7 +19,6 @@ import {
CustomPlayers, CustomPlayers,
PingResponse, PingResponse,
images, images,
} from "../src/subsonic"; } from "../src/subsonic";
import { import {
@@ -42,6 +41,7 @@ import {
AuthFailure, AuthFailure,
RadioStation, RadioStation,
AlbumSummary, AlbumSummary,
trackToTrackSummary,
} from "../src/music_library"; } from "../src/music_library";
import { import {
aGenre, aGenre,
@@ -4216,21 +4216,18 @@ describe("SubsonicMusicLibrary", () => {
const track1 = aTrack({ const track1 = aTrack({
id: "track1", id: "track1",
artist: artistToArtistSummary(artist1), artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1), album: album1,
genre: pop, genre: pop,
}); });
mockGET mockGET
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getSimilarSongsJson([track1]))) Promise.resolve(ok(getSimilarSongsJson([track1])))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album1)))
); );
const result = await subsonic.similarSongs(id); const result = await subsonic.similarSongs(id);
expect(result).toEqual([track1]); expect(result).toEqual([trackToTrackSummary(track1)]);
expect(mockGET).toHaveBeenCalledWith( expect(mockGET).toHaveBeenCalledWith(
url.append({ pathname: "/rest/getSimilarSongs2" }).href(), url.append({ pathname: "/rest/getSimilarSongs2" }).href(),
@@ -4288,20 +4285,15 @@ describe("SubsonicMusicLibrary", () => {
mockGET mockGET
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3])))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album1)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album2)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album1)))
); );
const result = await subsonic.similarSongs(id); const result = await subsonic.similarSongs(id);
expect(result).toEqual([track1, track2, track3]); expect(result).toEqual([
trackToTrackSummary(track1),
trackToTrackSummary(track2),
trackToTrackSummary(track3),
]);
expect(mockGET).toHaveBeenCalledWith( expect(mockGET).toHaveBeenCalledWith(
url.append({ pathname: "/rest/getSimilarSongs2" }).href(), url.append({ pathname: "/rest/getSimilarSongs2" }).href(),
@@ -4390,14 +4382,13 @@ describe("SubsonicMusicLibrary", () => {
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getTopSongsJson([track1]))) Promise.resolve(ok(getTopSongsJson([track1])))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album1)))
); );
const result = await subsonic.topSongs(artistId); const result = await subsonic.topSongs(artistId);
expect(result).toEqual([track1]); expect(result).toEqual([
trackToTrackSummary(track1)
]);
expect(mockGET).toHaveBeenCalledWith( expect(mockGET).toHaveBeenCalledWith(
url.append({ pathname: "/rest/getTopSongs" }).href(), url.append({ pathname: "/rest/getTopSongs" }).href(),
@@ -4452,20 +4443,15 @@ describe("SubsonicMusicLibrary", () => {
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) Promise.resolve(ok(getTopSongsJson([track1, track2, track3])))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album1)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album2)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(album1)))
); );
const result = await subsonic.topSongs(artistId); const result = await subsonic.topSongs(artistId);
expect(result).toEqual([track1, track2, track3]); expect(result).toEqual([
trackToTrackSummary(track1),
trackToTrackSummary(track2),
trackToTrackSummary(track3),
]);
expect(mockGET).toHaveBeenCalledWith( expect(mockGET).toHaveBeenCalledWith(
url.append({ pathname: "/rest/getTopSongs" }).href(), url.append({ pathname: "/rest/getTopSongs" }).href(),