Skip to content

Commit

Permalink
Add years menu (#202)
Browse files Browse the repository at this point in the history
  • Loading branch information
jnth authored Apr 23, 2024
1 parent e7f5f58 commit 0488f39
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 7 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/i8n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type KEY =
| "loginFailed"
| "noSonosDevices"
| "favourites"
| "years"
| "LOVE"
| "LOVE_SUCCESS"
| "STAR"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/music_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export type Genre = {
id: string;
}

export type Year = {
year: string;
}

export type Rating = {
love: boolean;
stars: number;
Expand Down Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 35 additions & 1 deletion src/smapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
AlbumSummary,
ArtistSummary,
Genre,
Year,
MusicService,
Playlist,
RadioStation,
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down
26 changes: 25 additions & 1 deletion src/subsonic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
Expand Down Expand Up @@ -446,6 +450,7 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
alphabeticalByArtist: "alphabeticalByArtist",
alphabeticalByName: "alphabeticalByName",
byGenre: "byGenre",
byYear: "byYear",
random: "random",
recentlyPlayed: "recent",
mostPlayed: "frequent",
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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") {
Expand Down
6 changes: 6 additions & 0 deletions tests/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions tests/in_memory_music_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
});
}

Expand Down
79 changes: 77 additions & 2 deletions tests/smapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import {
ROCK,
TRIP_HOP,
PUNK,
Y2024,
Y2023,
Y1969,
aPlaylist,
aRadioStation,
} from "./builders";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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: []});
Expand Down

0 comments on commit 0488f39

Please sign in to comment.