mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Ability to heart and star tracks whilst playing
Ability to heart and star tracks whilst playing
This commit is contained in:
29
src/i8n.ts
29
src/i8n.ts
@@ -12,7 +12,7 @@ export type KEY =
|
||||
| "playlists"
|
||||
| "genres"
|
||||
| "random"
|
||||
| "starred"
|
||||
| "topRated"
|
||||
| "recentlyAdded"
|
||||
| "recentlyPlayed"
|
||||
| "mostPlayed"
|
||||
@@ -37,7 +37,14 @@ export type KEY =
|
||||
| "invalidLinkCode"
|
||||
| "loginSuccessful"
|
||||
| "loginFailed"
|
||||
| "noSonosDevices";
|
||||
| "noSonosDevices"
|
||||
| "favourites"
|
||||
| "LOVE"
|
||||
| "LOVE_SUCCESS"
|
||||
| "STAR"
|
||||
| "UNSTAR"
|
||||
| "STAR_SUCCESS"
|
||||
| "UNSTAR_SUCCESS";
|
||||
|
||||
const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
"en-US": {
|
||||
@@ -48,7 +55,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
playlists: "Playlists",
|
||||
genres: "Genres",
|
||||
random: "Random",
|
||||
starred: "Starred",
|
||||
topRated: "Top Rated",
|
||||
recentlyAdded: "Recently added",
|
||||
recentlyPlayed: "Recently played",
|
||||
mostPlayed: "Most played",
|
||||
@@ -73,6 +80,13 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginSuccessful: "Login successful!",
|
||||
loginFailed: "Login failed!",
|
||||
noSonosDevices: "No sonos devices",
|
||||
favourites: "Favourites",
|
||||
STAR: "Star track",
|
||||
UNSTAR: "Un-star track",
|
||||
STAR_SUCCESS: "Track starred successfully",
|
||||
UNSTAR_SUCCESS: "Track un-starred successfully",
|
||||
LOVE: "Love",
|
||||
LOVE_SUCCESS: "Track loved"
|
||||
},
|
||||
"nl-NL": {
|
||||
AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
|
||||
@@ -82,7 +96,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
playlists: "Afspeellijsten",
|
||||
genres: "Genres",
|
||||
random: "Willekeurig",
|
||||
starred: "Favorieten",
|
||||
topRated: "Best beoordeeld",
|
||||
recentlyAdded: "Onlangs toegevoegd",
|
||||
recentlyPlayed: "Onlangs afgespeeld",
|
||||
mostPlayed: "Meest afgespeeld",
|
||||
@@ -107,6 +121,13 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginSuccessful: "Inloggen gelukt!",
|
||||
loginFailed: "Inloggen mislukt!",
|
||||
noSonosDevices: "Geen Sonos-apparaten",
|
||||
favourites: "Favorieten",
|
||||
STAR: "Ster spoor",
|
||||
UNSTAR: "Track zonder ster",
|
||||
STAR_SUCCESS: "Track succesvol gemarkeerd",
|
||||
UNSTAR_SUCCESS: "Succes zonder ster bijhouden",
|
||||
LOVE: "Liefde ",
|
||||
LOVE_SUCCESS: "Volg geliefd"
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
12
src/icon.ts
12
src/icon.ts
@@ -166,7 +166,7 @@ export type ICON =
|
||||
| "playlists"
|
||||
| "genres"
|
||||
| "random"
|
||||
| "starred"
|
||||
| "topRated"
|
||||
| "recentlyAdded"
|
||||
| "recentlyPlayed"
|
||||
| "mostPlayed"
|
||||
@@ -225,7 +225,10 @@ export type ICON =
|
||||
| "skywalker"
|
||||
| "leia"
|
||||
| "r2d2"
|
||||
| "yoda";
|
||||
| "yoda"
|
||||
| "heart"
|
||||
| "star"
|
||||
| "solidStar";
|
||||
|
||||
const iconFrom = (name: string) =>
|
||||
new SvgIcon(
|
||||
@@ -241,7 +244,7 @@ export const ICONS: Record<ICON, SvgIcon> = {
|
||||
playlists: iconFrom("navidrome-playlists.svg"),
|
||||
genres: iconFrom("Theatre-Mask-111172.svg"),
|
||||
random: iconFrom("navidrome-random.svg"),
|
||||
starred: iconFrom("navidrome-topRated.svg"),
|
||||
topRated: iconFrom("navidrome-topRated.svg"),
|
||||
recentlyAdded: iconFrom("navidrome-recentlyAdded.svg"),
|
||||
recentlyPlayed: iconFrom("navidrome-recentlyPlayed.svg"),
|
||||
mostPlayed: iconFrom("navidrome-mostPlayed.svg"),
|
||||
@@ -300,6 +303,9 @@ export const ICONS: Record<ICON, SvgIcon> = {
|
||||
leia: iconFrom("Princess-Leia-68568.svg"),
|
||||
r2d2: iconFrom("R2-D2-39423.svg"),
|
||||
yoda: iconFrom("Yoda-68107.svg"),
|
||||
heart: iconFrom("Heart-85038.svg"),
|
||||
star: iconFrom("Star-16101.svg"),
|
||||
solidStar: iconFrom("Star-43879.svg")
|
||||
};
|
||||
|
||||
export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda];
|
||||
|
||||
@@ -54,8 +54,8 @@ export type AlbumSummary = {
|
||||
genre: Genre | undefined;
|
||||
coverArt: string | undefined;
|
||||
|
||||
artistName: string;
|
||||
artistId: string;
|
||||
artistName: string | undefined;
|
||||
artistId: string | undefined;
|
||||
};
|
||||
|
||||
export type Album = AlbumSummary & {};
|
||||
@@ -65,6 +65,11 @@ export type Genre = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type Rating = {
|
||||
love: boolean;
|
||||
stars: number;
|
||||
}
|
||||
|
||||
export type Track = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -75,6 +80,7 @@ export type Track = {
|
||||
coverArt: string | undefined;
|
||||
album: AlbumSummary;
|
||||
artist: ArtistSummary;
|
||||
rating: Rating;
|
||||
};
|
||||
|
||||
export type Paging = {
|
||||
@@ -177,6 +183,7 @@ export interface MusicLibrary {
|
||||
trackId: string;
|
||||
range: string | undefined;
|
||||
}): Promise<TrackStream>;
|
||||
rate(trackId: string, rating: Rating): Promise<boolean>;
|
||||
coverArt(id: string, size?: number): Promise<CoverArt | undefined>;
|
||||
nowPlaying(id: string): Promise<boolean>
|
||||
scrobble(id: string): Promise<boolean>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Eta from "eta";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { PassThrough, Transform, TransformCallback } from "stream";
|
||||
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
CREATE_REGISTRATION_ROUTE,
|
||||
REMOVE_REGISTRATION_ROUTE,
|
||||
sonosifyMimeType,
|
||||
ratingFromInt,
|
||||
ratingAsInt,
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, isSuccess } from "./music_service";
|
||||
@@ -107,6 +110,8 @@ function server(
|
||||
const accessTokens = serverOpts.accessTokens();
|
||||
const clock = serverOpts.clock;
|
||||
|
||||
const startUpTime = dayjs();
|
||||
|
||||
const app = express();
|
||||
const i8n = makeI8N(service.name);
|
||||
|
||||
@@ -253,6 +258,28 @@ function server(
|
||||
});
|
||||
|
||||
app.get(PRESENTATION_MAP_ROUTE, (_, res) => {
|
||||
const LastModified = startUpTime.format("HH:mm:ss D MMM YYYY");
|
||||
|
||||
const nowPlayingRatingsMatch = (value: number) => {
|
||||
const rating = ratingFromInt(value);
|
||||
const nextLove = { ...rating, love: !rating.love };
|
||||
const nextStar = { ...rating, stars: (rating.stars === 5 ? 0 : rating.stars + 1) }
|
||||
|
||||
const loveRatingIcon = bonobUrl.append({pathname: rating.love ? '/love-selected.svg' : '/love-unselected.svg'}).href();
|
||||
const starsRatingIcon = bonobUrl.append({pathname: `/star${rating.stars}.svg`}).href();
|
||||
|
||||
return `<Match propname="rating" value="${value}">
|
||||
<Ratings>
|
||||
<Rating Id="${ratingAsInt(nextLove)}" AutoSkip="NEVER" OnSuccessStringId="LOVE_SUCCESS" StringId="LOVE">
|
||||
<Icon Controller="universal" LastModified="${LastModified}" Uri="${loveRatingIcon}" />
|
||||
</Rating>
|
||||
<Rating Id="${-ratingAsInt(nextStar)}" AutoSkip="NEVER" OnSuccessStringId="STAR_SUCCESS" StringId="STAR">
|
||||
<Icon Controller="universal" LastModified="${LastModified}" Uri="${starsRatingIcon}" />
|
||||
</Rating>
|
||||
</Ratings>
|
||||
</Match>`
|
||||
}
|
||||
|
||||
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Presentation>
|
||||
<PresentationMap type="ArtWorkSizeMap">
|
||||
@@ -285,6 +312,20 @@ function server(
|
||||
</SearchCategories>
|
||||
</Match>
|
||||
</PresentationMap>
|
||||
<PresentationMap type="NowPlayingRatings" trackEnabled="true" programEnabled="false">
|
||||
${nowPlayingRatingsMatch(100)}
|
||||
${nowPlayingRatingsMatch(101)}
|
||||
${nowPlayingRatingsMatch(110)}
|
||||
${nowPlayingRatingsMatch(111)}
|
||||
${nowPlayingRatingsMatch(120)}
|
||||
${nowPlayingRatingsMatch(121)}
|
||||
${nowPlayingRatingsMatch(130)}
|
||||
${nowPlayingRatingsMatch(131)}
|
||||
${nowPlayingRatingsMatch(140)}
|
||||
${nowPlayingRatingsMatch(141)}
|
||||
${nowPlayingRatingsMatch(150)}
|
||||
${nowPlayingRatingsMatch(151)}
|
||||
</PresentationMap>
|
||||
</Presentation>`);
|
||||
});
|
||||
|
||||
|
||||
68
src/smapi.ts
68
src/smapi.ts
@@ -14,6 +14,7 @@ import {
|
||||
Genre,
|
||||
MusicService,
|
||||
Playlist,
|
||||
Rating,
|
||||
slice2,
|
||||
Track,
|
||||
} from "./music_service";
|
||||
@@ -80,6 +81,12 @@ export type GetDeviceAuthTokenResult = {
|
||||
};
|
||||
};
|
||||
|
||||
export const ratingAsInt = (rating: Rating): number => rating.stars * 10 + (rating.love ? 1 : 0) + 100;
|
||||
export const ratingFromInt = (value: number): Rating => {
|
||||
const x = value - 100;
|
||||
return { love: (x % 10 == 1), stars: Math.floor(x / 10) }
|
||||
};
|
||||
|
||||
export type MediaCollection = {
|
||||
id: string;
|
||||
itemType: "collection";
|
||||
@@ -300,6 +307,9 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({
|
||||
genreId: track.album.genre?.id,
|
||||
trackNumber: track.number,
|
||||
},
|
||||
dynamic: {
|
||||
property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }],
|
||||
}
|
||||
});
|
||||
|
||||
export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
|
||||
@@ -403,7 +413,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaURIResult: bonobUrl
|
||||
.append({
|
||||
pathname: `/stream/${type}/${typeId}`,
|
||||
searchParams: { "bat": accessToken }
|
||||
searchParams: { bat: accessToken },
|
||||
})
|
||||
.href(),
|
||||
})),
|
||||
@@ -416,7 +426,10 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
||||
musicLibrary.track(typeId!).then((it) => ({
|
||||
getMediaMetadataResult: track(urlWithToken(accessToken), it),
|
||||
getMediaMetadataResult: track(
|
||||
urlWithToken(accessToken),
|
||||
it
|
||||
),
|
||||
}))
|
||||
),
|
||||
search: async (
|
||||
@@ -503,9 +516,10 @@ function bindSmapiSoapServiceToExpress(
|
||||
case "track":
|
||||
return musicLibrary.track(typeId).then((it) => ({
|
||||
getExtendedMetadataResult: {
|
||||
mediaMetadata: {
|
||||
...track(urlWithToken(accessToken), it),
|
||||
},
|
||||
mediaMetadata: track(
|
||||
urlWithToken(accessToken),
|
||||
it
|
||||
),
|
||||
},
|
||||
}));
|
||||
case "album":
|
||||
@@ -580,6 +594,24 @@ function bindSmapiSoapServiceToExpress(
|
||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "randomAlbums",
|
||||
title: lang("random"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "random").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "favouriteAlbums",
|
||||
title: lang("favourites"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "heart").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
// {
|
||||
// id: "topRatedAlbums",
|
||||
// title: lang("topRated"),
|
||||
// albumArtURI: iconArtURI(bonobUrl, "star").href(),
|
||||
// itemType: "albumList",
|
||||
// },
|
||||
{
|
||||
id: "playlists",
|
||||
title: lang("playlists"),
|
||||
@@ -597,18 +629,6 @@ function bindSmapiSoapServiceToExpress(
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "randomAlbums",
|
||||
title: lang("random"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "random").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "starredAlbums",
|
||||
title: lang("starred"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "starred").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: lang("recentlyAdded"),
|
||||
@@ -689,7 +709,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
type: "random",
|
||||
...paging,
|
||||
});
|
||||
case "starredAlbums":
|
||||
case "favouriteAlbums":
|
||||
return albums({
|
||||
type: "starred",
|
||||
...paging,
|
||||
@@ -872,6 +892,18 @@ function bindSmapiSoapServiceToExpress(
|
||||
}
|
||||
})
|
||||
.then((_) => ({ removeFromContainerResult: { updateId: "" } })),
|
||||
rateItem: async (
|
||||
{ id, rating }: { id: string; rating: number },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, typeId }) =>
|
||||
musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating)))
|
||||
)
|
||||
.then((_) => ({ rateItemResult: { shouldSkip: false } })),
|
||||
|
||||
setPlayedSeconds: async (
|
||||
{ id, seconds }: { id: string; seconds: string },
|
||||
_,
|
||||
|
||||
24
src/sonos.ts
24
src/sonos.ts
@@ -24,25 +24,25 @@ export const SONOS_LANG: LANG[] = [
|
||||
"zh-CN",
|
||||
];
|
||||
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "21";
|
||||
export const PRESENTATION_AND_STRINGS_VERSION =
|
||||
process.env["BNB_DEBUG"] === "true"
|
||||
? `${Math.round(new Date().getTime() / 1000)}`
|
||||
: "23";
|
||||
|
||||
// NOTE: manifest requires https for the URL,
|
||||
// otherwise you will get an error trying to register
|
||||
// NOTE: manifest requires https for the URL, otherwise you will get an error trying to register
|
||||
export type Capability =
|
||||
| "search"
|
||||
| "trFavorites"
|
||||
| "alFavorites"
|
||||
| "ucPlaylists"
|
||||
| "extendedMD"
|
||||
| "trFavorites" // Favorites: Adding/Removing Tracks (deprecated)
|
||||
| "alFavorites" // Favorites: Adding/Removing Albums (deprecated)
|
||||
| "ucPlaylists" // User Content Playlists
|
||||
| "extendedMD" // Extended Metadata (More Menu, Info & Options)
|
||||
| "contextHeaders"
|
||||
| "authorizationHeader"
|
||||
| "logging"
|
||||
| "logging" // Playback duration logging at track end (deprecated)
|
||||
| "manifest";
|
||||
|
||||
export const BONOB_CAPABILITIES: Capability[] = [
|
||||
"search",
|
||||
// "trFavorites",
|
||||
// "alFavorites",
|
||||
"ucPlaylists",
|
||||
"extendedMD",
|
||||
"logging",
|
||||
@@ -247,9 +247,7 @@ export type Discovery = {
|
||||
seedHost?: string;
|
||||
};
|
||||
|
||||
export default (
|
||||
sonosDiscovery: Discovery = { enabled: true }
|
||||
): Sonos =>
|
||||
export default (sonosDiscovery: Discovery = { enabled: true }): Sonos =>
|
||||
sonosDiscovery.enabled
|
||||
? autoDiscoverySonos(sonosDiscovery.seedHost)
|
||||
: SONOS_DISABLED;
|
||||
|
||||
315
src/subsonic.ts
315
src/subsonic.ts
@@ -19,8 +19,8 @@ import {
|
||||
Genre,
|
||||
Track,
|
||||
CoverArt,
|
||||
Rating,
|
||||
} from "./music_service";
|
||||
import X2JS from "x2js";
|
||||
import sharp from "sharp";
|
||||
import _ from "underscore";
|
||||
import fse from "fs-extra";
|
||||
@@ -60,36 +60,36 @@ export const isDodgyImage = (url: string) => url.endsWith(DODGY_IMAGE_NAME);
|
||||
export const validate = (url: string | undefined) =>
|
||||
url && !isDodgyImage(url) ? url : undefined;
|
||||
|
||||
export type SubconicEnvelope = {
|
||||
export type SubsonicEnvelope = {
|
||||
"subsonic-response": SubsonicResponse;
|
||||
};
|
||||
|
||||
export type SubsonicResponse = {
|
||||
_status: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type album = {
|
||||
_id: string;
|
||||
_name: string;
|
||||
_genre: string | undefined;
|
||||
_year: string | undefined;
|
||||
_coverArt: string | undefined;
|
||||
_artist: string;
|
||||
_artistId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string | undefined;
|
||||
artistId: string | undefined;
|
||||
coverArt: string | undefined;
|
||||
genre: string | undefined;
|
||||
year: string | undefined;
|
||||
};
|
||||
|
||||
export type artistSummary = {
|
||||
_id: string;
|
||||
_name: string;
|
||||
_albumCount: string;
|
||||
_artistImageUrl: string | undefined;
|
||||
id: string;
|
||||
name: string;
|
||||
albumCount: number;
|
||||
artistImageUrl: string | undefined;
|
||||
};
|
||||
|
||||
export type GetArtistsResponse = SubsonicResponse & {
|
||||
artists: {
|
||||
index: {
|
||||
artist: artistSummary[];
|
||||
_name: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
@@ -101,9 +101,9 @@ export type GetAlbumListResponse = SubsonicResponse & {
|
||||
};
|
||||
|
||||
export type genre = {
|
||||
_songCount: string;
|
||||
_albumCount: string;
|
||||
__text: string;
|
||||
songCount: number;
|
||||
albumCount: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type GetGenresResponse = SubsonicResponse & {
|
||||
@@ -114,8 +114,8 @@ export type GetGenresResponse = SubsonicResponse & {
|
||||
|
||||
export type SubsonicError = SubsonicResponse & {
|
||||
error: {
|
||||
_code: string;
|
||||
_message: string;
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -145,22 +145,25 @@ export type GetArtistResponse = SubsonicResponse & {
|
||||
};
|
||||
|
||||
export type song = {
|
||||
_id: string;
|
||||
_parent: string;
|
||||
_title: string;
|
||||
_album: string;
|
||||
_artist: string;
|
||||
_track: string | undefined;
|
||||
_genre: string;
|
||||
_coverArt: string | undefined;
|
||||
_created: "2004-11-08T23:36:11";
|
||||
_duration: string | undefined;
|
||||
_bitRate: "128";
|
||||
_suffix: "mp3";
|
||||
_contentType: string;
|
||||
_albumId: string;
|
||||
_artistId: string;
|
||||
_type: "music";
|
||||
id: string;
|
||||
parent: string | undefined;
|
||||
title: string;
|
||||
album: string | undefined;
|
||||
artist: string | undefined;
|
||||
track: number | undefined;
|
||||
year: string | undefined;
|
||||
genre: string | undefined;
|
||||
coverArt: string | undefined;
|
||||
created: string | undefined;
|
||||
duration: number | undefined;
|
||||
bitRate: number | undefined;
|
||||
suffix: string | undefined;
|
||||
contentType: string | undefined;
|
||||
albumId: string | undefined;
|
||||
artistId: string | undefined;
|
||||
type: string | undefined;
|
||||
userRating: number | undefined;
|
||||
starred: string | undefined;
|
||||
};
|
||||
|
||||
export type GetAlbumResponse = {
|
||||
@@ -170,31 +173,15 @@ export type GetAlbumResponse = {
|
||||
};
|
||||
|
||||
export type playlist = {
|
||||
_id: string;
|
||||
_name: string;
|
||||
};
|
||||
|
||||
export type entry = {
|
||||
_id: string;
|
||||
_parent: string;
|
||||
_title: string;
|
||||
_album: string;
|
||||
_artist: string;
|
||||
_track: string;
|
||||
_year: string;
|
||||
_genre: string;
|
||||
_coverArt: string;
|
||||
_contentType: string;
|
||||
_duration: string;
|
||||
_albumId: string;
|
||||
_artistId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type GetPlaylistResponse = {
|
||||
playlist: {
|
||||
_id: string;
|
||||
_name: string;
|
||||
entry: entry[];
|
||||
id: string;
|
||||
name: string;
|
||||
entry: song[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -214,6 +201,12 @@ export type GetSongResponse = {
|
||||
song: song;
|
||||
};
|
||||
|
||||
export type GetStarredResponse = {
|
||||
starred2: {
|
||||
song: song[];
|
||||
};
|
||||
};
|
||||
|
||||
export type Search3Response = SubsonicResponse & {
|
||||
searchResult3: {
|
||||
artist: artistSummary[];
|
||||
@@ -253,29 +246,33 @@ export const MAX_ALBUM_LIST = 500;
|
||||
const maybeAsCoverArt = (coverArt: string | undefined) =>
|
||||
coverArt ? `coverArt:${coverArt}` : undefined;
|
||||
|
||||
const asTrack = (album: Album, song: song) => ({
|
||||
id: song._id,
|
||||
name: song._title,
|
||||
mimeType: song._contentType,
|
||||
duration: parseInt(song._duration || "0"),
|
||||
number: parseInt(song._track || "0"),
|
||||
genre: maybeAsGenre(song._genre),
|
||||
coverArt: maybeAsCoverArt(song._coverArt),
|
||||
export const asTrack = (album: Album, song: song): Track => ({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
mimeType: song.contentType!,
|
||||
duration: song.duration || 0,
|
||||
number: song.track || 0,
|
||||
genre: maybeAsGenre(song.genre),
|
||||
coverArt: maybeAsCoverArt(song.coverArt),
|
||||
album,
|
||||
artist: {
|
||||
id: song._artistId,
|
||||
name: song._artist,
|
||||
id: `${song.artistId!}`,
|
||||
name: song.artist!,
|
||||
},
|
||||
rating: {
|
||||
love: song.starred != undefined,
|
||||
stars: (song.userRating && song.userRating <= 5 && song.userRating >= 0 ? song.userRating : 0),
|
||||
},
|
||||
});
|
||||
|
||||
const asAlbum = (album: album) => ({
|
||||
id: album._id,
|
||||
name: album._name,
|
||||
year: album._year,
|
||||
genre: maybeAsGenre(album._genre),
|
||||
artistId: album._artistId,
|
||||
artistName: album._artist,
|
||||
coverArt: maybeAsCoverArt(album._coverArt),
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
year: album.year,
|
||||
genre: maybeAsGenre(album.genre),
|
||||
artistId: album.artistId,
|
||||
artistName: album.artist,
|
||||
coverArt: maybeAsCoverArt(album.coverArt),
|
||||
});
|
||||
|
||||
export const asGenre = (genreName: string) => ({
|
||||
@@ -318,7 +315,7 @@ export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
|
||||
|
||||
export const cachingImageFetcher =
|
||||
(cacheDir: string, delegate: ImageFetcher) =>
|
||||
(url: string): Promise<CoverArt | undefined> => {
|
||||
async (url: string): Promise<CoverArt | undefined> => {
|
||||
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
|
||||
return fse
|
||||
.readFile(filename)
|
||||
@@ -402,31 +399,11 @@ export class Subsonic implements MusicService {
|
||||
path: string,
|
||||
q: {} = {}
|
||||
): Promise<T> =>
|
||||
this.get({ username, password }, path, q)
|
||||
.then(
|
||||
(response) =>
|
||||
new X2JS({
|
||||
arrayAccessFormPaths: [
|
||||
"subsonic-response.album.song",
|
||||
"subsonic-response.albumList2.album",
|
||||
"subsonic-response.artist.album",
|
||||
"subsonic-response.artists.index",
|
||||
"subsonic-response.artists.index.artist",
|
||||
"subsonic-response.artistInfo2.similarArtist",
|
||||
"subsonic-response.genres.genre",
|
||||
"subsonic-response.playlist.entry",
|
||||
"subsonic-response.playlists.playlist",
|
||||
"subsonic-response.searchResult3.album",
|
||||
"subsonic-response.searchResult3.artist",
|
||||
"subsonic-response.searchResult3.song",
|
||||
"subsonic-response.similarSongs2.song",
|
||||
"subsonic-response.topSongs.song",
|
||||
],
|
||||
}).xml2js(response.data) as SubconicEnvelope
|
||||
)
|
||||
this.get({ username, password }, path, { f: "json", ...q })
|
||||
.then((response) => response.data as SubsonicEnvelope)
|
||||
.then((json) => json["subsonic-response"])
|
||||
.then((json) => {
|
||||
if (isError(json)) throw `Subsonic error:${json.error._message}`;
|
||||
if (isError(json)) throw `Subsonic error:${json.error.message}`;
|
||||
else return json as unknown as T;
|
||||
});
|
||||
|
||||
@@ -451,9 +428,9 @@ export class Subsonic implements MusicService {
|
||||
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
|
||||
.then((artists) =>
|
||||
artists.map((artist) => ({
|
||||
id: artist._id,
|
||||
name: artist._name,
|
||||
albumCount: Number.parseInt(artist._albumCount),
|
||||
id: `${artist.id}`,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -469,9 +446,9 @@ export class Subsonic implements MusicService {
|
||||
large: validate(it.artistInfo2.largeImageUrl),
|
||||
},
|
||||
similarArtist: (it.artistInfo2.similarArtist || []).map((artist) => ({
|
||||
id: artist._id,
|
||||
name: artist._name,
|
||||
inLibrary: artist._id != "-1",
|
||||
id: `${artist.id}`,
|
||||
name: artist.name,
|
||||
inLibrary: artist.id != "-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -479,13 +456,13 @@ export class Subsonic implements MusicService {
|
||||
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
||||
.then((it) => it.album)
|
||||
.then((album) => ({
|
||||
id: album._id,
|
||||
name: album._name,
|
||||
year: album._year,
|
||||
genre: maybeAsGenre(album._genre),
|
||||
artistId: album._artistId,
|
||||
artistName: album._artist,
|
||||
coverArt: maybeAsCoverArt(album._coverArt),
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
year: album.year,
|
||||
genre: maybeAsGenre(album.genre),
|
||||
artistId: album.artistId,
|
||||
artistName: album.artist,
|
||||
coverArt: maybeAsCoverArt(album.coverArt),
|
||||
}));
|
||||
|
||||
getArtist = (
|
||||
@@ -497,8 +474,8 @@ export class Subsonic implements MusicService {
|
||||
})
|
||||
.then((it) => it.artist)
|
||||
.then((it) => ({
|
||||
id: it._id,
|
||||
name: it._name,
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
albums: this.toAlbumSummary(it.album || []),
|
||||
}));
|
||||
|
||||
@@ -526,20 +503,25 @@ export class Subsonic implements MusicService {
|
||||
})
|
||||
.then((it) => it.song)
|
||||
.then((song) =>
|
||||
this.getAlbum(credentials, song._albumId).then((album) =>
|
||||
this.getAlbum(credentials, song.albumId!).then((album) =>
|
||||
asTrack(album, song)
|
||||
)
|
||||
);
|
||||
|
||||
getStarred = (credentials: Credentials) =>
|
||||
this.getJSON<GetStarredResponse>(credentials, "/rest/getStarred2").then(
|
||||
(it) => new Set(it.starred2.song.map((it) => it.id))
|
||||
);
|
||||
|
||||
toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
|
||||
albumList.map((album) => ({
|
||||
id: album._id,
|
||||
name: album._name,
|
||||
year: album._year,
|
||||
genre: maybeAsGenre(album._genre),
|
||||
artistId: album._artistId,
|
||||
artistName: album._artist,
|
||||
coverArt: maybeAsCoverArt(album._coverArt),
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
year: album.year,
|
||||
genre: maybeAsGenre(album.genre),
|
||||
artistId: album.artistId,
|
||||
artistName: album.artist,
|
||||
coverArt: maybeAsCoverArt(album.coverArt),
|
||||
}));
|
||||
|
||||
search3 = (credentials: Credentials, q: any) =>
|
||||
@@ -596,8 +578,8 @@ export class Subsonic implements MusicService {
|
||||
.then((it) =>
|
||||
pipe(
|
||||
it.genres.genre || [],
|
||||
A.filter((it) => Number.parseInt(it._albumCount) > 0),
|
||||
A.map((it) => it.__text),
|
||||
A.filter((it) => it.albumCount > 0),
|
||||
A.map((it) => it.value),
|
||||
A.sort(ordString),
|
||||
A.map((it) => ({ id: b64Encode(it), name: it }))
|
||||
)
|
||||
@@ -612,6 +594,40 @@ export class Subsonic implements MusicService {
|
||||
(album.song || []).map((song) => asTrack(asAlbum(album), song))
|
||||
),
|
||||
track: (trackId: string) => subsonic.getTrack(credentials, trackId),
|
||||
rate: (trackId: string, rating: Rating) =>
|
||||
Promise.resolve(true)
|
||||
.then(() => {
|
||||
if (rating.stars >= 0 && rating.stars <= 5) {
|
||||
return subsonic.getTrack(credentials, trackId);
|
||||
} else {
|
||||
throw `Invalid rating.stars value of ${rating.stars}`;
|
||||
}
|
||||
})
|
||||
.then((track) => {
|
||||
const thingsToUpdate = [];
|
||||
if (track.rating.love != rating.love) {
|
||||
thingsToUpdate.push(
|
||||
subsonic.getJSON(
|
||||
credentials,
|
||||
`/rest/${rating.love ? "star" : "unstar"}`,
|
||||
{
|
||||
id: trackId,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
if (track.rating.stars != rating.stars) {
|
||||
thingsToUpdate.push(
|
||||
subsonic.getJSON(credentials, `/rest/setRating`, {
|
||||
id: trackId,
|
||||
rating: rating.stars,
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.all(thingsToUpdate);
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
stream: async ({
|
||||
trackId,
|
||||
range,
|
||||
@@ -713,7 +729,7 @@ export class Subsonic implements MusicService {
|
||||
},
|
||||
scrobble: async (id: string) =>
|
||||
subsonic
|
||||
.get(credentials, `/rest/scrobble`, {
|
||||
.getJSON(credentials, `/rest/scrobble`, {
|
||||
id,
|
||||
submission: true,
|
||||
})
|
||||
@@ -721,7 +737,7 @@ export class Subsonic implements MusicService {
|
||||
.catch(() => false),
|
||||
nowPlaying: async (id: string) =>
|
||||
subsonic
|
||||
.get(credentials, `/rest/scrobble`, {
|
||||
.getJSON(credentials, `/rest/scrobble`, {
|
||||
id,
|
||||
submission: false,
|
||||
})
|
||||
@@ -732,8 +748,8 @@ export class Subsonic implements MusicService {
|
||||
.search3(credentials, { query, artistCount: 20 })
|
||||
.then(({ artists }) =>
|
||||
artists.map((artist) => ({
|
||||
id: artist._id,
|
||||
name: artist._name,
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
}))
|
||||
),
|
||||
searchAlbums: async (query: string) =>
|
||||
@@ -745,7 +761,7 @@ export class Subsonic implements MusicService {
|
||||
.search3(credentials, { query, songCount: 20 })
|
||||
.then(({ songs }) =>
|
||||
Promise.all(
|
||||
songs.map((it) => subsonic.getTrack(credentials, it._id))
|
||||
songs.map((it) => subsonic.getTrack(credentials, it.id))
|
||||
)
|
||||
),
|
||||
playlists: async () =>
|
||||
@@ -753,7 +769,7 @@ export class Subsonic implements MusicService {
|
||||
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
|
||||
.then((it) => it.playlists.playlist || [])
|
||||
.then((playlists) =>
|
||||
playlists.map((it) => ({ id: it._id, name: it._name }))
|
||||
playlists.map((it) => ({ id: it.id, name: it.name }))
|
||||
),
|
||||
playlist: async (id: string) =>
|
||||
subsonic
|
||||
@@ -764,29 +780,22 @@ export class Subsonic implements MusicService {
|
||||
.then((playlist) => {
|
||||
let trackNumber = 1;
|
||||
return {
|
||||
id: playlist._id,
|
||||
name: playlist._name,
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
entries: (playlist.entry || []).map((entry) => ({
|
||||
id: entry._id,
|
||||
name: entry._title,
|
||||
mimeType: entry._contentType,
|
||||
duration: parseInt(entry._duration || "0"),
|
||||
...asTrack(
|
||||
{
|
||||
id: entry.albumId!,
|
||||
name: entry.album!,
|
||||
year: entry.year,
|
||||
genre: maybeAsGenre(entry.genre),
|
||||
artistName: entry.artist,
|
||||
artistId: entry.artistId,
|
||||
coverArt: maybeAsCoverArt(entry.coverArt),
|
||||
},
|
||||
entry
|
||||
),
|
||||
number: trackNumber++,
|
||||
genre: maybeAsGenre(entry._genre),
|
||||
coverArt: maybeAsCoverArt(entry._coverArt),
|
||||
album: {
|
||||
id: entry._albumId,
|
||||
name: entry._album,
|
||||
year: entry._year,
|
||||
genre: maybeAsGenre(entry._genre),
|
||||
artistName: entry._artist,
|
||||
artistId: entry._artistId,
|
||||
coverArt: maybeAsCoverArt(entry._coverArt),
|
||||
},
|
||||
artist: {
|
||||
id: entry._artistId,
|
||||
name: entry._artist,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}),
|
||||
@@ -796,7 +805,7 @@ export class Subsonic implements MusicService {
|
||||
name,
|
||||
})
|
||||
.then((it) => it.playlist)
|
||||
.then((it) => ({ id: it._id, name: it._name })),
|
||||
.then((it) => ({ id: it.id, name: it.name })),
|
||||
deletePlaylist: async (id: string) =>
|
||||
subsonic
|
||||
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
|
||||
@@ -829,7 +838,7 @@ export class Subsonic implements MusicService {
|
||||
Promise.all(
|
||||
songs.map((song) =>
|
||||
subsonic
|
||||
.getAlbum(credentials, song._albumId)
|
||||
.getAlbum(credentials, song.albumId!)
|
||||
.then((album) => asTrack(album, song))
|
||||
)
|
||||
)
|
||||
@@ -846,7 +855,7 @@ export class Subsonic implements MusicService {
|
||||
Promise.all(
|
||||
songs.map((song) =>
|
||||
subsonic
|
||||
.getAlbum(credentials, song._albumId)
|
||||
.getAlbum(credentials, song.albumId!)
|
||||
.then((album) => asTrack(album, song))
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user