From 4b1dd3c29f0f0678602e740e8a7e3e5bba6db09a Mon Sep 17 00:00:00 2001
From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
Date: Thu, 14 May 2026 15:19:42 +0200
Subject: [PATCH] refactor(dedup): consolidate byte / sanitize / clock /
album-duration helpers (Phase L, part 2) (#692)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Findings 5-8 of the dedup audit:
- F5 byte formatters: appUpdaterHelpers.fmtBytes + ZipDownloadOverlay
.formatMB route through the existing formatBytes; a new formatMb
(always-MB) backs playlistDetailHelpers.formatSize, AlbumHeader and
the 4 inline DeviceSyncPreSyncModal expressions. SongInfoModal.format
Size is intentionally left — it uses decimal (1e6) divisors, not 1024.
- F6 sanitizeHtml: extracted to utils/sanitizeHtml.ts; AlbumHeader,
ComposerDetail and the (now-empty, deleted) artistDetailHelpers use it
directly. nowPlayingHelpers keeps its own export but now delegates to
the shared sanitiser and only adds its trailing-link strip on top.
- F7 album duration: BecauseYouLikeRail's formatAlbumDuration drops in
favour of the shared formatHumanHoursMinutes. Behaviour note: total
minutes now floor instead of round (<=1 min display difference,
matches every other caller).
- F8 clock time: extracted to utils/format/formatClockTime.ts;
PlaybackDelayModal + QueueHeader use it (toLocaleTimeString and
Intl.DateTimeFormat produced identical output).
Behaviour preserved except the two explicitly noted divergences (F7
round->floor; F5 appUpdater/Zip now show GB above 1 GB instead of a
large MB number).
---
src/components/AlbumHeader.tsx | 29 ++-----------------
src/components/AppUpdater.tsx | 12 ++++----
src/components/BecauseYouLikeRail.tsx | 10 ++-----
src/components/PlaybackDelayModal.tsx | 6 +---
src/components/ZipDownloadOverlay.tsx | 10 ++-----
.../deviceSync/DeviceSyncPreSyncModal.tsx | 9 +++---
src/components/queuePanel/QueueHeader.tsx | 8 ++---
src/pages/ArtistDetail.tsx | 2 +-
src/pages/ComposerDetail.tsx | 20 +------------
.../componentHelpers/appUpdaterHelpers.ts | 5 ----
.../componentHelpers/nowPlayingHelpers.ts | 18 +++---------
.../componentHelpers/playlistDetailHelpers.ts | 4 +--
src/utils/format/formatBytes.ts | 6 ++++
src/utils/format/formatClockTime.ts | 4 +++
...artistDetailHelpers.ts => sanitizeHtml.ts} | 12 ++++++--
15 files changed, 50 insertions(+), 105 deletions(-)
create mode 100644 src/utils/format/formatClockTime.ts
rename src/utils/{componentHelpers/artistDetailHelpers.ts => sanitizeHtml.ts} (54%)
diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx
index d0b5f01b..86ce9a31 100644
--- a/src/components/AlbumHeader.tsx
+++ b/src/components/AlbumHeader.tsx
@@ -13,31 +13,8 @@ import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
import { showToast } from '../utils/ui/toast';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { formatLongDuration } from '../utils/format/formatDuration';
-
-function formatSize(bytes?: number): string {
- if (!bytes) return '';
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
-}
-
-function sanitizeHtml(html: string): string {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
- doc.querySelectorAll('*').forEach(el => {
- Array.from(el.attributes).forEach(attr => {
- const name = attr.name.toLowerCase();
- const val = attr.value.toLowerCase().trim();
- if (
- name.startsWith('on') ||
- (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) ||
- (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))
- ) {
- el.removeAttribute(attr.name);
- }
- });
- });
- return doc.body.innerHTML;
-}
+import { formatMb } from '../utils/format/formatBytes';
+import { sanitizeHtml } from '../utils/sanitizeHtml';
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
const { t } = useTranslation();
@@ -372,7 +349,7 @@ export default function AlbumHeader({
) : (
)}
{offlineStatus === 'downloading' && offlineProgress ? (
diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx
index c0705d4b..30383772 100644
--- a/src/components/AppUpdater.tsx
+++ b/src/components/AppUpdater.tsx
@@ -3,7 +3,7 @@ import { open } from '@tauri-apps/plugin-shell';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
-import { fmtBytes } from '../utils/componentHelpers/appUpdaterHelpers';
+import { formatBytes } from '../utils/format/formatBytes';
import { useAppUpdater } from '../hooks/useAppUpdater';
import Changelog from './appUpdater/Changelog';
@@ -112,8 +112,8 @@ export default function AppUpdater() {
{pct}%
- {fmtBytes(dlProgress.bytes)}
- {dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
+ {formatBytes(dlProgress.bytes)}
+ {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
)}
@@ -139,7 +139,7 @@ export default function AppUpdater() {
{dlState === 'idle' && (
{asset.name}
- {fmtBytes(asset.size)}
+ {formatBytes(asset.size)}
)}
{dlState === 'downloading' && (
@@ -149,8 +149,8 @@ export default function AppUpdater() {
{pct}%
- {fmtBytes(dlProgress.bytes)}
- {dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
+ {formatBytes(dlProgress.bytes)}
+ {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
)}
diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx
index 02fae902..c9ea50e2 100644
--- a/src/components/BecauseYouLikeRail.tsx
+++ b/src/components/BecauseYouLikeRail.tsx
@@ -12,6 +12,7 @@ import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { playAlbum } from '../utils/playback/playAlbum';
+import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from './AlbumRow';
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
@@ -72,13 +73,6 @@ function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): Anchor[] {
return out;
}
-function formatAlbumDuration(seconds: number, t: (key: string, opts?: Record) => string): string {
- const totalMin = Math.max(0, Math.round(seconds / 60));
- const hours = Math.floor(totalMin / 60);
- const minutes = totalMin % 60;
- if (hours > 0) return t('common.durationHoursMinutes', { hours, minutes });
- return t('common.durationMinutesOnly', { minutes: totalMin });
-}
/** Both rotation memories are **per-server** — server A and server B keep
* independent state, so switching servers doesn't snap the anchor cooldown
@@ -353,7 +347,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
{album.year ? {album.year} : null}
{album.songCount ? {t('home.becauseYouLikeTracks', { count: album.songCount })} : null}
- {album.duration ? {formatAlbumDuration(album.duration, t)} : null}
+ {album.duration ? {formatHumanHoursMinutes(album.duration)} : null}
diff --git a/src/components/PlaybackDelayModal.tsx b/src/components/PlaybackDelayModal.tsx
index dbc609bc..aefa9321 100644
--- a/src/components/PlaybackDelayModal.tsx
+++ b/src/components/PlaybackDelayModal.tsx
@@ -7,11 +7,7 @@ import { useShallow } from 'zustand/react/shallow';
import type { TFunction } from 'i18next';
import { formatPlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
-
-function formatClockTime(ts: number): string {
- const d = new Date(ts);
- return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
-}
+import { formatClockTime } from '../utils/format/formatClockTime';
/** One tap = schedule; custom minutes still covers any duration. */
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
diff --git a/src/components/ZipDownloadOverlay.tsx b/src/components/ZipDownloadOverlay.tsx
index 1194ba7d..8f169aaa 100644
--- a/src/components/ZipDownloadOverlay.tsx
+++ b/src/components/ZipDownloadOverlay.tsx
@@ -2,11 +2,7 @@ import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, Check, X } from 'lucide-react';
import { useZipDownloadStore } from '../store/zipDownloadStore';
-
-function formatMB(bytes: number): string {
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
+import { formatBytes } from '../utils/format/formatBytes';
function ZipDownloadItem({ id }: { id: string }) {
const dismiss = useZipDownloadStore(s => s.dismiss);
@@ -47,9 +43,9 @@ function ZipDownloadItem({ id }: { id: string }) {
{!item.done && !item.error && (
<>
- {formatMB(item.bytes)}
+ {formatBytes(item.bytes)}
{item.total !== null && item.total > 0 && (
- <> / {formatMB(item.total)} ({pct!.toFixed(0)}%)>
+ <> / {formatBytes(item.total)} ({pct!.toFixed(0)}%)>
)}
diff --git a/src/components/deviceSync/DeviceSyncPreSyncModal.tsx b/src/components/deviceSync/DeviceSyncPreSyncModal.tsx
index c7ee30e3..1f196b38 100644
--- a/src/components/deviceSync/DeviceSyncPreSyncModal.tsx
+++ b/src/components/deviceSync/DeviceSyncPreSyncModal.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { AlertCircle, Loader2 } from 'lucide-react';
import type { SyncDelta } from '../../utils/deviceSync/runDeviceSyncExecution';
+import { formatMb } from '../../utils/format/formatBytes';
interface Props {
preSyncOpen: boolean;
@@ -32,20 +33,20 @@ export default function DeviceSyncPreSyncModal({
{t('deviceSync.filesToAdd')}
- +{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB)
+ +{syncDelta.addCount} ({formatMb(syncDelta.addBytes)})
{t('deviceSync.filesToDelete')}
- -{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB)
+ -{syncDelta.delCount} ({formatMb(syncDelta.delBytes)})
{t('deviceSync.netChange')}
- {((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB
+ {formatMb(syncDelta.addBytes - syncDelta.delBytes)}
syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}>
{t('deviceSync.availableSpace')}
- {(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB
+ {formatMb(syncDelta.availableBytes)}
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx
index ab51a416..c5be0c33 100644
--- a/src/components/queuePanel/QueueHeader.tsx
+++ b/src/components/queuePanel/QueueHeader.tsx
@@ -5,6 +5,7 @@ import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes';
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
import { formatLongDuration } from '../../utils/format/formatDuration';
+import { formatClockTime } from '../../utils/format/formatClockTime';
interface Props {
queue: Track[];
@@ -35,16 +36,11 @@ export function QueueHeader({
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
- const fmtEta = (secs: number) => {
- const finishTime = new Date(Date.now() + secs * 1000);
- return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime);
- };
-
let dur: string | null = null;
if (queue.length > 0) {
if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs));
else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`;
- else dur = fmtEta(remainingSecs);
+ else dur = formatClockTime(Date.now() + remainingSecs * 1000);
}
const nextMode: DurationMode =
diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx
index c78c2b02..0d8c0a65 100644
--- a/src/pages/ArtistDetail.tsx
+++ b/src/pages/ArtistDetail.tsx
@@ -28,7 +28,7 @@ import { extractCoverColors } from '../utils/ui/dynamicColors';
import StarRating from '../components/StarRating';
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
-import { sanitizeHtml } from '../utils/componentHelpers/artistDetailHelpers';
+import { sanitizeHtml } from '../utils/sanitizeHtml';
import { useArtistDetailData } from '../hooks/useArtistDetailData';
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
import {
diff --git a/src/pages/ComposerDetail.tsx b/src/pages/ComposerDetail.tsx
index a1f4dcfd..ef662bb5 100644
--- a/src/pages/ComposerDetail.tsx
+++ b/src/pages/ComposerDetail.tsx
@@ -15,25 +15,7 @@ import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
import { showToast } from '../utils/ui/toast';
-
-/** Strip dangerous tags/attributes from server-provided HTML. Mirrors the
- * ArtistDetail sanitiser — kept inline because it's a 10-liner not worth a
- * separate util module. */
-function sanitizeHtml(html: string): string {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
- doc.querySelectorAll('*').forEach(el => {
- Array.from(el.attributes).forEach(attr => {
- const name = attr.name.toLowerCase();
- const val = attr.value.toLowerCase().trim();
- if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
- el.removeAttribute(attr.name);
- }
- });
- });
- return doc.body.innerHTML;
-}
+import { sanitizeHtml } from '../utils/sanitizeHtml';
export default function ComposerDetail() {
const { t } = useTranslation();
diff --git a/src/utils/componentHelpers/appUpdaterHelpers.ts b/src/utils/componentHelpers/appUpdaterHelpers.ts
index 3098551d..3872b08e 100644
--- a/src/utils/componentHelpers/appUpdaterHelpers.ts
+++ b/src/utils/componentHelpers/appUpdaterHelpers.ts
@@ -13,11 +13,6 @@ export function isNewer(a: string, b: string): boolean {
return false;
}
-export function fmtBytes(n: number): string {
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
-}
-
export interface GithubAsset {
name: string;
browser_download_url: string;
diff --git a/src/utils/componentHelpers/nowPlayingHelpers.ts b/src/utils/componentHelpers/nowPlayingHelpers.ts
index c7cf8931..7947f2e0 100644
--- a/src/utils/componentHelpers/nowPlayingHelpers.ts
+++ b/src/utils/componentHelpers/nowPlayingHelpers.ts
@@ -1,4 +1,5 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
+import { sanitizeHtml as sanitizeHtmlBase } from '../sanitizeHtml';
export function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00';
@@ -22,21 +23,10 @@ export function formatTotalDuration(s: number): string {
return `${sec}s`;
}
+/** Shared HTML sanitiser plus a now-playing-specific tweak: strip the trailing
+ * "Read more on Last.fm" style link so clamped bios end cleanly. */
export function sanitizeHtml(html: string): string {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
- doc.querySelectorAll('*').forEach(el => {
- Array.from(el.attributes).forEach(attr => {
- const name = attr.name.toLowerCase();
- const val = attr.value.toLowerCase().trim();
- if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
- el.removeAttribute(attr.name);
- }
- });
- });
- // Strip trailing "Read more on Last.fm" style links for cleaner clamped bios.
- return doc.body.innerHTML.replace(/
]*>.*?<\/a>\.?\s*$/i, '').trim();
+ return sanitizeHtmlBase(html).replace(/]*>.*?<\/a>\.?\s*$/i, '').trim();
}
export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
diff --git a/src/utils/componentHelpers/playlistDetailHelpers.ts b/src/utils/componentHelpers/playlistDetailHelpers.ts
index da984860..7e86a3b6 100644
--- a/src/utils/componentHelpers/playlistDetailHelpers.ts
+++ b/src/utils/componentHelpers/playlistDetailHelpers.ts
@@ -1,5 +1,6 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
import { formatHumanHoursMinutes } from '../format/formatHumanDuration';
+import { formatMb } from '../format/formatBytes';
export function sanitizeFilename(name: string): string {
return name
@@ -10,8 +11,7 @@ export function sanitizeFilename(name: string): string {
}
export function formatSize(bytes?: number): string {
- if (!bytes) return '';
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+ return bytes ? formatMb(bytes) : '';
}
export function totalDurationLabel(songs: SubsonicSong[]): string {
diff --git a/src/utils/format/formatBytes.ts b/src/utils/format/formatBytes.ts
index 885a3826..5f1cc098 100644
--- a/src/utils/format/formatBytes.ts
+++ b/src/utils/format/formatBytes.ts
@@ -4,6 +4,12 @@ export function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
+/** Always-MB variant for size totals that are conventionally shown in MB
+ * regardless of magnitude (album / playlist / device-sync totals). */
+export function formatMb(bytes: number): string {
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
/** Align hot-cache size slider (step 32 MB) to valid values. */
export function snapHotCacheMb(v: number): number {
const x = Math.min(20000, Math.max(32, Math.round(v)));
diff --git a/src/utils/format/formatClockTime.ts b/src/utils/format/formatClockTime.ts
new file mode 100644
index 00000000..2557a0b0
--- /dev/null
+++ b/src/utils/format/formatClockTime.ts
@@ -0,0 +1,4 @@
+/** Localized wall-clock `HH:MM` for a timestamp (sleep-timer / queue-ETA labels). */
+export function formatClockTime(timestampMs: number): string {
+ return new Date(timestampMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+}
diff --git a/src/utils/componentHelpers/artistDetailHelpers.ts b/src/utils/sanitizeHtml.ts
similarity index 54%
rename from src/utils/componentHelpers/artistDetailHelpers.ts
rename to src/utils/sanitizeHtml.ts
index 93a5196a..78d80168 100644
--- a/src/utils/componentHelpers/artistDetailHelpers.ts
+++ b/src/utils/sanitizeHtml.ts
@@ -1,4 +1,8 @@
-/** Strip dangerous tags/attributes from server-provided HTML */
+/**
+ * Strip dangerous tags / attributes from server-provided HTML (artist & album
+ * biographies). Removes embedded/active elements and `on*` / `javascript:` /
+ * `data:` handlers before the result is fed to `dangerouslySetInnerHTML`.
+ */
export function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
@@ -7,7 +11,11 @@ export function sanitizeHtml(html: string): string {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
- if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
+ if (
+ name.startsWith('on') ||
+ (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) ||
+ (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))
+ ) {
el.removeAttribute(attr.name);
}
});