Rendering playlist icon collage of 3x3 (#35)

This commit is contained in:
Simon J
2021-08-30 11:51:22 +10:00
committed by GitHub
parent e2e73209a2
commit ae29bc14eb
14 changed files with 750 additions and 177 deletions

View File

@@ -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, () => {

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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
View 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;
}