mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
more
This commit is contained in:
@@ -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[]>
|
||||
}
|
||||
|
||||
141
src/subsonic.ts
141
src/subsonic.ts
@@ -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,
|
||||
@@ -611,7 +621,6 @@ export class Subsonic {
|
||||
tracks: y
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
getArtist = (
|
||||
credentials: Credentials,
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -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,112 +283,31 @@ 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))
|
||||
)
|
||||
)
|
||||
);
|
||||
similarSongs = async (id: string) =>
|
||||
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 () =>
|
||||
this.subsonic
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user