diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ceeef43..23194e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,11 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The Previous / Play-Pause / Next buttons in the Windows taskbar thumbnail preview (the popup shown when hovering the taskbar icon) had stopped appearing. They are back, and the middle button's icon again reflects the current playback state. -### Album order within each artist when sorting by artist +### Album sorting within artists -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1115](https://github.com/Psychotoxical/psysonic/pull/1115)**, suggested by [@kingley82](https://github.com/kingley82) +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1115](https://github.com/Psychotoxical/psysonic/pull/1115), PR [#1120](https://github.com/Psychotoxical/psysonic/pull/1120)**, suggested by [@kingley82](https://github.com/kingley82) * When browsing albums sorted by artist, each artist's albums appeared in an arbitrary order. They are now ordered A–Z by album title within each artist. +* New **Artist → Year** sort option groups albums by artist and orders each artist's albums chronologically (oldest first). ### "Add to playlist" from the player bar added the whole album diff --git a/src/locales/de/albums.ts b/src/locales/de/albums.ts index f569d806..f4721262 100644 --- a/src/locales/de/albums.ts +++ b/src/locales/de/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'Alle Alben', sortByName: 'A–Z (Album)', sortByArtist: 'A–Z (Künstler)', + sortByArtistYear: 'Künstler → Jahr', sortNewest: 'Neueste zuerst', sortRandom: 'Zufällig', yearFrom: 'Von', diff --git a/src/locales/en/albums.ts b/src/locales/en/albums.ts index ff9cdf1f..f650a3bd 100644 --- a/src/locales/en/albums.ts +++ b/src/locales/en/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'All Albums', sortByName: 'A–Z (Album)', sortByArtist: 'A–Z (Artist)', + sortByArtistYear: 'Artist → Year', sortNewest: 'Newest first', sortRandom: 'Random', yearFrom: 'From', diff --git a/src/locales/es/albums.ts b/src/locales/es/albums.ts index cb4445b4..5d58c65b 100644 --- a/src/locales/es/albums.ts +++ b/src/locales/es/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'Todos los Álbumes', sortByName: 'A–Z (Álbum)', sortByArtist: 'A–Z (Artista)', + sortByArtistYear: 'Artista → Año', sortNewest: 'Más recientes primero', sortRandom: 'Aleatorio', yearFrom: 'Desde', diff --git a/src/locales/fr/albums.ts b/src/locales/fr/albums.ts index 60bd0fa4..2d45a4e3 100644 --- a/src/locales/fr/albums.ts +++ b/src/locales/fr/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'Tous les albums', sortByName: 'A–Z (Album)', sortByArtist: 'A–Z (Artiste)', + sortByArtistYear: 'Artiste → Année', sortNewest: 'Plus récents', sortRandom: 'Aléatoire', yearFrom: 'De', diff --git a/src/locales/nb/albums.ts b/src/locales/nb/albums.ts index c54f98b6..2848788c 100644 --- a/src/locales/nb/albums.ts +++ b/src/locales/nb/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'Alle album', sortByName: 'A–Å (Album)', sortByArtist: 'A–Å (Artist)', + sortByArtistYear: 'Artist → År', sortNewest: 'Vis nyeste først', sortRandom: 'Tilfeldig', yearFrom: 'Fra', diff --git a/src/locales/nl/albums.ts b/src/locales/nl/albums.ts index 00375c38..b09575ae 100644 --- a/src/locales/nl/albums.ts +++ b/src/locales/nl/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'Alle albums', sortByName: 'A–Z (Album)', sortByArtist: 'A–Z (Artiest)', + sortByArtistYear: 'Artiest → Jaar', sortNewest: 'Nieuwste eerst', sortRandom: 'Willekeurig', yearFrom: 'Van', diff --git a/src/locales/ro/albums.ts b/src/locales/ro/albums.ts index 5f574162..41191fe3 100644 --- a/src/locales/ro/albums.ts +++ b/src/locales/ro/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'Toate albumele', sortByName: 'A–Z (Album)', sortByArtist: 'A–Z (Artist)', + sortByArtistYear: 'Artist → An', sortNewest: 'Cele mai noi primele', sortRandom: 'Aleatoriu', yearFrom: 'De la', diff --git a/src/locales/ru/albums.ts b/src/locales/ru/albums.ts index 403e1001..01fad5bd 100644 --- a/src/locales/ru/albums.ts +++ b/src/locales/ru/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: 'Все альбомы', sortByName: 'А–Я (альбом)', sortByArtist: 'А–Я (исполнитель)', + sortByArtistYear: 'Исполнитель → Год', sortNewest: 'Сначала новые', sortRandom: 'Случайно', yearFrom: 'С', diff --git a/src/locales/zh/albums.ts b/src/locales/zh/albums.ts index 7a7eaee5..ac8bd038 100644 --- a/src/locales/zh/albums.ts +++ b/src/locales/zh/albums.ts @@ -2,6 +2,7 @@ export const albums = { title: '全部专辑', sortByName: '按名称排序 (A-Z)', sortByArtist: '按艺术家排序 (A-Z)', + sortByArtistYear: '艺术家 → 年份', sortNewest: '最新优先', sortRandom: '随机排序', yearFrom: '从', diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 0b390127..e37fc8be 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -354,6 +354,7 @@ export default function Albums() { const sortOptions: { value: SortType; label: string }[] = [ { value: 'alphabeticalByName', label: t('albums.sortByName') }, { value: 'alphabeticalByArtist', label: t('albums.sortByArtist') }, + { value: 'byArtistThenYear', label: t('albums.sortByArtistYear') }, ]; return ( diff --git a/src/utils/library/albumBrowseNetwork.ts b/src/utils/library/albumBrowseNetwork.ts index 1ceff285..06e9ac3d 100644 --- a/src/utils/library/albumBrowseNetwork.ts +++ b/src/utils/library/albumBrowseNetwork.ts @@ -7,7 +7,7 @@ import { filterAlbumsByYearBounds, } from './albumBrowseFilters'; import { albumYearSubsonicParams } from './albumYearFilter'; -import { sortSubsonicAlbums } from './albumBrowseSort'; +import { albumListFetchType, sortSubsonicAlbums } from './albumBrowseSort'; import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes'; import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes'; @@ -65,7 +65,7 @@ export async function fetchAlbumBrowseNetwork( } const data = applyNetworkPostFilters( - await getAlbumList(query.sort, pageSize, offset, {}), + await getAlbumList(albumListFetchType(query.sort), pageSize, offset, {}), query, ); return { albums: data, hasMore: data.length === pageSize }; diff --git a/src/utils/library/albumBrowseSort.test.ts b/src/utils/library/albumBrowseSort.test.ts index ad5c51e8..5f25d88d 100644 --- a/src/utils/library/albumBrowseSort.test.ts +++ b/src/utils/library/albumBrowseSort.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import type { SubsonicAlbum } from '../../api/subsonicTypes'; import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort'; -const album = (artist: string, name: string): SubsonicAlbum => - ({ id: `${artist}-${name}`, artist, name }) as SubsonicAlbum; +const album = (artist: string, name: string, year?: number): SubsonicAlbum => + ({ id: `${artist}-${name}`, artist, name, year }) as SubsonicAlbum; describe('albumSortClauses', () => { it('sorts by artist then album name', () => { @@ -19,33 +19,59 @@ describe('albumSortClauses', () => { { field: 'artist', dir: 'asc' }, ]); }); + + it('sorts by artist, then year, then album name', () => { + expect(albumSortClauses('byArtistThenYear')).toEqual([ + { field: 'artist', dir: 'asc' }, + { field: 'year', dir: 'asc' }, + { field: 'name', dir: 'asc' }, + ]); + }); }); describe('sortSubsonicAlbums', () => { it('orders each artist group by album name when sorting by artist', () => { const input = [ - album('Rammstein', 'Sehnsucht'), - album('Duran Duran', 'Rio'), - album('Rammstein', 'Mutter'), - album('Duran Duran', 'DD'), - album('Duran Duran', 'The Wedding Album'), + album('Artist B', 'Solitude'), + album('Artist A', 'Mirage'), + album('Artist B', 'Cascade'), + album('Artist A', 'Ember'), + album('Artist A', 'Vertex'), ]; const ordered = sortSubsonicAlbums(input, 'alphabeticalByArtist').map(a => `${a.artist} - ${a.name}`); expect(ordered).toEqual([ - 'Duran Duran - DD', - 'Duran Duran - Rio', - 'Duran Duran - The Wedding Album', - 'Rammstein - Mutter', - 'Rammstein - Sehnsucht', + 'Artist A - Ember', + 'Artist A - Mirage', + 'Artist A - Vertex', + 'Artist B - Cascade', + 'Artist B - Solitude', ]); }); it('breaks album-name ties by artist when sorting by name', () => { const input = [ - album('Zebra', 'Greatest Hits'), - album('Alpha', 'Greatest Hits'), + album('Artist Z', 'Greatest Hits'), + album('Artist A', 'Greatest Hits'), ]; const ordered = sortSubsonicAlbums(input, 'alphabeticalByName').map(a => a.artist); - expect(ordered).toEqual(['Alpha', 'Zebra']); + expect(ordered).toEqual(['Artist A', 'Artist Z']); + }); + + it('orders each artist chronologically (then by title) when sorting by artist+year', () => { + const input = [ + album('Artist A', 'Mirage', 1982), + album('Artist B', 'Nocturne', 1997), + album('Artist A', 'Debut', 1981), + album('Artist B', 'Aftermath', 2001), + album('Artist A', 'Reprise', 1982), // same year as Mirage → title tiebreak + ]; + const ordered = sortSubsonicAlbums(input, 'byArtistThenYear').map(a => `${a.artist} - ${a.name}`); + expect(ordered).toEqual([ + 'Artist A - Debut', + 'Artist A - Mirage', + 'Artist A - Reprise', + 'Artist B - Nocturne', + 'Artist B - Aftermath', + ]); }); }); diff --git a/src/utils/library/albumBrowseSort.ts b/src/utils/library/albumBrowseSort.ts index dc55087e..f18530fb 100644 --- a/src/utils/library/albumBrowseSort.ts +++ b/src/utils/library/albumBrowseSort.ts @@ -1,12 +1,19 @@ import type { SubsonicAlbum } from '../../api/subsonicTypes'; import type { LibrarySortClause } from '../../api/library'; -export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist'; +export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist' | 'byArtistThenYear'; export function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] { - // Always append a secondary key so albums sharing the primary key keep a - // stable order — by artist groups each artist's albums by title (rather than - // an undefined order within the artist), mirroring `sortSubsonicAlbums`. + // Always append secondary keys so albums sharing the primary key keep a stable + // order (mirrors `sortSubsonicAlbums`). + if (sort === 'byArtistThenYear') { + // Artist, then chronological (oldest first), then title as a same-year tiebreak. + return [ + { field: 'artist', dir: 'asc' }, + { field: 'year', dir: 'asc' }, + { field: 'name', dir: 'asc' }, + ]; + } if (sort === 'alphabeticalByArtist') { return [ { field: 'artist', dir: 'asc' }, @@ -19,12 +26,31 @@ export function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] { ]; } +/** + * Subsonic `getAlbumList` type to fetch with for a browse sort (server fallback + * path only — the local index handles sorting itself). `byArtistThenYear` has + * no server equivalent, so fetch by artist and let `sortSubsonicAlbums` apply + * the per-page year ordering on top. + */ +export function albumListFetchType( + sort: AlbumBrowseSort, +): 'alphabeticalByName' | 'alphabeticalByArtist' { + return sort === 'alphabeticalByName' ? 'alphabeticalByName' : 'alphabeticalByArtist'; +} + export function sortSubsonicAlbums(albums: SubsonicAlbum[], sort: AlbumBrowseSort): SubsonicAlbum[] { const out = [...albums]; - out.sort((a, b) => - sort === 'alphabeticalByArtist' + out.sort((a, b) => { + if (sort === 'byArtistThenYear') { + return ( + a.artist.localeCompare(b.artist) || + (a.year ?? 0) - (b.year ?? 0) || + a.name.localeCompare(b.name) + ); + } + return sort === 'alphabeticalByArtist' ? a.artist.localeCompare(b.artist) || a.name.localeCompare(b.name) - : a.name.localeCompare(b.name) || a.artist.localeCompare(b.artist), - ); + : a.name.localeCompare(b.name) || a.artist.localeCompare(b.artist); + }); return out; }