mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Rendering playlist icon collage of 3x3 (#35)
This commit is contained in:
13
src/app.ts
13
src/app.ts
@@ -61,11 +61,14 @@ const app = server(
|
||||
bonob,
|
||||
config.bonobUrl,
|
||||
featureFlagAwareMusicService,
|
||||
new InMemoryLinkCodes(),
|
||||
new InMemoryAccessTokens(sha256(config.secret)),
|
||||
SystemClock,
|
||||
config.icons,
|
||||
true,
|
||||
{
|
||||
linkCodes: () => new InMemoryLinkCodes(),
|
||||
accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)),
|
||||
clock: SystemClock,
|
||||
iconColors: config.icons,
|
||||
applyContextPath: true,
|
||||
logRequests: true
|
||||
}
|
||||
);
|
||||
|
||||
app.listen(config.port, () => {
|
||||
|
||||
@@ -267,7 +267,8 @@ export type ICON =
|
||||
| "trumpet"
|
||||
| "conductor"
|
||||
| "reggae"
|
||||
| "music";
|
||||
| "music"
|
||||
| "error";
|
||||
|
||||
const iconFrom = (name: string) =>
|
||||
new SvgIcon(
|
||||
@@ -310,7 +311,8 @@ export const ICONS: Record<ICON, Icon> = {
|
||||
trumpet: iconFrom("Trumpet-17823.svg"),
|
||||
conductor: iconFrom("Music-Conductor-225.svg"),
|
||||
reggae: iconFrom("Reggae-24843.svg"),
|
||||
music: iconFrom("Music-14097.svg")
|
||||
music: iconFrom("Music-14097.svg"),
|
||||
error: iconFrom("Error-82783.svg"),
|
||||
};
|
||||
|
||||
export type RULE = (genre: string) => boolean;
|
||||
|
||||
@@ -120,6 +120,11 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
||||
artistId: it.artistId,
|
||||
});
|
||||
|
||||
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
|
||||
id: it.id,
|
||||
name: it.name
|
||||
})
|
||||
|
||||
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
|
||||
|
||||
export type TrackStream = {
|
||||
|
||||
143
src/server.ts
143
src/server.ts
@@ -1,7 +1,7 @@
|
||||
import { option as O } from "fp-ts";
|
||||
import express, { Express, Request } from "express";
|
||||
import * as Eta from "eta";
|
||||
import morgan from "morgan";
|
||||
// import morgan from "morgan";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
@@ -27,6 +27,9 @@ import { pipe } from "fp-ts/lib/function";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
|
||||
import { Icon, makeFestive, ICONS } from "./icon";
|
||||
import _, { shuffle } from "underscore";
|
||||
import morgan from "morgan";
|
||||
import { takeWithRepeats } from "./utils";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||
|
||||
@@ -67,24 +70,46 @@ export class RangeBytesFromFilter extends Transform {
|
||||
range = (number: number) => `${this.from}-${number - 1}/${number}`;
|
||||
}
|
||||
|
||||
export type ServerOpts = {
|
||||
linkCodes: () => LinkCodes;
|
||||
accessTokens: () => AccessTokens;
|
||||
clock: Clock;
|
||||
iconColors: {
|
||||
foregroundColor: string | undefined;
|
||||
backgroundColor: string | undefined;
|
||||
};
|
||||
applyContextPath: boolean;
|
||||
logRequests: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||
linkCodes: () => new InMemoryLinkCodes(),
|
||||
accessTokens: () => new AccessTokenPerAuthToken(),
|
||||
clock: SystemClock,
|
||||
iconColors: { foregroundColor: undefined, backgroundColor: undefined },
|
||||
applyContextPath: true,
|
||||
logRequests: false,
|
||||
};
|
||||
|
||||
function server(
|
||||
sonos: Sonos,
|
||||
service: Service,
|
||||
bonobUrl: URLBuilder,
|
||||
musicService: MusicService,
|
||||
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
||||
accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
|
||||
clock: Clock = SystemClock,
|
||||
iconColors: {
|
||||
foregroundColor: string | undefined;
|
||||
backgroundColor: string | undefined;
|
||||
} = { foregroundColor: undefined, backgroundColor: undefined },
|
||||
applyContextPath = true
|
||||
opts: Partial<ServerOpts> = {}
|
||||
): Express {
|
||||
const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts };
|
||||
|
||||
const linkCodes = serverOpts.linkCodes();
|
||||
const accessTokens = serverOpts.accessTokens();
|
||||
const clock = serverOpts.clock;
|
||||
|
||||
const app = express();
|
||||
const i8n = makeI8N(service.name);
|
||||
|
||||
app.use(morgan("combined"));
|
||||
if (serverOpts.logRequests) {
|
||||
app.use(morgan("combined"));
|
||||
}
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// todo: pass options in here?
|
||||
@@ -94,6 +119,8 @@ function server(
|
||||
app.set("view engine", "eta");
|
||||
app.set("views", path.resolve(__dirname, "..", "web", "views"));
|
||||
|
||||
app.set("query parser", "simple");
|
||||
|
||||
const langFor = (req: Request) => {
|
||||
logger.debug(
|
||||
`${req.path} (req[accept-language]=${req.headers["accept-language"]})`
|
||||
@@ -387,46 +414,92 @@ function server(
|
||||
};
|
||||
|
||||
return Promise.resolve(
|
||||
makeFestive(icon.with({ text, ...iconColors }), clock).toString()
|
||||
makeFestive(
|
||||
icon.with({ text, ...serverOpts.iconColors }),
|
||||
clock
|
||||
).toString()
|
||||
)
|
||||
.then(spec.responseFormatter)
|
||||
.then((data) => res.status(200).type(spec.mimeType).send(data));
|
||||
}
|
||||
});
|
||||
|
||||
const GRAVITY_9 = [
|
||||
"north",
|
||||
"northeast",
|
||||
"east",
|
||||
"southeast",
|
||||
"south",
|
||||
"southwest",
|
||||
"west",
|
||||
"northwest",
|
||||
"centre",
|
||||
];
|
||||
|
||||
app.get("/art/:type/:id/size/:size", (req, res) => {
|
||||
app.get("/art/:type/:ids/size/:size", (req, res) => {
|
||||
const authToken = accessTokens.authTokenFor(
|
||||
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
|
||||
);
|
||||
const type = req.params["type"]!;
|
||||
const id = req.params["id"]!;
|
||||
const size = req.params["size"]!;
|
||||
const ids = req.params["ids"]!.split("&");
|
||||
const size = Number.parseInt(req.params["size"]!);
|
||||
|
||||
if (!authToken) {
|
||||
return res.status(401).send();
|
||||
} else if (type != "artist" && type != "album") {
|
||||
return res.status(400).send();
|
||||
} else if (!(size.match(/^\d+$/) && Number.parseInt(size) > 0)) {
|
||||
} else if (!(size > 0)) {
|
||||
return res.status(400).send();
|
||||
} else {
|
||||
return musicService
|
||||
.login(authToken)
|
||||
.then((it) => it.coverArt(id, type, Number.parseInt(size)))
|
||||
.then((coverArt) => {
|
||||
if (coverArt) {
|
||||
res.status(200);
|
||||
res.setHeader("content-type", coverArt.contentType);
|
||||
return res.send(coverArt.data);
|
||||
} else {
|
||||
return res.status(404).send();
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
logger.error(`Failed fetching image ${type}/${id}/size/${size}`, {
|
||||
cause: e,
|
||||
});
|
||||
return res.status(500).send();
|
||||
});
|
||||
}
|
||||
|
||||
return musicService
|
||||
.login(authToken)
|
||||
.then((it) => Promise.all(ids.map((id) => it.coverArt(id, type, size))))
|
||||
.then((coverArts) => coverArts.filter((it) => it))
|
||||
.then(shuffle)
|
||||
.then((coverArts) => {
|
||||
if (coverArts.length == 1) {
|
||||
const coverArt = coverArts[0]!;
|
||||
res.status(200);
|
||||
res.setHeader("content-type", coverArt.contentType);
|
||||
return res.send(coverArt.data);
|
||||
} else if (coverArts.length > 1) {
|
||||
const gravity = [...GRAVITY_9];
|
||||
return sharp({
|
||||
create: {
|
||||
width: size * 3,
|
||||
height: size * 3,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 255, b: 255 },
|
||||
},
|
||||
})
|
||||
.composite(
|
||||
takeWithRepeats(coverArts, 9).map((art) => ({
|
||||
input: art?.data,
|
||||
gravity: gravity.pop(),
|
||||
}))
|
||||
)
|
||||
.png()
|
||||
.toBuffer()
|
||||
.then((image) => sharp(image).resize(size).png().toBuffer())
|
||||
.then((image) => {
|
||||
res.status(200);
|
||||
res.setHeader("content-type", "image/png");
|
||||
return res.send(image);
|
||||
});
|
||||
} else {
|
||||
return res.status(404).send();
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
logger.error(
|
||||
`Failed fetching image ${type}/${ids.join("&")}/size/${size}`,
|
||||
{
|
||||
cause: e,
|
||||
}
|
||||
);
|
||||
return res.status(500).send();
|
||||
});
|
||||
});
|
||||
|
||||
bindSmapiSoapServiceToExpress(
|
||||
@@ -440,7 +513,7 @@ function server(
|
||||
i8n
|
||||
);
|
||||
|
||||
if (applyContextPath) {
|
||||
if (serverOpts.applyContextPath) {
|
||||
const container = express();
|
||||
container.use(bonobUrl.path(), app);
|
||||
return container;
|
||||
|
||||
135
src/smapi.ts
135
src/smapi.ts
@@ -5,7 +5,6 @@ import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import logger from "./logger";
|
||||
|
||||
|
||||
import { LinkCodes } from "./link_codes";
|
||||
import {
|
||||
Album,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
ArtistSummary,
|
||||
Genre,
|
||||
MusicService,
|
||||
PlaylistSummary,
|
||||
Playlist,
|
||||
slice2,
|
||||
Track,
|
||||
} from "./music_service";
|
||||
@@ -24,6 +23,7 @@ import { Clock } from "./clock";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
import { asLANGs, I8N } from "./i8n";
|
||||
import { ICON, iconForGenre } from "./icon";
|
||||
import { uniq } from "underscore";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||
@@ -184,11 +184,14 @@ class SonosSoap {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
logger.info("Client not linked, awaiting user to associate account with link code by logging in.")
|
||||
logger.info(
|
||||
"Client not linked, awaiting user to associate account with link code by logging in."
|
||||
);
|
||||
throw {
|
||||
Fault: {
|
||||
faultcode: "Client.NOT_LINKED_RETRY",
|
||||
faultstring: "Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
||||
faultstring:
|
||||
"Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
||||
detail: {
|
||||
ExceptionInfo: "NOT_LINKED_RETRY",
|
||||
SonosError: "5",
|
||||
@@ -212,13 +215,18 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
||||
itemType: "container",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(),
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
iconForGenre(genre.name),
|
||||
genre.name
|
||||
).href(),
|
||||
});
|
||||
|
||||
const playlist = (playlist: PlaylistSummary) => ({
|
||||
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
title: playlist.name,
|
||||
albumArtURI: playlistAlbumArtURL(bonobUrl, playlist).href(),
|
||||
canPlay: true,
|
||||
attributes: {
|
||||
readOnly: false,
|
||||
@@ -227,11 +235,32 @@ const playlist = (playlist: PlaylistSummary) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const playlistAlbumArtURL = (
|
||||
bonobUrl: URLBuilder,
|
||||
playlist: Playlist
|
||||
) => {
|
||||
const ids = uniq(playlist.entries.map((it) => it.album?.id).filter((it) => it));
|
||||
if (ids.length == 0) {
|
||||
return iconArtURI(bonobUrl, "error");
|
||||
} else {
|
||||
return bonobUrl.append({
|
||||
pathname: `/art/album/${ids.slice(0, 9).join("&")}/size/180`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
|
||||
bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` });
|
||||
|
||||
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) =>
|
||||
bonobUrl.append({ pathname: `/icon/${icon}/size/legacy`, searchParams: text ? { text } : {} });
|
||||
export const iconArtURI = (
|
||||
bonobUrl: URLBuilder,
|
||||
icon: ICON,
|
||||
text: string | undefined = undefined
|
||||
) =>
|
||||
bonobUrl.append({
|
||||
pathname: `/icon/${icon}/size/legacy`,
|
||||
searchParams: text ? { text } : {},
|
||||
});
|
||||
|
||||
export const defaultArtistArtURI = (
|
||||
bonobUrl: URLBuilder,
|
||||
@@ -338,7 +367,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
i8n: I8N
|
||||
) {
|
||||
const sonosSoap = new SonosSoap(bonobUrl, linkCodes);
|
||||
|
||||
|
||||
const urlWithToken = (accessToken: string) =>
|
||||
bonobUrl.append({
|
||||
searchParams: {
|
||||
@@ -366,7 +395,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaURI: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -386,22 +415,19 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaMetadata: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.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 (
|
||||
{ id, term }: { id: string; term: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -446,7 +472,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
}: // recursive,
|
||||
{ id: string; index: number; count: number; recursive: boolean },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -467,7 +493,8 @@ function bindSmapiSoapServiceToExpress(
|
||||
album(urlWithToken(accessToken), it)
|
||||
),
|
||||
relatedBrowse:
|
||||
artist.similarArtists.filter(it => it.inLibrary).length > 0
|
||||
artist.similarArtists.filter((it) => it.inLibrary)
|
||||
.length > 0
|
||||
? [
|
||||
{
|
||||
id: `relatedArtists:${artist.id}`,
|
||||
@@ -535,7 +562,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
{ id: string; index: number; count: number; recursive: boolean },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, 'headers'>
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -606,19 +633,28 @@ function bindSmapiSoapServiceToExpress(
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: lang("recentlyAdded"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "recentlyAdded").href(),
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyAdded"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "recentlyPlayed",
|
||||
title: lang("recentlyPlayed"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "recentlyPlayed").href(),
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "mostPlayed",
|
||||
title: lang("mostPlayed"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(),
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"mostPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
],
|
||||
@@ -628,9 +664,21 @@ function bindSmapiSoapServiceToExpress(
|
||||
case "search":
|
||||
return getMetadataResult({
|
||||
mediaCollection: [
|
||||
{ itemType: "search", id: "artists", title: lang("artists") },
|
||||
{ itemType: "search", id: "albums", title: lang("albums") },
|
||||
{ itemType: "search", id: "tracks", title: lang("tracks") },
|
||||
{
|
||||
itemType: "search",
|
||||
id: "artists",
|
||||
title: lang("artists"),
|
||||
},
|
||||
{
|
||||
itemType: "search",
|
||||
id: "albums",
|
||||
title: lang("albums"),
|
||||
},
|
||||
{
|
||||
itemType: "search",
|
||||
id: "tracks",
|
||||
title: lang("tracks"),
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
total: 3,
|
||||
@@ -688,7 +736,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaCollection: page.map(it => genre(bonobUrl, it)),
|
||||
mediaCollection: page.map((it) =>
|
||||
genre(bonobUrl, it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
})
|
||||
@@ -696,14 +746,23 @@ function bindSmapiSoapServiceToExpress(
|
||||
case "playlists":
|
||||
return musicLibrary
|
||||
.playlists()
|
||||
.then((it) =>
|
||||
Promise.all(
|
||||
it.map((playlist) =>
|
||||
musicLibrary.playlist(playlist.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaCollection: page.map(playlist),
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
mediaCollection: page.map((it) =>
|
||||
playlist(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
case "playlist":
|
||||
return musicLibrary
|
||||
.playlist(typeId!)
|
||||
@@ -736,7 +795,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
return musicLibrary
|
||||
.artist(typeId!)
|
||||
.then((artist) => artist.similarArtists)
|
||||
.then(similarArtists => similarArtists.filter(it => it.inLibrary))
|
||||
.then((similarArtists) =>
|
||||
similarArtists.filter((it) => it.inLibrary)
|
||||
)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
@@ -767,7 +828,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
createContainer: async (
|
||||
{ title, seedId }: { title: string; seedId: string | undefined },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) =>
|
||||
@@ -793,7 +854,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
deleteContainer: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
||||
@@ -801,7 +862,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
addToContainer: async (
|
||||
{ id, parentId }: { id: string; parentId: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -812,7 +873,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
removeFromContainer: async (
|
||||
{ id, indices }: { id: string; indices: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -835,7 +896,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
setPlayedSeconds: async (
|
||||
{ id, seconds }: { id: string; seconds: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
|
||||
7
src/utils.ts
Normal file
7
src/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function takeWithRepeats<T>(things:T[], count: number) {
|
||||
const result = [];
|
||||
for(let i = 0; i < count; i++) {
|
||||
result.push(things[i % things.length])
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user