feat(albums): "Artist → Year" album sort option (#1120)

* feat(albums): "Artist → Year" sort option

Adds a third album browse sort that groups albums by artist and orders each
artist's albums chronologically (oldest first, title as a same-year tiebreak)
— the double-sort requested in #1113. The local index sorts globally via
[{artist},{year},{name}]; the server fallback fetches by artist and applies the
year ordering per page (Subsonic has no compound sort).

* i18n(albums): Artist → Year sort label (9 locales)

* docs(changelog): add Artist → Year to the album sorting entry (#1120)
This commit is contained in:
Psychotoxical
2026-06-17 22:50:25 +02:00
committed by GitHub
parent ad74578ef6
commit ed52a9991f
14 changed files with 90 additions and 27 deletions
+3 -2
View File
@@ -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 AZ 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
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: 'Alle Alben',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Künstler)',
sortByArtistYear: 'Künstler → Jahr',
sortNewest: 'Neueste zuerst',
sortRandom: 'Zufällig',
yearFrom: 'Von',
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: 'All Albums',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Artist)',
sortByArtistYear: 'Artist → Year',
sortNewest: 'Newest first',
sortRandom: 'Random',
yearFrom: 'From',
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: 'Todos los Álbumes',
sortByName: 'AZ (Álbum)',
sortByArtist: 'AZ (Artista)',
sortByArtistYear: 'Artista → Año',
sortNewest: 'Más recientes primero',
sortRandom: 'Aleatorio',
yearFrom: 'Desde',
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: 'Tous les albums',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Artiste)',
sortByArtistYear: 'Artiste → Année',
sortNewest: 'Plus récents',
sortRandom: 'Aléatoire',
yearFrom: 'De',
+1
View File
@@ -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',
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: 'Alle albums',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Artiest)',
sortByArtistYear: 'Artiest → Jaar',
sortNewest: 'Nieuwste eerst',
sortRandom: 'Willekeurig',
yearFrom: 'Van',
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: 'Toate albumele',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Artist)',
sortByArtistYear: 'Artist → An',
sortNewest: 'Cele mai noi primele',
sortRandom: 'Aleatoriu',
yearFrom: 'De la',
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: 'Все альбомы',
sortByName: 'А–Я (альбом)',
sortByArtist: 'А–Я (исполнитель)',
sortByArtistYear: 'Исполнитель → Год',
sortNewest: 'Сначала новые',
sortRandom: 'Случайно',
yearFrom: 'С',
+1
View File
@@ -2,6 +2,7 @@ export const albums = {
title: '全部专辑',
sortByName: '按名称排序 (A-Z)',
sortByArtist: '按艺术家排序 (A-Z)',
sortByArtistYear: '艺术家 → 年份',
sortNewest: '最新优先',
sortRandom: '随机排序',
yearFrom: '从',
+1
View File
@@ -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 (
+2 -2
View File
@@ -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 };
+41 -15
View File
@@ -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',
]);
});
});
+34 -8
View File
@@ -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'
? a.artist.localeCompare(b.artist) || a.name.localeCompare(b.name)
: a.name.localeCompare(b.name) || a.artist.localeCompare(b.artist),
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);
});
return out;
}