mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(lib,playback,whatsNew): home waveform→lib, timeline utils→playback, releaseNotes+changelog→whatsNew
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user