mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(releases): scope genre filters to catalog feed
This commit is contained in:
@@ -791,6 +791,9 @@ pub struct LibraryMainstageAlbumsRequest {
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u32>,
|
||||
/// OR-matched atomic genres, applied before chronological album grouping.
|
||||
#[serde(default)]
|
||||
pub genres: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -798,6 +801,7 @@ pub struct LibraryMainstageAlbumsRequest {
|
||||
pub struct LibraryMainstageAlbumsResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub has_more: bool,
|
||||
pub genre_counts: Vec<GenreAlbumCountDto>,
|
||||
}
|
||||
|
||||
/// FTS track search over an ordered multi-library scope.
|
||||
|
||||
@@ -6,8 +6,8 @@ use rusqlite::params_from_iter;
|
||||
use crate::album_compilation_filter::pick_album_group_artist;
|
||||
use crate::browse_support::overlay_album_starred_at_rows;
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryMainstageAlbumFeed, LibraryMainstageAlbumsRequest,
|
||||
LibraryMainstageAlbumsResponse, LibraryScopePair,
|
||||
GenreAlbumCountDto, LibraryAlbumDto, LibraryMainstageAlbumFeed,
|
||||
LibraryMainstageAlbumsRequest, LibraryMainstageAlbumsResponse, LibraryScopePair,
|
||||
};
|
||||
use crate::scope_merge::{
|
||||
non_empty_scopes, scope_cte_sql, ALBUM_DEDUP_KEY, ALBUM_PICK_KEY,
|
||||
@@ -35,12 +35,22 @@ fn candidate_columns(feed_at: &str, priority: usize) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn new_release_candidates_sql(scopes: &[LibraryScopePair]) -> String {
|
||||
fn new_release_candidates_sql(scopes: &[LibraryScopePair], genre_count: usize) -> String {
|
||||
scopes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(priority, _)| {
|
||||
let columns = candidate_columns("t.server_created_at", priority);
|
||||
let genre_predicate = if genre_count == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
let placeholders = (0..genre_count).map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
format!(
|
||||
" AND EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
AND tg.genre COLLATE NOCASE IN ({placeholders}))"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"SELECT * FROM ( \
|
||||
SELECT {columns} \
|
||||
@@ -49,7 +59,7 @@ fn new_release_candidates_sql(scopes: &[LibraryScopePair]) -> String {
|
||||
ON ck.server_id = t.server_id AND ck.track_id = t.id \
|
||||
WHERE t.server_id = ? AND t.library_id = ? \
|
||||
AND t.deleted = 0 AND t.server_created_at IS NOT NULL \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' {genre_predicate} \
|
||||
ORDER BY t.server_created_at DESC, t.album_id ASC, t.id ASC \
|
||||
LIMIT ? \
|
||||
)"
|
||||
@@ -81,6 +91,7 @@ fn recently_played_candidates_sql() -> String {
|
||||
fn build_mainstage_query(
|
||||
scopes: &[LibraryScopePair],
|
||||
feed: LibraryMainstageAlbumFeed,
|
||||
genres: &[String],
|
||||
bounded_candidates: u32,
|
||||
result_offset: u32,
|
||||
result_limit: u32,
|
||||
@@ -91,9 +102,12 @@ fn build_mainstage_query(
|
||||
for pair in scopes {
|
||||
binds.push(SqlValue::Text(pair.server_id.clone()));
|
||||
binds.push(SqlValue::Text(pair.library_id.clone()));
|
||||
for genre in genres {
|
||||
binds.push(SqlValue::Text(genre.clone()));
|
||||
}
|
||||
binds.push(SqlValue::Integer(i64::from(bounded_candidates)));
|
||||
}
|
||||
new_release_candidates_sql(scopes)
|
||||
new_release_candidates_sql(scopes, genres.len())
|
||||
}
|
||||
LibraryMainstageAlbumFeed::RecentlyPlayed => {
|
||||
binds.push(SqlValue::Integer(i64::from(bounded_candidates)));
|
||||
@@ -151,6 +165,33 @@ fn build_mainstage_query(
|
||||
(sql, binds)
|
||||
}
|
||||
|
||||
fn new_release_genre_counts(
|
||||
conn: &rusqlite::Connection,
|
||||
scopes: &[LibraryScopePair],
|
||||
) -> rusqlite::Result<Vec<GenreAlbumCountDto>> {
|
||||
let (cte, binds) = scope_cte_sql(scopes);
|
||||
let sql = format!(
|
||||
"{cte} \
|
||||
SELECT tg.genre, COUNT(DISTINCT t.album_id), COUNT(DISTINCT t.id) \
|
||||
FROM scope s CROSS JOIN track t \
|
||||
ON t.server_id = s.server_id AND t.library_id = s.library_id \
|
||||
INNER JOIN track_genre tg ON tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
WHERE t.deleted = 0 AND t.server_created_at IS NOT NULL \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
GROUP BY tg.genre COLLATE NOCASE \
|
||||
ORDER BY COUNT(DISTINCT t.album_id) DESC, tg.genre COLLATE NOCASE ASC"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
|
||||
Ok(GenreAlbumCountDto {
|
||||
value: row.get(0)?,
|
||||
album_count: row.get::<_, i64>(1)?.max(0) as u32,
|
||||
song_count: row.get::<_, i64>(2)?.max(0) as u32,
|
||||
})
|
||||
})?.collect::<rusqlite::Result<Vec<_>>>();
|
||||
rows
|
||||
}
|
||||
|
||||
fn map_mainstage_album(
|
||||
r: &rusqlite::Row<'_>,
|
||||
include_catalog_created_at: bool,
|
||||
@@ -194,11 +235,17 @@ pub fn list_mainstage_albums(
|
||||
let initial_candidates = candidate_limit(offset, fetch_limit);
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let genre_counts = if request.feed == LibraryMainstageAlbumFeed::NewReleases {
|
||||
new_release_genre_counts(conn, scopes)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut bounded_candidates = initial_candidates;
|
||||
loop {
|
||||
let (sql, binds) = build_mainstage_query(
|
||||
scopes,
|
||||
request.feed,
|
||||
&request.genres,
|
||||
bounded_candidates,
|
||||
0,
|
||||
requested_results,
|
||||
@@ -233,7 +280,7 @@ pub fn list_mainstage_albums(
|
||||
let has_more = albums.len() > limit as usize;
|
||||
albums.truncate(limit as usize);
|
||||
overlay_album_starred_at_rows(conn, &mut albums);
|
||||
return Ok(LibraryMainstageAlbumsResponse { albums, has_more });
|
||||
return Ok(LibraryMainstageAlbumsResponse { albums, has_more, genre_counts });
|
||||
}
|
||||
}).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -311,6 +358,7 @@ mod tests {
|
||||
feed,
|
||||
limit: Some(30),
|
||||
offset: None,
|
||||
genres: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +439,50 @@ mod tests {
|
||||
assert_eq!(response.albums[0].name, "Selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_filter_and_counts_stay_within_dated_selected_release_scope() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "rock", "Rock release", "a-rock", "l1", Some(200)),
|
||||
track("s2", "jazz", "Jazz release", "a-jazz", "l2", Some(300)),
|
||||
track("s1", "missing-date", "Undated", "a-undated", "l1", None),
|
||||
track("s1", "outside", "Outside", "a-outside", "other", Some(400)),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test.mainstage_genres", |conn| {
|
||||
for (server, track_id, genre) in [
|
||||
("s1", "rock", "Rock"),
|
||||
("s2", "jazz", "Jazz"),
|
||||
("s1", "missing-date", "Ambient"),
|
||||
("s1", "outside", "Metal"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO track_genre (server_id, track_id, genre, album_id, library_id) \
|
||||
VALUES (?1, ?2, ?3, (SELECT album_id FROM track WHERE server_id = ?1 AND id = ?2), \
|
||||
(SELECT library_id FROM track WHERE server_id = ?1 AND id = ?2))",
|
||||
rusqlite::params![server, track_id, genre],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut req = request(
|
||||
vec![scope("s1", "l1"), scope("s2", "l2")],
|
||||
LibraryMainstageAlbumFeed::NewReleases,
|
||||
);
|
||||
req.genres = vec!["rock".into()];
|
||||
let response = list_mainstage_albums(&store, &req).unwrap();
|
||||
|
||||
assert_eq!(response.albums.iter().map(|album| album.id.as_str()).collect::<Vec<_>>(), ["a-rock"]);
|
||||
assert_eq!(
|
||||
response.genre_counts.iter().map(|row| (row.value.as_str(), row.album_count)).collect::<Vec<_>>(),
|
||||
[("Jazz", 1), ("Rock", 1)],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_played_collapses_repeated_sessions_and_uses_latest_global_time() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
@@ -549,6 +641,7 @@ mod tests {
|
||||
let response = LibraryMainstageAlbumsResponse {
|
||||
albums: Vec::new(),
|
||||
has_more: true,
|
||||
genre_counts: Vec::new(),
|
||||
};
|
||||
assert_eq!(serde_json::to_value(response).unwrap()["hasMore"], true);
|
||||
}
|
||||
@@ -596,6 +689,7 @@ mod tests {
|
||||
let (sql, binds) = build_mainstage_query(
|
||||
scopes,
|
||||
feed,
|
||||
&[],
|
||||
candidate_limit(0, 31),
|
||||
0,
|
||||
31,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
|
||||
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
|
||||
import { resolveAlbum } from '@/features/offline';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { dedupeById } from '@/lib/util/dedupeById';
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Download, HardDriveDownload } from 'lucide-react';
|
||||
import SelectionToggleButton from '@/ui/SelectionToggleButton';
|
||||
@@ -42,7 +40,6 @@ import { deriveLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
|
||||
import { loadLocalNewReleases } from '@/lib/library/newReleasesLocal';
|
||||
import { mergeHotNewReleases } from '@/features/album/utils/hotNewReleases';
|
||||
import { useHotNewReleaseOverlay } from '@/features/album/hooks/useHotNewReleaseOverlay';
|
||||
import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
@@ -51,11 +48,6 @@ function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
}
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
return dedupeById(results.flat()).sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
||||
}
|
||||
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
@@ -108,6 +100,7 @@ export default function NewReleases() {
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
||||
const [hasMore, setHasMore] = useState(() => initialHasMore ?? true);
|
||||
const [genreCounts, setGenreCounts] = useState<Array<{ genre: string; count: number }>>([]);
|
||||
const {
|
||||
scrollBodyEl,
|
||||
bindScrollBody: bindNewReleasesScrollBody,
|
||||
@@ -115,7 +108,6 @@ export default function NewReleases() {
|
||||
} = useInpageScrollViewport();
|
||||
const {
|
||||
loading,
|
||||
setLoading,
|
||||
resetPage,
|
||||
runLoad,
|
||||
requestNextPage,
|
||||
@@ -143,12 +135,6 @@ export default function NewReleases() {
|
||||
? mergeHotNewReleases(albums, hotAlbums)
|
||||
: albums;
|
||||
}, [textSearchActive, textSearchAlbums, albums, hotAlbums, genreFiltered, selectedGenres, scopedSearchQuery]);
|
||||
const hotReleaseCount = useMemo(
|
||||
() => !genreFiltered && !scopedSearchQuery
|
||||
? displayAlbums.filter(album => isAlbumRecentlyAdded(album.created)).length
|
||||
: 0,
|
||||
[displayAlbums, genreFiltered, scopedSearchQuery],
|
||||
);
|
||||
|
||||
const loadingGrid = textSearchActive ? textSearchLoading : loading;
|
||||
const gridHasMore = textSearchActive ? false : (!genreFiltered && hasMore);
|
||||
@@ -212,42 +198,35 @@ export default function NewReleases() {
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const load = useCallback(async (offset: number, append = false) => {
|
||||
const load = useCallback(async (offset: number, append = false, genres: string[] = []) => {
|
||||
await runLoad(async () => {
|
||||
const data = await loadLocalNewReleases(anchorServerId ?? '', releaseScopes, PAGE_SIZE, offset);
|
||||
const data = await loadLocalNewReleases(
|
||||
anchorServerId ?? '', releaseScopes, PAGE_SIZE, offset, genres,
|
||||
);
|
||||
if (append) setAlbums(prev => [...prev, ...data.albums]);
|
||||
else setAlbums(data.albums);
|
||||
setHasMore(data.hasMore);
|
||||
if (!append) setGenreCounts(data.genreCounts.map(row => ({ genre: row.value, count: row.albumCount })));
|
||||
});
|
||||
}, [anchorServerId, releaseScopes, runLoad]);
|
||||
|
||||
const loadFiltered = useCallback(async (genres: string[]) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setAlbums(await fetchByGenres(genres));
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// musicLibraryFilterVersion is an intentional re-create trigger (fetchByGenres
|
||||
// reads the active library filter internally); the setters are stable. The
|
||||
// loader must refresh when that version bumps even though it is unused here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [musicLibraryFilterVersion]);
|
||||
await load(0, false, genres);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoringSessionRef.current || scopedSearchQuery) return;
|
||||
if (genreFiltered) loadFiltered(selectedGenres);
|
||||
else {
|
||||
resetPage();
|
||||
void load(0);
|
||||
void load(0, false, selectedGenres);
|
||||
}
|
||||
}, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery, releaseScopeFingerprint]);
|
||||
}, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery, releaseScopeFingerprint, musicLibraryFilterVersion]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!gridHasMore || genreFiltered || textSearchActive || isBlocked()) return;
|
||||
requestNextPage(offset => load(offset, true));
|
||||
}, [gridHasMore, genreFiltered, textSearchActive, isBlocked, requestNextPage, load]);
|
||||
requestNextPage(offset => load(offset, true, selectedGenres));
|
||||
}, [gridHasMore, genreFiltered, textSearchActive, isBlocked, requestNextPage, load, selectedGenres]);
|
||||
|
||||
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
||||
active: gridHasMore,
|
||||
@@ -294,11 +273,6 @@ export default function NewReleases() {
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('sidebar.newReleases')}
|
||||
</h1>
|
||||
{!selectionMode && hotReleaseCount > 0 && (
|
||||
<span className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||
{t('albums.newReleasesHotCount', { count: hotReleaseCount })}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
@@ -312,7 +286,11 @@ export default function NewReleases() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<GenreFilterBar
|
||||
selected={selectedGenres}
|
||||
onSelectionChange={setSelectedGenres}
|
||||
catalogGenres={genreCounts}
|
||||
/>
|
||||
)}
|
||||
<SelectionToggleButton
|
||||
active={selectionMode}
|
||||
|
||||
@@ -145,6 +145,7 @@ describe('homeFeedLoader failure isolation', () => {
|
||||
libraryScopeListMainstageAlbums: vi.fn(() => new Promise<{
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
genreCounts: [];
|
||||
}>(() => {})),
|
||||
},
|
||||
});
|
||||
@@ -168,7 +169,7 @@ describe('homeFeedLoader failure isolation', () => {
|
||||
it('distinguishes a successful empty chronological query from an error', async () => {
|
||||
const success = await loadHomeChronologicalFeed({
|
||||
anchorServerId: 'a', scopes: [], feed: 'recentlyPlayed',
|
||||
deps: { libraryScopeListMainstageAlbums: vi.fn(async () => ({ albums: [], hasMore: false })) },
|
||||
deps: { libraryScopeListMainstageAlbums: vi.fn(async () => ({ albums: [], hasMore: false, genreCounts: [] })) },
|
||||
});
|
||||
const error = await loadHomeChronologicalFeed({
|
||||
anchorServerId: 'a', scopes: [], feed: 'recentlyPlayed',
|
||||
@@ -355,6 +356,7 @@ describe('homeFeedLoader failure isolation', () => {
|
||||
const libraryScopeListMainstageAlbums = vi.fn(async () => ({
|
||||
albums: [albumDto('b', 'next-2'), albumDto('a', 'next-1')],
|
||||
hasMore: false,
|
||||
genreCounts: [],
|
||||
}));
|
||||
const result = await loadMoreHomeAlbums({
|
||||
snapshot: snapshot(), section: 'recent', mixConfig,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type {
|
||||
LibraryAlbumDto,
|
||||
LibraryArtistDto,
|
||||
GenreAlbumCountRow,
|
||||
LibraryScopePair,
|
||||
LibraryTrackDto,
|
||||
} from './dto';
|
||||
@@ -36,11 +37,13 @@ export interface LibraryScopeMainstageAlbumsRequest {
|
||||
feed: 'newReleases' | 'recentlyPlayed';
|
||||
limit: number;
|
||||
offset: number;
|
||||
genres?: string[];
|
||||
}
|
||||
|
||||
export interface LibraryScopeMainstageAlbumsResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
genreCounts: GenreAlbumCountRow[];
|
||||
}
|
||||
|
||||
export interface LibraryScopeAlbumDetailRequest {
|
||||
@@ -143,6 +146,7 @@ export function libraryScopeListMainstageAlbums(
|
||||
}).then(response => ({
|
||||
albums: mapAlbumsServerId(response.albums, serverId),
|
||||
hasMore: response.hasMore,
|
||||
genreCounts: response.genreCounts,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('loadLocalNewReleases', () => {
|
||||
feed: 'newReleases',
|
||||
limit: 30,
|
||||
offset: 60,
|
||||
genres: [],
|
||||
});
|
||||
expect(result.albums.map(album => [album.serverId, album.id])).toEqual([
|
||||
['server-b', 'newer'],
|
||||
@@ -38,7 +39,9 @@ describe('loadLocalNewReleases', () => {
|
||||
});
|
||||
|
||||
it('skips IPC for an empty selected scope', async () => {
|
||||
await expect(loadLocalNewReleases('', [], 30)).resolves.toEqual({ albums: [], hasMore: false });
|
||||
await expect(loadLocalNewReleases('', [], 30)).resolves.toEqual({
|
||||
albums: [], hasMore: false, genreCounts: [],
|
||||
});
|
||||
expect(libraryScopeListMainstageAlbums).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { libraryScopeListMainstageAlbums, type LibraryScopePair } from '@/lib/api/library/scopeReads';
|
||||
import { albumToAlbum } from '@/lib/library/advancedSearchLocal';
|
||||
import type { GenreAlbumCountRow } from '@/lib/api/library/dto';
|
||||
|
||||
export async function loadLocalNewReleases(
|
||||
anchorServerId: string,
|
||||
scopes: LibraryScopePair[],
|
||||
limit: number,
|
||||
offset = 0,
|
||||
) {
|
||||
if (!anchorServerId || scopes.length === 0) return { albums: [], hasMore: false };
|
||||
genres: string[] = [],
|
||||
): Promise<{ albums: ReturnType<typeof albumToAlbum>[]; hasMore: boolean; genreCounts: GenreAlbumCountRow[] }> {
|
||||
if (!anchorServerId || scopes.length === 0) return { albums: [], hasMore: false, genreCounts: [] };
|
||||
const response = await libraryScopeListMainstageAlbums(anchorServerId, {
|
||||
scopes,
|
||||
feed: 'newReleases',
|
||||
limit,
|
||||
offset,
|
||||
genres,
|
||||
});
|
||||
return { albums: response.albums.map(albumToAlbum), hasMore: response.hasMore };
|
||||
return {
|
||||
albums: response.albums.map(albumToAlbum),
|
||||
hasMore: response.hasMore,
|
||||
genreCounts: response.genreCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Няма любими албуми, съответстващи на текущите филтри.',
|
||||
noCompilations: 'Няма компилации, съответстващи на текущите филтри.',
|
||||
noMatchingFilters: 'Няма албуми, съответстващи на текущите филтри.',
|
||||
newReleasesHotCount_one: '{{count}} нов албум през последните 48 часа',
|
||||
newReleasesHotCount_other: '{{count}} нови албума през последните 48 часа',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Keine Lieblingsalben entsprechen den aktuellen Filtern.',
|
||||
noCompilations: 'Keine Sampler entsprechen den aktuellen Filtern.',
|
||||
noMatchingFilters: 'Keine Alben entsprechen den aktuellen Filtern.',
|
||||
newReleasesHotCount_one: '{{count}} neu in den letzten 48 Stunden',
|
||||
newReleasesHotCount_other: '{{count}} neu in den letzten 48 Stunden',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'No favorite albums match the current filters.',
|
||||
noCompilations: 'No compilations match the current filters.',
|
||||
noMatchingFilters: 'No albums match the current filters.',
|
||||
newReleasesHotCount_one: '{{count}} new in the last 48 hours',
|
||||
newReleasesHotCount_other: '{{count}} new in the last 48 hours',
|
||||
};
|
||||
|
||||
@@ -38,7 +38,5 @@ export const albums = {
|
||||
noFavorites: 'Ningún álbum favorito coincide con los filtros actuales.',
|
||||
noCompilations: 'Ninguna compilación coincide con los filtros actuales.',
|
||||
noMatchingFilters: 'Ningún álbum coincide con los filtros actuales.',
|
||||
newReleasesHotCount_one: '{{count}} novedad en las últimas 48 horas',
|
||||
newReleasesHotCount_other: '{{count}} novedades en las últimas 48 horas',
|
||||
addToPlaylist: 'Agregar a Lista de Reproducción',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Aucun album favori ne correspond aux filtres actuels.',
|
||||
noCompilations: 'Aucune compilation ne correspond aux filtres actuels.',
|
||||
noMatchingFilters: 'Aucun album ne correspond aux filtres actuels.',
|
||||
newReleasesHotCount_one: '{{count}} nouveauté au cours des dernières 48 heures',
|
||||
newReleasesHotCount_other: '{{count}} nouveautés au cours des dernières 48 heures',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Egyetlen kedvenc album sem felel meg a jelenlegi szűrőknek.',
|
||||
noCompilations: 'Egyetlen válogatás sem felel meg a jelenlegi szűrőknek.',
|
||||
noMatchingFilters: 'Egyetlen album sem felel meg a jelenlegi szűrőknek.',
|
||||
newReleasesHotCount_one: '{{count}} új album az elmúlt 48 órában',
|
||||
newReleasesHotCount_other: '{{count}} új album az elmúlt 48 órában',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Nessun album preferito corrisponde ai filtri attuali.',
|
||||
noCompilations: 'Nessuna compilation corrisponde ai filtri attualmente selezionati.',
|
||||
noMatchingFilters: 'Nessun album corrisponde ai filtri attuali.',
|
||||
newReleasesHotCount_one: '{{count}} novità nelle ultime 48 ore',
|
||||
newReleasesHotCount_other: '{{count}} novità nelle ultime 48 ore',
|
||||
};
|
||||
|
||||
@@ -38,5 +38,4 @@ export const albums = {
|
||||
noFavorites: '現在のフィルターに一致するお気に入りアルバムはありません。',
|
||||
noCompilations: '現在のフィルターに一致するコンピレーションはありません。',
|
||||
noMatchingFilters: '現在のフィルターに一致するアルバムはありません。',
|
||||
newReleasesHotCount_other: '過去48時間の新着: {{count}} 枚',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Ingen favorittalbum samsvarer med gjeldende filtre.',
|
||||
noCompilations: 'Ingen samleplater samsvarer med gjeldende filtre.',
|
||||
noMatchingFilters: 'Ingen album samsvarer med gjeldende filtre.',
|
||||
newReleasesHotCount_one: '{{count}} nyhet de siste 48 timene',
|
||||
newReleasesHotCount_other: '{{count}} nyheter de siste 48 timene',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Geen favoriete albums komen overeen met de huidige filters.',
|
||||
noCompilations: 'Geen compilaties komen overeen met de huidige filters.',
|
||||
noMatchingFilters: 'Geen albums komen overeen met de huidige filters.',
|
||||
newReleasesHotCount_one: '{{count}} nieuw album in de afgelopen 48 uur',
|
||||
newReleasesHotCount_other: '{{count}} nieuwe albums in de afgelopen 48 uur',
|
||||
};
|
||||
|
||||
@@ -38,8 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Brak ulubionych albumów pasujących do bieżących filtrów.',
|
||||
noCompilations: 'Brak kompilacji pasujących do bieżących filtrów.',
|
||||
noMatchingFilters: 'Brak albumów pasujących do bieżących filtrów.',
|
||||
newReleasesHotCount_one: '{{count}} nowy album w ciągu ostatnich 48 godzin',
|
||||
newReleasesHotCount_few: '{{count}} nowe albumy w ciągu ostatnich 48 godzin',
|
||||
newReleasesHotCount_many: '{{count}} nowych albumów w ciągu ostatnich 48 godzin',
|
||||
newReleasesHotCount_other: '{{count}} nowych albumów w ciągu ostatnich 48 godzin',
|
||||
};
|
||||
|
||||
@@ -38,6 +38,4 @@ export const albums = {
|
||||
noFavorites: 'Niciun album favorit nu corespunde filtrelor curente.',
|
||||
noCompilations: 'Nicio compilație nu corespunde filtrelor curente.',
|
||||
noMatchingFilters: 'Niciun album nu corespunde filtrelor curente.',
|
||||
newReleasesHotCount_one: '{{count}} noutate în ultimele 48 de ore',
|
||||
newReleasesHotCount_other: '{{count}} noutăți în ultimele 48 de ore',
|
||||
};
|
||||
|
||||
@@ -42,8 +42,4 @@ export const albums = {
|
||||
noFavorites: 'Нет избранных альбомов с текущими фильтрами.',
|
||||
noCompilations: 'Нет сборников с текущими фильтрами.',
|
||||
noMatchingFilters: 'Нет альбомов с текущими фильтрами.',
|
||||
newReleasesHotCount_one: '{{count}} новинка за последние 48 часов',
|
||||
newReleasesHotCount_few: '{{count}} новинки за последние 48 часов',
|
||||
newReleasesHotCount_many: '{{count}} новинок за последние 48 часов',
|
||||
newReleasesHotCount_other: '{{count}} новинки за последние 48 часов',
|
||||
};
|
||||
|
||||
@@ -38,5 +38,4 @@ export const albums = {
|
||||
noFavorites: '没有符合当前筛选条件的收藏专辑。',
|
||||
noCompilations: '没有符合当前筛选条件的合辑。',
|
||||
noMatchingFilters: '没有符合当前筛选条件的专辑。',
|
||||
newReleasesHotCount_other: '过去 48 小时内有 {{count}} 张新专辑',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user