mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0488f398c1 |
@@ -9,14 +9,14 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Integrates with Subsonic API clones (Navidrome, Gonic)
|
- Integrates with Subsonic API clones (Navidrome, Gonic)
|
||||||
- Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Recently Added Albums, Recently Played Albums, Most Played Albums
|
- Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Years, Recently Added Albums, Recently Played Albums, Most Played Albums
|
||||||
- Artist & Album Art
|
- Artist & Album Art
|
||||||
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
||||||
- Now playing & Track Scrobbling
|
- Now playing & Track Scrobbling
|
||||||
- Search by Album, Artist, Track
|
- Search by Album, Artist, Track
|
||||||
- Playlist editing through sonos app.
|
- Playlist editing through sonos app.
|
||||||
- Marking of songs as favourites and with ratings through the sonos app.
|
- Marking of songs as favourites and with ratings through the sonos app.
|
||||||
- Localization (only en-US, da-DK & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization)
|
- Localization (only en-US, da-DK, nl-NL & fr-FR supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization)
|
||||||
- Auto discovery of sonos devices
|
- Auto discovery of sonos devices
|
||||||
- Discovery of sonos devices using seed IP address
|
- Discovery of sonos devices using seed IP address
|
||||||
- Auto registration with sonos on start
|
- Auto registration with sonos on start
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type KEY =
|
|||||||
| "loginFailed"
|
| "loginFailed"
|
||||||
| "noSonosDevices"
|
| "noSonosDevices"
|
||||||
| "favourites"
|
| "favourites"
|
||||||
|
| "years"
|
||||||
| "LOVE"
|
| "LOVE"
|
||||||
| "LOVE_SUCCESS"
|
| "LOVE_SUCCESS"
|
||||||
| "STAR"
|
| "STAR"
|
||||||
@@ -83,6 +84,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "Login failed!",
|
loginFailed: "Login failed!",
|
||||||
noSonosDevices: "No sonos devices",
|
noSonosDevices: "No sonos devices",
|
||||||
favourites: "Favourites",
|
favourites: "Favourites",
|
||||||
|
years: "Years",
|
||||||
STAR: "Star",
|
STAR: "Star",
|
||||||
UNSTAR: "Un-star",
|
UNSTAR: "Un-star",
|
||||||
STAR_SUCCESS: "Track starred",
|
STAR_SUCCESS: "Track starred",
|
||||||
@@ -125,6 +127,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "Log på fejlede!",
|
loginFailed: "Log på fejlede!",
|
||||||
noSonosDevices: "Ingen Sonos enheder",
|
noSonosDevices: "Ingen Sonos enheder",
|
||||||
favourites: "Favoritter",
|
favourites: "Favoritter",
|
||||||
|
years: "Flere år",
|
||||||
STAR: "Tilføj stjerne",
|
STAR: "Tilføj stjerne",
|
||||||
UNSTAR: "Fjern stjerne",
|
UNSTAR: "Fjern stjerne",
|
||||||
STAR_SUCCESS: "Stjerne tilføjet",
|
STAR_SUCCESS: "Stjerne tilføjet",
|
||||||
@@ -167,6 +170,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "La connexion a échoué !",
|
loginFailed: "La connexion a échoué !",
|
||||||
noSonosDevices: "Aucun appareil Sonos",
|
noSonosDevices: "Aucun appareil Sonos",
|
||||||
favourites: "Favoris",
|
favourites: "Favoris",
|
||||||
|
years: "Années",
|
||||||
STAR: "Suivre",
|
STAR: "Suivre",
|
||||||
UNSTAR: "Ne plus suivre",
|
UNSTAR: "Ne plus suivre",
|
||||||
STAR_SUCCESS: "Piste suivie",
|
STAR_SUCCESS: "Piste suivie",
|
||||||
@@ -209,6 +213,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "Inloggen mislukt!",
|
loginFailed: "Inloggen mislukt!",
|
||||||
noSonosDevices: "Geen Sonos-apparaten",
|
noSonosDevices: "Geen Sonos-apparaten",
|
||||||
favourites: "Favorieten",
|
favourites: "Favorieten",
|
||||||
|
years: "Jaren",
|
||||||
STAR: "Ster ",
|
STAR: "Ster ",
|
||||||
UNSTAR: "Een ster",
|
UNSTAR: "Een ster",
|
||||||
STAR_SUCCESS: "Nummer met ster",
|
STAR_SUCCESS: "Nummer met ster",
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export type Genre = {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Year = {
|
||||||
|
year: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type Rating = {
|
export type Rating = {
|
||||||
love: boolean;
|
love: boolean;
|
||||||
stars: number;
|
stars: number;
|
||||||
@@ -100,11 +104,13 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
|
|||||||
|
|
||||||
export type ArtistQuery = Paging;
|
export type ArtistQuery = Paging;
|
||||||
|
|
||||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
|
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'byYear' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
|
||||||
|
|
||||||
export type AlbumQuery = Paging & {
|
export type AlbumQuery = Paging & {
|
||||||
type: AlbumQueryType;
|
type: AlbumQueryType;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
|
fromYear?: string;
|
||||||
|
toYear?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
||||||
@@ -173,6 +179,7 @@ export interface MusicLibrary {
|
|||||||
tracks(albumId: string): Promise<Track[]>;
|
tracks(albumId: string): Promise<Track[]>;
|
||||||
track(trackId: string): Promise<Track>;
|
track(trackId: string): Promise<Track>;
|
||||||
genres(): Promise<Genre[]>;
|
genres(): Promise<Genre[]>;
|
||||||
|
years(): Promise<Year[]>;
|
||||||
stream({
|
stream({
|
||||||
trackId,
|
trackId,
|
||||||
range,
|
range,
|
||||||
|
|||||||
36
src/smapi.ts
36
src/smapi.ts
@@ -15,6 +15,7 @@ import {
|
|||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
ArtistSummary,
|
ArtistSummary,
|
||||||
Genre,
|
Genre,
|
||||||
|
Year,
|
||||||
MusicService,
|
MusicService,
|
||||||
Playlist,
|
Playlist,
|
||||||
RadioStation,
|
RadioStation,
|
||||||
@@ -244,12 +245,19 @@ export type Container = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
||||||
itemType: "container",
|
itemType: "albumList",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
|
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const year = (bonobUrl: URLBuilder, year: Year) => ({
|
||||||
|
itemType: "albumList",
|
||||||
|
id: `year:${year.year}`,
|
||||||
|
title: year.year,
|
||||||
|
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||||
|
});
|
||||||
|
|
||||||
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
||||||
itemType: "playlist",
|
itemType: "playlist",
|
||||||
id: `playlist:${playlist.id}`,
|
id: `playlist:${playlist.id}`,
|
||||||
@@ -740,6 +748,12 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "years",
|
||||||
|
title: lang("years"),
|
||||||
|
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||||
|
itemType: "container",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "recentlyAdded",
|
id: "recentlyAdded",
|
||||||
title: lang("recentlyAdded"),
|
title: lang("recentlyAdded"),
|
||||||
@@ -817,6 +831,13 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
genre: typeId,
|
genre: typeId,
|
||||||
...paging,
|
...paging,
|
||||||
});
|
});
|
||||||
|
case "year":
|
||||||
|
return albums({
|
||||||
|
type: "byYear",
|
||||||
|
fromYear: typeId,
|
||||||
|
toYear: typeId,
|
||||||
|
...paging,
|
||||||
|
});
|
||||||
case "randomAlbums":
|
case "randomAlbums":
|
||||||
return albums({
|
return albums({
|
||||||
type: "random",
|
type: "random",
|
||||||
@@ -860,6 +881,19 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
case "years":
|
||||||
|
return musicLibrary
|
||||||
|
.years()
|
||||||
|
.then(slice2(paging))
|
||||||
|
.then(([page, total]) =>
|
||||||
|
getMetadataResult({
|
||||||
|
mediaCollection: page.map((it) =>
|
||||||
|
year(bonobUrl, it)
|
||||||
|
),
|
||||||
|
index: paging._index,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
);
|
||||||
case "genres":
|
case "genres":
|
||||||
return musicLibrary
|
return musicLibrary
|
||||||
.genres()
|
.genres()
|
||||||
|
|||||||
@@ -346,6 +346,10 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
|||||||
O.getOrElseW(() => undefined)
|
O.getOrElseW(() => undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const asYear = (year: string) => ({
|
||||||
|
year: year,
|
||||||
|
});
|
||||||
|
|
||||||
export interface CustomPlayers {
|
export interface CustomPlayers {
|
||||||
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
|
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
|
||||||
}
|
}
|
||||||
@@ -446,6 +450,7 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
|||||||
alphabeticalByArtist: "alphabeticalByArtist",
|
alphabeticalByArtist: "alphabeticalByArtist",
|
||||||
alphabeticalByName: "alphabeticalByName",
|
alphabeticalByName: "alphabeticalByName",
|
||||||
byGenre: "byGenre",
|
byGenre: "byGenre",
|
||||||
|
byYear: "byYear",
|
||||||
random: "random",
|
random: "random",
|
||||||
recentlyPlayed: "recent",
|
recentlyPlayed: "recent",
|
||||||
mostPlayed: "frequent",
|
mostPlayed: "frequent",
|
||||||
@@ -720,6 +725,8 @@ export class Subsonic implements MusicService {
|
|||||||
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||||
type: AlbumQueryTypeToSubsonicType[q.type],
|
type: AlbumQueryTypeToSubsonicType[q.type],
|
||||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||||
|
...(q.fromYear ? { fromYear: q.fromYear} : {}),
|
||||||
|
...(q.toYear ? { toYear: q.toYear} : {}),
|
||||||
size: 500,
|
size: 500,
|
||||||
offset: q._index,
|
offset: q._index,
|
||||||
})
|
})
|
||||||
@@ -1037,7 +1044,24 @@ export class Subsonic implements MusicService {
|
|||||||
.then(it =>
|
.then(it =>
|
||||||
it.find(station => station.id === id)!
|
it.find(station => station.id === id)!
|
||||||
),
|
),
|
||||||
|
years: async () => {
|
||||||
|
const q: AlbumQuery = {
|
||||||
|
_index: 0,
|
||||||
|
_count: 100000, // FIXME: better than this ?
|
||||||
|
type: "alphabeticalByArtist",
|
||||||
|
};
|
||||||
|
const years = subsonic.getAlbumList2(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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (credentials.type == "navidrome") {
|
if (credentials.type == "navidrome") {
|
||||||
|
|||||||
@@ -166,6 +166,12 @@ export const SAMPLE_GENRES = [
|
|||||||
];
|
];
|
||||||
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
|
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
|
||||||
|
|
||||||
|
export const aYear = (year: string) => ({ id: year, year });
|
||||||
|
|
||||||
|
export const Y2024 = aYear("2024");
|
||||||
|
export const Y2023 = aYear("2023");
|
||||||
|
export const Y1969 = aYear("1969");
|
||||||
|
|
||||||
export function aTrack(fields: Partial<Track> = {}): Track {
|
export function aTrack(fields: Partial<Track> = {}): Track {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
const artist = anArtist();
|
const artist = anArtist();
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
topSongs: async (_: string) => Promise.resolve([]),
|
topSongs: async (_: string) => Promise.resolve([]),
|
||||||
radioStations: async () => Promise.resolve([]),
|
radioStations: async () => Promise.resolve([]),
|
||||||
radioStation: async (_: string) => Promise.reject("Unsupported operation"),
|
radioStation: async (_: string) => Promise.reject("Unsupported operation"),
|
||||||
|
years: async () => Promise.resolve([]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
ROCK,
|
ROCK,
|
||||||
TRIP_HOP,
|
TRIP_HOP,
|
||||||
PUNK,
|
PUNK,
|
||||||
|
Y2024,
|
||||||
|
Y2023,
|
||||||
|
Y1969,
|
||||||
aPlaylist,
|
aPlaylist,
|
||||||
aRadioStation,
|
aRadioStation,
|
||||||
} from "./builders";
|
} from "./builders";
|
||||||
@@ -575,6 +578,8 @@ describe("wsdl api", () => {
|
|||||||
artists: jest.fn(),
|
artists: jest.fn(),
|
||||||
artist: jest.fn(),
|
artist: jest.fn(),
|
||||||
genres: jest.fn(),
|
genres: jest.fn(),
|
||||||
|
years: jest.fn(),
|
||||||
|
year: jest.fn(),
|
||||||
playlists: jest.fn(),
|
playlists: jest.fn(),
|
||||||
playlist: jest.fn(),
|
playlist: jest.fn(),
|
||||||
album: jest.fn(),
|
album: jest.fn(),
|
||||||
@@ -1153,6 +1158,12 @@ describe("wsdl api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "years",
|
||||||
|
title: "Years",
|
||||||
|
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||||
|
itemType: "container",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "recentlyAdded",
|
id: "recentlyAdded",
|
||||||
title: "Recently added",
|
title: "Recently added",
|
||||||
@@ -1247,6 +1258,12 @@ describe("wsdl api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "years",
|
||||||
|
title: "Jaren",
|
||||||
|
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||||
|
itemType: "container",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "recentlyAdded",
|
id: "recentlyAdded",
|
||||||
title: "Onlangs toegevoegd",
|
title: "Onlangs toegevoegd",
|
||||||
@@ -1324,7 +1341,7 @@ describe("wsdl api", () => {
|
|||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult({
|
||||||
mediaCollection: expectedGenres.map((genre) => ({
|
mediaCollection: expectedGenres.map((genre) => ({
|
||||||
itemType: "container",
|
itemType: "albumList",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(
|
albumArtURI: iconArtURI(
|
||||||
@@ -1349,7 +1366,7 @@ describe("wsdl api", () => {
|
|||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult({
|
||||||
mediaCollection: [PUNK, ROCK].map((genre) => ({
|
mediaCollection: [PUNK, ROCK].map((genre) => ({
|
||||||
itemType: "container",
|
itemType: "albumList",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(
|
albumArtURI: iconArtURI(
|
||||||
@@ -1365,6 +1382,64 @@ describe("wsdl api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("asking for a year", () => {
|
||||||
|
const expectedYears = [Y1969, Y2023, Y2024];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
musicLibrary.years.mockResolvedValue(expectedYears);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asking for all years", () => {
|
||||||
|
it("should return a collection of years", async () => {
|
||||||
|
const result = await ws.getMetadataAsync({
|
||||||
|
id: `years`,
|
||||||
|
index: 0,
|
||||||
|
count: 100,
|
||||||
|
});
|
||||||
|
expect(result[0]).toEqual(
|
||||||
|
getMetadataResult({
|
||||||
|
mediaCollection: expectedYears.map((year) => ({
|
||||||
|
itemType: "albumList",
|
||||||
|
id: `year:${year.id}`,
|
||||||
|
title: year.year,
|
||||||
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
"music",
|
||||||
|
).href(),
|
||||||
|
})),
|
||||||
|
index: 0,
|
||||||
|
total: expectedYears.length,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asking for a page of years", () => {
|
||||||
|
it("should return just that page", async () => {
|
||||||
|
const result = await ws.getMetadataAsync({
|
||||||
|
id: `years`,
|
||||||
|
index: 1,
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
expect(result[0]).toEqual(
|
||||||
|
getMetadataResult({
|
||||||
|
mediaCollection: [Y2023, Y2024].map((year) => ({
|
||||||
|
itemType: "albumList",
|
||||||
|
id: `year:${year.id}`,
|
||||||
|
title: year.year,
|
||||||
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
"music"
|
||||||
|
).href(),
|
||||||
|
})),
|
||||||
|
index: 1,
|
||||||
|
total: expectedYears.length,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("asking for playlists", () => {
|
describe("asking for playlists", () => {
|
||||||
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
||||||
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
||||||
|
|||||||
Reference in New Issue
Block a user