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:
Psychotoxical
2026-06-28 04:23:12 +02:00
committed by GitHub
parent 281e86fd3b
commit 184501744b
10 changed files with 346 additions and 28 deletions
+23
View File
@@ -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);
});
});
+21
View File
@@ -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;
}
+59
View File
@@ -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
View File
@@ -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);
})();
+52
View File
@@ -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();
});
});
+11 -3
View File
@@ -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) {
+19
View File
@@ -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 {