Add i8n support for nl-NL (#19)

This commit is contained in:
Simon J
2021-08-14 17:43:21 +10:00
committed by GitHub
parent 43c335ecfc
commit 0d21c34243
12 changed files with 723 additions and 229 deletions

134
src/i8n.ts Normal file
View File

@@ -0,0 +1,134 @@
import _ from "underscore";
export type LANG = "en-US" | "nl-NL";
export type KEY =
| "AppLinkMessage"
| "artists"
| "albums"
| "playlists"
| "genres"
| "random"
| "starred"
| "recentlyAdded"
| "recentlyPlayed"
| "mostPlayed"
| "tracks"
| "success"
| "failure"
| "expectedConfig"
| "existingServiceConfig"
| "noExistingServiceRegistration"
| "register"
| "removeRegistration"
| "devices"
| "services"
| "login"
| "logInToBonob"
| "username"
| "password"
| "successfullyRegistered"
| "registrationFailed"
| "successfullyRemovedRegistration"
| "failedToRemoveRegistration"
| "invalidLinkCode"
| "loginSuccessful"
| "loginFailed";
const translations: Record<LANG, Record<KEY, string>> = {
"en-US": {
AppLinkMessage: "Linking sonos with $BONOB_SONOS_SERVICE_NAME",
artists: "Artists",
albums: "Albums",
tracks: "Tracks",
playlists: "Playlists",
genres: "Genres",
random: "Random",
starred: "Starred",
recentlyAdded: "Recently added",
recentlyPlayed: "Recently played",
mostPlayed: "Most played",
success: "Success",
failure: "Failure",
expectedConfig: "Expected configuration",
existingServiceConfig: "Existing service configuration",
noExistingServiceRegistration: "No existing service registration",
register: "Register",
removeRegistration: "Remove registration",
devices: "Devices",
services: "Services",
login: "Login",
logInToBonob: "Log in to $BONOB_SONOS_SERVICE_NAME",
username: "Username",
password: "Password",
successfullyRegistered: "Successfully registered",
registrationFailed: "Registration failed!",
successfullyRemovedRegistration: "Successfully removed registration",
failedToRemoveRegistration: "Failed to remove registration!",
invalidLinkCode: "Invalid linkCode!",
loginSuccessful: "Login successful!",
loginFailed: "Login failed!",
},
"nl-NL": {
AppLinkMessage: "Sonos koppelen aan $BONOB_SONOS_SERVICE_NAME",
artists: "Artiesten",
albums: "Albums",
tracks: "Nummers",
playlists: "Afspeellijsten",
genres: "Genres",
random: "Willekeurig",
starred: "Favorieten",
recentlyAdded: "Onlangs toegevoegd",
recentlyPlayed: "Onlangs afgespeeld",
mostPlayed: "Meest afgespeeld",
success: "Gelukt",
failure: "Mislukt",
expectedConfig: "Verwachte configuratie",
existingServiceConfig: "Bestaande serviceconfiguratie",
noExistingServiceRegistration: "Geen bestaande serviceregistratie",
register: "Register",
removeRegistration: "Verwijder registratie",
devices: "Apparaten",
services: "Services",
login: "Inloggen",
logInToBonob: "Login op $BONOB_SONOS_SERVICE_NAME",
username: "Gebruikersnaam",
password: "Wachtwoord",
successfullyRegistered: "Registratie geslaagd",
registrationFailed: "Registratie mislukt!",
successfullyRemovedRegistration: "Registratie succesvol verwijderd",
failedToRemoveRegistration: "Kon registratie niet verwijderen!",
invalidLinkCode: "Ongeldige linkcode!",
loginSuccessful: "Inloggen gelukt!",
loginFailed: "Inloggen mislukt!",
},
};
export const randomLang = () => _.shuffle(["en-US", "nl-NL"])[0]!;
export const asLANGs = (acceptLanguageHeader: string | undefined) => {
const z = acceptLanguageHeader?.split(";")[0];
return z && z != "" ? z.split(",") : [];
};
export type I8N = (...langs: string[]) => Lang;
export type Lang = (key: KEY) => string;
export const langs = () => Object.keys(translations);
export const keys = (lang: LANG = "en-US") => Object.keys(translations[lang]);
export default (serviceName: string): I8N =>
(...langs: string[]): Lang => {
const langToUse =
langs.map((l) => translations[l as LANG]).find((it) => it) ||
translations["en-US"];
return (key: KEY) => {
const value = langToUse[key]?.replace(
"$BONOB_SONOS_SERVICE_NAME",
serviceName
);
if (value) return value;
else throw `No translation found for ${langs}:${key}`;
};
};

View File

@@ -1,11 +1,11 @@
import { option as O } from "fp-ts";
import express, { Express } from "express";
import express, { Express, Request } from "express";
import * as Eta from "eta";
import morgan from "morgan";
import { PassThrough, Transform, TransformCallback } from "stream";
import { Sonos, Service } from "./sonos";
import { Sonos, Service, SONOS_LANG } from "./sonos";
import {
SOAP_PATH,
STRINGS_ROUTE,
@@ -23,6 +23,7 @@ import logger from "./logger";
import { Clock, SystemClock } from "./clock";
import { pipe } from "fp-ts/lib/function";
import { URLBuilder } from "./url_builder";
import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
@@ -74,6 +75,7 @@ function server(
applyContextPath = true
): Express {
const app = express();
const i8n = makeI8N(service.name);
app.use(morgan("combined"));
app.use(express.urlencoded({ extended: false }));
@@ -85,13 +87,17 @@ function server(
app.set("view engine", "eta");
app.set("views", "./web/views");
app.get("/", (_, res) => {
const langFor = (req: Request) => i8n(...asLANGs(req.headers["accept-language"]))
app.get("/", (req, res) => {
const lang = langFor(req);
Promise.all([sonos.devices(), sonos.services()]).then(
([devices, services]) => {
const registeredBonobService = services.find(
(it) => it.sid == service.sid
);
res.render("index", {
lang,
devices,
services,
bonobService: service,
@@ -112,47 +118,56 @@ function server(
});
});
app.post(CREATE_REGISTRATION_ROUTE, (_, res) => {
app.post(CREATE_REGISTRATION_ROUTE, (req, res) => {
const lang = langFor(req);
sonos.register(service).then((success) => {
if (success) {
res.render("success", {
message: `Successfully registered`,
lang,
message: lang("successfullyRegistered"),
});
} else {
res.status(500).render("failure", {
message: `Registration failed!`,
lang,
message: lang("registrationFailed"),
});
}
});
});
app.post(REMOVE_REGISTRATION_ROUTE, (_, res) => {
app.post(REMOVE_REGISTRATION_ROUTE, (req, res) => {
const lang = langFor(req);
sonos.remove(service.sid).then((success) => {
if (success) {
res.render("success", {
message: `Successfully removed registration`,
lang,
message: lang("successfullyRemovedRegistration"),
});
} else {
res.status(500).render("failure", {
message: `Failed to remove registration!`,
lang,
message: lang("failedToRemoveRegistration"),
});
}
});
});
app.get(LOGIN_ROUTE, (req, res) => {
const lang = langFor(req);
res.render("login", {
bonobService: service,
lang,
linkCode: req.query.linkCode,
loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(),
});
});
app.post(LOGIN_ROUTE, async (req, res) => {
const lang = langFor(req);
const { username, password, linkCode } = req.body;
if (!linkCodes.has(linkCode)) {
res.status(400).render("failure", {
message: `Invalid linkCode!`,
lang,
message: lang("invalidLinkCode"),
});
} else {
const authResult = await musicService.generateToken({
@@ -162,25 +177,26 @@ function server(
if (isSuccess(authResult)) {
linkCodes.associate(linkCode, authResult);
res.render("success", {
message: `Login successful!`,
lang,
message: lang("loginSuccessful"),
});
} else {
res.status(403).render("failure", {
message: `Login failed! ${authResult.message}!`,
lang,
message: lang("loginFailed"),
cause: authResult.message
});
}
}
});
app.get(STRINGS_ROUTE, (_, res) => {
const stringNode = (id: string, value: string) => `<string stringId="${id}"><![CDATA[${value}]]></string>`
const stringtableNode = (langName: string) => `<stringtable rev="1" xml:lang="${langName}">${i8nKeys().map(key => stringNode(key, i8n(langName as LANG)(key as KEY))).join("")}</stringtable>`
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
<stringtables xmlns="http://sonos.com/sonosapi">
<stringtable rev="1" xml:lang="en-US">
<string stringId="AppLinkMessage">Linking sonos with ${service.name}</string>
</stringtable>
<stringtable rev="1" xml:lang="fr-FR">
<string stringId="AppLinkMessage">Lier les sonos à la ${service.name}</string>
</stringtable>
${SONOS_LANG.map(stringtableNode).join("")}
</stringtables>
`);
});
@@ -192,9 +208,9 @@ function server(
<Match>
<imageSizeMap>
${SONOS_RECOMMENDED_IMAGE_SIZES.map(
(size) =>
`<sizeEntry size="${size}" substitution="/art/size/${size}"/>`
).join("")}
(size) =>
`<sizeEntry size="${size}" substitution="/art/size/${size}"/>`
).join("")}
</imageSizeMap>
</Match>
</PresentationMap>
@@ -236,8 +252,7 @@ function server(
)
.then(({ musicLibrary, stream }) => {
logger.info(
`stream response from music service for ${id}, status=${
stream.status
`stream response from music service for ${id}, status=${stream.status
}, headers=(${JSON.stringify(stream.headers)})`
);
@@ -351,7 +366,8 @@ function server(
linkCodes,
musicService,
accessTokens,
clock
clock,
i8n
);
if (applyContextPath) {

View File

@@ -1,10 +1,11 @@
import crypto from "crypto";
import { Express } from "express";
import { Express, Request } from "express";
import { listen } from "soap";
import { readFileSync } from "fs";
import path from "path";
import logger from "./logger";
import { LinkCodes } from "./link_codes";
import {
Album,
@@ -21,6 +22,7 @@ import { AccessTokens } from "./access_tokens";
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
import { Clock } from "./clock";
import { URLBuilder } from "./url_builder";
import { I8N, LANG } from "./i8n";
export const LOGIN_ROUTE = "/login";
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
@@ -277,9 +279,9 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
const auth = async (
musicService: MusicService,
accessTokens: AccessTokens,
headers?: SoapyHeaders
credentials?: Credentials
) => {
if (!headers?.credentials) {
if (!credentials) {
throw {
Fault: {
faultcode: "Client.LoginUnsupported",
@@ -287,7 +289,7 @@ const auth = async (
},
};
}
const authToken = headers.credentials.loginToken.token;
const authToken = credentials.loginToken.token;
const accessToken = accessTokens.mint(authToken);
return musicService
@@ -327,9 +329,11 @@ function bindSmapiSoapServiceToExpress(
linkCodes: LinkCodes,
musicService: MusicService,
accessTokens: AccessTokens,
clock: Clock
clock: Clock,
i8n: I8N
) {
const sonosSoap = new SonosSoap(bonobUrl, linkCodes);
const urlWithToken = (accessToken: string) =>
bonobUrl.append({
searchParams: {
@@ -357,9 +361,9 @@ function bindSmapiSoapServiceToExpress(
getMediaURI: async (
{ id }: { id: string },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then(({ accessToken, type, typeId }) => ({
getMediaURIResult: bonobUrl
@@ -377,9 +381,9 @@ function bindSmapiSoapServiceToExpress(
getMediaMetadata: async (
{ id }: { id: string },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then(async ({ musicLibrary, accessToken, typeId }) =>
musicLibrary.track(typeId!).then((it) => ({
@@ -392,9 +396,9 @@ function bindSmapiSoapServiceToExpress(
search: async (
{ id, term }: { id: string; term: string },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then(async ({ musicLibrary, accessToken }) => {
switch (id) {
@@ -437,9 +441,9 @@ function bindSmapiSoapServiceToExpress(
}: // recursive,
{ id: string; index: number; count: number; recursive: boolean },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then(async ({ musicLibrary, accessToken, type, typeId }) => {
const paging = { _index: index, _count: count };
@@ -525,14 +529,16 @@ function bindSmapiSoapServiceToExpress(
}: // recursive,
{ id: string; index: number; count: number; recursive: boolean },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
{ headers }: Pick<Request, 'headers'>
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then(({ musicLibrary, accessToken, type, typeId }) => {
const paging = { _index: index, _count: count };
const lang = i8n((headers["accept-language"] || "en-US") as LANG);
logger.debug(
`Fetching metadata type=${type}, typeId=${typeId}`
`Fetching metadata type=${type}, typeId=${typeId}, lang=${lang}`
);
const albums = (q: AlbumQuery): Promise<GetMetadataResponse> =>
@@ -553,17 +559,17 @@ function bindSmapiSoapServiceToExpress(
{
itemType: "container",
id: "artists",
title: "Artists",
title: lang("artists"),
},
{
itemType: "albumList",
id: "albums",
title: "Albums",
title: lang("albums"),
},
{
itemType: "playlist",
id: "playlists",
title: "Playlists",
title: lang("playlists"),
attributes: {
readOnly: false,
userContent: true,
@@ -573,32 +579,32 @@ function bindSmapiSoapServiceToExpress(
{
itemType: "container",
id: "genres",
title: "Genres",
title: lang("genres"),
},
{
itemType: "albumList",
id: "randomAlbums",
title: "Random",
title: lang("random"),
},
{
itemType: "albumList",
id: "starredAlbums",
title: "Starred",
title: lang("starred"),
},
{
itemType: "albumList",
id: "recentlyAdded",
title: "Recently Added",
title: lang("recentlyAdded"),
},
{
itemType: "albumList",
id: "recentlyPlayed",
title: "Recently Played",
title: lang("recentlyPlayed"),
},
{
itemType: "albumList",
id: "mostPlayed",
title: "Most Played",
title: lang("mostPlayed"),
},
],
index: 0,
@@ -607,9 +613,9 @@ function bindSmapiSoapServiceToExpress(
case "search":
return getMetadataResult({
mediaCollection: [
{ itemType: "search", id: "artists", title: "Artists" },
{ itemType: "search", id: "albums", title: "Albums" },
{ itemType: "search", id: "tracks", title: "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,
@@ -746,9 +752,9 @@ function bindSmapiSoapServiceToExpress(
createContainer: async (
{ title, seedId }: { title: string; seedId: string | undefined },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(({ musicLibrary }) =>
musicLibrary
.createPlaylist(title)
@@ -772,17 +778,17 @@ function bindSmapiSoapServiceToExpress(
deleteContainer: async (
{ id }: { id: string },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
.then((_) => ({ deleteContainerResult: {} })),
addToContainer: async (
{ id, parentId }: { id: string; parentId: string },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then(({ musicLibrary, typeId }) =>
musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId)
@@ -791,9 +797,9 @@ function bindSmapiSoapServiceToExpress(
removeFromContainer: async (
{ id, indices }: { id: string; indices: string },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then((it) => ({
...it,
@@ -814,9 +820,9 @@ function bindSmapiSoapServiceToExpress(
setPlayedSeconds: async (
{ id, seconds }: { id: string; seconds: string },
_,
headers?: SoapyHeaders
soapyHeaders: SoapyHeaders,
) =>
auth(musicService, accessTokens, headers)
auth(musicService, accessTokens, soapyHeaders?.credentials)
.then(splitId(id))
.then(({ musicLibrary, type, typeId }) => {
switch (type) {

View File

@@ -8,7 +8,9 @@ import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
import qs from "querystring";
import { URLBuilder } from "./url_builder";
export const PRESENTATION_AND_STRINGS_VERSION = "18";
export const SONOS_LANG = ["en-US", "da-DK", "de-DE", "es-ES", "fr-FR", "it-IT", "ja-JP", "nb-NO", "nl-NL", "pt-BR", "sv-SE", "zh-CN"]
export const PRESENTATION_AND_STRINGS_VERSION = "19";
// NOTE: manifest requires https for the URL,
// otherwise you will get an error trying to register