mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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:
+3
-2
@@ -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.
|
* 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.
|
* 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
|
### "Add to playlist" from the player bar added the whole album
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'Alle Alben',
|
title: 'Alle Alben',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
sortByArtist: 'A–Z (Künstler)',
|
sortByArtist: 'A–Z (Künstler)',
|
||||||
|
sortByArtistYear: 'Künstler → Jahr',
|
||||||
sortNewest: 'Neueste zuerst',
|
sortNewest: 'Neueste zuerst',
|
||||||
sortRandom: 'Zufällig',
|
sortRandom: 'Zufällig',
|
||||||
yearFrom: 'Von',
|
yearFrom: 'Von',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'All Albums',
|
title: 'All Albums',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
sortByArtist: 'A–Z (Artist)',
|
sortByArtist: 'A–Z (Artist)',
|
||||||
|
sortByArtistYear: 'Artist → Year',
|
||||||
sortNewest: 'Newest first',
|
sortNewest: 'Newest first',
|
||||||
sortRandom: 'Random',
|
sortRandom: 'Random',
|
||||||
yearFrom: 'From',
|
yearFrom: 'From',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'Todos los Álbumes',
|
title: 'Todos los Álbumes',
|
||||||
sortByName: 'A–Z (Álbum)',
|
sortByName: 'A–Z (Álbum)',
|
||||||
sortByArtist: 'A–Z (Artista)',
|
sortByArtist: 'A–Z (Artista)',
|
||||||
|
sortByArtistYear: 'Artista → Año',
|
||||||
sortNewest: 'Más recientes primero',
|
sortNewest: 'Más recientes primero',
|
||||||
sortRandom: 'Aleatorio',
|
sortRandom: 'Aleatorio',
|
||||||
yearFrom: 'Desde',
|
yearFrom: 'Desde',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'Tous les albums',
|
title: 'Tous les albums',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
sortByArtist: 'A–Z (Artiste)',
|
sortByArtist: 'A–Z (Artiste)',
|
||||||
|
sortByArtistYear: 'Artiste → Année',
|
||||||
sortNewest: 'Plus récents',
|
sortNewest: 'Plus récents',
|
||||||
sortRandom: 'Aléatoire',
|
sortRandom: 'Aléatoire',
|
||||||
yearFrom: 'De',
|
yearFrom: 'De',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'Alle album',
|
title: 'Alle album',
|
||||||
sortByName: 'A–Å (Album)',
|
sortByName: 'A–Å (Album)',
|
||||||
sortByArtist: 'A–Å (Artist)',
|
sortByArtist: 'A–Å (Artist)',
|
||||||
|
sortByArtistYear: 'Artist → År',
|
||||||
sortNewest: 'Vis nyeste først',
|
sortNewest: 'Vis nyeste først',
|
||||||
sortRandom: 'Tilfeldig',
|
sortRandom: 'Tilfeldig',
|
||||||
yearFrom: 'Fra',
|
yearFrom: 'Fra',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'Alle albums',
|
title: 'Alle albums',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
sortByArtist: 'A–Z (Artiest)',
|
sortByArtist: 'A–Z (Artiest)',
|
||||||
|
sortByArtistYear: 'Artiest → Jaar',
|
||||||
sortNewest: 'Nieuwste eerst',
|
sortNewest: 'Nieuwste eerst',
|
||||||
sortRandom: 'Willekeurig',
|
sortRandom: 'Willekeurig',
|
||||||
yearFrom: 'Van',
|
yearFrom: 'Van',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'Toate albumele',
|
title: 'Toate albumele',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
sortByArtist: 'A–Z (Artist)',
|
sortByArtist: 'A–Z (Artist)',
|
||||||
|
sortByArtistYear: 'Artist → An',
|
||||||
sortNewest: 'Cele mai noi primele',
|
sortNewest: 'Cele mai noi primele',
|
||||||
sortRandom: 'Aleatoriu',
|
sortRandom: 'Aleatoriu',
|
||||||
yearFrom: 'De la',
|
yearFrom: 'De la',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: 'Все альбомы',
|
title: 'Все альбомы',
|
||||||
sortByName: 'А–Я (альбом)',
|
sortByName: 'А–Я (альбом)',
|
||||||
sortByArtist: 'А–Я (исполнитель)',
|
sortByArtist: 'А–Я (исполнитель)',
|
||||||
|
sortByArtistYear: 'Исполнитель → Год',
|
||||||
sortNewest: 'Сначала новые',
|
sortNewest: 'Сначала новые',
|
||||||
sortRandom: 'Случайно',
|
sortRandom: 'Случайно',
|
||||||
yearFrom: 'С',
|
yearFrom: 'С',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const albums = {
|
|||||||
title: '全部专辑',
|
title: '全部专辑',
|
||||||
sortByName: '按名称排序 (A-Z)',
|
sortByName: '按名称排序 (A-Z)',
|
||||||
sortByArtist: '按艺术家排序 (A-Z)',
|
sortByArtist: '按艺术家排序 (A-Z)',
|
||||||
|
sortByArtistYear: '艺术家 → 年份',
|
||||||
sortNewest: '最新优先',
|
sortNewest: '最新优先',
|
||||||
sortRandom: '随机排序',
|
sortRandom: '随机排序',
|
||||||
yearFrom: '从',
|
yearFrom: '从',
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ export default function Albums() {
|
|||||||
const sortOptions: { value: SortType; label: string }[] = [
|
const sortOptions: { value: SortType; label: string }[] = [
|
||||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||||
|
{ value: 'byArtistThenYear', label: t('albums.sortByArtistYear') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
filterAlbumsByYearBounds,
|
filterAlbumsByYearBounds,
|
||||||
} from './albumBrowseFilters';
|
} from './albumBrowseFilters';
|
||||||
import { albumYearSubsonicParams } from './albumYearFilter';
|
import { albumYearSubsonicParams } from './albumYearFilter';
|
||||||
import { sortSubsonicAlbums } from './albumBrowseSort';
|
import { albumListFetchType, sortSubsonicAlbums } from './albumBrowseSort';
|
||||||
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
|
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
|
||||||
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export async function fetchAlbumBrowseNetwork(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = applyNetworkPostFilters(
|
const data = applyNetworkPostFilters(
|
||||||
await getAlbumList(query.sort, pageSize, offset, {}),
|
await getAlbumList(albumListFetchType(query.sort), pageSize, offset, {}),
|
||||||
query,
|
query,
|
||||||
);
|
);
|
||||||
return { albums: data, hasMore: data.length === pageSize };
|
return { albums: data, hasMore: data.length === pageSize };
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
|
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
|
||||||
|
|
||||||
const album = (artist: string, name: string): SubsonicAlbum =>
|
const album = (artist: string, name: string, year?: number): SubsonicAlbum =>
|
||||||
({ id: `${artist}-${name}`, artist, name }) as SubsonicAlbum;
|
({ id: `${artist}-${name}`, artist, name, year }) as SubsonicAlbum;
|
||||||
|
|
||||||
describe('albumSortClauses', () => {
|
describe('albumSortClauses', () => {
|
||||||
it('sorts by artist then album name', () => {
|
it('sorts by artist then album name', () => {
|
||||||
@@ -19,33 +19,59 @@ describe('albumSortClauses', () => {
|
|||||||
{ field: 'artist', dir: 'asc' },
|
{ 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', () => {
|
describe('sortSubsonicAlbums', () => {
|
||||||
it('orders each artist group by album name when sorting by artist', () => {
|
it('orders each artist group by album name when sorting by artist', () => {
|
||||||
const input = [
|
const input = [
|
||||||
album('Rammstein', 'Sehnsucht'),
|
album('Artist B', 'Solitude'),
|
||||||
album('Duran Duran', 'Rio'),
|
album('Artist A', 'Mirage'),
|
||||||
album('Rammstein', 'Mutter'),
|
album('Artist B', 'Cascade'),
|
||||||
album('Duran Duran', 'DD'),
|
album('Artist A', 'Ember'),
|
||||||
album('Duran Duran', 'The Wedding Album'),
|
album('Artist A', 'Vertex'),
|
||||||
];
|
];
|
||||||
const ordered = sortSubsonicAlbums(input, 'alphabeticalByArtist').map(a => `${a.artist} - ${a.name}`);
|
const ordered = sortSubsonicAlbums(input, 'alphabeticalByArtist').map(a => `${a.artist} - ${a.name}`);
|
||||||
expect(ordered).toEqual([
|
expect(ordered).toEqual([
|
||||||
'Duran Duran - DD',
|
'Artist A - Ember',
|
||||||
'Duran Duran - Rio',
|
'Artist A - Mirage',
|
||||||
'Duran Duran - The Wedding Album',
|
'Artist A - Vertex',
|
||||||
'Rammstein - Mutter',
|
'Artist B - Cascade',
|
||||||
'Rammstein - Sehnsucht',
|
'Artist B - Solitude',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('breaks album-name ties by artist when sorting by name', () => {
|
it('breaks album-name ties by artist when sorting by name', () => {
|
||||||
const input = [
|
const input = [
|
||||||
album('Zebra', 'Greatest Hits'),
|
album('Artist Z', 'Greatest Hits'),
|
||||||
album('Alpha', 'Greatest Hits'),
|
album('Artist A', 'Greatest Hits'),
|
||||||
];
|
];
|
||||||
const ordered = sortSubsonicAlbums(input, 'alphabeticalByName').map(a => a.artist);
|
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 { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
import type { LibrarySortClause } from '../../api/library';
|
import type { LibrarySortClause } from '../../api/library';
|
||||||
|
|
||||||
export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist';
|
export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist' | 'byArtistThenYear';
|
||||||
|
|
||||||
export function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] {
|
export function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] {
|
||||||
// Always append a secondary key so albums sharing the primary key keep a
|
// Always append secondary keys so albums sharing the primary key keep a stable
|
||||||
// stable order — by artist groups each artist's albums by title (rather than
|
// order (mirrors `sortSubsonicAlbums`).
|
||||||
// an undefined order within the artist), mirroring `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') {
|
if (sort === 'alphabeticalByArtist') {
|
||||||
return [
|
return [
|
||||||
{ field: 'artist', dir: 'asc' },
|
{ 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[] {
|
export function sortSubsonicAlbums(albums: SubsonicAlbum[], sort: AlbumBrowseSort): SubsonicAlbum[] {
|
||||||
const out = [...albums];
|
const out = [...albums];
|
||||||
out.sort((a, b) =>
|
out.sort((a, b) => {
|
||||||
sort === 'alphabeticalByArtist'
|
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.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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user