mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Move subsonic music service/library into own file
This commit is contained in:
@@ -15,7 +15,7 @@ import { InMemoryAPITokens, sha256 } from "./api_tokens";
|
|||||||
import { InMemoryLinkCodes } from "./link_codes";
|
import { InMemoryLinkCodes } from "./link_codes";
|
||||||
import readConfig from "./config";
|
import readConfig from "./config";
|
||||||
import sonos, { bonobService } from "./sonos";
|
import sonos, { bonobService } from "./sonos";
|
||||||
import { MusicService } from "./music_service";
|
import { MusicService } from "./music_library";
|
||||||
import { SystemClock } from "./clock";
|
import { SystemClock } from "./clock";
|
||||||
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
ratingAsInt,
|
ratingAsInt,
|
||||||
} from "./smapi";
|
} from "./smapi";
|
||||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||||
import { MusicService, AuthFailure, AuthSuccess } from "./music_service";
|
import { MusicService, AuthFailure, AuthSuccess } from "./music_library";
|
||||||
import bindSmapiSoapServiceToExpress from "./smapi";
|
import bindSmapiSoapServiceToExpress from "./smapi";
|
||||||
import { APITokens, InMemoryAPITokens } from "./api_tokens";
|
import { APITokens, InMemoryAPITokens } from "./api_tokens";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Rating,
|
Rating,
|
||||||
slice2,
|
slice2,
|
||||||
Track,
|
Track,
|
||||||
} from "./music_service";
|
} from "./music_library";
|
||||||
import { APITokens } from "./api_tokens";
|
import { APITokens } from "./api_tokens";
|
||||||
import { Clock } from "./clock";
|
import { Clock } from "./clock";
|
||||||
import { URLBuilder } from "./url_builder";
|
import { URLBuilder } from "./url_builder";
|
||||||
|
|||||||
482
src/subsonic.ts
482
src/subsonic.ts
@@ -1,29 +1,18 @@
|
|||||||
import { option as O, taskEither as TE } from "fp-ts";
|
import { option as O } from "fp-ts";
|
||||||
import * as A from "fp-ts/Array";
|
|
||||||
import { ordString } from "fp-ts/lib/Ord";
|
|
||||||
import { pipe } from "fp-ts/lib/function";
|
import { pipe } from "fp-ts/lib/function";
|
||||||
import { Md5 } from "ts-md5";
|
import { Md5 } from "ts-md5";
|
||||||
import {
|
import {
|
||||||
Credentials,
|
Credentials,
|
||||||
MusicService,
|
|
||||||
Album,
|
Album,
|
||||||
Result,
|
|
||||||
slice2,
|
|
||||||
AlbumQuery,
|
AlbumQuery,
|
||||||
ArtistQuery,
|
|
||||||
MusicLibrary,
|
|
||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
Genre,
|
Genre,
|
||||||
Track,
|
Track,
|
||||||
CoverArt,
|
CoverArt,
|
||||||
Rating,
|
|
||||||
AlbumQueryType,
|
AlbumQueryType,
|
||||||
Artist,
|
|
||||||
AuthFailure,
|
|
||||||
PlaylistSummary,
|
PlaylistSummary,
|
||||||
Encoding,
|
Encoding,
|
||||||
AuthSuccess,
|
} from "./music_library";
|
||||||
} from "./music_service";
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
import fse from "fs-extra";
|
import fse from "fs-extra";
|
||||||
@@ -32,8 +21,7 @@ import path from "path";
|
|||||||
import axios, { AxiosRequestConfig } from "axios";
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
import randomstring from "randomstring";
|
import randomstring from "randomstring";
|
||||||
import { b64Encode, b64Decode } from "./b64";
|
import { b64Encode, b64Decode } from "./b64";
|
||||||
import logger from "./logger";
|
import { BUrn } from "./burn";
|
||||||
import { assertSystem, BUrn } from "./burn";
|
|
||||||
import { artist } from "./smapi";
|
import { artist } from "./smapi";
|
||||||
import { URLBuilder } from "./url_builder";
|
import { URLBuilder } from "./url_builder";
|
||||||
|
|
||||||
@@ -109,7 +97,7 @@ type genre = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetGenresResponse = SubsonicResponse & {
|
export type GetGenresResponse = SubsonicResponse & {
|
||||||
genres: {
|
genres: {
|
||||||
genre: genre[];
|
genre: genre[];
|
||||||
};
|
};
|
||||||
@@ -172,7 +160,7 @@ export type song = {
|
|||||||
starred: string | undefined;
|
starred: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetAlbumResponse = {
|
export type GetAlbumResponse = {
|
||||||
album: album & {
|
album: album & {
|
||||||
song: song[];
|
song: song[];
|
||||||
};
|
};
|
||||||
@@ -184,7 +172,7 @@ type playlist = {
|
|||||||
coverArt: string | undefined;
|
coverArt: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
@@ -194,19 +182,19 @@ type GetPlaylistResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetPlaylistsResponse = {
|
export type GetPlaylistsResponse = {
|
||||||
playlists: { playlist: playlist[] };
|
playlists: { playlist: playlist[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetSimilarSongsResponse = {
|
export type GetSimilarSongsResponse = {
|
||||||
similarSongs2: { song: song[] };
|
similarSongs2: { song: song[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetTopSongsResponse = {
|
export type GetTopSongsResponse = {
|
||||||
topSongs: { song: song[] };
|
topSongs: { song: song[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetInternetRadioStationsResponse = {
|
export type GetInternetRadioStationsResponse = {
|
||||||
internetRadioStations: { internetRadioStation: {
|
internetRadioStations: { internetRadioStation: {
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -215,11 +203,11 @@ type GetInternetRadioStationsResponse = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetSongResponse = {
|
export type GetSongResponse = {
|
||||||
song: song;
|
song: song;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetStarredResponse = {
|
export type GetStarredResponse = {
|
||||||
starred2: {
|
starred2: {
|
||||||
song: song[];
|
song: song[];
|
||||||
album: album[];
|
album: album[];
|
||||||
@@ -233,7 +221,7 @@ export type PingResponse = {
|
|||||||
serverVersion: string;
|
serverVersion: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Search3Response = SubsonicResponse & {
|
export type Search3Response = SubsonicResponse & {
|
||||||
searchResult3: {
|
searchResult3: {
|
||||||
artist: artist[];
|
artist: artist[];
|
||||||
album: album[];
|
album: album[];
|
||||||
@@ -247,12 +235,12 @@ export function isError(
|
|||||||
return (subsonicResponse as SubsonicError).error !== undefined;
|
return (subsonicResponse as SubsonicError).error !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IdName = {
|
export type IdName = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
export const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
||||||
pipe(
|
pipe(
|
||||||
coverArt,
|
coverArt,
|
||||||
O.fromNullable,
|
O.fromNullable,
|
||||||
@@ -317,7 +305,7 @@ export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers):
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const asAlbum = (album: album): Album => ({
|
export const asAlbum = (album: album): Album => ({
|
||||||
id: album.id,
|
id: album.id,
|
||||||
name: album.name,
|
name: album.name,
|
||||||
year: album.year,
|
year: album.year,
|
||||||
@@ -328,7 +316,7 @@ const asAlbum = (album: album): Album => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// coverArtURN
|
// coverArtURN
|
||||||
const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
|
export const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
name: playlist.name,
|
name: playlist.name,
|
||||||
coverArt: coverArtURN(playlist.coverArt),
|
coverArt: coverArtURN(playlist.coverArt),
|
||||||
@@ -339,7 +327,7 @@ export const asGenre = (genreName: string) => ({
|
|||||||
name: genreName,
|
name: genreName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
export const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||||
pipe(
|
pipe(
|
||||||
genreName,
|
genreName,
|
||||||
O.fromNullable,
|
O.fromNullable,
|
||||||
@@ -395,8 +383,8 @@ export const NO_CUSTOM_PLAYERS: CustomPlayers = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CLIENT_APPLICATION = "bonob";
|
export const DEFAULT_CLIENT_APPLICATION = "bonob";
|
||||||
const USER_AGENT = "bonob";
|
export const USER_AGENT = "bonob";
|
||||||
|
|
||||||
export const asURLSearchParams = (q: any) => {
|
export const asURLSearchParams = (q: any) => {
|
||||||
const urlSearchParams = new URLSearchParams();
|
const urlSearchParams = new URLSearchParams();
|
||||||
@@ -474,436 +462,6 @@ export const asToken = (credentials: SubsonicCredentials) =>
|
|||||||
export const parseToken = (token: string): SubsonicCredentials =>
|
export const parseToken = (token: string): SubsonicCredentials =>
|
||||||
JSON.parse(b64Decode(token));
|
JSON.parse(b64Decode(token));
|
||||||
|
|
||||||
export class SubsonicMusicLibrary implements MusicLibrary {
|
|
||||||
subsonic: Subsonic;
|
|
||||||
credentials: Credentials
|
|
||||||
customPlayers: CustomPlayers
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
subsonic: Subsonic,
|
|
||||||
credentials: Credentials,
|
|
||||||
customPlayers: CustomPlayers
|
|
||||||
) {
|
|
||||||
this.subsonic = subsonic
|
|
||||||
this.credentials = credentials
|
|
||||||
this.customPlayers = customPlayers
|
|
||||||
}
|
|
||||||
|
|
||||||
flavour = () => "subsonic"
|
|
||||||
|
|
||||||
bearerToken = (_: Credentials) => TE.right<AuthFailure, string | undefined>(undefined)
|
|
||||||
|
|
||||||
artists = (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
|
|
||||||
this.subsonic
|
|
||||||
.getArtists(this.credentials)
|
|
||||||
.then(slice2(q))
|
|
||||||
.then(([page, total]) => ({
|
|
||||||
total,
|
|
||||||
results: page.map((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
name: it.name,
|
|
||||||
image: it.image,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
|
|
||||||
artist = async (id: string): Promise<Artist> =>
|
|
||||||
this.subsonic.getArtistWithInfo(this.credentials, id)
|
|
||||||
|
|
||||||
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
|
||||||
this.subsonic.getAlbumList2(this.credentials, q)
|
|
||||||
|
|
||||||
album = (id: string): Promise<Album> => this.subsonic.getAlbum(this.credentials, id)
|
|
||||||
|
|
||||||
genres = () =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetGenresResponse>(this.credentials, "/rest/getGenres")
|
|
||||||
.then((it) =>
|
|
||||||
pipe(
|
|
||||||
it.genres.genre || [],
|
|
||||||
A.filter((it) => it.albumCount > 0),
|
|
||||||
A.map((it) => it.value),
|
|
||||||
A.sort(ordString),
|
|
||||||
A.map((it) => ({ id: b64Encode(it), name: it }))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
tracks = (albumId: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetAlbumResponse>(this.credentials, "/rest/getAlbum", {
|
|
||||||
id: albumId,
|
|
||||||
})
|
|
||||||
.then((it) => it.album)
|
|
||||||
.then((album) =>
|
|
||||||
(album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers))
|
|
||||||
)
|
|
||||||
|
|
||||||
track = (trackId: string) => this.subsonic.getTrack(this.credentials, trackId)
|
|
||||||
|
|
||||||
rate = (trackId: string, rating: Rating) =>
|
|
||||||
Promise.resolve(true)
|
|
||||||
.then(() => {
|
|
||||||
if (rating.stars >= 0 && rating.stars <= 5) {
|
|
||||||
return this.subsonic.getTrack(this.credentials, trackId);
|
|
||||||
} else {
|
|
||||||
throw `Invalid rating.stars value of ${rating.stars}`;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((track) => {
|
|
||||||
const thingsToUpdate = [];
|
|
||||||
if (track.rating.love != rating.love) {
|
|
||||||
thingsToUpdate.push(
|
|
||||||
this.subsonic.getJSON(
|
|
||||||
this.credentials,
|
|
||||||
`/rest/${rating.love ? "star" : "unstar"}`,
|
|
||||||
{
|
|
||||||
id: trackId,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (track.rating.stars != rating.stars) {
|
|
||||||
thingsToUpdate.push(
|
|
||||||
this.subsonic.getJSON(this.credentials, `/rest/setRating`, {
|
|
||||||
id: trackId,
|
|
||||||
rating: rating.stars,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Promise.all(thingsToUpdate);
|
|
||||||
})
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
stream = async ({
|
|
||||||
trackId,
|
|
||||||
range,
|
|
||||||
}: {
|
|
||||||
trackId: string;
|
|
||||||
range: string | undefined;
|
|
||||||
}) =>
|
|
||||||
this.subsonic.getTrack(this.credentials, trackId).then((track) =>
|
|
||||||
this.subsonic
|
|
||||||
.get(
|
|
||||||
this.credentials,
|
|
||||||
`/rest/stream`,
|
|
||||||
{
|
|
||||||
id: trackId,
|
|
||||||
c: track.encoding.player,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: pipe(
|
|
||||||
range,
|
|
||||||
O.fromNullable,
|
|
||||||
O.map((range) => ({
|
|
||||||
"User-Agent": USER_AGENT,
|
|
||||||
Range: range,
|
|
||||||
})),
|
|
||||||
O.getOrElse(() => ({
|
|
||||||
"User-Agent": USER_AGENT,
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
responseType: "stream",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((stream) => ({
|
|
||||||
status: stream.status,
|
|
||||||
headers: {
|
|
||||||
"content-type": stream.headers["content-type"],
|
|
||||||
"content-length": stream.headers["content-length"],
|
|
||||||
"content-range": stream.headers["content-range"],
|
|
||||||
"accept-ranges": stream.headers["accept-ranges"],
|
|
||||||
},
|
|
||||||
stream: stream.data,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
coverArt = async (coverArtURN: BUrn, size?: number) =>
|
|
||||||
Promise.resolve(coverArtURN)
|
|
||||||
.then((it) => assertSystem(it, "subsonic"))
|
|
||||||
.then((it) => this.subsonic.getCoverArt(this.credentials, it.resource.split(":")[1]!, size))
|
|
||||||
.then((res) => ({
|
|
||||||
contentType: res.headers["content-type"],
|
|
||||||
data: Buffer.from(res.data, "binary"),
|
|
||||||
}))
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(
|
|
||||||
`Failed getting coverArt for urn:'${coverArtURN}': ${e}`
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
|
|
||||||
scrobble = async (id: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON(this.credentials, `/rest/scrobble`, {
|
|
||||||
id,
|
|
||||||
submission: true,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
nowPlaying = async (id: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON(this.credentials, `/rest/scrobble`, {
|
|
||||||
id,
|
|
||||||
submission: false,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
searchArtists = async (query: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.search3(this.credentials, { query, artistCount: 20 })
|
|
||||||
.then(({ artists }) =>
|
|
||||||
artists.map((artist) => ({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
image: artistImageURN({
|
|
||||||
artistId: artist.id,
|
|
||||||
artistImageURL: artist.artistImageUrl,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
searchAlbums = async (query: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.search3(this.credentials, { query, albumCount: 20 })
|
|
||||||
.then(({ albums }) => this.subsonic.toAlbumSummary(albums))
|
|
||||||
|
|
||||||
searchTracks = async (query: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.search3(this.credentials, { query, songCount: 20 })
|
|
||||||
.then(({ songs }) =>
|
|
||||||
Promise.all(
|
|
||||||
songs.map((it) => this.subsonic.getTrack(this.credentials, it.id))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
playlists = async () =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistsResponse>(this.credentials, "/rest/getPlaylists")
|
|
||||||
.then(({ playlists }) => (playlists.playlist || []).map(asPlayListSummary))
|
|
||||||
|
|
||||||
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++,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
|
|
||||||
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),
|
|
||||||
}))
|
|
||||||
|
|
||||||
deletePlaylist = async (id: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/deletePlaylist", {
|
|
||||||
id,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
|
|
||||||
addToPlaylist = async (playlistId: string, trackId: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
|
|
||||||
playlistId,
|
|
||||||
songIdToAdd: trackId,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
|
|
||||||
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
|
|
||||||
playlistId,
|
|
||||||
songIndexToRemove: indicies,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
|
|
||||||
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(album, song, this.customPlayers))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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(album, song, this.customPlayers))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
radioStations = async () => this.subsonic
|
|
||||||
.getJSON<GetInternetRadioStationsResponse>(
|
|
||||||
this.credentials,
|
|
||||||
"/rest/getInternetRadioStations"
|
|
||||||
)
|
|
||||||
.then((it) => it.internetRadioStations.internetRadioStation || [])
|
|
||||||
.then((stations) => stations.map((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
name: it.name,
|
|
||||||
url: it.streamUrl,
|
|
||||||
homePage: it.homePageUrl
|
|
||||||
})))
|
|
||||||
|
|
||||||
radioStation = async (id: string) => this.radioStations()
|
|
||||||
.then(it =>
|
|
||||||
it.find(station => station.id === id)!
|
|
||||||
)
|
|
||||||
|
|
||||||
years = async () => {
|
|
||||||
const q: AlbumQuery = {
|
|
||||||
_index: 0,
|
|
||||||
_count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something
|
|
||||||
type: "alphabeticalByArtist",
|
|
||||||
};
|
|
||||||
const years = this.subsonic.getAlbumList2(this.credentials, q)
|
|
||||||
.then(({ results }) =>
|
|
||||||
results.map((album) => album.year || "?")
|
|
||||||
.filter((item, i, ar) => ar.indexOf(item) === i)
|
|
||||||
.sort()
|
|
||||||
.map((year) => ({
|
|
||||||
...asYear(year)
|
|
||||||
}))
|
|
||||||
.reverse()
|
|
||||||
);
|
|
||||||
return years;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SubsonicMusicService implements MusicService {
|
|
||||||
subsonic: Subsonic;
|
|
||||||
customPlayers: CustomPlayers;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
subsonic: Subsonic,
|
|
||||||
customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS
|
|
||||||
) {
|
|
||||||
this.subsonic = subsonic;
|
|
||||||
this.customPlayers = customPlayers;
|
|
||||||
}
|
|
||||||
|
|
||||||
generateToken = (credentials: Credentials): TE.TaskEither<AuthFailure, AuthSuccess> => {
|
|
||||||
const x: TE.TaskEither<AuthFailure, PingResponse> = TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.subsonic.getJSON<PingResponse>(
|
|
||||||
_.pick(credentials, "username", "password"),
|
|
||||||
"/rest/ping.view"
|
|
||||||
),
|
|
||||||
(e) => new AuthFailure(e as string)
|
|
||||||
)
|
|
||||||
return pipe(
|
|
||||||
x,
|
|
||||||
TE.flatMap(({ type }) =>
|
|
||||||
pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() => this.libraryFor({ ...credentials, type }),
|
|
||||||
() => new AuthFailure("Failed to get library")
|
|
||||||
),
|
|
||||||
TE.map((library) => ({ type, library }))
|
|
||||||
)
|
|
||||||
),
|
|
||||||
TE.flatMap(({ library, type }) =>
|
|
||||||
pipe(
|
|
||||||
library.bearerToken(credentials),
|
|
||||||
TE.map((bearer) => ({ bearer, type }))
|
|
||||||
)
|
|
||||||
),
|
|
||||||
TE.map(({ bearer, type }) => ({
|
|
||||||
serviceToken: asToken({ ...credentials, bearer, type }),
|
|
||||||
userId: credentials.username,
|
|
||||||
nickname: credentials.username,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshToken = (serviceToken: string) =>
|
|
||||||
this.generateToken(parseToken(serviceToken));
|
|
||||||
|
|
||||||
login = async (token: string) => this.libraryFor(parseToken(token));
|
|
||||||
|
|
||||||
private libraryFor = (
|
|
||||||
credentials: Credentials & { type: string }
|
|
||||||
): Promise<SubsonicMusicLibrary> => {
|
|
||||||
const genericSubsonic = new SubsonicMusicLibrary(this.subsonic, credentials, this.customPlayers);
|
|
||||||
// return Promise.resolve(genericSubsonic);
|
|
||||||
|
|
||||||
if (credentials.type == "navidrome") {
|
|
||||||
// todo: there does not seem to be a test for this??
|
|
||||||
const nd: SubsonicMusicLibrary = {
|
|
||||||
...genericSubsonic,
|
|
||||||
flavour: () => "navidrome",
|
|
||||||
bearerToken: (credentials: Credentials) =>
|
|
||||||
pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
axios.post(
|
|
||||||
this.subsonic.url.append({ pathname: "/auth/login" }).href(),
|
|
||||||
_.pick(credentials, "username", "password")
|
|
||||||
),
|
|
||||||
() => new AuthFailure("Failed to get bearerToken")
|
|
||||||
),
|
|
||||||
TE.map((it) => it.data.token as string | undefined)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return Promise.resolve(nd);
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(genericSubsonic);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Subsonic {
|
export class Subsonic {
|
||||||
url: URLBuilder;
|
url: URLBuilder;
|
||||||
customPlayers: CustomPlayers;
|
customPlayers: CustomPlayers;
|
||||||
|
|||||||
483
src/subsonic_music_library.ts
Normal file
483
src/subsonic_music_library.ts
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { option as O, taskEither as TE } from "fp-ts";
|
||||||
|
import * as A from "fp-ts/Array";
|
||||||
|
import { ordString } from "fp-ts/lib/Ord";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
import {
|
||||||
|
Credentials,
|
||||||
|
MusicService,
|
||||||
|
ArtistSummary,
|
||||||
|
Album,
|
||||||
|
Result,
|
||||||
|
slice2,
|
||||||
|
AlbumQuery,
|
||||||
|
ArtistQuery,
|
||||||
|
MusicLibrary,
|
||||||
|
AlbumSummary,
|
||||||
|
Rating,
|
||||||
|
Artist,
|
||||||
|
AuthFailure,
|
||||||
|
AuthSuccess,
|
||||||
|
} from "./music_library";
|
||||||
|
import {
|
||||||
|
Subsonic,
|
||||||
|
CustomPlayers,
|
||||||
|
GetGenresResponse,
|
||||||
|
GetAlbumResponse,
|
||||||
|
asTrack,
|
||||||
|
asAlbum,
|
||||||
|
PingResponse,
|
||||||
|
NO_CUSTOM_PLAYERS,
|
||||||
|
asToken,
|
||||||
|
parseToken,
|
||||||
|
artistImageURN,
|
||||||
|
USER_AGENT,
|
||||||
|
GetPlaylistsResponse,
|
||||||
|
GetPlaylistResponse,
|
||||||
|
asPlayListSummary,
|
||||||
|
coverArtURN,
|
||||||
|
maybeAsGenre,
|
||||||
|
GetSimilarSongsResponse,
|
||||||
|
GetTopSongsResponse,
|
||||||
|
GetInternetRadioStationsResponse,
|
||||||
|
asYear,
|
||||||
|
} from "./subsonic";
|
||||||
|
import _ from "underscore";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { b64Encode } from "./b64";
|
||||||
|
import logger from "./logger";
|
||||||
|
import { assertSystem, BUrn } from "./burn";
|
||||||
|
|
||||||
|
|
||||||
|
export class SubsonicMusicService implements MusicService {
|
||||||
|
subsonic: Subsonic;
|
||||||
|
customPlayers: CustomPlayers;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
subsonic: Subsonic,
|
||||||
|
customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS
|
||||||
|
) {
|
||||||
|
this.subsonic = subsonic;
|
||||||
|
this.customPlayers = customPlayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateToken = (credentials: Credentials): TE.TaskEither<AuthFailure, AuthSuccess> => {
|
||||||
|
const x: TE.TaskEither<AuthFailure, PingResponse> = TE.tryCatch(
|
||||||
|
() =>
|
||||||
|
this.subsonic.getJSON<PingResponse>(
|
||||||
|
_.pick(credentials, "username", "password"),
|
||||||
|
"/rest/ping.view"
|
||||||
|
),
|
||||||
|
(e) => new AuthFailure(e as string)
|
||||||
|
)
|
||||||
|
return pipe(
|
||||||
|
x,
|
||||||
|
TE.flatMap(({ type }) =>
|
||||||
|
pipe(
|
||||||
|
TE.tryCatch(
|
||||||
|
() => this.libraryFor({ ...credentials, type }),
|
||||||
|
() => new AuthFailure("Failed to get library")
|
||||||
|
),
|
||||||
|
TE.map((library) => ({ type, library }))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
TE.flatMap(({ library, type }) =>
|
||||||
|
pipe(
|
||||||
|
library.bearerToken(credentials),
|
||||||
|
TE.map((bearer) => ({ bearer, type }))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
TE.map(({ bearer, type }) => ({
|
||||||
|
serviceToken: asToken({ ...credentials, bearer, type }),
|
||||||
|
userId: credentials.username,
|
||||||
|
nickname: credentials.username,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken = (serviceToken: string) =>
|
||||||
|
this.generateToken(parseToken(serviceToken));
|
||||||
|
|
||||||
|
login = async (token: string) => this.libraryFor(parseToken(token));
|
||||||
|
|
||||||
|
private libraryFor = (
|
||||||
|
credentials: Credentials & { type: string }
|
||||||
|
): Promise<SubsonicMusicLibrary> => {
|
||||||
|
const genericSubsonic = new SubsonicMusicLibrary(this.subsonic, credentials, this.customPlayers);
|
||||||
|
// return Promise.resolve(genericSubsonic);
|
||||||
|
|
||||||
|
if (credentials.type == "navidrome") {
|
||||||
|
// todo: there does not seem to be a test for this??
|
||||||
|
const nd: SubsonicMusicLibrary = {
|
||||||
|
...genericSubsonic,
|
||||||
|
flavour: () => "navidrome",
|
||||||
|
bearerToken: (credentials: Credentials) =>
|
||||||
|
pipe(
|
||||||
|
TE.tryCatch(
|
||||||
|
() =>
|
||||||
|
axios.post(
|
||||||
|
this.subsonic.url.append({ pathname: "/auth/login" }).href(),
|
||||||
|
_.pick(credentials, "username", "password")
|
||||||
|
),
|
||||||
|
() => new AuthFailure("Failed to get bearerToken")
|
||||||
|
),
|
||||||
|
TE.map((it) => it.data.token as string | undefined)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return Promise.resolve(nd);
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(genericSubsonic);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class SubsonicMusicLibrary implements MusicLibrary {
|
||||||
|
subsonic: Subsonic;
|
||||||
|
credentials: Credentials
|
||||||
|
customPlayers: CustomPlayers
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
subsonic: Subsonic,
|
||||||
|
credentials: Credentials,
|
||||||
|
customPlayers: CustomPlayers
|
||||||
|
) {
|
||||||
|
this.subsonic = subsonic
|
||||||
|
this.credentials = credentials
|
||||||
|
this.customPlayers = customPlayers
|
||||||
|
}
|
||||||
|
|
||||||
|
flavour = () => "subsonic"
|
||||||
|
|
||||||
|
bearerToken = (_: Credentials) => TE.right<AuthFailure, string | undefined>(undefined)
|
||||||
|
|
||||||
|
artists = (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
|
||||||
|
this.subsonic
|
||||||
|
.getArtists(this.credentials)
|
||||||
|
.then(slice2(q))
|
||||||
|
.then(([page, total]) => ({
|
||||||
|
total,
|
||||||
|
results: page.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
image: it.image,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
artist = async (id: string): Promise<Artist> =>
|
||||||
|
this.subsonic.getArtistWithInfo(this.credentials, id)
|
||||||
|
|
||||||
|
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
||||||
|
this.subsonic.getAlbumList2(this.credentials, q)
|
||||||
|
|
||||||
|
album = (id: string): Promise<Album> => this.subsonic.getAlbum(this.credentials, id)
|
||||||
|
|
||||||
|
genres = () =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON<GetGenresResponse>(this.credentials, "/rest/getGenres")
|
||||||
|
.then((it) =>
|
||||||
|
pipe(
|
||||||
|
it.genres.genre || [],
|
||||||
|
A.filter((it) => it.albumCount > 0),
|
||||||
|
A.map((it) => it.value),
|
||||||
|
A.sort(ordString),
|
||||||
|
A.map((it) => ({ id: b64Encode(it), name: it }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks = (albumId: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON<GetAlbumResponse>(this.credentials, "/rest/getAlbum", {
|
||||||
|
id: albumId,
|
||||||
|
})
|
||||||
|
.then((it) => it.album)
|
||||||
|
.then((album) =>
|
||||||
|
(album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers))
|
||||||
|
)
|
||||||
|
|
||||||
|
track = (trackId: string) => this.subsonic.getTrack(this.credentials, trackId)
|
||||||
|
|
||||||
|
rate = (trackId: string, rating: Rating) =>
|
||||||
|
Promise.resolve(true)
|
||||||
|
.then(() => {
|
||||||
|
if (rating.stars >= 0 && rating.stars <= 5) {
|
||||||
|
return this.subsonic.getTrack(this.credentials, trackId);
|
||||||
|
} else {
|
||||||
|
throw `Invalid rating.stars value of ${rating.stars}`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((track) => {
|
||||||
|
const thingsToUpdate = [];
|
||||||
|
if (track.rating.love != rating.love) {
|
||||||
|
thingsToUpdate.push(
|
||||||
|
this.subsonic.getJSON(
|
||||||
|
this.credentials,
|
||||||
|
`/rest/${rating.love ? "star" : "unstar"}`,
|
||||||
|
{
|
||||||
|
id: trackId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (track.rating.stars != rating.stars) {
|
||||||
|
thingsToUpdate.push(
|
||||||
|
this.subsonic.getJSON(this.credentials, `/rest/setRating`, {
|
||||||
|
id: trackId,
|
||||||
|
rating: rating.stars,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(thingsToUpdate);
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
stream = async ({
|
||||||
|
trackId,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
range: string | undefined;
|
||||||
|
}) =>
|
||||||
|
this.subsonic.getTrack(this.credentials, trackId).then((track) =>
|
||||||
|
this.subsonic
|
||||||
|
.get(
|
||||||
|
this.credentials,
|
||||||
|
`/rest/stream`,
|
||||||
|
{
|
||||||
|
id: trackId,
|
||||||
|
c: track.encoding.player,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: pipe(
|
||||||
|
range,
|
||||||
|
O.fromNullable,
|
||||||
|
O.map((range) => ({
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
Range: range,
|
||||||
|
})),
|
||||||
|
O.getOrElse(() => ({
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
responseType: "stream",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((stream) => ({
|
||||||
|
status: stream.status,
|
||||||
|
headers: {
|
||||||
|
"content-type": stream.headers["content-type"],
|
||||||
|
"content-length": stream.headers["content-length"],
|
||||||
|
"content-range": stream.headers["content-range"],
|
||||||
|
"accept-ranges": stream.headers["accept-ranges"],
|
||||||
|
},
|
||||||
|
stream: stream.data,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
coverArt = async (coverArtURN: BUrn, size?: number) =>
|
||||||
|
Promise.resolve(coverArtURN)
|
||||||
|
.then((it) => assertSystem(it, "subsonic"))
|
||||||
|
.then((it) => this.subsonic.getCoverArt(this.credentials, it.resource.split(":")[1]!, size))
|
||||||
|
.then((res) => ({
|
||||||
|
contentType: res.headers["content-type"],
|
||||||
|
data: Buffer.from(res.data, "binary"),
|
||||||
|
}))
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed getting coverArt for urn:'${coverArtURN}': ${e}`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
|
||||||
|
scrobble = async (id: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON(this.credentials, `/rest/scrobble`, {
|
||||||
|
id,
|
||||||
|
submission: true,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
nowPlaying = async (id: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON(this.credentials, `/rest/scrobble`, {
|
||||||
|
id,
|
||||||
|
submission: false,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
searchArtists = async (query: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.search3(this.credentials, { query, artistCount: 20 })
|
||||||
|
.then(({ artists }) =>
|
||||||
|
artists.map((artist) => ({
|
||||||
|
id: artist.id,
|
||||||
|
name: artist.name,
|
||||||
|
image: artistImageURN({
|
||||||
|
artistId: artist.id,
|
||||||
|
artistImageURL: artist.artistImageUrl,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
searchAlbums = async (query: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.search3(this.credentials, { query, albumCount: 20 })
|
||||||
|
.then(({ albums }) => this.subsonic.toAlbumSummary(albums))
|
||||||
|
|
||||||
|
searchTracks = async (query: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.search3(this.credentials, { query, songCount: 20 })
|
||||||
|
.then(({ songs }) =>
|
||||||
|
Promise.all(
|
||||||
|
songs.map((it) => this.subsonic.getTrack(this.credentials, it.id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
playlists = async () =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON<GetPlaylistsResponse>(this.credentials, "/rest/getPlaylists")
|
||||||
|
.then(({ playlists }) => (playlists.playlist || []).map(asPlayListSummary))
|
||||||
|
|
||||||
|
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++,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
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),
|
||||||
|
}))
|
||||||
|
|
||||||
|
deletePlaylist = async (id: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/deletePlaylist", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
|
||||||
|
addToPlaylist = async (playlistId: string, trackId: string) =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
|
||||||
|
playlistId,
|
||||||
|
songIdToAdd: trackId,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
|
||||||
|
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
|
||||||
|
this.subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
|
||||||
|
playlistId,
|
||||||
|
songIndexToRemove: indicies,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
|
||||||
|
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(album, song, this.customPlayers))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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(album, song, this.customPlayers))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
radioStations = async () => this.subsonic
|
||||||
|
.getJSON<GetInternetRadioStationsResponse>(
|
||||||
|
this.credentials,
|
||||||
|
"/rest/getInternetRadioStations"
|
||||||
|
)
|
||||||
|
.then((it) => it.internetRadioStations.internetRadioStation || [])
|
||||||
|
.then((stations) => stations.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
url: it.streamUrl,
|
||||||
|
homePage: it.homePageUrl
|
||||||
|
})))
|
||||||
|
|
||||||
|
radioStation = async (id: string) => this.radioStations()
|
||||||
|
.then(it =>
|
||||||
|
it.find(station => station.id === id)!
|
||||||
|
)
|
||||||
|
|
||||||
|
years = async () => {
|
||||||
|
const q: AlbumQuery = {
|
||||||
|
_index: 0,
|
||||||
|
_count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something
|
||||||
|
type: "alphabeticalByArtist",
|
||||||
|
};
|
||||||
|
const years = this.subsonic.getAlbumList2(this.credentials, q)
|
||||||
|
.then(({ results }) =>
|
||||||
|
results.map((album) => album.year || "?")
|
||||||
|
.filter((item, i, ar) => ar.indexOf(item) === i)
|
||||||
|
.sort()
|
||||||
|
.map((year) => ({
|
||||||
|
...asYear(year)
|
||||||
|
}))
|
||||||
|
.reverse()
|
||||||
|
);
|
||||||
|
return years;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
SimilarArtist,
|
SimilarArtist,
|
||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
RadioStation
|
RadioStation
|
||||||
} from "../src/music_service";
|
} from "../src/music_library";
|
||||||
|
|
||||||
import { b64Encode } from "../src/b64";
|
import { b64Encode } from "../src/b64";
|
||||||
import { artistImageURN } from "../src/subsonic";
|
import { artistImageURN } from "../src/subsonic";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
MusicLibrary,
|
MusicLibrary,
|
||||||
artistToArtistSummary,
|
artistToArtistSummary,
|
||||||
albumToAlbumSummary,
|
albumToAlbumSummary,
|
||||||
} from "../src/music_service";
|
} from "../src/music_library";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import {
|
import {
|
||||||
anArtist,
|
anArtist,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
Track,
|
Track,
|
||||||
Genre,
|
Genre,
|
||||||
Rating,
|
Rating,
|
||||||
} from "../src/music_service";
|
} from "../src/music_library";
|
||||||
import { BUrn } from "../src/burn";
|
import { BUrn } from "../src/burn";
|
||||||
|
|
||||||
export class InMemoryMusicService implements MusicService {
|
export class InMemoryMusicService implements MusicService {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { anArtist } from "./builders";
|
import { anArtist } from "./builders";
|
||||||
import { artistToArtistSummary } from "../src/music_service";
|
import { artistToArtistSummary } from "../src/music_library";
|
||||||
|
|
||||||
describe("artistToArtistSummary", () => {
|
describe("artistToArtistSummary", () => {
|
||||||
it("should map fields correctly", () => {
|
it("should map fields correctly", () => {
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "./builders";
|
} from "./builders";
|
||||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||||
import { InMemoryLinkCodes } from "../src/link_codes";
|
import { InMemoryLinkCodes } from "../src/link_codes";
|
||||||
import { Credentials } from "../src/music_service";
|
import { Credentials } from "../src/music_library";
|
||||||
import makeServer from "../src/server";
|
import makeServer from "../src/server";
|
||||||
import { Service, bonobService, Sonos } from "../src/sonos";
|
import { Service, bonobService, Sonos } from "../src/sonos";
|
||||||
import supersoap from "./supersoap";
|
import supersoap from "./supersoap";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import request from "supertest";
|
|||||||
import Image from "image-js";
|
import Image from "image-js";
|
||||||
import { either as E, taskEither as TE } from "fp-ts";
|
import { either as E, taskEither as TE } from "fp-ts";
|
||||||
|
|
||||||
import { AuthFailure, MusicService } from "../src/music_service";
|
import { AuthFailure, MusicService } from "../src/music_library";
|
||||||
import makeServer, {
|
import makeServer, {
|
||||||
BONOB_ACCESS_TOKEN_HEADER,
|
BONOB_ACCESS_TOKEN_HEADER,
|
||||||
RangeBytesFromFilter,
|
RangeBytesFromFilter,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
artistToArtistSummary,
|
artistToArtistSummary,
|
||||||
MusicService,
|
MusicService,
|
||||||
playlistToPlaylistSummary,
|
playlistToPlaylistSummary,
|
||||||
} from "../src/music_service";
|
} from "../src/music_library";
|
||||||
import { APITokens } from "../src/api_tokens";
|
import { APITokens } from "../src/api_tokens";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import url, { URLBuilder } from "../src/url_builder";
|
import url, { URLBuilder } from "../src/url_builder";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
4619
tests/subsonic_music_library.test.ts
Normal file
4619
tests/subsonic_music_library.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user