diff --git a/README.md b/README.md index 73dd14e..f8b9878 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/i8n.ts b/src/i8n.ts index 666ec61..a29b508 100644 --- a/src/i8n.ts +++ b/src/i8n.ts @@ -40,6 +40,7 @@ export type KEY = | "loginFailed" | "noSonosDevices" | "favourites" + | "years" | "LOVE" | "LOVE_SUCCESS" | "STAR" @@ -83,6 +84,7 @@ const translations: Record> = { 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> = { 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> = { 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> = { loginFailed: "Inloggen mislukt!", noSonosDevices: "Geen Sonos-apparaten", favourites: "Favorieten", + years: "Jaren", STAR: "Ster ", UNSTAR: "Een ster", STAR_SUCCESS: "Nummer met ster", diff --git a/src/music_service.ts b/src/music_service.ts index 2fa350d..2504f29 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -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 = ([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(trackId: string): Promise; genres(): Promise; + years(): Promise; stream({ trackId, range, diff --git a/src/smapi.ts b/src/smapi.ts index 6c0bd35..c6c0a55 100644 --- a/src/smapi.ts +++ b/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() diff --git a/src/subsonic.ts b/src/subsonic.ts index c0fef83..ee7bce8 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -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 } @@ -446,6 +450,7 @@ const AlbumQueryTypeToSubsonicType: Record = { 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(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") { diff --git a/tests/builders.ts b/tests/builders.ts index 6dc65fd..ac1c2a4 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -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 { const id = uuid(); const artist = anArtist(); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 644586a..7a39683 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -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([]), }); } diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 17f5654..f5ee19a 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -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: []});