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

View File

@@ -12,9 +12,9 @@ import {
Track,
CoverArt,
AlbumQueryType,
PlaylistSummary,
Encoding,
albumToAlbumSummary,
TrackSummary,
} from "./music_library";
import sharp from "sharp";
import _ from "underscore";
@@ -170,24 +170,34 @@ export type GetAlbumResponse = {
};
};
type playlist = {
id: string;
name: string;
coverArt: string | undefined;
};
export type GetPlaylistResponse = {
// todo: isnt the type here a composite? playlistSummary && { entry: song[]; }
playlist: {
id: string;
name: string;
coverArt: string | undefined;
entry: song[];
// todo: this is an ND specific field?
coverArt: string | undefined;
};
};
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 = {
@@ -280,11 +290,10 @@ export const artistImageURN = (
}
};
export const asTrack = (
album: AlbumSummary,
export const asTrackSummary = (
song: song,
customPlayers: CustomPlayers
): Track => ({
): TrackSummary => ({
id: song.id,
name: song.title,
encoding: pipe(
@@ -300,7 +309,6 @@ export const asTrack = (
number: song.track || 0,
genre: maybeAsGenre(song.genre),
coverArt: coverArtURN(song.coverArt),
album: album,
artist: {
id: song.artistId,
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 => ({
id: album.id,
name: album.name,
@@ -327,13 +344,6 @@ export const asAlbumSummary = (album: album): AlbumSummary => ({
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) => ({
id: b64Encode(genreName),
name: genreName,
@@ -612,7 +622,6 @@ export class Subsonic {
};
});
getArtist = (
credentials: Credentials,
id: string
@@ -764,5 +773,93 @@ export class Subsonic {
"accept-ranges": stream.headers["accept-ranges"],
},
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,
AuthFailure,
AuthSuccess,
albumToAlbumSummary,
} from "./music_library";
import {
Subsonic,
CustomPlayers,
asTrack,
PingResponse,
NO_CUSTOM_PLAYERS,
asToken,
parseToken,
artistImageURN,
GetPlaylistsResponse,
GetPlaylistResponse,
asPlayListSummary,
coverArtURN,
maybeAsGenre,
GetSimilarSongsResponse,
GetTopSongsResponse,
GetInternetRadioStationsResponse,
asYear,
isValidImage
@@ -292,111 +283,30 @@ export class SubsonicMusicLibrary implements MusicLibrary {
);
playlists = async () =>
this.subsonic
.getJSON<GetPlaylistsResponse>(this.credentials, "/rest/getPlaylists")
.then(({ playlists }) =>
(playlists.playlist || []).map(asPlayListSummary)
);
this.subsonic.playlists(this.credentials);
playlist = async (id: string) =>
this.subsonic
.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++,
})),
};
});
this.subsonic.playlist(this.credentials, id);
createPlaylist = async (name: string) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/createPlaylist", {
name,
})
.then(({ playlist }) => ({
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
}));
this.subsonic.createPlayList(this.credentials, name);
deletePlaylist = async (id: string) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/deletePlaylist", {
id,
})
.then((_) => true);
this.subsonic.deletePlayList(this.credentials, id);
addToPlaylist = async (playlistId: string, trackId: string) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
playlistId,
songIdToAdd: trackId,
})
.then((_) => true);
this.subsonic.updatePlaylist(this.credentials, playlistId, { songIdToAdd: trackId });
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
playlistId,
songIndexToRemove: indicies,
})
.then((_) => true);
this.subsonic.updatePlaylist(this.credentials, playlistId, { songIndexToRemove: indicies });
similarSongs = async (id: string) =>
this.subsonic
.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))
)
)
);
this.subsonic.getSimilarSongs2(this.credentials, id)
topSongs = async (artistId: string) =>
this.subsonic.getArtist(this.credentials, artistId).then(({ name }) =>
this.subsonic
.getJSON<GetTopSongsResponse>(this.credentials, "/rest/getTopSongs", {
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))
)
)
)
this.subsonic.getArtist(this.credentials, artistId)
.then(({ name }) =>
this.subsonic.getTopSongs(this.credentials, name)
);
radioStations = async () =>

View File

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

View File

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