mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,6 +2,7 @@ export const albums = {
|
||||
title: 'Все альбомы',
|
||||
sortByName: 'А–Я (альбом)',
|
||||
sortByArtist: 'А–Я (исполнитель)',
|
||||
sortByArtistYear: 'Исполнитель → Год',
|
||||
sortNewest: 'Сначала новые',
|
||||
sortRandom: 'Случайно',
|
||||
yearFrom: 'С',
|
||||
|
||||
@@ -2,6 +2,7 @@ export const albums = {
|
||||
title: '全部专辑',
|
||||
sortByName: '按名称排序 (A-Z)',
|
||||
sortByArtist: '按艺术家排序 (A-Z)',
|
||||
sortByArtistYear: '艺术家 → 年份',
|
||||
sortNewest: '最新优先',
|
||||
sortRandom: '随机排序',
|
||||
yearFrom: '从',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user