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
+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'
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;
}