mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(cover): restore full-resolution album and artist covers (#1205)
* fix(cover): build cover tiers from the full-resolution source Derive the larger tiers from the decoded download instead of re-reading the just-written smaller tier (resize never upscales, so 512/800 were stored at the small resolution). Resolve the full-res 2000 tier exactly so it is actually downloaded and cached. Bump the cache layout stamp to drop already-poisoned tiers. * fix(cover): open the cover lightbox at full resolution Require the exact requested tier for the full-res helper so a smaller warmed tier no longer pins the lightbox to a downscaled image. Race the 2000 fetch against a 500ms opening window, show the 800 tier meanwhile, and persist 2000 for the next open. Adds an opening animation (reduced-motion aware) and tests. * docs(changelog): note full-resolution cover fix (#1205) * fix(cover): keep full-res peek exact in peek_batch and the grid seeder Review follow-up: ensure-path peek alone left a hole — cover_cache_peek_batch still laddered a 2000 request down to a smaller tier, and the in-memory grid seeder wrote that smaller path under the 2000 key, so Hero/fullscreen/lightbox surfaces (which peek 2000 before ensure) still showed a downscaled cover. Share one exact-2000 rule (peek_plain_cover_tier) across ensure and peek_batch, and never seed the full-res key from a smaller tier file.
This commit is contained in:
@@ -55,3 +55,26 @@ describe('rememberDiskSrcLadder', () => {
|
||||
expect(keys).toContain('srv:cover:album:al-1:800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full-res (2000) seed guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(rememberDiskSrc).mockClear();
|
||||
vi.mocked(rememberDiskSrc).mockReturnValue('asset://x');
|
||||
});
|
||||
|
||||
it('never seeds the 2000 key from a smaller tier file', () => {
|
||||
const ref = albumCoverRef('al-1', 'al-1');
|
||||
rememberGridDiskSrc(ref, 2000, '/data/512.webp');
|
||||
const keys = vi.mocked(rememberDiskSrc).mock.calls.map(c => c[0]);
|
||||
expect(keys.some(k => k.endsWith(':2000'))).toBe(false);
|
||||
// Smaller display keys are still seeded.
|
||||
expect(keys.some(k => k.endsWith(':512'))).toBe(true);
|
||||
});
|
||||
|
||||
it('seeds the 2000 key from a real 2000 file', () => {
|
||||
const ref = albumCoverRef('al-1', 'al-1');
|
||||
rememberGridDiskSrc(ref, 2000, '/data/2000.webp');
|
||||
const keys = vi.mocked(rememberDiskSrc).mock.calls.map(c => c[0]);
|
||||
expect(keys.some(k => k.endsWith(':2000'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,25 @@ import { hasCoverDiskReadyListeners, notifyCoverDiskReady } from './diskHandoff'
|
||||
import { coverStorageKeyFromRef } from './storageKeys';
|
||||
import type { CoverArtRef, CoverArtTier } from './types';
|
||||
|
||||
/** Tier embedded in a cover file path (`…/512.webp`, `…/2000-fanart.webp`). */
|
||||
function coverPathTier(fsPath: string): number | null {
|
||||
const m = /(\d+)(?:-[a-z0-9]+)?\.webp$/i.exec(fsPath);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Never seed the full-res (≥2000) key from a smaller tier's file. The grid lookup
|
||||
* order intentionally cross-seeds smaller display keys, but pinning a downscaled
|
||||
* image under the 2000 key would make Hero / fullscreen / the lightbox show a
|
||||
* small cover (they read the 2000 key before running ensure). Mirrors the Rust
|
||||
* `peek_plain_cover_tier` exact-only rule for full-res.
|
||||
*/
|
||||
function skipFullResSeedTier(tier: CoverArtTier, fsPath: string): boolean {
|
||||
if (tier < 2000) return false;
|
||||
const src = coverPathTier(fsPath);
|
||||
return src == null || src < 2000;
|
||||
}
|
||||
|
||||
/** Dense grids: prefer a larger on-disk tier (800) before tiny thumbs when the ideal tier is missing. */
|
||||
export function gridDiskSrcLookupOrder(want: CoverArtTier): CoverArtTier[] {
|
||||
const out: CoverArtTier[] = [want];
|
||||
@@ -30,6 +49,7 @@ export function seedGridDiskSrcCache(ref: CoverArtRef, wantTier: CoverArtTier, f
|
||||
if (!fsPath) return false;
|
||||
let hit = false;
|
||||
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
|
||||
if (skipFullResSeedTier(tier, fsPath)) continue;
|
||||
if (rememberDiskSrc(coverStorageKeyFromRef(ref, tier), fsPath)) hit = true;
|
||||
}
|
||||
return hit;
|
||||
@@ -58,6 +78,7 @@ export function rememberDiskSrcLadder(
|
||||
if (!serverIndexKey || !ref.cacheEntityId || !fsPath) return false;
|
||||
let hit = false;
|
||||
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
|
||||
if (skipFullResSeedTier(tier, fsPath)) continue;
|
||||
const key = `${serverIndexKey}:cover:${ref.cacheKind}:${ref.cacheEntityId}:${tier}`;
|
||||
if (rememberDiskSrc(key, fsPath)) hit = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { albumCoverRef } from './ref';
|
||||
|
||||
vi.mock('./resolveDisk', () => ({ ensureCoverTierDiskSrc: vi.fn() }));
|
||||
vi.mock('./diskSrcLookup', () => ({ getDiskSrcForGrid: vi.fn(() => '') }));
|
||||
vi.mock('./fetchUrl', () => ({ buildCoverArtFetchUrl: vi.fn(() => 'net://2000') }));
|
||||
vi.mock('./imgSrc', () => ({ coverImgSrc: (s: string) => s }));
|
||||
vi.mock('../components/CoverLightbox', () => ({ default: () => null }));
|
||||
|
||||
import { useCoverLightboxSrc } from './lightbox';
|
||||
import { ensureCoverTierDiskSrc } from './resolveDisk';
|
||||
import { getDiskSrcForGrid } from './diskSrcLookup';
|
||||
|
||||
const ref = albumCoverRef('al-1', 'al-1');
|
||||
|
||||
describe('useCoverLightboxSrc — full-res opening race', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(ensureCoverTierDiskSrc).mockReset();
|
||||
vi.mocked(getDiskSrcForGrid).mockReset().mockReturnValue('');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows full-res 2000 when the ensure resolves within the window', async () => {
|
||||
vi.mocked(ensureCoverTierDiskSrc).mockResolvedValue('asset://2000');
|
||||
const { result } = renderHook(() => useCoverLightboxSrc(ref));
|
||||
act(() => result.current.open());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.src).toBe('asset://2000');
|
||||
});
|
||||
|
||||
it('falls back to the warm 800 tier when 2000 is not ready within 500ms', async () => {
|
||||
// 2000 ensure never resolves in time.
|
||||
vi.mocked(ensureCoverTierDiskSrc).mockReturnValue(new Promise<string>(() => {}));
|
||||
vi.mocked(getDiskSrcForGrid).mockReturnValue('asset://800');
|
||||
const { result } = renderHook(() => useCoverLightboxSrc(ref));
|
||||
act(() => result.current.open());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
expect(result.current.src).toBe('asset://800');
|
||||
});
|
||||
|
||||
it('falls back to the network full-res url when no warm tier is cached', async () => {
|
||||
vi.mocked(ensureCoverTierDiskSrc).mockReturnValue(new Promise<string>(() => {}));
|
||||
vi.mocked(getDiskSrcForGrid).mockReturnValue('');
|
||||
const { result } = renderHook(() => useCoverLightboxSrc(ref));
|
||||
act(() => result.current.open());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
expect(result.current.src).toBe('net://2000');
|
||||
});
|
||||
});
|
||||
+18
-4
@@ -2,9 +2,13 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { buildCoverArtFetchUrl } from './fetchUrl';
|
||||
import { coverImgSrc } from './imgSrc';
|
||||
import { getDiskSrcForGrid } from './diskSrcLookup';
|
||||
import { ensureCoverTierDiskSrc } from './resolveDisk';
|
||||
import type { CoverArtRef } from './types';
|
||||
|
||||
/** Opening window: wait this long for the full-res 2000 tier before showing 800. */
|
||||
const LIGHTBOX_FULLRES_WINDOW_MS = 500;
|
||||
|
||||
export function useCoverLightboxSrc(
|
||||
ref: CoverArtRef | null,
|
||||
opts?: { alt?: string },
|
||||
@@ -20,12 +24,22 @@ export function useCoverLightboxSrc(
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
const diskSrc = await ensureCoverTierDiskSrc(ref, 2000);
|
||||
// Kick the full-res (2000) ensure — Rust downloads + stores `2000.webp`. Do
|
||||
// not block the open on it: race it against a short opening window. If the
|
||||
// 2000 lands in time, show it; otherwise show the warm 800 tier now and let
|
||||
// the 2000 finish + persist in the background, so the next open is full-res.
|
||||
const fullSrc = ensureCoverTierDiskSrc(ref, 2000);
|
||||
const winner = await Promise.race([
|
||||
fullSrc,
|
||||
new Promise<''>(resolve => {
|
||||
setTimeout(() => resolve(''), LIGHTBOX_FULLRES_WINDOW_MS);
|
||||
}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (diskSrc) {
|
||||
setSrc(diskSrc);
|
||||
if (winner) {
|
||||
setSrc(winner);
|
||||
} else {
|
||||
setSrc(buildCoverArtFetchUrl(ref, 2000));
|
||||
setSrc(getDiskSrcForGrid(ref, 800) || buildCoverArtFetchUrl(ref, 2000));
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { albumCoverRef } from './ref';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => true }));
|
||||
vi.mock('../api/coverCache', () => ({ coverCacheEnsure: vi.fn() }));
|
||||
vi.mock('../utils/imageCache', () => ({ invalidateCacheKey: vi.fn() }));
|
||||
vi.mock('./diskSrcCache', () => ({
|
||||
getDiskSrc: vi.fn(() => ''),
|
||||
rememberDiskSrc: vi.fn((_key: string, path: string) => `asset://${path}`),
|
||||
}));
|
||||
|
||||
import { ensureCoverTierDiskSrc } from './resolveDisk';
|
||||
import { coverCacheEnsure } from '../api/coverCache';
|
||||
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
|
||||
|
||||
const ref = albumCoverRef('al-1', 'al-1');
|
||||
|
||||
describe('ensureCoverTierDiskSrc — full-res exact-tier guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(coverCacheEnsure).mockReset();
|
||||
vi.mocked(getDiskSrc).mockReset().mockReturnValue('');
|
||||
vi.mocked(rememberDiskSrc).mockReset().mockImplementation((_k, p) => `asset://${p}`);
|
||||
});
|
||||
|
||||
it('rejects a backend hit that served a smaller tier than requested', async () => {
|
||||
// The Rust peek can report a hit with a smaller tier's file for a full-res
|
||||
// request — the lightbox must treat that as a miss so it can fetch full-res.
|
||||
vi.mocked(coverCacheEnsure).mockResolvedValue({
|
||||
hit: true,
|
||||
path: 'C:/cc/srv/album/al-1/512.webp',
|
||||
tier: 2000,
|
||||
});
|
||||
expect(await ensureCoverTierDiskSrc(ref, 2000)).toBe('');
|
||||
});
|
||||
|
||||
it('accepts an exact-tier hit on either path separator', async () => {
|
||||
vi.mocked(coverCacheEnsure).mockResolvedValue({
|
||||
hit: true,
|
||||
path: 'C:\\cc\\srv\\album\\al-1\\2000.webp',
|
||||
tier: 2000,
|
||||
});
|
||||
expect(await ensureCoverTierDiskSrc(ref, 2000)).toBe(
|
||||
'asset://C:\\cc\\srv\\album\\al-1\\2000.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an exact in-memory hit without calling the backend', async () => {
|
||||
vi.mocked(getDiskSrc).mockReturnValue('asset://cached-2000');
|
||||
expect(await ensureCoverTierDiskSrc(ref, 2000)).toBe('asset://cached-2000');
|
||||
expect(coverCacheEnsure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import { isTauri } from '@tauri-apps/api/core';
|
||||
import { coverCacheEnsure } from '../api/coverCache';
|
||||
import { invalidateCacheKey } from '../utils/imageCache';
|
||||
import { getDiskSrcForGrid } from './diskSrcLookup';
|
||||
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
|
||||
import { coverStorageKeyFromRef } from './storageKeys';
|
||||
import type { CoverArtRef, CoverArtTier } from './types';
|
||||
|
||||
/**
|
||||
* Full-res / lightbox — Rust WebP on disk (`cover-cache/…/2000.webp`), not IndexedDB.
|
||||
*
|
||||
* The exact requested tier is mandatory here: the grid disk-src ladder
|
||||
* (`getDiskSrcForGrid`) and the Rust cover peek both fall back to a SMALLER
|
||||
* already-warmed tier (e.g. a browsed 800) for an unmet request. That is correct
|
||||
* for a grid cell, but for the lightbox it pins a downscaled image and never
|
||||
* loads full-res — the "cover preview stays small after the first open" bug. So
|
||||
* we only accept an exact-tier in-memory hit, and reject a backend hit whose path
|
||||
* is a smaller tier, letting the caller fall back to the network full-res URL.
|
||||
*/
|
||||
export async function ensureCoverTierDiskSrc(
|
||||
ref: CoverArtRef,
|
||||
@@ -16,11 +23,12 @@ export async function ensureCoverTierDiskSrc(
|
||||
if (!ref.fetchCoverArtId || !isTauri()) return '';
|
||||
|
||||
const storageKey = coverStorageKeyFromRef(ref, tier);
|
||||
const cached = getDiskSrcForGrid(ref, tier) || getDiskSrc(storageKey);
|
||||
const cached = getDiskSrc(storageKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = await coverCacheEnsure(ref, tier, 'high');
|
||||
if (!result.hit || !result.path) return '';
|
||||
const exactTier = new RegExp(`[\\\\/]${tier}\\.webp$`).test(result.path);
|
||||
if (!result.hit || !result.path || !exactTier) return '';
|
||||
|
||||
const src = rememberDiskSrc(storageKey, result.path);
|
||||
if (src) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
box-sizing: border-box;
|
||||
cursor: zoom-out;
|
||||
backdrop-filter: blur(6px);
|
||||
animation: cover-lightbox-overlay-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.cover-lightbox-img {
|
||||
@@ -20,6 +21,24 @@
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8);
|
||||
cursor: default;
|
||||
animation: cover-lightbox-img-in 220ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes cover-lightbox-overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes cover-lightbox-img-in {
|
||||
from { opacity: 0; transform: scale(0.94); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cover-lightbox-overlay,
|
||||
.cover-lightbox-img {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-lightbox-close {
|
||||
|
||||
Reference in New Issue
Block a user