refactor(lib,playback,whatsNew): home waveform→lib, timeline utils→playback, releaseNotes+changelog→whatsNew

This commit is contained in:
Psychotoxical
2026-06-30 20:26:34 +02:00
parent 695462ed87
commit aea8b7750b
37 changed files with 52 additions and 52 deletions
@@ -13,7 +13,7 @@ vi.mock('@/lib/api/library', () => ({
TIMELINE_HISTORY_BOOTSTRAP_LIMIT: 50,
}));
vi.mock('@/utils/queue/timelineBootstrapReady', () => ({
vi.mock('@/features/playback/utils/timelineBootstrapReady', () => ({
timelineBootstrapIndexReady: vi.fn(),
}));
@@ -23,7 +23,7 @@ vi.mock('@/features/playback/store/queueTrackResolver', async importOriginal =>
});
import { libraryGetRecentPlaySessions } from '@/lib/api/library';
import { timelineBootstrapIndexReady } from '@/utils/queue/timelineBootstrapReady';
import { timelineBootstrapIndexReady } from '@/features/playback/utils/timelineBootstrapReady';
describe('ensureTimelineBootstrap', () => {
beforeEach(() => {
@@ -13,8 +13,8 @@ import {
import {
bootstrapTrackFromPlaySession,
timelineHistoryToQueueRefs,
} from '@/utils/queue/timelineHistoryRefs';
import { timelineBootstrapIndexReady } from '@/utils/queue/timelineBootstrapReady';
} from '@/features/playback/utils/timelineHistoryRefs';
import { timelineBootstrapIndexReady } from '@/features/playback/utils/timelineBootstrapReady';
const BOOTSTRAP_RETRY_MS = 2_000;
@@ -88,7 +88,7 @@ import {
getSeekTargetSetAt,
} from '@/features/playback/store/seekTargetState';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { analyzeBoundary, computeWaveformSilence } from '@/utils/waveform/waveformSilence';
import { analyzeBoundary, computeWaveformSilence } from '@/lib/waveform/waveformSilence';
import { autodjMaxOverlapCapSec } from '@/utils/playback/autodjOverlapCap';
import {
autodjJsTriggerAtSec,
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { autodjMaxOverlapCapSec } from '@/utils/playback/autodjOverlapCap';
import { computeWaveformSilence, planCrossfadeTransition } from '@/utils/waveform/waveformSilence';
import { computeWaveformSilence, planCrossfadeTransition } from '@/lib/waveform/waveformSilence';
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
@@ -10,9 +10,9 @@
* Kept out of the persisted Zustand store on purpose: this is ephemeral,
* per-transition playback data, not user state.
*/
import type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
import type { CrossfadeTransitionPlan } from '@/lib/waveform/waveformSilence';
export type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
export type { CrossfadeTransitionPlan } from '@/lib/waveform/waveformSilence';
/** trackId → planned transition for when this track starts under crossfade. */
const planByTrackId = new Map<string, CrossfadeTransitionPlan>();
@@ -8,7 +8,7 @@ import {
computeAutodjManualBlendPlan,
shouldAutodjInterruptBlend,
} from '@/features/playback/utils/playback/autodjManualBlend';
import type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
import type { CrossfadeTransitionPlan } from '@/lib/waveform/waveformSilence';
import {
armInterruptHandoff,
clearInterruptHandoff,
@@ -16,7 +16,7 @@ import {
shouldDeferInterruptHandoffUi,
} from '@/features/playback/utils/playback/autodjInterruptPrep';
import { isCrossfadeNextReady } from '@/features/playback/store/crossfadePreload';
import { STANDARD_BLEND_SEC } from '@/utils/waveform/waveformSilence';
import { STANDARD_BLEND_SEC } from '@/lib/waveform/waveformSilence';
import { armAutodjMixing, clearAutodjTransitionUi } from '@/features/playback/store/autodjTransitionUi';
import {
bindQueueServerForTracks,
@@ -22,7 +22,7 @@ const hoisted = vi.hoisted(() => ({
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('@/utils/waveform/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
vi.mock('@/lib/waveform/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerSnapshot,
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { coerceWaveformBins } from '@/utils/waveform/waveformParse';
import { coerceWaveformBins } from '@/lib/waveform/waveformParse';
import { getPlaybackIndexKey } from '@/features/playback/utils/playback/playbackServer';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { getWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen';
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { buildTimelineDisplayRows, findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows';
import type { QueueItemRef } from '@/lib/media/trackTypes';
const ref = (trackId: string, extra?: Partial<QueueItemRef>): QueueItemRef => ({
serverId: 's1',
trackId,
...extra,
});
describe('buildTimelineDisplayRows', () => {
it('orders history, current, and upcoming', () => {
const rows = buildTimelineDisplayRows({
historyRefs: [{ serverId: 's1', trackId: 'h1', playedAtMs: 1 }],
queueItems: [ref('c'), ref('u1'), ref('u2')],
queueIndex: 0,
});
expect(rows.map(r => r.kind)).toEqual([
'divider', 'history', 'current', 'divider', 'upcoming', 'upcoming',
]);
});
it('finds current row local index for scroll', () => {
const rows = buildTimelineDisplayRows({
historyRefs: [{ serverId: 's1', trackId: 'h1', playedAtMs: 1 }],
queueItems: [ref('c'), ref('u1')],
queueIndex: 0,
});
expect(findTimelineScrollLocalIndex(rows)).toBe(2);
});
});
@@ -0,0 +1,80 @@
import type { QueueItemRef } from '@/lib/media/trackTypes';
import type { TimelinePlayedRef } from '@/features/playback/store/timelineSessionHistory';
export type TimelineDisplayRow =
| { kind: 'history'; ref: TimelinePlayedRef; localIndex: number; key: string }
| { kind: 'divider'; labelKey: 'queue.history' | 'queue.upNext'; localIndex: number; key: string }
| { kind: 'current'; ref: QueueItemRef; queueIndex: number; localIndex: number; key: string }
| { kind: 'upcoming'; ref: QueueItemRef; queueIndex: number; localIndex: number; key: string };
export function buildTimelineDisplayRows(args: {
historyRefs: TimelinePlayedRef[];
queueItems: QueueItemRef[];
queueIndex: number;
}): TimelineDisplayRow[] {
const { historyRefs, queueItems, queueIndex } = args;
const rows: TimelineDisplayRow[] = [];
let localIndex = 0;
if (historyRefs.length > 0) {
rows.push({
kind: 'divider',
labelKey: 'queue.history',
localIndex: localIndex++,
key: 'divider-history',
});
for (const ref of historyRefs) {
rows.push({
kind: 'history',
ref,
localIndex: localIndex++,
key: `history:${ref.serverId}:${ref.trackId}:${ref.playedAtMs}`,
});
}
}
const currentRef = queueIndex >= 0 && queueIndex < queueItems.length
? queueItems[queueIndex]
: null;
if (currentRef) {
rows.push({
kind: 'current',
ref: currentRef,
queueIndex,
localIndex: localIndex++,
key: `current:${currentRef.serverId}:${currentRef.trackId}:${queueIndex}`,
});
}
const upcoming = queueIndex >= 0
? queueItems.slice(queueIndex + 1)
: queueItems;
if (upcoming.length > 0) {
rows.push({
kind: 'divider',
labelKey: 'queue.upNext',
localIndex: localIndex++,
key: 'divider-upnext',
});
for (let i = 0; i < upcoming.length; i++) {
const absIdx = queueIndex >= 0 ? queueIndex + 1 + i : i;
const ref = upcoming[i]!;
rows.push({
kind: 'upcoming',
ref,
queueIndex: absIdx,
localIndex: localIndex++,
key: `upcoming:${ref.serverId}:${ref.trackId}:${absIdx}`,
});
}
}
return rows;
}
export function findTimelineScrollLocalIndex(rows: TimelineDisplayRow[]): number | null {
const current = rows.find(r => r.kind === 'current');
if (current) return current.localIndex;
const firstUpcoming = rows.find(r => r.kind === 'upcoming');
return firstUpcoming?.localIndex ?? null;
}
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resetPlayerStore } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
import { playTimelineHistoryTrack } from '@/features/playback/utils/playTimelineHistoryTrack';
vi.mock('@/features/playback/store/queueTrackResolver', async importOriginal => {
const actual = await importOriginal<typeof import('@/features/playback/store/queueTrackResolver')>();
return {
...actual,
resolveBatch: vi.fn(async () => undefined),
};
});
describe('playTimelineHistoryTrack', () => {
beforeEach(() => {
resetPlayerStore();
});
it('inserts after current when the track is not in the queue', async () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
const c = makeTrack({ id: 'c' });
const h1 = makeTrack({ id: 'h1' });
seedQueue([a, b, c], { index: 1, currentTrack: b, serverId: 's1' });
seedQueueResolver('s1', [h1]);
const playTrack = vi.fn();
usePlayerStore.setState({ playTrack });
await playTimelineHistoryTrack('s1', 'h1');
expect(playTrack).toHaveBeenCalledTimes(1);
const [track, queue, , , targetIdx] = playTrack.mock.calls[0]!;
expect(track.id).toBe('h1');
expect(queue?.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'h1', 'c']);
expect(targetIdx).toBe(2);
});
it('jumps to an upcoming slot when the track is already queued ahead', async () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
const c = makeTrack({ id: 'c' });
seedQueue([a, b, c], { index: 0, currentTrack: a, serverId: 's1' });
const playTrack = vi.fn();
usePlayerStore.setState({ playTrack });
await playTimelineHistoryTrack('s1', 'c');
expect(playTrack).toHaveBeenCalledWith(
expect.objectContaining({ id: 'c' }),
undefined,
undefined,
undefined,
2,
);
});
it('does not replace the queue when replaying a track that was already played in-queue', async () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
seedQueue([a, b], { index: 1, currentTrack: b, serverId: 's1' });
const playTrack = vi.fn();
usePlayerStore.setState({ playTrack });
await playTimelineHistoryTrack('s1', 'a');
expect(playTrack).toHaveBeenCalledTimes(1);
const [, queue] = playTrack.mock.calls[0]!;
expect(queue?.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'a']);
});
it('does not jump to the wrong server when track ids collide', async () => {
const b = makeTrack({ id: 'b' });
const s1Shared = makeTrack({ id: 'shared' });
const s2Shared = makeTrack({ id: 'shared', serverId: 's2' });
seedQueueResolver('s1', [s1Shared, b]);
seedQueueResolver('s2', [s2Shared]);
usePlayerStore.setState({
queueItems: [
{ serverId: 's1', trackId: 'shared' },
{ serverId: 's2', trackId: 'shared' },
{ serverId: 's1', trackId: 'b' },
],
queueIndex: 2,
currentTrack: b,
queueServerId: 's1',
playTrack: vi.fn(),
});
const playTrack = usePlayerStore.getState().playTrack as ReturnType<typeof vi.fn>;
await playTimelineHistoryTrack('s2', 'shared');
expect(playTrack).toHaveBeenCalledTimes(1);
const [, queue, , , targetIdx] = playTrack.mock.calls[0]!;
expect(targetIdx).toBe(3);
expect(queue?.map((t: { id: string }) => t.id)).toEqual(['shared', 'shared', 'b', 'shared']);
});
});
@@ -0,0 +1,52 @@
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { QueueItemRef } from '@/lib/media/trackTypes';
import { getQueueTracksView, resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import { resolveBatch } from '@/features/playback/store/queueTrackResolver';
import { findQueueItemRefIndex, sameQueueItemRef } from '@/features/playback/utils/playback/queueIdentity';
/**
* Play a timeline history row without replacing the queue. Upcoming slots jump
* in place; everything else inserts after the current track (play-now semantics).
*/
export async function playTimelineHistoryTrack(
serverId: string,
trackId: string,
canonicalQueue?: QueueItemRef[],
): Promise<void> {
const ref = { serverId, trackId };
await resolveBatch([ref]);
const track = resolveQueueTrack(ref);
const state = usePlayerStore.getState();
const { queueItems, queueIndex, currentTrack, playTrack } = state;
const lookup = canonicalQueue ?? queueItems;
const absIdx = findQueueItemRefIndex(lookup, ref);
const currentRef = queueIndex >= 0 ? lookup[queueIndex] : undefined;
if (
absIdx === queueIndex
&& currentTrack
&& currentRef
&& sameQueueItemRef(currentRef, ref)
) {
return;
}
if (absIdx > queueIndex) {
playTrack(track, undefined, undefined, undefined, absIdx);
return;
}
if (!currentTrack || queueItems.length === 0) {
playTrack(track, [track]);
return;
}
const resolved = getQueueTracksView(queueItems);
const insertAt = Math.min(queueIndex + 1, resolved.length);
const newQueue = [
...resolved.slice(0, insertAt),
track,
...resolved.slice(insertAt),
];
playTrack(track, newQueue, undefined, undefined, insertAt);
}
@@ -1,4 +1,4 @@
import { DYNAMIC_OVERLAP_HARD_CAP_SEC, STANDARD_BLEND_SEC } from '@/utils/waveform/waveformSilence';
import { DYNAMIC_OVERLAP_HARD_CAP_SEC, STANDARD_BLEND_SEC } from '@/lib/waveform/waveformSilence';
import type { QueueItemRef } from '@/lib/media/trackTypes';
export type QueueRepeatMode = 'off' | 'all' | 'one';
@@ -5,7 +5,7 @@ import {
planCrossfadeTransition,
STANDARD_BLEND_SEC,
type CrossfadeTransitionPlan,
} from '@/utils/waveform/waveformSilence';
} from '@/lib/waveform/waveformSilence';
import { getTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
/** Same trust threshold as end-of-track scenario A in `waveformSilence.ts`. */
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { timelineBootstrapIndexReady } from '@/features/playback/utils/timelineBootstrapReady';
vi.mock('@/lib/library/libraryReady', () => ({
libraryIsReady: vi.fn(),
}));
import { libraryIsReady } from '@/lib/library/libraryReady';
describe('timelineBootstrapIndexReady', () => {
beforeEach(() => {
vi.mocked(libraryIsReady).mockReset();
useAuthStore.setState({
servers: [
{ id: 's1', name: 'A', url: 'https://a', username: 'u', password: 'p' },
{ id: 's2', name: 'B', url: 'https://b', username: 'u', password: 'p' },
],
activeServerId: 's1',
} as never);
useLibraryIndexStore.setState({
masterEnabled: true,
isIndexEnabled: id => !!id,
indexedServerIds: ids => ids,
});
});
it('returns true when no servers are configured', async () => {
useAuthStore.setState({ servers: [], activeServerId: null } as never);
await expect(timelineBootstrapIndexReady()).resolves.toBe(true);
});
it('returns true when any indexed server is ready', async () => {
vi.mocked(libraryIsReady).mockImplementation(async id => id === 's2');
await expect(timelineBootstrapIndexReady()).resolves.toBe(true);
});
it('returns false when no indexed server is ready yet', async () => {
vi.mocked(libraryIsReady).mockResolvedValue(false);
await expect(timelineBootstrapIndexReady()).resolves.toBe(false);
});
});
@@ -0,0 +1,23 @@
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { libraryIsReady } from '@/lib/library/libraryReady';
/**
* Timeline cold bootstrap reads local `play_session` (cross-server). Gate on at
* least one indexed profile being ready so JOIN metadata is trustworthy; when no
* servers are configured, allow fetch immediately.
*/
export async function timelineBootstrapIndexReady(): Promise<boolean> {
const servers = useAuthStore.getState().servers;
if (servers.length === 0) return true;
const indexed = useLibraryIndexStore
.getState()
.indexedServerIds(servers.map(s => s.id));
if (indexed.length === 0) return true;
for (const serverId of indexed) {
if (await libraryIsReady(serverId)) return true;
}
return false;
}
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { bootstrapTrackFromPlaySession, timelineHistoryToQueueRefs } from '@/features/playback/utils/timelineHistoryRefs';
describe('timelineHistoryRefs', () => {
it('maps history rows to queue refs', () => {
expect(timelineHistoryToQueueRefs([
{ serverId: 's1', trackId: 't1', playedAtMs: 1 },
{ serverId: 's2', trackId: 't2', playedAtMs: 2 },
])).toEqual([
{ serverId: 's1', trackId: 't1' },
{ serverId: 's2', trackId: 't2' },
]);
});
it('seeds bootstrap tracks with album cover metadata', () => {
const track = bootstrapTrackFromPlaySession({
serverId: 's2',
trackId: 't1',
title: 'Song',
artist: 'Artist',
album: 'Album',
albumId: 'al-1',
coverArtId: 'cover-1',
startedAtMs: 1,
listenedSec: 30,
completion: 'full',
});
expect(track.albumId).toBe('al-1');
expect(track.coverArt).toBe('cover-1');
expect(track.serverId).toBe('s2');
});
});
@@ -0,0 +1,25 @@
import type { QueueItemRef } from '@/lib/media/trackTypes';
import type { PlaySessionRecentTrack } from '@/lib/api/library';
import type { TimelinePlayedRef } from '@/features/playback/store/timelineSessionHistory';
import type { Track } from '@/lib/media/trackTypes';
export function timelineHistoryToQueueRefs(
history: TimelinePlayedRef[],
): QueueItemRef[] {
return history.map(row => ({ serverId: row.serverId, trackId: row.trackId }));
}
export function bootstrapTrackFromPlaySession(row: PlaySessionRecentTrack): Track {
const albumId = row.albumId ?? '';
const coverArt = row.coverArtId ?? albumId;
return {
id: row.trackId,
title: row.title,
artist: row.artist ?? '',
album: row.album ?? '',
albumId,
coverArt,
duration: 0,
serverId: row.serverId,
};
}
+3 -3
View File
@@ -15,9 +15,9 @@ import {
subscribeQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueIdentity';
import type { TimelineDisplayRow } from '@/utils/queue/buildTimelineDisplayRows';
import { findTimelineScrollLocalIndex } from '@/utils/queue/buildTimelineDisplayRows';
import { playTimelineHistoryTrack } from '@/utils/queue/playTimelineHistoryTrack';
import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows';
import { findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows';
import { playTimelineHistoryTrack } from '@/features/playback/utils/playTimelineHistoryTrack';
type StartDrag = (
payload: { data: string; label: string },
+1 -1
View File
@@ -33,7 +33,7 @@ import { QueueList } from '@/features/queue/components/QueueList';
import { QueueTabBar } from '@/features/queue/components/QueueTabBar';
import { useQueueAutoScroll } from '@/features/queue/hooks/useQueueAutoScroll';
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '@/features/playback/hooks/useTimelinePlayHistory';
import { buildTimelineDisplayRows } from '@/utils/queue/buildTimelineDisplayRows';
import { buildTimelineDisplayRows } from '@/features/playback/utils/buildTimelineDisplayRows';
import { activeServerQueueTrackIds } from '@/features/playback/utils/playback/trackServerScope';
export default function QueuePanel() {
@@ -4,8 +4,8 @@ import {
resolveChangelogEntry,
resolveReleaseNotes,
type ReleaseNotesSource,
} from '@/utils/releaseNotes/releaseNotesResolve';
import type { ReleaseNotesEntry } from '@/utils/releaseNotes/releaseNotesMatch';
} from '@/features/whatsNew/utils/releaseNotesResolve';
import type { ReleaseNotesEntry } from '@/features/whatsNew/utils/releaseNotesMatch';
export interface UseReleaseNotesResult {
loading: boolean;
@@ -0,0 +1,11 @@
/** Re-exports — implementation lives in releaseNotes/releaseNotesMatch.ts */
export {
changelogVersionCore,
findChangelogReleaseEntry,
findReleaseNotesEntry,
releaseNotesVersionCore,
splitChangelogBlocks,
splitReleaseNotesBlocks,
type ChangelogReleaseEntry,
type ReleaseNotesEntry,
} from '@/features/whatsNew/utils/releaseNotesMatch';
@@ -0,0 +1,32 @@
import { BaseDirectory, exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { isTauri } from '@tauri-apps/api/core';
const CACHE_DIR = 'release-notes';
function cacheRelPath(version: string): string {
const safe = version.replace(/[^a-zA-Z0-9._-]+/g, '_');
return `${CACHE_DIR}/${safe}.md`;
}
export async function readReleaseNotesCache(version: string): Promise<string | null> {
if (!isTauri()) return null;
const path = cacheRelPath(version);
try {
if (!(await exists(path, { baseDir: BaseDirectory.AppData }))) return null;
const text = (await readTextFile(path, { baseDir: BaseDirectory.AppData })).trim();
return text || null;
} catch {
return null;
}
}
export async function writeReleaseNotesCache(version: string, body: string): Promise<void> {
if (!isTauri()) return;
const path = cacheRelPath(version);
try {
await mkdir(CACHE_DIR, { baseDir: BaseDirectory.AppData, recursive: true });
await writeTextFile(path, body, { baseDir: BaseDirectory.AppData });
} catch {
// cache is best-effort
}
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { isDevChannelVersion, isWorkspaceReleaseNotesMode } from '@/features/whatsNew/utils/releaseNotesChannel';
describe('releaseNotesChannel', () => {
it('detects -dev channel versions', () => {
expect(isDevChannelVersion('1.48.0-dev')).toBe(true);
expect(isDevChannelVersion('1.48.0')).toBe(false);
expect(isDevChannelVersion('1.48.0-rc.1')).toBe(false);
});
it('enables workspace mode in vitest DEV builds', () => {
expect(isWorkspaceReleaseNotesMode('1.48.0')).toBe(import.meta.env.DEV);
expect(isWorkspaceReleaseNotesMode('1.48.0-dev')).toBe(true);
});
});
@@ -0,0 +1,7 @@
export function isDevChannelVersion(version: string): boolean {
return /-dev(?:\b|$)/i.test(version.trim());
}
export function isWorkspaceReleaseNotesMode(version: string): boolean {
return import.meta.env.DEV || isDevChannelVersion(version);
}
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const invoke = vi.fn();
const isTauri = vi.fn(() => true);
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invoke(...args),
isTauri: () => isTauri(),
}));
const { fetchWhatsNewAsset, whatsNewDownloadUrl } = await import('@/features/whatsNew/utils/releaseNotesFetch');
describe('releaseNotesFetch', () => {
beforeEach(() => {
invoke.mockReset();
isTauri.mockReturnValue(true);
});
it('builds the release download URL from version', () => {
expect(whatsNewDownloadUrl('1.48.0-rc.1')).toBe(
'https://github.com/Psychotoxical/psysonic/releases/download/app-v1.48.0-rc.1/whats-new.md',
);
});
it('fetches via fetch_url_bytes Rust proxy', async () => {
const body = '# Highlights\n- Item';
invoke.mockResolvedValueOnce([[...new TextEncoder().encode(body)], 'text/plain']);
const text = await fetchWhatsNewAsset('1.48.0');
expect(invoke).toHaveBeenCalledWith('fetch_url_bytes', {
url: 'https://github.com/Psychotoxical/psysonic/releases/download/app-v1.48.0/whats-new.md',
});
expect(text).toBe(body);
});
it('returns null outside Tauri', async () => {
isTauri.mockReturnValue(false);
expect(await fetchWhatsNewAsset('1.48.0')).toBeNull();
expect(invoke).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,43 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
export const RELEASE_NOTES_REPO = 'Psychotoxical/psysonic';
const MAX_BODY_BYTES = 64 * 1024;
/** Asset name for remote what's new (i18n: add locale suffix later). */
export function whatsNewAssetName(_locale?: string): string {
return 'whats-new.md';
}
export function whatsNewDownloadUrl(version: string): string {
const asset = whatsNewAssetName();
return `https://github.com/${RELEASE_NOTES_REPO}/releases/download/app-v${version}/${asset}`;
}
/**
* GitHub release download URLs are served without CORS — use the Rust proxy
* (same as radio favicons and other non-CORS endpoints).
*/
async function fetchBytesViaRust(url: string): Promise<Uint8Array> {
const [bytes] = await invoke<[number[], string]>('fetch_url_bytes', { url });
return new Uint8Array(bytes);
}
export async function fetchWhatsNewAsset(version: string): Promise<string | null> {
if (!isTauri()) return null;
const url = whatsNewDownloadUrl(version);
for (let attempt = 0; attempt < 2; attempt++) {
try {
const buf = await fetchBytesViaRust(url);
if (buf.byteLength > MAX_BODY_BYTES) return null;
const text = new TextDecoder().decode(buf).trim();
return text || null;
} catch {
// Retry once on network/timeout only (404/4xx throw from Rust — same on retry).
if (attempt === 1) return null;
}
}
return null;
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { findReleaseNotesEntry } from '@/features/whatsNew/utils/releaseNotesMatch';
const FIXTURE = `
# Notes
## [1.48.0] - 2026-06-10
## Highlights
### Theme Store
- Install themes
## [1.47.0]
Old line
`;
describe('findReleaseNotesEntry', () => {
it('matches exact version header', () => {
const e = findReleaseNotesEntry(FIXTURE, '1.48.0');
expect(e?.headerVersion).toBe('1.48.0');
expect(e?.date).toBe('2026-06-10');
expect(e?.body).toContain('Theme Store');
});
it('resolves -rc to base release line', () => {
const e = findReleaseNotesEntry(FIXTURE, '1.48.0-rc.2');
expect(e?.headerVersion).toBe('1.48.0');
});
it('resolves -dev to base release line', () => {
const e = findReleaseNotesEntry(FIXTURE, '1.48.0-dev');
expect(e?.headerVersion).toBe('1.48.0');
});
it('prefers plain X.Y.Z header when multiple share core and no exact header', () => {
const raw = `
## [1.48.0-rc.1]
RC only
## [1.48.0]
Stable line
`;
const e = findReleaseNotesEntry(raw, '1.48.0-rc.2');
expect(e?.headerVersion).toBe('1.48.0');
expect(e?.body).toContain('Stable line');
});
it('returns null when no section matches', () => {
expect(findReleaseNotesEntry(FIXTURE, '9.9.9')).toBeNull();
});
});
@@ -0,0 +1,80 @@
/**
* Match ## [version] sections in release-notes markdown to the app version.
* Exact match first; otherwise same semver major.minor.patch (ignores -rc, -dev, etc.).
*/
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
export function releaseNotesVersionCore(version: string): string | null {
const m = version.trim().match(SEMVER_CORE);
return m ? m[1] : null;
}
function isPlainSemverTriple(header: string): boolean {
return /^\d+\.\d+\.\d+$/.test(header.trim());
}
export function splitReleaseNotesBlocks(markdownRaw: string): string[] {
return markdownRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
}
export interface ReleaseNotesEntry {
headerVersion: string;
date: string;
body: string;
}
function parseBlock(block: string): ReleaseNotesEntry | null {
const lines = block.split('\n');
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
if (!m) return null;
return {
headerVersion: m[1],
date: (m[2] ?? '').trim(),
body: lines.slice(1).join('\n').trim(),
};
}
function headerVersionFromBlock(block: string): string | null {
const m = block.match(/^## \[([^\]]+)\]/);
return m ? m[1] : null;
}
export function findReleaseNotesEntry(
markdownRaw: string,
appVersion: string,
): ReleaseNotesEntry | null {
const blocks = splitReleaseNotesBlocks(markdownRaw);
const exact = blocks.find((b: string) => b.startsWith(`## [${appVersion}]`));
if (exact) return parseBlock(exact);
const appCore = releaseNotesVersionCore(appVersion);
if (!appCore) return null;
const candidates = blocks.filter((b: string) => {
const hv = headerVersionFromBlock(b);
if (!hv) return false;
return releaseNotesVersionCore(hv) === appCore;
});
if (candidates.length === 0) return null;
const plain = candidates.find((b: string) => {
const hv = headerVersionFromBlock(b);
return hv !== null && isPlainSemverTriple(hv);
});
const chosen = plain ?? candidates[0];
return parseBlock(chosen);
}
/** @deprecated Use findReleaseNotesEntry */
export const findChangelogReleaseEntry = findReleaseNotesEntry;
/** @deprecated Use ReleaseNotesEntry */
export type ChangelogReleaseEntry = ReleaseNotesEntry;
/** @deprecated Use releaseNotesVersionCore */
export const changelogVersionCore = releaseNotesVersionCore;
/** @deprecated Use splitReleaseNotesBlocks */
export const splitChangelogBlocks = splitReleaseNotesBlocks;
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWhatsNewAsset = vi.fn();
const readReleaseNotesCache = vi.fn();
const writeReleaseNotesCache = vi.fn();
vi.mock('@/features/whatsNew/utils/releaseNotesChannel', () => ({
isDevChannelVersion: (version: string) => /-dev(?:\b|$)/i.test(version.trim()),
isWorkspaceReleaseNotesMode: (version: string) => /-dev(?:\b|$)/i.test(version.trim()),
}));
vi.mock('@/features/whatsNew/utils/releaseNotesFetch', () => ({
fetchWhatsNewAsset: (...args: unknown[]) => fetchWhatsNewAsset(...args),
}));
vi.mock('@/features/whatsNew/utils/releaseNotesCache', () => ({
readReleaseNotesCache: (...args: unknown[]) => readReleaseNotesCache(...args),
writeReleaseNotesCache: (...args: unknown[]) => writeReleaseNotesCache(...args),
}));
vi.mock('@/generated/releaseNotesBundle', () => ({
WHATS_NEW_RAW: `## [1.48.0] - 2026-06-10
## Highlights
- Embedded what's new`,
CHANGELOG_RAW: `## [1.48.0]
## Added
- Embedded changelog fallback`,
}));
const { resolveReleaseNotes } = await import('@/features/whatsNew/utils/releaseNotesResolve');
describe('resolveReleaseNotes (shipped channel)', () => {
beforeEach(() => {
fetchWhatsNewAsset.mockReset();
readReleaseNotesCache.mockReset();
writeReleaseNotesCache.mockReset();
readReleaseNotesCache.mockResolvedValue(null);
fetchWhatsNewAsset.mockResolvedValue(null);
});
it('uses cache before network', async () => {
readReleaseNotesCache.mockResolvedValue('Cached body line');
const r = await resolveReleaseNotes('1.48.0');
expect(r.source).toBe('cache');
expect(r.entry?.body).toContain('Cached body line');
expect(fetchWhatsNewAsset).not.toHaveBeenCalled();
});
it('fetches remote and writes cache when no cache hit', async () => {
fetchWhatsNewAsset.mockResolvedValue('Fresh remote body');
const r = await resolveReleaseNotes('1.48.0-rc.1');
expect(r.source).toBe('remote');
expect(r.entry?.body).toBe('Fresh remote body');
expect(writeReleaseNotesCache).toHaveBeenCalledWith('1.48.0-rc.1', 'Fresh remote body');
});
it('falls back to embedded whats-new when offline', async () => {
const r = await resolveReleaseNotes('1.48.0');
expect(r.source).toBe('embedded-whats-new');
expect(r.entry?.body).toContain("Embedded what's new");
expect(r.entry?.date).toBe('2026-06-10');
});
});
describe('resolveReleaseNotes (workspace)', () => {
beforeEach(() => {
fetchWhatsNewAsset.mockReset();
readReleaseNotesCache.mockReset();
});
it('skips fetch for -dev versions', async () => {
const r = await resolveReleaseNotes('1.48.0-dev');
expect(['workspace', 'workspace-changelog']).toContain(r.source);
expect(fetchWhatsNewAsset).not.toHaveBeenCalled();
expect(r.entry?.body.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,123 @@
import { isWorkspaceReleaseNotesMode } from '@/features/whatsNew/utils/releaseNotesChannel';
import { readReleaseNotesCache, writeReleaseNotesCache } from '@/features/whatsNew/utils/releaseNotesCache';
import { fetchWhatsNewAsset } from '@/features/whatsNew/utils/releaseNotesFetch';
import { findReleaseNotesEntry, type ReleaseNotesEntry } from '@/features/whatsNew/utils/releaseNotesMatch';
export type ReleaseNotesSource =
| 'workspace'
| 'workspace-changelog'
| 'cache'
| 'remote'
| 'embedded-whats-new'
| 'embedded-changelog'
| 'empty';
export interface ResolvedReleaseNotes {
source: ReleaseNotesSource;
entry: ReleaseNotesEntry | null;
}
async function loadWorkspaceWhatsNewRaw(): Promise<string> {
if (import.meta.env.DEV) {
return (await import('@/../WHATS_NEW.md?raw')).default;
}
const { WHATS_NEW_RAW } = await import('@/generated/releaseNotesBundle');
return WHATS_NEW_RAW;
}
async function loadWorkspaceChangelogRaw(): Promise<string> {
if (import.meta.env.DEV) {
return (await import('@/../CHANGELOG.md?raw')).default;
}
const { CHANGELOG_RAW } = await import('@/generated/releaseNotesBundle');
return CHANGELOG_RAW;
}
async function loadEmbeddedWhatsNewRaw(): Promise<string> {
const { WHATS_NEW_RAW } = await import('@/generated/releaseNotesBundle');
return WHATS_NEW_RAW;
}
async function loadEmbeddedChangelogRaw(): Promise<string> {
const { CHANGELOG_RAW } = await import('@/generated/releaseNotesBundle');
return CHANGELOG_RAW;
}
function mergeBodyWithMeta(body: string, meta: ReleaseNotesEntry | null, version: string): ReleaseNotesEntry {
return {
body,
date: meta?.date ?? '',
headerVersion: meta?.headerVersion ?? version,
};
}
async function resolveWorkspace(version: string): Promise<ResolvedReleaseNotes> {
const whatsNewRaw = await loadWorkspaceWhatsNewRaw();
const fromWhatsNew = findReleaseNotesEntry(whatsNewRaw, version);
if (fromWhatsNew?.body) {
return { source: 'workspace', entry: fromWhatsNew };
}
const changelogRaw = await loadWorkspaceChangelogRaw();
const fromChangelog = findReleaseNotesEntry(changelogRaw, version);
if (fromChangelog?.body) {
return { source: 'workspace-changelog', entry: fromChangelog };
}
return { source: 'empty', entry: null };
}
async function resolveShipped(version: string): Promise<ResolvedReleaseNotes> {
const embeddedMeta = findReleaseNotesEntry(await loadEmbeddedWhatsNewRaw(), version);
const cached = await readReleaseNotesCache(version);
if (cached) {
return {
source: 'cache',
entry: mergeBodyWithMeta(cached, embeddedMeta, version),
};
}
const remote = await fetchWhatsNewAsset(version);
if (remote) {
await writeReleaseNotesCache(version, remote);
return {
source: 'remote',
entry: mergeBodyWithMeta(remote, embeddedMeta, version),
};
}
const fromEmbeddedWhatsNew = findReleaseNotesEntry(await loadEmbeddedWhatsNewRaw(), version);
if (fromEmbeddedWhatsNew?.body) {
return { source: 'embedded-whats-new', entry: fromEmbeddedWhatsNew };
}
const fromEmbeddedChangelog = findReleaseNotesEntry(await loadEmbeddedChangelogRaw(), version);
if (fromEmbeddedChangelog?.body) {
return { source: 'embedded-changelog', entry: fromEmbeddedChangelog };
}
return { source: 'empty', entry: null };
}
export async function resolveReleaseNotes(version: string): Promise<ResolvedReleaseNotes> {
if (isWorkspaceReleaseNotesMode(version)) {
return resolveWorkspace(version);
}
return resolveShipped(version);
}
/** Technical changelog for the same version (embedded or workspace; never remote). */
export async function resolveChangelogEntry(version: string): Promise<ResolvedReleaseNotes> {
const changelogRaw = isWorkspaceReleaseNotesMode(version)
? await loadWorkspaceChangelogRaw()
: await loadEmbeddedChangelogRaw();
const entry = findReleaseNotesEntry(changelogRaw, version);
if (entry?.body) {
return {
source: isWorkspaceReleaseNotesMode(version) ? 'workspace-changelog' : 'embedded-changelog',
entry,
};
}
return { source: 'empty', entry: null };
}