From fc40d235d09b35730c3741ba7915eb9c84b7c625 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 10 Apr 2026 01:18:44 +0300 Subject: [PATCH 1/3] fix(statistics): scope genre insights to selected music library getGenres() ignores musicFolderId. Derive genre counts from the same paginated getAlbumList('newest') scan used for playtime and totals, using each album's genre and songCount. --- src/pages/Statistics.tsx | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index d5abcba9..034b4d42 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic'; +import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic'; import AlbumRow from '../components/AlbumRow'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; @@ -64,30 +64,29 @@ export default function Statistics() { getAlbumList('frequent', 12).catch(() => []), getAlbumList('highest', 12).catch(() => []), getArtists().catch(() => []), - getGenres().catch(() => []), - ]).then(([rc, fr, hi, a, g]) => { + ]).then(([rc, fr, hi, a]) => { setRecent(rc); setFrequent(fr); setHighest(hi); setArtistCount(a.length); - // Album/song totals come from paginated getAlbumList (see playtime effect) — getGenres is not musicFolder-scoped. - const sorted = [...g].sort((a, b) => b.songCount - a.songCount); - setGenres(sorted); setLoading(false); }).catch(() => setLoading(false)); }, [musicLibraryFilterVersion]); - // Background: playtime + album/song counts (same paginated list as library filter; caps at 5000 albums) + // Background: playtime, album/song counts, genre insights (paginated getAlbumList — respects library filter; caps at 5000 albums) useEffect(() => { let cancelled = false; setTotalPlaytime(null); setTotalAlbums(null); setTotalSongs(null); setPlaytimeCapped(false); + setGenres([]); + const unknownGenre = t('statistics.decadeUnknown'); (async () => { let playtimeSec = 0; let albumsCounted = 0; let songsCounted = 0; + const genreAgg = new Map(); let offset = 0; const pageSize = 500; const maxPages = 10; @@ -99,7 +98,13 @@ export default function Statistics() { for (const a of albums) { playtimeSec += a.duration ?? 0; albumsCounted += 1; - songsCounted += a.songCount ?? 0; + const sc = a.songCount ?? 0; + songsCounted += sc; + const label = (a.genre?.trim()) ? a.genre.trim() : unknownGenre; + const cur = genreAgg.get(label) ?? { songCount: 0, albumCount: 0 }; + cur.songCount += sc; + cur.albumCount += 1; + genreAgg.set(label, cur); } if (albums.length < pageSize) break; if (page === maxPages - 1) capped = true; @@ -113,10 +118,14 @@ export default function Statistics() { setTotalAlbums(albumsCounted); setTotalSongs(songsCounted); setPlaytimeCapped(capped); + const sorted: SubsonicGenre[] = [...genreAgg.entries()] + .map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount })) + .sort((a, b) => b.songCount - a.songCount); + setGenres(sorted); } })(); return () => { cancelled = true; }; - }, [musicLibraryFilterVersion]); + }, [musicLibraryFilterVersion, t]); // Background fetch: format distribution (sample of 500 random songs) useEffect(() => { From 6f6cb0fd6b0bb3703b0f891ae538963d764ef6b0 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 10 Apr 2026 01:39:08 +0300 Subject: [PATCH 2/3] fix(statistics): cache Subsonic stat fetches and localize duration units Add 7-minute TTL caches (same idea as rating prefetch) for overview album strips, random-song format sample, and paginated library aggregates; key by server and music folder. Introduce formatHumanHoursMinutes with common.duration* strings in all locales for Statistics and playlist duration labels. Refresh RU/ZH statistics and nav wording; fix zh playtime label. --- src/api/subsonic.ts | 151 +++++++++++++++++++++++++++++++ src/locales/de.ts | 2 + src/locales/en.ts | 2 + src/locales/fr.ts | 2 + src/locales/nb.ts | 2 + src/locales/nl.ts | 2 + src/locales/ru.ts | 10 +- src/locales/zh.ts | 4 +- src/pages/PlaylistDetail.tsx | 5 +- src/pages/Playlists.tsx | 6 +- src/pages/Statistics.tsx | 126 ++++++++++---------------- src/utils/formatHumanDuration.ts | 11 +++ 12 files changed, 233 insertions(+), 90 deletions(-) create mode 100644 src/utils/formatHumanDuration.ts diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 7974e35a..feaf025e 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -425,6 +425,157 @@ export async function prefetchAlbumUserRatings( return out; } +/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */ +export interface StatisticsLibraryAggregates { + playtimeSec: number; + albumsCounted: number; + songsCounted: number; + capped: boolean; + genres: SubsonicGenre[]; +} + +/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */ +function statisticsPageCacheKey(prefix: string): string | null { + const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); + if (!activeServerId) return null; + const folder = musicLibraryFilterByServer[activeServerId] ?? 'all'; + const folderPart = folder === 'all' ? 'all' : folder; + return `${prefix}:${activeServerId}:${folderPart}`; +} + +const statisticsAggregatesCache = new Map(); + +/** + * Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for + * 7 minutes (same `RATING_CACHE_TTL` as album/artist rating prefetch). + * Unknown/missing album genre is stored as `value: ''`; UI should map to i18n. + */ +export async function fetchStatisticsLibraryAggregates(): Promise { + const key = statisticsPageCacheKey('statsAgg'); + if (key) { + const hit = statisticsAggregatesCache.get(key); + if (hit && Date.now() < hit.expiresAt) return hit.value; + } + + let playtimeSec = 0; + let albumsCounted = 0; + let songsCounted = 0; + const genreAgg = new Map(); + const pageSize = 500; + const maxPages = 10; + let capped = false; + let offset = 0; + let nextPage = getAlbumList('newest', pageSize, 0); + for (let page = 0; page < maxPages; page++) { + try { + const albums = await nextPage; + for (const a of albums) { + playtimeSec += a.duration ?? 0; + albumsCounted += 1; + const sc = a.songCount ?? 0; + songsCounted += sc; + const label = (a.genre?.trim()) ? a.genre.trim() : ''; + let g = genreAgg.get(label); + if (!g) { + g = { songCount: 0, albumCount: 0 }; + genreAgg.set(label, g); + } + g.songCount += sc; + g.albumCount += 1; + } + if (albums.length < pageSize) break; + if (page === maxPages - 1) { + capped = true; + break; + } + offset += pageSize; + nextPage = getAlbumList('newest', pageSize, offset); + } catch { + break; + } + } + + const genres: SubsonicGenre[] = [...genreAgg.entries()] + .map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount })) + .sort((a, b) => b.songCount - a.songCount); + + const result: StatisticsLibraryAggregates = { + playtimeSec, + albumsCounted, + songsCounted, + capped, + genres, + }; + if (key) { + statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL }); + } + return result; +} + +/** Recent / frequent / highest album strips + artist count for Statistics. */ +export interface StatisticsOverviewData { + recent: SubsonicAlbum[]; + frequent: SubsonicAlbum[]; + highest: SubsonicAlbum[]; + artistCount: number; +} + +const statisticsOverviewCache = new Map(); + +export async function fetchStatisticsOverview(): Promise { + const key = statisticsPageCacheKey('statsOverview'); + if (key) { + const hit = statisticsOverviewCache.get(key); + if (hit && Date.now() < hit.expiresAt) return hit.value; + } + const [recent, frequent, highest, artists] = await Promise.all([ + getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]), + getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]), + getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]), + getArtists().catch(() => [] as SubsonicArtist[]), + ]); + const result: StatisticsOverviewData = { + recent, + frequent, + highest, + artistCount: artists.length, + }; + if (key) { + statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL }); + } + return result; +} + +/** Format (suffix) histogram from a random sample for Statistics. */ +export interface StatisticsFormatSample { + rows: { format: string; count: number }[]; + sampleSize: number; +} + +const statisticsFormatCache = new Map(); + +export async function fetchStatisticsFormatSample(): Promise { + const key = statisticsPageCacheKey('statsFormat'); + if (key) { + const hit = statisticsFormatCache.get(key); + if (hit && Date.now() < hit.expiresAt) return hit.value; + } + const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]); + const counts: Record = {}; + for (const song of songs) { + const fmt = song.suffix?.toUpperCase() ?? 'Unknown'; + counts[fmt] = (counts[fmt] ?? 0) + 1; + } + const rows = Object.entries(counts) + .map(([format, count]) => ({ format, count })) + .sort((a, b) => b.count - a.count); + const result: StatisticsFormatSample = { rows, sampleSize: songs.length }; + if (key) { + statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL }); + } + return result; +} + export async function getArtists(): Promise { const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', { ...libraryFilterParams(), diff --git a/src/locales/de.ts b/src/locales/de.ts index c510ded2..123b782f 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -361,6 +361,8 @@ export const deTranslation = { updaterAurHint: 'Update über AUR installieren:', updaterErrorMsg: 'Download fehlgeschlagen', updaterRetryBtn: 'Erneut versuchen', + durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.', + durationMinutesOnly: '{{minutes}} Min.', updaterOpenGitHub: 'Auf GitHub öffnen', }, settings: { diff --git a/src/locales/en.ts b/src/locales/en.ts index d2997076..4348f5d1 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -362,6 +362,8 @@ export const enTranslation = { updaterAurHint: 'Install the update via AUR:', updaterErrorMsg: 'Download failed', updaterRetryBtn: 'Retry', + durationHoursMinutes: '{{hours}}h {{minutes}}m', + durationMinutesOnly: '{{minutes}}m', updaterOpenGitHub: 'Open on GitHub', }, settings: { diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 4e760c65..2a4ae285 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -361,6 +361,8 @@ export const frTranslation = { updaterAurHint: 'Installer la mise à jour via AUR :', updaterErrorMsg: 'Échec du téléchargement', updaterRetryBtn: 'Réessayer', + durationHoursMinutes: '{{hours}} h {{minutes}} min', + durationMinutesOnly: '{{minutes}} min', updaterOpenGitHub: 'Ouvrir sur GitHub', }, settings: { diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 22755919..5e70c9d9 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -361,6 +361,8 @@ export const nbTranslation = { updaterAurHint: 'Installer oppdateringen via AUR:', updaterErrorMsg: 'Nedlasting mislyktes', updaterRetryBtn: 'Prøv igjen', + durationHoursMinutes: '{{hours}} t {{minutes}} min', + durationMinutesOnly: '{{minutes}} min', updaterOpenGitHub: 'Åpne på GitHub', }, settings: { diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 5225d698..4b4c73da 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -361,6 +361,8 @@ export const nlTranslation = { updaterAurHint: 'Update installeren via AUR:', updaterErrorMsg: 'Downloaden mislukt', updaterRetryBtn: 'Opnieuw proberen', + durationHoursMinutes: '{{hours}} u {{minutes}} min', + durationMinutesOnly: '{{minutes}} min', updaterOpenGitHub: 'Openen op GitHub', }, settings: { diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 0e3ebcc6..b5570c69 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -21,7 +21,7 @@ export const ruTranslation = { offlineLibrary: 'Офлайн-библиотека', genres: 'Жанры', playlists: 'Плейлисты', - mostPlayed: 'Часто слушаемое', + mostPlayed: 'Чаще всего', radio: 'Онлайн-радио', folderBrowser: 'Браузер папок', libraryScope: 'Область медиатеки', @@ -375,6 +375,8 @@ export const ruTranslation = { updaterAurHint: 'Установить обновление через AUR:', updaterErrorMsg: 'Ошибка загрузки', updaterRetryBtn: 'Повторить', + durationHoursMinutes: '{{hours}}ч {{minutes}}мин', + durationMinutesOnly: '{{minutes}}мин', updaterOpenGitHub: 'Открыть на GitHub', }, settings: { @@ -794,8 +796,8 @@ export const ruTranslation = { statAlbums: 'Альбомы', statSongs: 'Треки', statGenres: 'Жанры', - statPlaytime: 'Всего прослушано', - genreInsights: 'Жанры подробнее', + statPlaytime: 'Время звучания', + genreInsights: 'По жанрам', formatDistribution: 'Форматы', formatSample: 'Выборка {{n}} треков', computing: 'Считаем…', @@ -933,7 +935,7 @@ export const ruTranslation = { downloadZip: 'Скачать (ZIP)', }, mostPlayed: { - title: 'Часто слушаемое', + title: 'Чаще всего', topArtists: 'Топ исполнителей', topAlbums: 'Топ альбомов', plays: '{{n}} прослушиваний', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index ddd2d037..3b3d1aa6 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -357,6 +357,8 @@ export const zhTranslation = { updaterAurHint: '通过 AUR 安装更新:', updaterErrorMsg: '下载失败', updaterRetryBtn: '重试', + durationHoursMinutes: '{{hours}}小时{{minutes}}分钟', + durationMinutesOnly: '{{minutes}}分钟', updaterOpenGitHub: '在 GitHub 上打开', }, settings: { @@ -739,7 +741,7 @@ export const zhTranslation = { statAlbums: '专辑', statSongs: '歌曲', statGenres: '流派', - statPlaytime: '总播放时长', + statPlaytime: '音频总时长', genreInsights: '流派洞察', formatDistribution: '格式分布', formatSample: '{{n}} 首曲目的样本', diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index a0adbf0d..bc420e84 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -23,6 +23,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage'; import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; +import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import StarRating from '../components/StarRating'; function sanitizeFilename(name: string): string { @@ -41,9 +42,7 @@ function formatDuration(seconds: number): string { function totalDurationLabel(songs: SubsonicSong[]): string { const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0); - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - return h > 0 ? `${h}h ${m}m` : `${m}m`; + return formatHumanHoursMinutes(total); } function codecLabel(song: SubsonicSong): string { diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 6dff0aa1..192e020e 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -6,12 +6,10 @@ import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlaylistStore } from '../store/playlistStore'; import CachedImage from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; +import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; + return formatHumanHoursMinutes(seconds); } export default function Playlists() { diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index 034b4d42..0ccf55e2 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -1,5 +1,13 @@ import React, { useEffect, useState } from 'react'; -import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic'; +import { + fetchStatisticsFormatSample, + fetchStatisticsLibraryAggregates, + fetchStatisticsOverview, + getAlbumList, + SubsonicAlbum, + SubsonicGenre, +} from '../api/subsonic'; +import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import AlbumRow from '../components/AlbumRow'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; @@ -15,13 +23,6 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string) return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) }); } -function formatPlaytime(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (h > 0) return `${h.toLocaleString()}h ${m}m`; - return `${m}m`; -} - const PERIODS: { key: LastfmPeriod; label: string }[] = [ { key: '7day', label: 'lfmPeriod7day' }, { key: '1month', label: 'lfmPeriod1month' }, @@ -59,21 +60,18 @@ export default function Statistics() { const [lfmRecentLoading, setLfmRecentLoading] = useState(false); useEffect(() => { - Promise.all([ - getAlbumList('recent', 20).catch(() => []), - getAlbumList('frequent', 12).catch(() => []), - getAlbumList('highest', 12).catch(() => []), - getArtists().catch(() => []), - ]).then(([rc, fr, hi, a]) => { - setRecent(rc); - setFrequent(fr); - setHighest(hi); - setArtistCount(a.length); - setLoading(false); - }).catch(() => setLoading(false)); + fetchStatisticsOverview() + .then(d => { + setRecent(d.recent); + setFrequent(d.frequent); + setHighest(d.highest); + setArtistCount(d.artistCount); + setLoading(false); + }) + .catch(() => setLoading(false)); }, [musicLibraryFilterVersion]); - // Background: playtime, album/song counts, genre insights (paginated getAlbumList — respects library filter; caps at 5000 albums) + // Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch) useEffect(() => { let cancelled = false; setTotalPlaytime(null); @@ -81,68 +79,40 @@ export default function Statistics() { setTotalSongs(null); setPlaytimeCapped(false); setGenres([]); - const unknownGenre = t('statistics.decadeUnknown'); (async () => { - let playtimeSec = 0; - let albumsCounted = 0; - let songsCounted = 0; - const genreAgg = new Map(); - let offset = 0; - const pageSize = 500; - const maxPages = 10; - let capped = false; - for (let page = 0; page < maxPages; page++) { - try { - const albums = await getAlbumList('newest', pageSize, offset); - if (cancelled) return; - for (const a of albums) { - playtimeSec += a.duration ?? 0; - albumsCounted += 1; - const sc = a.songCount ?? 0; - songsCounted += sc; - const label = (a.genre?.trim()) ? a.genre.trim() : unknownGenre; - const cur = genreAgg.get(label) ?? { songCount: 0, albumCount: 0 }; - cur.songCount += sc; - cur.albumCount += 1; - genreAgg.set(label, cur); - } - if (albums.length < pageSize) break; - if (page === maxPages - 1) capped = true; - offset += pageSize; - } catch { - break; + try { + const agg = await fetchStatisticsLibraryAggregates(); + if (cancelled) return; + setTotalPlaytime(agg.playtimeSec); + setTotalAlbums(agg.albumsCounted); + setTotalSongs(agg.songsCounted); + setPlaytimeCapped(agg.capped); + setGenres(agg.genres); + } catch { + if (!cancelled) { + setTotalPlaytime(0); + setTotalAlbums(0); + setTotalSongs(0); + setPlaytimeCapped(false); + setGenres([]); } } - if (!cancelled) { - setTotalPlaytime(playtimeSec); - setTotalAlbums(albumsCounted); - setTotalSongs(songsCounted); - setPlaytimeCapped(capped); - const sorted: SubsonicGenre[] = [...genreAgg.entries()] - .map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount })) - .sort((a, b) => b.songCount - a.songCount); - setGenres(sorted); - } })(); return () => { cancelled = true; }; - }, [musicLibraryFilterVersion, t]); + }, [musicLibraryFilterVersion]); - // Background fetch: format distribution (sample of 500 random songs) + // Background: format distribution (cached random sample, same TTL as other Statistics fetches) useEffect(() => { let cancelled = false; - getRandomSongs(500).then(songs => { - if (cancelled) return; - const counts: Record = {}; - for (const song of songs) { - const fmt = song.suffix?.toUpperCase() ?? 'Unknown'; - counts[fmt] = (counts[fmt] ?? 0) + 1; - } - const sorted = Object.entries(counts) - .map(([format, count]) => ({ format, count })) - .sort((a, b) => b.count - a.count); - setFormatData(sorted); - setFormatSampleSize(songs.length); - }).catch(() => {}); + setFormatData(null); + setFormatSampleSize(0); + fetchStatisticsFormatSample() + .then(s => { + if (cancelled) return; + setFormatData(s.rows); + setFormatSampleSize(s.sampleSize); + }) + .catch(() => {}); return () => { cancelled = true; }; }, [musicLibraryFilterVersion]); @@ -185,7 +155,7 @@ export default function Statistics() { const playtimeDisplay = totalPlaytime === null ? t('statistics.computing') - : (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime); + : (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime); const countDisplay = (n: number | null) => n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString(); @@ -229,10 +199,10 @@ export default function Statistics() {
{topGenres.map(g => ( -
+
- {g.value} + {g.value.trim() ? g.value : t('statistics.decadeUnknown')} {g.songCount.toLocaleString()} diff --git a/src/utils/formatHumanDuration.ts b/src/utils/formatHumanDuration.ts new file mode 100644 index 00000000..25655fda --- /dev/null +++ b/src/utils/formatHumanDuration.ts @@ -0,0 +1,11 @@ +import i18n from '../i18n'; + +/** Totals / statistics: localized "N hours M minutes" (not track mm:ss). */ +export function formatHumanHoursMinutes(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) { + return i18n.t('common.durationHoursMinutes', { hours: h.toLocaleString(), minutes: m }); + } + return i18n.t('common.durationMinutesOnly', { minutes: m }); +} From d2592839b074b5a26aec92896b502dedf0fc018c Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 10 Apr 2026 02:06:24 +0300 Subject: [PATCH 3/3] =?UTF-8?q?i18n(ru):=20use=20=C2=AB=D0=9F=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D1=80=D0=BD=D0=BE=D0=B5=C2=BB=20for=20most-p?= =?UTF-8?q?layed=20nav,=20home,=20and=20page=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Чаще всего» read awkwardly; «Популярное» matches the play-count sense without sounding like a grammar exercise. --- src/locales/ru.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/locales/ru.ts b/src/locales/ru.ts index b5570c69..a1d74aab 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -21,7 +21,7 @@ export const ruTranslation = { offlineLibrary: 'Офлайн-библиотека', genres: 'Жанры', playlists: 'Плейлисты', - mostPlayed: 'Чаще всего', + mostPlayed: 'Популярное', radio: 'Онлайн-радио', folderBrowser: 'Браузер папок', libraryScope: 'Область медиатеки', @@ -31,7 +31,7 @@ export const ruTranslation = { hero: 'Подборка', starred: 'Личное избранное', recent: 'Недавно добавлено', - mostPlayed: 'Чаще всего', + mostPlayed: 'Популярное', recentlyPlayed: 'Недавно проиграно', discover: 'Обзор', loadMore: 'Ещё', @@ -935,7 +935,7 @@ export const ruTranslation = { downloadZip: 'Скачать (ZIP)', }, mostPlayed: { - title: 'Чаще всего', + title: 'Популярное', topArtists: 'Топ исполнителей', topAlbums: 'Топ альбомов', plays: '{{n}} прослушиваний',