mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(cover,lib): fold utils/cover into cover/ + utils/media into lib/media (generic infra)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/** Resolved user media root `M` (empty → Rust default `{app_data}/media/`). */
|
||||
export function getMediaDir(): string | null {
|
||||
const dir = useAuthStore.getState().mediaDir?.trim();
|
||||
return dir || null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { layoutFingerprintFromLibraryTrack, sanitizeAndTruncateSegment } from '@/lib/media/mediaLayout';
|
||||
|
||||
// Keep in sync with `short_hash` in `src-tauri/crates/psysonic-core/src/media_layout.rs`.
|
||||
function shortHash(s: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
describe('mediaLayout', () => {
|
||||
it('shortHash parity anchor matches Rust imul-31 UTF-16', () => {
|
||||
expect(shortHash('Radiohead')).toBe('3da68c3b');
|
||||
});
|
||||
|
||||
it('does not truncate Cyrillic segments under 120 code points (byte length may exceed 120)', () => {
|
||||
const segment = 'а'.repeat(100);
|
||||
expect([...segment].length).toBe(100);
|
||||
expect(Buffer.byteLength(segment, 'utf8')).toBeGreaterThan(120);
|
||||
expect(sanitizeAndTruncateSegment(segment)).toBe(segment);
|
||||
});
|
||||
|
||||
it('truncates long Cyrillic segments with the same hash suffix as Rust', () => {
|
||||
const segment = 'а'.repeat(130);
|
||||
expect(sanitizeAndTruncateSegment(segment)).toBe(
|
||||
`${'а'.repeat(111)}_eef20600`,
|
||||
);
|
||||
});
|
||||
|
||||
it('layout fingerprint uses truncated hash for very long segments', () => {
|
||||
const longName = 'A'.repeat(200);
|
||||
const track = {
|
||||
serverId: 'srv',
|
||||
id: 't1',
|
||||
title: 'Song',
|
||||
artist: longName,
|
||||
album: 'Album',
|
||||
albumArtist: null,
|
||||
trackNumber: 1,
|
||||
discNumber: 1,
|
||||
durationSec: 180,
|
||||
suffix: 'mp3',
|
||||
rawJson: null,
|
||||
syncedAt: 0,
|
||||
};
|
||||
const fp = layoutFingerprintFromLibraryTrack(track);
|
||||
expect(fp).toContain('_');
|
||||
expect(fp.length).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { LibraryTrackDto } from '@/lib/api/library';
|
||||
|
||||
const MAX_SEGMENT_LEN = 120;
|
||||
// eslint-disable-next-line no-control-regex -- intentional: strip C0/DEL control chars from path segments (mirrors Rust sanitize_path_segment)
|
||||
const FORBIDDEN = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
|
||||
const WINDOWS_RESERVED = new Set([
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||||
]);
|
||||
|
||||
/** Keep path-segment rules aligned with `psysonic_core::cover_cache_layout::sanitize_path_segment`. */
|
||||
function sanitizeSegment(segment: string): string {
|
||||
const trimmed = segment.trim().replace(/[. ]+$/, '');
|
||||
if (!trimmed) return '_';
|
||||
const cleaned = trimmed.replace(FORBIDDEN, '_');
|
||||
if (!cleaned || cleaned === '.' || cleaned === '..') return '_';
|
||||
if (WINDOWS_RESERVED.has(cleaned.toUpperCase())) return `_${cleaned}`;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function shortHash(s: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0').slice(0, 8);
|
||||
}
|
||||
|
||||
function sanitizeAndTruncate(segment: string, maxLen: number): string {
|
||||
const sanitized = sanitizeSegment(segment);
|
||||
const chars = [...sanitized];
|
||||
if (chars.length <= maxLen) return sanitized;
|
||||
const hash = shortHash(segment);
|
||||
const keep = maxLen - 1 - hash.length;
|
||||
return `${chars.slice(0, keep).join('')}_${hash}`;
|
||||
}
|
||||
|
||||
function variousArtistsLabel(s: string): boolean {
|
||||
return s.trim().toLowerCase().includes('various artists');
|
||||
}
|
||||
|
||||
function trackIsCompilation(track: Pick<LibraryTrackDto, 'artist' | 'rawJson'>): boolean {
|
||||
if (variousArtistsLabel(track.artist ?? '')) return true;
|
||||
const raw = track.rawJson;
|
||||
if (!raw || typeof raw !== 'object') return false;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (obj.isCompilation === true) return true;
|
||||
if (obj.compilation === true || obj.compilation === 1 || obj.compilation === '1') return true;
|
||||
const releaseTypes = obj.releaseTypes;
|
||||
if (Array.isArray(releaseTypes)) {
|
||||
return releaseTypes.some(
|
||||
rt => typeof rt === 'string' && rt.toLowerCase() === 'compilation',
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function artistFolderSegment(
|
||||
track: Pick<LibraryTrackDto, 'artist' | 'albumArtist' | 'rawJson'>,
|
||||
): string {
|
||||
const artist = (track.artist ?? '').trim();
|
||||
const albumArtist = (track.albumArtist ?? '').trim();
|
||||
const chosen = !artist || trackIsCompilation(track)
|
||||
? (albumArtist || 'Various Artists')
|
||||
: artist;
|
||||
return sanitizeAndTruncate(chosen, MAX_SEGMENT_LEN);
|
||||
}
|
||||
|
||||
function trackFilenameStem(track: Pick<LibraryTrackDto, 'title' | 'trackNumber' | 'discNumber'>): string {
|
||||
const title = (track.title ?? '').trim() || 'Unknown Title';
|
||||
const trackN = Math.max(0, track.trackNumber ?? 0);
|
||||
const discN = Math.max(0, track.discNumber ?? 1);
|
||||
if (discN > 1) {
|
||||
return `${String(discN).padStart(2, '0')}-${String(trackN).padStart(2, '0')} - ${title}`;
|
||||
}
|
||||
return `${String(trackN).padStart(2, '0')} - ${title}`;
|
||||
}
|
||||
|
||||
/** Parity-tested with `sanitize_and_truncate_segment` in `media_layout.rs`. */
|
||||
export function sanitizeAndTruncateSegment(segment: string): string {
|
||||
return sanitizeAndTruncate(segment, MAX_SEGMENT_LEN);
|
||||
}
|
||||
|
||||
/** Stable fingerprint — keep in sync with `psysonic_core::media_layout::layout_fingerprint`. */
|
||||
export function layoutFingerprintFromLibraryTrack(
|
||||
track: LibraryTrackDto,
|
||||
suffix?: string,
|
||||
): string {
|
||||
const artistSeg = artistFolderSegment(track);
|
||||
const albumSeg = sanitizeAndTruncate((track.album ?? '').trim() || 'Unknown Album', MAX_SEGMENT_LEN);
|
||||
const stem = trackFilenameStem(track);
|
||||
const ext = (suffix ?? track.suffix ?? '').trim();
|
||||
const trackN = track.trackNumber ?? 0;
|
||||
const discN = track.discNumber ?? 0;
|
||||
const albumArtist = (track.albumArtist ?? '').trim();
|
||||
return `artist=${artistSeg}|album_artist=${albumArtist}|album=${albumSeg}|title=${(track.title ?? '').trim()}|track=${trackN}|disc=${discN}|stem=${stem}|suffix=${ext}`;
|
||||
}
|
||||
Reference in New Issue
Block a user