mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Compare commits
1 Commits
feature/ye
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0488f398c1 |
@@ -9,14 +9,14 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
||||
## Features
|
||||
|
||||
- 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
|
||||
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
||||
- Now playing & Track Scrobbling
|
||||
- Search by Album, Artist, Track
|
||||
- Playlist editing through 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
|
||||
- Discovery of sonos devices using seed IP address
|
||||
- Auto registration with sonos on start
|
||||
|
||||
@@ -40,6 +40,7 @@ export type KEY =
|
||||
| "loginFailed"
|
||||
| "noSonosDevices"
|
||||
| "favourites"
|
||||
| "years"
|
||||
| "LOVE"
|
||||
| "LOVE_SUCCESS"
|
||||
| "STAR"
|
||||
@@ -83,6 +84,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Login failed!",
|
||||
noSonosDevices: "No sonos devices",
|
||||
favourites: "Favourites",
|
||||
years: "Years",
|
||||
STAR: "Star",
|
||||
UNSTAR: "Un-star",
|
||||
STAR_SUCCESS: "Track starred",
|
||||
@@ -125,6 +127,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Log på fejlede!",
|
||||
noSonosDevices: "Ingen Sonos enheder",
|
||||
favourites: "Favoritter",
|
||||
years: "Flere år",
|
||||
STAR: "Tilføj stjerne",
|
||||
UNSTAR: "Fjern stjerne",
|
||||
STAR_SUCCESS: "Stjerne tilføjet",
|
||||
@@ -167,6 +170,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "La connexion a échoué !",
|
||||
noSonosDevices: "Aucun appareil Sonos",
|
||||
favourites: "Favoris",
|
||||
years: "Années",
|
||||
STAR: "Suivre",
|
||||
UNSTAR: "Ne plus suivre",
|
||||
STAR_SUCCESS: "Piste suivie",
|
||||
@@ -209,6 +213,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Inloggen mislukt!",
|
||||
noSonosDevices: "Geen Sonos-apparaten",
|
||||
favourites: "Favorieten",
|
||||
years: "Jaren",
|
||||
STAR: "Ster ",
|
||||
UNSTAR: "Een ster",
|
||||
STAR_SUCCESS: "Nummer met ster",
|
||||
|
||||
@@ -46,6 +46,10 @@ export type Genre = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type Year = {
|
||||
year: string;
|
||||
}
|
||||
|
||||
export type Rating = {
|
||||
love: boolean;
|
||||
stars: number;
|
||||
@@ -100,11 +104,13 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
|
||||
|
||||
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 & {
|
||||
type: AlbumQueryType;
|
||||
genre?: string;
|
||||
fromYear?: string;
|
||||
toYear?: string;
|
||||
};
|
||||
|
||||
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
||||
@@ -173,6 +179,7 @@ export interface MusicLibrary {
|
||||
tracks(albumId: string): Promise<Track[]>;
|
||||
track(trackId: string): Promise<Track>;
|
||||
genres(): Promise<Genre[]>;
|
||||
years(): Promise<Year[]>;
|
||||
stream({
|
||||
trackId,
|
||||
range,
|
||||
|
||||
36
src/smapi.ts
36
src/smapi.ts
@@ -15,6 +15,7 @@ import {
|
||||
AlbumSummary,
|
||||
ArtistSummary,
|
||||
Genre,
|
||||
Year,
|
||||
MusicService,
|
||||
Playlist,
|
||||
RadioStation,
|
||||
@@ -244,12 +245,19 @@ export type Container = {
|
||||
};
|
||||
|
||||
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
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) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
@@ -740,6 +748,12 @@ function bindSmapiSoapServiceToExpress(
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: lang("years"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: lang("recentlyAdded"),
|
||||
@@ -817,6 +831,13 @@ function bindSmapiSoapServiceToExpress(
|
||||
genre: typeId,
|
||||
...paging,
|
||||
});
|
||||
case "year":
|
||||
return albums({
|
||||
type: "byYear",
|
||||
fromYear: typeId,
|
||||
toYear: typeId,
|
||||
...paging,
|
||||
});
|
||||
case "randomAlbums":
|
||||
return albums({
|
||||
type: "random",
|
||||
@@ -860,6 +881,19 @@ function bindSmapiSoapServiceToExpress(
|
||||
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":
|
||||
return musicLibrary
|
||||
.genres()
|
||||
|
||||
@@ -346,6 +346,10 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||
O.getOrElseW(() => undefined)
|
||||
);
|
||||
|
||||
export const asYear = (year: string) => ({
|
||||
year: year,
|
||||
});
|
||||
|
||||
export interface CustomPlayers {
|
||||
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
|
||||
}
|
||||
@@ -446,6 +450,7 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||
alphabeticalByArtist: "alphabeticalByArtist",
|
||||
alphabeticalByName: "alphabeticalByName",
|
||||
byGenre: "byGenre",
|
||||
byYear: "byYear",
|
||||
random: "random",
|
||||
recentlyPlayed: "recent",
|
||||
mostPlayed: "frequent",
|
||||
@@ -720,6 +725,8 @@ export class Subsonic implements MusicService {
|
||||
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||
type: AlbumQueryTypeToSubsonicType[q.type],
|
||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||
...(q.fromYear ? { fromYear: q.fromYear} : {}),
|
||||
...(q.toYear ? { toYear: q.toYear} : {}),
|
||||
size: 500,
|
||||
offset: q._index,
|
||||
})
|
||||
@@ -1037,7 +1044,24 @@ export class Subsonic implements MusicService {
|
||||
.then(it =>
|
||||
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") {
|
||||
|
||||
@@ -166,6 +166,12 @@ export const SAMPLE_GENRES = [
|
||||
];
|
||||
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 {
|
||||
const id = uuid();
|
||||
const artist = anArtist();
|
||||
|
||||
@@ -163,6 +163,7 @@ export class InMemoryMusicService implements MusicService {
|
||||
topSongs: async (_: string) => Promise.resolve([]),
|
||||
radioStations: async () => Promise.resolve([]),
|
||||
radioStation: async (_: string) => Promise.reject("Unsupported operation"),
|
||||
years: async () => Promise.resolve([]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
ROCK,
|
||||
TRIP_HOP,
|
||||
PUNK,
|
||||
Y2024,
|
||||
Y2023,
|
||||
Y1969,
|
||||
aPlaylist,
|
||||
aRadioStation,
|
||||
} from "./builders";
|
||||
@@ -575,6 +578,8 @@ describe("wsdl api", () => {
|
||||
artists: jest.fn(),
|
||||
artist: jest.fn(),
|
||||
genres: jest.fn(),
|
||||
years: jest.fn(),
|
||||
year: jest.fn(),
|
||||
playlists: jest.fn(),
|
||||
playlist: jest.fn(),
|
||||
album: jest.fn(),
|
||||
@@ -1153,6 +1158,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: "Years",
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: "Recently added",
|
||||
@@ -1247,6 +1258,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: "Jaren",
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: "Onlangs toegevoegd",
|
||||
@@ -1324,7 +1341,7 @@ describe("wsdl api", () => {
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: expectedGenres.map((genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
@@ -1349,7 +1366,7 @@ describe("wsdl api", () => {
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: [PUNK, ROCK].map((genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
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", () => {
|
||||
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
||||
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<style>
|
||||
.txt {
|
||||
font: bold 13px helvetica;
|
||||
}
|
||||
</style>
|
||||
<text y="20%" class="txt" textLength="80%" lengthAdjust="spacingAndGlyphs">80s</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 226 B |
Reference in New Issue
Block a user