(value: T | undefined): asserts value is T {
expect(value).toBeDefined()
}
/** @public */
export const dbGetAllAndExpectLength = async (
storeName: S,
expectedCount: number,
message?: string,
) => {
const db = await getDatabase()
const items = await db.getAll(storeName)
expect(items, message).toHaveLength(expectedCount)
return items
}
================================================
FILE: src/lib/helpers/ui-action.ts
================================================
/**
* Executes a UI action that shows a success message upon completion or an error message if the action fails.
*/
export const createUIAction = (
successMessage: string | false,
action: (...params: P) => Promise,
) => {
const wrappedAction = async (...params: P): Promise => {
try {
await action(...params)
if (successMessage) {
snackbar(successMessage)
}
} catch (error) {
console.error('Error executing UI action:', error)
snackbar.unexpectedError(error)
}
}
return wrappedAction
}
================================================
FILE: src/lib/helpers/utils/array.ts
================================================
/** @public */
export const toShuffledArray = (input: T[]): T[] => {
const output = [...input]
for (let i = output.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1))
const temp = output[i] as T
output[i] = output[j] as T
output[j] = temp
}
return output
}
================================================
FILE: src/lib/helpers/utils/assign.ts
================================================
// biome-ignore lint/suspicious/noExplicitAny: needed for inference
type Impossible = {
[P in K]: never
}
export const assign = >(
target: T,
source: S & Impossible>,
): S & T => Object.assign(target, source)
================================================
FILE: src/lib/helpers/utils/clamp.ts
================================================
export const clamp = (num: number, min: number, max: number): number =>
Math.min(Math.max(num, min), max)
================================================
FILE: src/lib/helpers/utils/debounce.ts
================================================
/** @public */
export const debounce = ) => ReturnType>(
fn: Fn,
delay: number,
): {
(...args: Parameters): void
cancel: () => void
} => {
let timeout: undefined | number
const debounceFn = (...args: Parameters) => {
clearTimeout(timeout)
timeout = setTimeout(fn, delay, ...(args as unknown[]))
}
debounceFn.cancel = () => {
if (timeout) {
clearTimeout(timeout)
timeout = undefined
}
}
return debounceFn
}
================================================
FILE: src/lib/helpers/utils/format-duration.ts
================================================
const twoDigits = (num: number) => num.toString().padStart(2, '0')
export const formatDuration = (seconds: number) => {
if (!Number.isFinite(seconds)) {
return '--:--'
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
return `${hours ? `${hours}:` : ''}${twoDigits(minutes)}:${twoDigits(secs)}`
}
================================================
FILE: src/lib/helpers/utils/integers.ts
================================================
export const safeInteger = (num: number, fallback = 0): number => {
if (Number.isSafeInteger(num)) {
return num
}
return fallback
}
================================================
FILE: src/lib/helpers/utils/navigate.ts
================================================
export const navigateToExternal = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
================================================
FILE: src/lib/helpers/utils/text.ts
================================================
import { type StringOrUnknownItem, UNKNOWN_ITEM } from '$lib/library/types.ts'
export const truncate = (text: string, length: number): string => {
if (text.length <= length) {
return text
}
return `${text.slice(0, length)}...`
}
export const formatArtists = (artists: readonly StringOrUnknownItem[]): string =>
artists.filter((artist) => artist !== UNKNOWN_ITEM).join(', ')
export const formatNameOrUnknown = (name: StringOrUnknownItem, fallback = m.unknown()): string =>
name === UNKNOWN_ITEM ? fallback : name
export const getItemLanguage = (language: string | undefined): string | undefined => {
if (!language) {
return
}
const lang = language.toLowerCase()
switch (lang) {
case 'jp':
case 'jap':
case 'japanese':
return 'ja'
case 'korean':
return 'ko'
case 'zh-cn':
return 'zh-CN'
case 'zh-tw':
return 'zh-TW'
case 'zho':
case 'chinese':
case 'zh':
return 'zh-CN'
case 'cantonese':
return 'yue'
case 'fre':
case 'french':
return 'fr'
case 'esp':
case 'spanish':
return 'es'
case 'eng':
case 'english':
return 'en'
default:
return lang
}
}
================================================
FILE: src/lib/helpers/utils/throttle.ts
================================================
export const throttle = ) => ReturnType>(
fn: Fn,
delay: number,
): {
(...args: Parameters): ReturnType
cancel: () => void
} => {
let wait = false
let timeout: undefined | number
let prevValue: ReturnType | undefined
const throttleFn = (...args: Parameters) => {
if (wait) {
// prevValue always defined by the
// time wait is true
return prevValue as ReturnType
}
const val = fn(...args)
prevValue = val
wait = true
timeout = window.setTimeout(() => {
wait = false
}, delay)
return val
}
throttleFn.cancel = () => {
clearTimeout(timeout)
}
return throttleFn
}
================================================
FILE: src/lib/helpers/utils/ua.ts
================================================
const isMobileRegex = /Android|iPhone|iPad|iPod/i
const isMacRegex = /Macintosh|Mac OS X/i
const isWindowsRegex = /Windows/i
const isAndroidRegex = /Android/i
const runOnce = (fn: () => T): (() => T) => {
let result: T
let hasRun = false
return () => {
if (hasRun) {
return result
}
result = fn()
hasRun = true
return result
}
}
/** @public */
export const isMobile = runOnce((): boolean => {
if (navigator.userAgentData) {
return navigator.userAgentData.mobile
}
return isMobileRegex.test(navigator.userAgent)
})
/** @public */
export const isSafari = runOnce(() => {
const ua = navigator.userAgent.toLowerCase()
return ua.includes('applewebkit') && !ua.includes('chrome') && !ua.includes('chromium')
})
/** @public */
export const isMac = runOnce((): boolean => {
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform === 'macOS'
}
return isMacRegex.test(navigator.userAgent)
})
/** @public */
export const isWindows = runOnce((): boolean => {
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform === 'Windows'
}
return isWindowsRegex.test(navigator.userAgent)
})
export const isAndroid = runOnce((): boolean => {
if (navigator.userAgentData) {
return navigator.userAgentData.platform === 'Android'
}
return isAndroidRegex.test(navigator.userAgent)
})
export const isChromiumBased = runOnce((): boolean => {
// All of our supported Chromium versions will have this property
if (navigator.userAgentData) {
return navigator.userAgentData.brands.some((brand) =>
brand.brand.toLowerCase().includes('chromium'),
)
}
return false
})
/**
* Returns whether the primary modifier key is pressed.
* On Mac this is the Meta key (Cmd), on Windows/Linux it's the Ctrl key.
* @public
*/
export const isPrimaryModifierKey = (event: KeyboardEvent | MouseEvent): boolean => {
if (isMac()) {
return event.metaKey
}
return event.ctrlKey
}
================================================
FILE: src/lib/helpers/utils/wait.ts
================================================
/** @public */
export const wait = (duration: number): Promise =>
new Promise((resolve) => {
setTimeout(resolve, duration)
})
================================================
FILE: src/lib/helpers/virtualizer.svelte.ts
================================================
import { Virtualizer, type VirtualizerOptions } from '@tanstack/virtual-core'
export * from '@tanstack/virtual-core'
export function createVirtualizerBase<
TScrollElement extends Element | Window,
TItemElement extends Element,
>(
options: () => VirtualizerOptions,
): Virtualizer {
const instance = new Virtualizer(options())
let virtualItems = $state(instance.getVirtualItems())
let totalSize = $state(instance.getTotalSize())
const virtualizer = new Proxy(instance, {
get(target, prop) {
switch (prop) {
case 'getVirtualItems':
return () => virtualItems
case 'getTotalSize':
return () => totalSize
default:
return Reflect.get(target, prop)
}
},
})
$effect(() => {
const cleanup = untrack(() => {
const cleanupInner = virtualizer._didMount()
return cleanupInner
})
return cleanup
})
$effect(() => {
const resolvedOptions = options()
virtualizer.setOptions({
...resolvedOptions,
onChange: (instance, sync) => {
instance._willUpdate()
virtualItems = instance.getVirtualItems()
totalSize = instance.getTotalSize()
resolvedOptions.onChange?.(instance, sync)
},
})
virtualizer.measure()
})
return virtualizer
}
================================================
FILE: src/lib/layout-bottom-bar.svelte.ts
================================================
import { createContext } from 'svelte'
import { SvelteMap } from 'svelte/reactivity'
export interface BottomBarState {
bottomBar: Snippet | null
abovePlayer: SvelteMap
}
const [getContext, setContext] = createContext()
export const setupOverlaySnippets = () => {
const state: BottomBarState = $state({
bottomBar: null,
abovePlayer: new SvelteMap(),
})
setContext(state)
return {
get bottomBar(): BottomBarState['bottomBar'] {
return state.bottomBar
},
get abovePlayer(): Snippet[] {
return [...state.abovePlayer.values()]
},
}
}
let counter = 0
export const useSetOverlaySnippet = (
type: 'bottom-bar' | 'above-player',
getSnippet: () => Snippet | null,
): void => {
const state = getContext()
const id = counter
counter += 1
$effect.pre(() => {
if (type === 'bottom-bar') {
state.bottomBar = getSnippet()
return () => {
state.bottomBar = null
}
}
if (type === 'above-player') {
const snippet = getSnippet()
if (snippet) {
state.abovePlayer.set(id, snippet)
} else {
state.abovePlayer.delete(id)
}
return () => {
state.abovePlayer.delete(id)
}
}
return undefined
})
}
================================================
FILE: src/lib/library/__tests__/play-history.test.ts
================================================
import 'fake-indexeddb/auto'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDatabase } from '$lib/db/database.ts'
import { clearDatabaseStores } from '$lib/helpers/test-helpers.ts'
import { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts'
import { LEGACY_NO_NATIVE_DIRECTORY, type Track } from '$lib/library/types.ts'
const seedTrack = async (id: number) => {
const db = await getDatabase()
const trackData: Track = {
id,
uuid: `track-${id}`,
name: `Track ${id}`,
artists: ['Artist'],
album: 'Album',
year: '2026',
duration: 180,
genre: [],
trackNo: 1,
trackOf: 1,
discNo: 1,
discOf: 1,
fileName: `track-${id}.mp3`,
directory: LEGACY_NO_NATIVE_DIRECTORY,
scannedAt: Date.now(),
file: new File(['x'], `track-${id}.mp3`, { type: 'audio/mpeg' }),
}
await db.add('tracks', trackData)
}
describe('play history actions', () => {
afterEach(async () => {
vi.restoreAllMocks()
await clearDatabaseStores()
})
it('keeps only one entry per track id', async () => {
let now = 100
vi.spyOn(Date, 'now').mockImplementation(() => {
now += 1
return now
})
await seedTrack(1)
await seedTrack(2)
await dbAddToPlayHistory(1)
await dbAddToPlayHistory(2)
await dbAddToPlayHistory(1)
const db = await getDatabase()
const entries = await db.getAllFromIndex('playHistory', 'playedAt')
expect(entries).toHaveLength(2)
expect(entries.filter((entry) => entry.trackId === 1)).toHaveLength(1)
expect(entries.map((entry) => entry.trackId)).toEqual([2, 1])
})
it('does not increase history size when replaying the same track', async () => {
let now = 200
vi.spyOn(Date, 'now').mockImplementation(() => {
now += 1
return now
})
await seedTrack(10)
await dbAddToPlayHistory(10)
await dbAddToPlayHistory(10)
await dbAddToPlayHistory(10)
const db = await getDatabase()
const entries = await db.getAll('playHistory')
expect(entries).toHaveLength(1)
expect(entries[0]?.trackId).toBe(10)
})
it('keeps only the most recent 100 history entries', async () => {
let now = 300
vi.spyOn(Date, 'now').mockImplementation(() => {
now += 1
return now
})
for (let trackId = 1; trackId <= 120; trackId += 1) {
await seedTrack(trackId)
await dbAddToPlayHistory(trackId)
}
const db = await getDatabase()
const entries = await db.getAllFromIndex('playHistory', 'playedAt')
const trackIds = entries.map((entry) => entry.trackId)
expect(entries).toHaveLength(100)
expect(trackIds[0]).toBe(21)
expect(trackIds.at(-1)).toBe(120)
expect(trackIds).not.toContain(20)
})
})
================================================
FILE: src/lib/library/__tests__/playlists.test.ts
================================================
import 'fake-indexeddb/auto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDatabase } from '$lib/db/database.ts'
import {
clearDatabaseStores,
dbGetAllAndExpectLength,
expectToBeDefined,
} from '$lib/helpers/test-helpers.ts'
import {
createPlaylist,
dbAddTracksToPlaylistsWithTx,
dbBatchModifyPlaylistsSelection,
dbCreatePlaylist,
dbRemovePlaylist,
dbRemoveTracksFromPlaylistsWithTx,
getPlaylistEntriesDatabaseStore,
removeTrackEntryFromPlaylist,
toggleFavoriteTrack,
type UpdatePlaylistOptions,
updatePlaylist,
} from '$lib/library/playlists-actions.ts'
import { dbImportTrack } from '$lib/library/scan-actions/scanner/import-track.ts'
import { FAVORITE_PLAYLIST_ID, type UnknownTrack } from '$lib/library/types.ts'
vi.mock('$lib/components/snackbar/snackbar', () => ({
snackbar: Object.assign(vi.fn(), {
unexpectedError: vi.fn(),
}),
}))
let uuidCounter = 0
vi.stubGlobal('crypto', {
randomUUID: vi.fn(() => {
uuidCounter += 1
return `test-uuid-${uuidCounter}`
}),
})
vi.stubGlobal('Date', {
now: vi.fn(() => 1_234_567_890),
})
let trackCounter = 0
const dbImportTestTrack = async (overrides: Partial = {}): Promise => {
trackCounter += 1
const trackData: UnknownTrack = {
uuid: `test-track-uuid-${trackCounter}`,
name: `Test Track ${trackCounter}`,
album: 'Test Album',
artists: ['Test Artist'],
year: '2023',
duration: 180,
trackNo: 1,
trackOf: 10,
discNo: 1,
discOf: 1,
genre: ['Rock'],
file: new File(['test'], 'test.mp3', { type: 'audio/mp3' }) as UnknownTrack['file'],
scannedAt: Date.now(),
fileName: `test-${trackCounter}.mp3`,
directory: 1,
...overrides,
}
return await dbImportTrack(trackData, undefined)
}
describe('playlists', () => {
beforeEach(async () => {
await clearDatabaseStores()
trackCounter = 0
uuidCounter = 0
})
afterEach(() => {
vi.clearAllMocks()
})
describe('playlist creation', () => {
it('creates new playlist with all fields', async () => {
await dbCreatePlaylist('Test Playlist', 'My description')
const db = await getDatabase()
const [playlist] = await db.getAll('playlists')
expect(playlist?.name).toBe('Test Playlist')
expect(playlist?.description).toBe('My description')
expect(playlist?.uuid).toBe('test-uuid-1')
expect(playlist?.createdAt).toBe(1_234_567_890)
})
})
describe('UI wrapper functions', () => {
it('creates playlist via UI wrapper', async () => {
await createPlaylist('UI Playlist', 'Created via UI')
await dbGetAllAndExpectLength('playlists', 1)
const db = await getDatabase()
const [playlist] = await db.getAll('playlists')
expectToBeDefined(playlist)
expect(playlist.name).toBe('UI Playlist')
expect(playlist.description).toBe('Created via UI')
})
it('removes track entry from playlist via UI action', async () => {
const trackId = await dbImportTestTrack()
const playlistId = await dbCreatePlaylist('Test Playlist', '')
const store = await getPlaylistEntriesDatabaseStore()
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlistId],
trackIds: [trackId],
})
const db = await getDatabase()
const [entry] = await db.getAll('playlistEntries')
expectToBeDefined(entry)
await removeTrackEntryFromPlaylist(entry.id)
await dbGetAllAndExpectLength('playlistEntries', 0)
})
})
describe('playlist updates', () => {
it('updates playlist name and description', async () => {
const playlistId = await dbCreatePlaylist('Original Name', 'Original description')
const updateOptions: UpdatePlaylistOptions = {
id: playlistId,
name: 'Updated Name',
description: 'Updated description',
}
const result = await updatePlaylist(updateOptions)
expect(result).toBe(true)
const db = await getDatabase()
const [playlist] = await db.getAll('playlists')
expectToBeDefined(playlist)
expect(playlist.name).toBe('Updated Name')
expect(playlist.description).toBe('Updated description')
expect(playlist.uuid).toBe('test-uuid-1')
expect(playlist.createdAt).toBe(1_234_567_890)
})
it('fails to update non-existent playlist', async () => {
const updateOptions: UpdatePlaylistOptions = {
id: 999,
name: 'Non-existent',
description: 'Should fail',
}
const result = await updatePlaylist(updateOptions)
expect(result).toBe(false)
})
})
describe('playlist removal', () => {
it('removes playlist and associated entries', async () => {
const trackId = await dbImportTestTrack()
const playlistId = await dbCreatePlaylist('Test Playlist', '')
const store = await getPlaylistEntriesDatabaseStore()
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlistId],
trackIds: [trackId],
})
await dbGetAllAndExpectLength('playlistEntries', 1)
await dbRemovePlaylist(playlistId)
await dbGetAllAndExpectLength('playlists', 0)
await dbGetAllAndExpectLength('playlistEntries', 0)
})
it('removes only entries for specific playlist', async () => {
const trackId = await dbImportTestTrack()
const playlist1Id = await dbCreatePlaylist('Playlist 1', '')
const playlist2Id = await dbCreatePlaylist('Playlist 2', '')
const store = await getPlaylistEntriesDatabaseStore()
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlist1Id, playlist2Id],
trackIds: [trackId],
})
await dbGetAllAndExpectLength('playlistEntries', 2)
await dbRemovePlaylist(playlist1Id)
await dbGetAllAndExpectLength('playlists', 1)
const [remainingEntry] = await dbGetAllAndExpectLength('playlistEntries', 1)
expectToBeDefined(remainingEntry)
expect(remainingEntry.playlistId).toBe(playlist2Id)
})
})
describe('playlist entries management', () => {
it('adds tracks to multiple playlists', async () => {
const track1Id = await dbImportTestTrack({ name: 'Track 1' })
const track2Id = await dbImportTestTrack({ name: 'Track 2' })
const playlist1Id = await dbCreatePlaylist('Playlist 1', '')
const playlist2Id = await dbCreatePlaylist('Playlist 2', '')
const store = await getPlaylistEntriesDatabaseStore()
const changes = await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlist1Id, playlist2Id],
trackIds: [track1Id, track2Id],
})
expect(changes).toHaveLength(4)
await dbGetAllAndExpectLength('playlistEntries', 4)
const db = await getDatabase()
const entries = await db.getAll('playlistEntries')
const playlist1Entries = entries.filter((e) => e.playlistId === playlist1Id)
const playlist2Entries = entries.filter((e) => e.playlistId === playlist2Id)
expect(playlist1Entries).toHaveLength(2)
expect(playlist2Entries).toHaveLength(2)
})
it('removes tracks from specific playlists', async () => {
const track1Id = await dbImportTestTrack({ name: 'Track 1' })
const track2Id = await dbImportTestTrack({ name: 'Track 2' })
const playlist1Id = await dbCreatePlaylist('Playlist 1', '')
const playlist2Id = await dbCreatePlaylist('Playlist 2', '')
let store = await getPlaylistEntriesDatabaseStore()
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlist1Id, playlist2Id],
trackIds: [track1Id, track2Id],
})
await dbGetAllAndExpectLength('playlistEntries', 4)
store = await getPlaylistEntriesDatabaseStore()
const changes = await dbRemoveTracksFromPlaylistsWithTx(store, {
playlistIds: [playlist1Id],
trackIds: [track1Id],
})
expect(changes).toHaveLength(1)
await dbGetAllAndExpectLength('playlistEntries', 3)
const db = await getDatabase()
const entries = await db.getAll('playlistEntries')
const hasTrack1InPlaylist1 = entries.some(
(e) => e.playlistId === playlist1Id && e.trackId === track1Id,
)
expect(hasTrack1InPlaylist1).toBe(false)
})
it('batch modifies playlist selections', async () => {
const track1Id = await dbImportTestTrack({ name: 'Track 1' })
const track2Id = await dbImportTestTrack({ name: 'Track 2' })
const playlist1Id = await dbCreatePlaylist('Playlist 1', '')
const playlist2Id = await dbCreatePlaylist('Playlist 2', '')
const playlist3Id = await dbCreatePlaylist('Playlist 3', '')
const store = await getPlaylistEntriesDatabaseStore()
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlist1Id],
trackIds: [track1Id, track2Id],
})
await dbGetAllAndExpectLength('playlistEntries', 2)
const result = await dbBatchModifyPlaylistsSelection({
trackIds: [track1Id, track2Id],
playlistsIdsAddTo: [playlist2Id, playlist3Id],
playlistsIdsRemoveFrom: [playlist1Id],
})
expect(result).toBe(true)
await dbGetAllAndExpectLength('playlistEntries', 4)
const db = await getDatabase()
const entries = await db.getAll('playlistEntries')
const playlist1Entries = entries.filter((e) => e.playlistId === playlist1Id)
const playlist2Entries = entries.filter((e) => e.playlistId === playlist2Id)
const playlist3Entries = entries.filter((e) => e.playlistId === playlist3Id)
expect(playlist1Entries).toHaveLength(0)
expect(playlist2Entries).toHaveLength(2)
expect(playlist3Entries).toHaveLength(2)
})
it('returns false when no changes are made', async () => {
const result = await dbBatchModifyPlaylistsSelection({
trackIds: [],
playlistsIdsAddTo: [],
playlistsIdsRemoveFrom: [],
})
expect(result).toBe(false)
})
})
describe('favorites functionality', () => {
it('adds track to favorites', async () => {
const trackId = await dbImportTestTrack()
await toggleFavoriteTrack(false, trackId)
const db = await getDatabase()
const [entry] = await db.getAll('playlistEntries')
expectToBeDefined(entry)
expect(entry.playlistId).toBe(FAVORITE_PLAYLIST_ID)
expect(entry.trackId).toBe(trackId)
expect(entry.addedAt).toBe(1_234_567_890)
})
it('removes track from favorites', async () => {
const trackId = await dbImportTestTrack()
await toggleFavoriteTrack(false, trackId)
await dbGetAllAndExpectLength('playlistEntries', 1)
await toggleFavoriteTrack(true, trackId)
await dbGetAllAndExpectLength('playlistEntries', 0)
})
})
describe('playlist entries data structure', () => {
it('creates entries with correct structure', async () => {
const trackId = await dbImportTestTrack()
const playlistId = await dbCreatePlaylist('Test Playlist', '')
const store = await getPlaylistEntriesDatabaseStore()
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlistId],
trackIds: [trackId],
})
const db = await getDatabase()
const [entry] = await db.getAll('playlistEntries')
expect(entry).toEqual({
id: expect.any(Number),
playlistId,
trackId,
addedAt: 1_234_567_890,
})
})
it('maintains chronological order of entries', async () => {
const track1Id = await dbImportTestTrack({ name: 'Track 1' })
const track2Id = await dbImportTestTrack({ name: 'Track 2' })
const playlistId = await dbCreatePlaylist('Test Playlist', '')
const store = await getPlaylistEntriesDatabaseStore()
vi.mocked(Date.now).mockReturnValueOnce(1000)
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlistId],
trackIds: [track1Id],
})
vi.mocked(Date.now).mockReturnValueOnce(2000)
await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: [playlistId],
trackIds: [track2Id],
})
const db = await getDatabase()
const entries = await db.getAll('playlistEntries')
entries.sort((a, b) => a.addedAt - b.addedAt)
expect(entries).toHaveLength(2)
expect(entries[0]?.trackId).toBe(track1Id)
expect(entries[0]?.addedAt).toBe(1000)
expect(entries[1]?.trackId).toBe(track2Id)
expect(entries[1]?.addedAt).toBe(2000)
})
})
})
================================================
FILE: src/lib/library/__tests__/remove.test.ts
================================================
import 'fake-indexeddb/auto'
import { beforeEach, describe, expect, it } from 'vitest'
import { getDatabase } from '$lib/db/database.ts'
import {
clearDatabaseStores,
dbGetAllAndExpectLength,
expectToBeDefined,
} from '$lib/helpers/test-helpers.ts'
import { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts'
import { dbCreatePlaylist } from '$lib/library/playlists-actions.ts'
import { dbRemoveAlbum, dbRemoveArtist, dbRemoveTracks } from '$lib/library/remove.ts'
import { dbImportTrack } from '$lib/library/scan-actions/scanner/import-track.ts'
import type { PlaylistEntry, UnknownTrack } from '$lib/library/types.ts'
const dbImportTestTrack = (overrides: Partial = {}): Promise => {
const trackData: UnknownTrack = {
uuid: crypto.randomUUID(),
name: 'Test Track',
album: 'Test Album',
artists: ['Test Artist'],
year: '2023',
duration: 180,
trackNo: 1,
trackOf: 10,
discNo: 1,
discOf: 1,
genre: ['Rock'],
file: new File(['test'], 'test.mp3', { type: 'audio/mp3' }),
scannedAt: Date.now(),
fileName: 'test.mp3',
directory: 1,
...overrides,
}
return dbImportTrack(trackData, undefined)
}
const createTestPlaylist = async (name = 'Test Playlist'): Promise =>
dbCreatePlaylist(name, '')
const addTrackToPlaylist = async (playlistId: number, trackId: number): Promise => {
const db = await getDatabase()
const playlistEntry: Omit = {
playlistId,
trackId,
addedAt: Date.now(),
}
await db.add('playlistEntries', playlistEntry as PlaylistEntry)
}
const addTracksToPlaylist = async (
playlistId: number,
trackIds: readonly number[],
): Promise => {
for (const trackId of trackIds) {
await addTrackToPlaylist(playlistId, trackId)
}
}
const addTracksToPlayHistory = async (trackIds: readonly number[]): Promise => {
for (const trackId of trackIds) {
await dbAddToPlayHistory(trackId)
}
}
const expectOnlyTrackWithReferences = async (trackId: number): Promise => {
const tracksAfter = await dbGetAllAndExpectLength('tracks', 1)
expect(tracksAfter[0]?.id).toBe(trackId)
const playlistEntriesAfter = await dbGetAllAndExpectLength('playlistEntries', 1)
expect(playlistEntriesAfter[0]?.trackId).toBe(trackId)
const playHistoryAfter = await dbGetAllAndExpectLength('playHistory', 1)
expect(playHistoryAfter[0]?.trackId).toBe(trackId)
}
describe('remove functions', () => {
beforeEach(async () => {
await clearDatabaseStores()
})
describe('dbRemoveTracks', () => {
it('should remove a track and clean up unused album and artist', async () => {
const trackId = await dbImportTestTrack()
const db = await getDatabase()
// Verify track, album, and artist were created
await dbGetAllAndExpectLength('tracks', 1)
const albums = await dbGetAllAndExpectLength('albums', 1)
expect(albums[0]?.name).toBe('Test Album')
const artists = await dbGetAllAndExpectLength('artists', 1)
expect(artists[0]?.name).toBe('Test Artist')
// Remove the track
await dbRemoveTracks([trackId])
// Verify track is removed
const removedTrack = await db.get('tracks', trackId)
expect(removedTrack).toBeUndefined()
// Verify album and artist are also removed (cleanup)
await dbGetAllAndExpectLength('albums', 0)
await dbGetAllAndExpectLength('artists', 0)
})
it('should not remove album or artist if still referenced by other tracks', async () => {
// Create two tracks with the same album and artist
const track1Id = await dbImportTestTrack({ name: 'Track 1' })
const track2Id = await dbImportTestTrack({ name: 'Track 2' })
await dbGetAllAndExpectLength('tracks', 2)
await dbGetAllAndExpectLength('albums', 1)
await dbGetAllAndExpectLength('artists', 1)
await dbRemoveTracks([track1Id])
// Verify only one track is removed
const tracksAfter = await dbGetAllAndExpectLength('tracks', 1)
expect(tracksAfter[0]?.id).toBe(track2Id)
// Verify album and artist are still there
await dbGetAllAndExpectLength('albums', 1)
await dbGetAllAndExpectLength('artists', 1)
})
it('should remove track from all playlists when removing the track', async () => {
const trackId = await dbImportTestTrack()
const playlistId1 = await createTestPlaylist('Test Playlist 1')
const playlistId2 = await createTestPlaylist('Test Playlist 2')
await dbGetAllAndExpectLength('playlists', 2)
await addTracksToPlaylist(playlistId1, [trackId])
await addTracksToPlaylist(playlistId2, [trackId])
const playlistEntries = await dbGetAllAndExpectLength('playlistEntries', 2)
expect(playlistEntries.every((entry) => entry.trackId === trackId)).toBe(true)
await dbRemoveTracks([trackId])
await dbGetAllAndExpectLength('playlistEntries', 0)
await dbGetAllAndExpectLength('playlists', 2)
})
it('should remove deleted tracks from play history and keep unrelated entries', async () => {
const track1Id = await dbImportTestTrack({ name: 'Track 1' })
const track2Id = await dbImportTestTrack({ name: 'Track 2', album: 'Album 2' })
await dbAddToPlayHistory(track1Id)
await dbAddToPlayHistory(track2Id)
await dbGetAllAndExpectLength('playHistory', 2)
await dbRemoveTracks([track1Id])
const historyEntries = await dbGetAllAndExpectLength('playHistory', 1)
expect(historyEntries[0]?.trackId).toBe(track2Id)
})
it('should handle removing non-existent track gracefully', async () => {
// Try to remove a track that doesn't exist
await expect(dbRemoveTracks([999])).resolves.toBeUndefined()
})
it('should ignore duplicate track ids in one removal request', async () => {
const trackId = await dbImportTestTrack()
const playlistId = await createTestPlaylist()
await addTracksToPlaylist(playlistId, [trackId, trackId])
await addTracksToPlayHistory([trackId])
await dbRemoveTracks([trackId, trackId])
await dbGetAllAndExpectLength('tracks', 0)
await dbGetAllAndExpectLength('albums', 0)
await dbGetAllAndExpectLength('artists', 0)
await dbGetAllAndExpectLength('playlistEntries', 0)
await dbGetAllAndExpectLength('playHistory', 0)
})
it('should remove existing tracks and ignore missing ids in the same request', async () => {
const track1Id = await dbImportTestTrack({ name: 'Track 1' })
const track2Id = await dbImportTestTrack({
name: 'Track 2',
album: 'Album 2',
artists: ['Artist 2'],
})
const playlistId = await createTestPlaylist()
await addTracksToPlaylist(playlistId, [track1Id, track2Id])
await addTracksToPlayHistory([track1Id, track2Id])
await dbRemoveTracks([track1Id, 999])
await expectOnlyTrackWithReferences(track2Id)
const albumsAfter = await dbGetAllAndExpectLength('albums', 1)
expect(albumsAfter[0]?.name).toBe('Album 2')
const artistsAfter = await dbGetAllAndExpectLength('artists', 1)
expect(artistsAfter[0]?.name).toBe('Artist 2')
})
it('should remove multiple tracks in one operation and clean up shared data once unused', async () => {
const track1Id = await dbImportTestTrack({ name: 'Track 1', artists: ['Artist 1'] })
const track2Id = await dbImportTestTrack({
name: 'Track 2',
album: 'Album 2',
artists: ['Artist 2'],
})
const playlistId = await createTestPlaylist()
await addTracksToPlaylist(playlistId, [track1Id, track2Id])
await dbRemoveTracks([track1Id, track2Id])
await dbGetAllAndExpectLength('tracks', 0)
await dbGetAllAndExpectLength('albums', 0)
await dbGetAllAndExpectLength('artists', 0)
await dbGetAllAndExpectLength('playlistEntries', 0)
await dbGetAllAndExpectLength('playlists', 1)
})
it('should return early for empty input', async () => {
await expect(dbRemoveTracks([])).resolves.toBeUndefined()
})
})
describe('dbRemoveAlbum', () => {
it('should remove album and all its tracks', async () => {
// Create two tracks with the same album
await dbImportTestTrack({ name: 'Track 1' })
await dbImportTestTrack({ name: 'Track 2' })
const albums = await dbGetAllAndExpectLength('albums', 1)
const albumId = albums[0]?.id
expectToBeDefined(albumId)
await dbGetAllAndExpectLength('tracks', 2)
await dbRemoveAlbum(albumId)
await dbGetAllAndExpectLength('tracks', 0)
await dbGetAllAndExpectLength('albums', 0)
})
it('should handle removing non-existent album gracefully', async () => {
// Try to remove an album that doesn't exist
await expect(dbRemoveAlbum(999)).resolves.toBeUndefined()
})
it('should clear playlists and play history for removed album tracks only', async () => {
const albumTrack1Id = await dbImportTestTrack({ name: 'Track 1' })
const albumTrack2Id = await dbImportTestTrack({ name: 'Track 2' })
const survivorTrackId = await dbImportTestTrack({
name: 'Track 3',
album: 'Album 2',
artists: ['Artist 2'],
})
const playlistId = await createTestPlaylist()
await addTracksToPlaylist(playlistId, [albumTrack1Id, albumTrack2Id, survivorTrackId])
await addTracksToPlayHistory([albumTrack1Id, albumTrack2Id, survivorTrackId])
const albums = await dbGetAllAndExpectLength('albums', 2)
const albumId = albums.find((album) => album.name === 'Test Album')?.id
expectToBeDefined(albumId)
await dbRemoveAlbum(albumId)
await expectOnlyTrackWithReferences(survivorTrackId)
})
it('should keep shared artists that are still referenced by survivor tracks from other albums', async () => {
await dbImportTestTrack({
name: 'Album Track 1',
album: 'Album 1',
artists: ['Shared Artist', 'Album 1 Artist'],
})
await dbImportTestTrack({
name: 'Album Track 2',
album: 'Album 1',
artists: ['Shared Artist'],
})
const survivorTrackId = await dbImportTestTrack({
name: 'Survivor Track',
album: 'Album 2',
artists: ['Shared Artist', 'Album 2 Artist'],
})
const albums = await dbGetAllAndExpectLength('albums', 2)
const albumId = albums.find((album) => album.name === 'Album 1')?.id
expectToBeDefined(albumId)
await dbRemoveAlbum(albumId)
const tracksAfter = await dbGetAllAndExpectLength('tracks', 1)
expect(tracksAfter[0]?.id).toBe(survivorTrackId)
const albumsAfter = await dbGetAllAndExpectLength('albums', 1)
expect(albumsAfter[0]?.name).toBe('Album 2')
const artistsAfter = await dbGetAllAndExpectLength('artists', 2)
expect(artistsAfter.map((artist) => artist.name).sort()).toEqual([
'Album 2 Artist',
'Shared Artist',
])
})
})
describe('dbRemoveArtist', () => {
it('should remove artist and all tracks by that artist', async () => {
// Create two tracks with the same artist
await dbImportTestTrack({
name: 'Track 1',
album: 'Album 1',
})
await dbImportTestTrack({
name: 'Track 2',
album: 'Album 2',
})
await dbGetAllAndExpectLength('tracks', 2)
const artists = await dbGetAllAndExpectLength('artists', 1)
const artistId = artists[0]?.id
expectToBeDefined(artistId)
await dbRemoveArtist(artistId)
await dbGetAllAndExpectLength('tracks', 0)
await dbGetAllAndExpectLength('artists', 0)
})
it('should handle removing non-existent artist gracefully', async () => {
await expect(dbRemoveArtist(999)).resolves.toBeUndefined()
})
it('should remove tracks with multiple artists correctly', async () => {
await dbImportTestTrack({
artists: ['Artist 1', 'Artist 2'],
album: 'Collaboration Album',
})
// Another track with only one of the artists
const track2Id = await dbImportTestTrack({
name: 'Track 2',
artists: ['Artist 2'],
album: 'Solo Album',
})
const artists = await dbGetAllAndExpectLength('artists', 2)
const artist1 = artists.find((a) => a.name === 'Artist 1')
expectToBeDefined(artist1?.id)
await dbRemoveArtist(artist1.id)
const tracksAfter = await dbGetAllAndExpectLength('tracks', 1)
expect(tracksAfter[0]?.id, 'Expected Track 2 to remain').toBe(track2Id)
const artistsAfter = await dbGetAllAndExpectLength('artists', 1)
expect(artistsAfter[0]?.name, 'Expected Artist 2 to remain').toBe('Artist 2')
})
it('should clear playlists and play history for removed artist tracks only', async () => {
const artistTrack1Id = await dbImportTestTrack({
name: 'Track 1',
album: 'Album 1',
artists: ['Artist 1'],
})
const artistTrack2Id = await dbImportTestTrack({
name: 'Track 2',
album: 'Album 2',
artists: ['Artist 1'],
})
const survivorTrackId = await dbImportTestTrack({
name: 'Track 3',
album: 'Album 3',
artists: ['Artist 2'],
})
const playlistId = await createTestPlaylist()
await addTracksToPlaylist(playlistId, [artistTrack1Id, artistTrack2Id, survivorTrackId])
await addTracksToPlayHistory([artistTrack1Id, artistTrack2Id, survivorTrackId])
const artists = await dbGetAllAndExpectLength('artists', 2)
const artistId = artists.find((artist) => artist.name === 'Artist 1')?.id
expectToBeDefined(artistId)
await dbRemoveArtist(artistId)
await expectOnlyTrackWithReferences(survivorTrackId)
})
it('should keep shared albums that are still referenced by survivor tracks from other artists', async () => {
await dbImportTestTrack({
name: 'Artist 1 Track 1',
album: 'Shared Album',
artists: ['Artist 1'],
})
await dbImportTestTrack({
name: 'Artist 1 Track 2',
album: 'Artist 1 Album',
artists: ['Artist 1'],
})
const survivorTrackId = await dbImportTestTrack({
name: 'Artist 2 Survivor',
album: 'Shared Album',
artists: ['Artist 2'],
})
const artists = await dbGetAllAndExpectLength('artists', 2)
const artistId = artists.find((artist) => artist.name === 'Artist 1')?.id
expectToBeDefined(artistId)
await dbRemoveArtist(artistId)
const tracksAfter = await dbGetAllAndExpectLength('tracks', 1)
expect(tracksAfter[0]?.id).toBe(survivorTrackId)
const albumsAfter = await dbGetAllAndExpectLength('albums', 1)
expect(albumsAfter[0]?.name).toBe('Shared Album')
const artistsAfter = await dbGetAllAndExpectLength('artists', 1)
expect(artistsAfter[0]?.name).toBe('Artist 2')
})
})
})
================================================
FILE: src/lib/library/get/__tests__/value.test.ts
================================================
import 'fake-indexeddb/auto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDatabase } from '$lib/db/database.ts'
import type { DatabaseChangeDetails } from '$lib/db/events.ts'
import { clearDatabaseStores } from '$lib/helpers/test-helpers.ts'
import {
clearLibraryValueCache,
getLibraryValue,
LibraryValueNotFoundError,
preloadLibraryValue,
shouldRefetchLibraryValue,
} from '$lib/library/get/value.ts'
import { FAVORITE_PLAYLIST_ID, FAVORITE_PLAYLIST_UUID } from '$lib/library/types.ts'
// Mock crypto.randomUUID for consistent UUIDs
vi.stubGlobal('crypto', {
randomUUID: vi.fn(() => 'test-uuid-123'),
})
// Mock Date.now for consistent timestamps
vi.stubGlobal('Date', {
now: vi.fn(() => 1_234_567_890),
})
describe('getLibraryValue', () => {
beforeEach(async () => {
await clearDatabaseStores()
clearLibraryValueCache()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('tracks', () => {
it('should return track data with favorite status false', async () => {
const db = await getDatabase()
// Insert a track
const trackData = {
id: 1,
name: 'Test Track',
album: 'Test Album',
artists: ['Test Artist'],
uuid: 'track-uuid-1',
year: '2023',
duration: 180,
genre: ['Rock'],
trackNo: 1,
trackOf: 10,
discNo: 1,
discOf: 1,
file: {} as File,
scannedAt: 1_234_567_890,
fileName: 'test-track.mp3',
directory: 1,
}
await db.add('tracks', trackData)
const result = await getLibraryValue('tracks', 1)
expect(result).toEqual({
...trackData,
type: 'track',
favorite: false,
})
})
it('should return track data with favorite status true when track is in favorites', async () => {
const db = await getDatabase()
// Insert a track
const trackData = {
id: 1,
name: 'Test Track',
album: 'Test Album',
artists: ['Test Artist'],
uuid: 'track-uuid-1',
year: '2023',
duration: 180,
genre: ['Rock'],
trackNo: 1,
trackOf: 10,
discNo: 1,
discOf: 1,
file: {} as File,
scannedAt: 1_234_567_890,
fileName: 'test-track.mp3',
directory: 1,
}
await db.add('tracks', trackData)
// Add track to favorites
await db.add('playlistEntries', {
id: 1,
playlistId: FAVORITE_PLAYLIST_ID,
trackId: 1,
addedAt: 1_234_567_890,
})
const result = await getLibraryValue('tracks', 1)
expect(result).toEqual({
...trackData,
type: 'track',
favorite: true,
})
})
it('should throw LibraryValueNotFoundError for non-existent track', async () => {
await expect(getLibraryValue('tracks', 999)).rejects.toThrow(LibraryValueNotFoundError)
})
it('should return undefined for non-existent track when allowEmpty is true', async () => {
const result = await getLibraryValue('tracks', 999, true)
expect(result).toBeUndefined()
})
it('should return cached value on subsequent calls', async () => {
const db = await getDatabase()
const trackData = {
id: 1,
name: 'Test Track',
album: 'Test Album',
artists: ['Test Artist'],
uuid: 'track-uuid-1',
year: '2023',
duration: 180,
genre: ['Rock'],
trackNo: 1,
trackOf: 10,
discNo: 1,
discOf: 1,
file: {} as File,
scannedAt: 1_234_567_890,
fileName: 'test-track.mp3',
directory: 1,
}
await db.add('tracks', trackData)
// First call - should fetch from database
const result1 = await getLibraryValue('tracks', 1)
// Second call - should return cached value
const result2 = await getLibraryValue('tracks', 1)
expect(result1).toEqual(result2)
})
})
describe('albums', () => {
it('should return album data', async () => {
const db = await getDatabase()
const albumData = {
id: 1,
name: 'Test Album',
uuid: 'album-uuid-1',
artists: ['Test Artist'],
year: '2023',
image: {} as Blob,
}
await db.add('albums', albumData)
const result = await getLibraryValue('albums', 1)
expect(result).toEqual({
...albumData,
type: 'album',
})
})
it('should throw LibraryValueNotFoundError for non-existent album', async () => {
await expect(getLibraryValue('albums', 999)).rejects.toThrow(LibraryValueNotFoundError)
})
it('should return undefined for non-existent album when allowEmpty is true', async () => {
const result = await getLibraryValue('albums', 999, true)
expect(result).toBeUndefined()
})
})
describe('artists', () => {
it('should return artist data', async () => {
const db = await getDatabase()
const artistData = {
id: 1,
name: 'Test Artist',
uuid: 'artist-uuid-1',
}
await db.add('artists', artistData)
const result = await getLibraryValue('artists', 1)
expect(result).toEqual({
...artistData,
type: 'artist',
})
})
it('should throw LibraryValueNotFoundError for non-existent artist', async () => {
await expect(getLibraryValue('artists', 999)).rejects.toThrow(LibraryValueNotFoundError)
})
it('should return undefined for non-existent artist when allowEmpty is true', async () => {
const result = await getLibraryValue('artists', 999, true)
expect(result).toBeUndefined()
})
})
describe('playlists', () => {
it('should return playlist data', async () => {
const db = await getDatabase()
const playlistData = {
id: 1,
name: 'Test Playlist',
description: '',
uuid: 'playlist-uuid-1',
createdAt: 1_234_567_890,
}
await db.add('playlists', playlistData)
const result = await getLibraryValue('playlists', 1)
expect(result).toEqual({
...playlistData,
type: 'playlist',
})
})
it('should return favorite playlist for FAVORITE_PLAYLIST_ID', async () => {
const result = await getLibraryValue('playlists', FAVORITE_PLAYLIST_ID)
expect(result).toEqual({
type: 'playlist',
id: FAVORITE_PLAYLIST_ID,
uuid: FAVORITE_PLAYLIST_UUID,
description: '',
name: 'Favorites',
createdAt: 0,
})
})
it('should throw LibraryValueNotFoundError for non-existent playlist', async () => {
await expect(getLibraryValue('playlists', 999)).rejects.toThrow(
LibraryValueNotFoundError,
)
})
it('should return undefined for non-existent playlist when allowEmpty is true', async () => {
const result = await getLibraryValue('playlists', 999, true)
expect(result).toBeUndefined()
})
})
describe('preloadLibraryValue', () => {
it('should preload track value into cache', async () => {
const db = await getDatabase()
const trackData = {
id: 1,
name: 'Test Track',
album: 'Test Album',
artists: ['Test Artist'],
uuid: 'track-uuid-1',
year: '2023',
duration: 180,
genre: ['Rock'],
trackNo: 1,
trackOf: 10,
discNo: 1,
discOf: 1,
file: {} as File,
scannedAt: 1_234_567_890,
fileName: 'test-track.mp3',
directory: 1,
}
await db.add('tracks', trackData)
// Preload the value
await preloadLibraryValue('tracks', 1)
// This should now return synchronously from cache
const result = getLibraryValue('tracks', 1)
// If it's synchronous, it should not be a Promise
expect(result).not.toBeInstanceOf(Promise)
expect(result).toEqual({
...trackData,
type: 'track',
favorite: false,
})
})
it('should not throw error for non-existent value', async () => {
// Should not throw even though the value doesn't exist
await expect(preloadLibraryValue('tracks', 999)).resolves.toBeUndefined()
})
})
describe('shouldRefetchLibraryValue', () => {
it('should return true when track is updated', () => {
const changes: readonly DatabaseChangeDetails[] = [
{
storeName: 'tracks',
operation: 'update',
key: 1,
},
]
const result = shouldRefetchLibraryValue('tracks', 1, changes)
expect(result).toBe(true)
})
it('should return true when track favorite status changes', () => {
const changes: readonly DatabaseChangeDetails[] = [
{
storeName: 'playlistEntries',
operation: 'add',
key: 1,
value: {
id: 1,
playlistId: FAVORITE_PLAYLIST_ID,
trackId: 1,
addedAt: 1_234_567_890,
},
},
]
const result = shouldRefetchLibraryValue('tracks', 1, changes)
expect(result).toBe(true)
})
it('should return false when unrelated changes occur', () => {
const changes: readonly DatabaseChangeDetails[] = [
{
storeName: 'tracks',
operation: 'update',
key: 2,
},
]
const result = shouldRefetchLibraryValue('tracks', 1, changes)
expect(result).toBe(false)
})
it('should return true when album is updated', () => {
const changes: readonly DatabaseChangeDetails[] = [
{
storeName: 'albums',
operation: 'update',
key: 1,
},
]
const result = shouldRefetchLibraryValue('albums', 1, changes)
expect(result).toBe(true)
})
it('should return true when artist is deleted', () => {
const changes: readonly DatabaseChangeDetails[] = [
{
storeName: 'artists',
operation: 'delete',
key: 1,
},
]
const result = shouldRefetchLibraryValue('artists', 1, changes)
expect(result).toBe(true)
})
it('should return true when playlist is updated', () => {
const changes: readonly DatabaseChangeDetails[] = [
{
storeName: 'playlists',
operation: 'update',
key: 1,
},
]
const result = shouldRefetchLibraryValue('playlists', 1, changes)
expect(result).toBe(true)
})
it('should return false when no relevant changes occur', () => {
const changes: readonly DatabaseChangeDetails[] = [
{
storeName: 'albums',
operation: 'update',
key: 2,
},
]
const result = shouldRefetchLibraryValue('tracks', 1, changes)
expect(result).toBe(false)
})
})
describe('LibraryValueNotFoundError', () => {
it('should have correct message and name', () => {
const error = new LibraryValueNotFoundError('tracks:1')
expect(error.message).toBe('Value not found. Cache key: tracks:1')
expect(error.name).toBe('LibraryValueNotFoundError')
expect(error).toBeInstanceOf(Error)
})
})
describe('concurrent access', () => {
it('should handle concurrent requests for same value', async () => {
const db = await getDatabase()
const trackData = {
id: 1,
name: 'Test Track',
album: 'Test Album',
artists: ['Test Artist'],
uuid: 'track-uuid-1',
year: '2023',
duration: 180,
genre: ['Rock'],
trackNo: 1,
trackOf: 10,
discNo: 1,
discOf: 1,
file: {} as File,
scannedAt: 1_234_567_890,
fileName: 'test-track.mp3',
directory: 1,
}
await db.add('tracks', trackData)
// Make multiple concurrent requests
const promises = [
getLibraryValue('tracks', 1),
getLibraryValue('tracks', 1),
getLibraryValue('tracks', 1),
]
const results = await Promise.all(promises)
// All results should be identical
expect(results[0]).toEqual(results[1])
expect(results[1]).toEqual(results[2])
expect(results[0]).toEqual({
...trackData,
type: 'track',
favorite: false,
})
})
})
describe('error handling', () => {
it('should handle LibraryValueNotFoundError correctly', async () => {
await expect(getLibraryValue('tracks', 999)).rejects.toThrow(LibraryValueNotFoundError)
await expect(getLibraryValue('albums', 999)).rejects.toThrow(LibraryValueNotFoundError)
await expect(getLibraryValue('artists', 999)).rejects.toThrow(LibraryValueNotFoundError)
await expect(getLibraryValue('playlists', 999)).rejects.toThrow(
LibraryValueNotFoundError,
)
})
})
})
================================================
FILE: src/lib/library/get/ids-queries.ts
================================================
import type { DatabaseChangeDetailsList } from '$lib/db/events.ts'
import type { DbChangeActions } from '$lib/db/query/base-query.svelte.ts'
import {
createPageQuery,
type PageQueryOptions,
type PageQueryResult,
type QueryKey,
} from '$lib/db/query/page-query.svelte.ts'
import type { LibraryStoreName } from '../types.ts'
import { preloadLibraryValue } from './value.ts'
export type { PageQueryResult } from '$lib/db/query/page-query.svelte.ts'
export type { QueryResult } from '$lib/db/query/query.ts'
const preloadLimit = 12
const preloadLibraryListValues = async (
storeName: Store,
keys: number[],
) => {
const preload = Array.from({ length: Math.min(keys.length, preloadLimit) }, (_, index) => {
const id = keys[index]
if (id) {
return preloadLibraryValue(storeName, id)
}
return null
})
await Promise.all(preload)
}
const keysListDatabaseChangeHandler = (
storeName: Store,
changes: DatabaseChangeDetailsList,
{ mutate, refetch }: DbChangeActions,
): void => {
let needRefetch = false
for (const change of changes) {
if (change.storeName !== storeName) {
continue
}
if (
// We have no way of knowing where should the new item be inserted.
// So we just refetch the whole list.
change.operation === 'add' ||
// If playlist name changes, order might change as well.
(storeName === 'playlists' && change.operation === 'update')
) {
needRefetch = true
break
}
if (change.operation === 'delete' && change.key !== undefined) {
mutate((value) => {
if (!value) {
return []
}
const index = value.indexOf(change.key)
if (index === -1) {
return value
}
value.splice(index, 1)
return value
})
}
}
if (needRefetch) {
refetch()
}
}
export type LibraryItemKeysPageQueryOptions = Omit<
PageQueryOptions,
'onDatabaseChange'
>
export const createLibraryItemKeysPageQuery = <
Store extends LibraryStoreName,
const K extends QueryKey,
>(
storeName: Store,
options: LibraryItemKeysPageQueryOptions,
): Promise> =>
createPageQuery({
...options,
fetcher: async (key, signal) => {
const result = await options.fetcher(key, signal)
await preloadLibraryListValues(storeName, result)
return result
},
onDatabaseChange: keysListDatabaseChangeHandler.bind(null, storeName),
})
================================================
FILE: src/lib/library/get/ids.ts
================================================
import type { IDBPIndex } from 'idb'
import { type AppDB, type AppIndexNames, getDatabase } from '$lib/db/database.ts'
import type { LibraryStoreName } from '../types.ts'
export type SortOrder = 'asc' | 'desc'
export type LibraryItemSortKey = Exclude<
AppIndexNames,
symbol
>
export interface GetLibraryItemIdsOptions {
sort: LibraryItemSortKey
order?: SortOrder
searchTerm?: string
searchFn?: (value: AppDB[Store]['value'], term: string) => boolean
signal?: AbortSignal
}
type GetLibraryItemIdsIndex = IDBPIndex<
AppDB,
[Store],
Store,
keyof AppDB[Store]['indexes'] & string
>
const getLibraryItemIdsWithSearchSlow = async (
storeIndex: GetLibraryItemIdsIndex,
searchTerm: string,
searchFn: (value: AppDB[Store]['value'], term: string) => boolean,
signal: AbortSignal | undefined,
) => {
const data: number[] = []
for await (const cursor of storeIndex.iterate()) {
if (signal?.aborted) {
break
}
if (searchFn(cursor.value, searchTerm)) {
data.push(cursor.primaryKey)
}
}
return data
}
export const getLibraryItemIds = async (
store: Store,
options: GetLibraryItemIdsOptions,
): Promise => {
const db = await getDatabase()
const storeIndex = db.transaction(store).store.index(options.sort)
const { searchTerm, searchFn } = options
let data: number[]
if (searchTerm && searchFn) {
data = await getLibraryItemIdsWithSearchSlow(
storeIndex,
searchTerm,
searchFn,
options.signal,
)
} else {
// Fast path
data = await db.getAllKeysFromIndex(store, options.sort)
}
if (options.order === 'desc') {
data.reverse()
}
return data
}
export const dbGetAlbumTracksIdsByName = async (albumName: string): Promise => {
const db = await getDatabase()
const tracksIds = await db.getAllKeysFromIndex(
'tracks',
'byAlbumSorted',
IDBKeyRange.bound([albumName], [albumName, '\uffff']),
)
return tracksIds
}
export const dbGetArtistTracksIdsByName = async (artistName: string): Promise => {
const db = await getDatabase()
const tracksIds = await db.getAllKeysFromIndex(
'tracks',
'artists',
IDBKeyRange.only(artistName),
)
return tracksIds
}
================================================
FILE: src/lib/library/get/value-queries.ts
================================================
import { createQuery, type QueryResult } from '$lib/db/query/query.ts'
import type { LibraryStoreName } from '../types.ts'
import { type GetLibraryValueResult, getLibraryValue, shouldRefetchLibraryValue } from './value.ts'
export type { AlbumData, ArtistData, PlaylistData, TrackData } from './value.ts'
export interface LibraryValueQueryOptions {
allowEmpty?: AllowEmpty
}
const defineQuery =
(storeName: Store) =>
(
idGetter: () => number,
options: LibraryValueQueryOptions = {},
): QueryResult> =>
createQuery({
key: idGetter,
fetcher: (id) => getLibraryValue(storeName, id, options.allowEmpty),
onDatabaseChange: (changes, { refetch }) => {
if (shouldRefetchLibraryValue(storeName, idGetter(), changes)) {
void refetch()
}
},
})
type LibraryItemQuery = ReturnType>
export const createTrackQuery: LibraryItemQuery<'tracks'> = /* @__PURE__ */ defineQuery('tracks')
export const createAlbumQuery: LibraryItemQuery<'albums'> = /* @__PURE__ */ defineQuery('albums')
export const createArtistQuery: LibraryItemQuery<'artists'> = /* @__PURE__ */ defineQuery('artists')
export const createPlaylistQuery: LibraryItemQuery<'playlists'> =
/* @__PURE__ */ defineQuery('playlists')
================================================
FILE: src/lib/library/get/value.ts
================================================
import { WeakLRUCache } from 'weak-lru-cache'
import { type DbKey, getDatabase } from '$lib/db/database.ts'
import { type DatabaseChangeDetails, onDatabaseChange } from '$lib/db/events.ts'
import type { Album, Artist, Playlist, Track } from '$lib/library/types.ts'
import { FAVORITE_PLAYLIST_ID, FAVORITE_PLAYLIST_UUID, type LibraryStoreName } from '../types.ts'
type CacheKey = `${Store}:${string}`
const getCacheKey = (
storeName: Store,
key: DbKey,
): CacheKey => `${storeName}:${key}`
interface QueryConfig {
fetch: (id: number) => Promise
shouldRefetch: (
itemId: number | undefined,
changes: readonly DatabaseChangeDetails[],
) => boolean
}
const defaultRefreshOnDatabaseChanges = (
storeName: LibraryStoreName,
itemId: number | undefined,
changes: readonly DatabaseChangeDetails[],
) => {
for (const change of changes) {
if (change.storeName === storeName) {
if (itemId === undefined) {
return true
}
if (change.key === itemId) {
return true
}
}
}
return false
}
export interface TrackData extends Track {
type: 'track'
favorite: boolean
}
const trackConfig: QueryConfig = {
fetch: async (id) => {
const db = await getDatabase()
const tx = db.transaction(['tracks', 'playlistEntries'], 'readonly')
const [item, favorite] = await Promise.all([
tx.objectStore('tracks').get(id),
tx
.objectStore('playlistEntries')
.index('playlistTrack')
.get([FAVORITE_PLAYLIST_ID, id]),
])
if (!item) {
return undefined
}
return {
...item,
type: 'track',
favorite: !!favorite,
} as TrackData
},
shouldRefetch: (itemId, changes) => {
for (const change of changes) {
if (change.storeName === 'playlistEntries') {
const playlistEntry = change.value
if (
playlistEntry.playlistId === FAVORITE_PLAYLIST_ID &&
itemId === playlistEntry.trackId
) {
return true
}
}
if (change.storeName === 'tracks' && change.key === itemId) {
return true
}
}
return false
},
}
const dbGetValue = async (
storeName: Store,
type: T,
id: number,
) => {
const db = await getDatabase()
const value = await db.get(storeName, id)
if (!value) {
return undefined
}
return {
...value,
type,
}
}
export interface AlbumData extends Album {
type: 'album'
}
const albumConfig: QueryConfig = {
fetch: (id) => dbGetValue('albums', 'album', id),
shouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'albums'),
}
export interface ArtistData extends Artist {
type: 'artist'
}
const artistConfig: QueryConfig = {
fetch: (id) => dbGetValue('artists', 'artist', id),
shouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'artists'),
}
export interface PlaylistData extends Playlist {
type: 'playlist'
}
const playlistsConfig: QueryConfig = {
fetch: (id) => {
if (id === FAVORITE_PLAYLIST_ID) {
const favoritePlaylist: PlaylistData = {
type: 'playlist',
id: FAVORITE_PLAYLIST_ID,
uuid: FAVORITE_PLAYLIST_UUID,
name: m.favorites(),
description: '',
createdAt: 0,
}
return Promise.resolve(favoritePlaylist)
}
return dbGetValue('playlists', 'playlist', id)
},
shouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'playlists'),
}
interface LibraryValueMap {
tracks: TrackData
albums: AlbumData
artists: ArtistData
playlists: PlaylistData
}
type LibraryValue = LibraryValueMap[Store]
type LibraryConfigMap = {
[Store in LibraryStoreName]: QueryConfig>
}
const libraryConfigMap = {
tracks: trackConfig,
albums: albumConfig,
artists: artistConfig,
playlists: playlistsConfig,
} satisfies LibraryConfigMap
type LibraryCachedValue =
| LibraryValue
| Promise | undefined>
class LibraryValueCache {
#cache = new WeakLRUCache, LibraryCachedValue>({
cacheSize: 10_000,
})
get(key: CacheKey) {
return this.#cache.getValue(key) as LibraryCachedValue | undefined
}
set(
key: CacheKey,
value: LibraryCachedValue | undefined,
) {
if (value) {
this.#cache.setValue(key, value)
} else {
this.delete(key)
}
}
delete(key: CacheKey) {
this.#cache.delete(key)
}
clear() {
this.#cache.clear()
}
}
// Fast in memory cache for `items`, so we do not need to
// call indexed db for every access.
// IMPORTANT. Only store whole library items in here.
const valueCache = new LibraryValueCache()
if (import.meta.env.DEV) {
// @ts-expect-error used for debugging
globalThis.libraryValueCache = valueCache
}
if (!import.meta.env.SSR) {
onDatabaseChange((changes) => {
for (const change of changes) {
const { storeName } = change
if (
storeName === 'tracks' ||
storeName === 'albums' ||
storeName === 'artists' ||
storeName === 'playlists'
) {
if (change.operation === 'delete' || change.operation === 'update') {
const cacheKey = getCacheKey(storeName, change.key)
valueCache.delete(cacheKey)
}
} else if (storeName === 'playlistEntries') {
const playlistEntry = change.value
if (playlistEntry.playlistId === FAVORITE_PLAYLIST_ID) {
const cacheKey = getCacheKey('tracks', playlistEntry.trackId)
valueCache.delete(cacheKey)
}
}
}
})
}
export class LibraryValueNotFoundError extends Error {
constructor(cacheKey: CacheKey) {
super(`Value not found. Cache key: ${cacheKey}`)
this.name = 'LibraryValueNotFoundError'
}
}
const assertsValue = (
value: T,
allowEmpty: AllowEmpty | undefined,
cacheKey: CacheKey,
) => {
if (!(value || allowEmpty)) {
throw new LibraryValueNotFoundError(cacheKey)
}
return value
}
const getCachedOrFetchValue = (
key: CacheKey,
fetchValue: () => Promise | undefined>,
): LibraryValue | Promise | undefined> => {
const cachedValue = valueCache.get(key)
if (cachedValue) {
return cachedValue
}
const promise = fetchValue()
.then((value) => {
valueCache.set(key, value)
return value
})
.catch((error) => {
valueCache.delete(key)
throw error
})
valueCache.set(key, promise)
return promise
}
export type GetLibraryValueResult<
Store extends LibraryStoreName,
AllowEmpty extends boolean = false,
> = AllowEmpty extends true ? LibraryValue | undefined : LibraryValue
/** @public */
export const getLibraryValue = (
storeName: Store,
id: number,
allowEmpty?: AllowEmpty,
): Promise> | GetLibraryValueResult => {
const key = getCacheKey(storeName, id)
const result = getCachedOrFetchValue(key, () => {
const config: LibraryConfigMap[Store] = libraryConfigMap[storeName]
return config.fetch(id)
})
if (result instanceof Promise) {
const promiseResult = result.then((value) =>
assertsValue(value, allowEmpty, key),
) as Promise>
return promiseResult
}
return assertsValue(result, allowEmpty, key)
}
/** @public */
export const preloadLibraryValue = async (
storeName: LibraryStoreName,
id: number,
): Promise => {
try {
// this will fetch data and store it inside cache
await getLibraryValue(storeName, id)
} catch {
// Ignore
}
}
export const shouldRefetchLibraryValue = (
storeName: LibraryStoreName,
id: number | undefined,
changes: readonly DatabaseChangeDetails[],
): boolean => {
const config = libraryConfigMap[storeName]
return config.shouldRefetch(id, changes)
}
/** @private - Used for testing only */
export const clearLibraryValueCache = () => {
valueCache.clear()
}
================================================
FILE: src/lib/library/play-history-actions.ts
================================================
import { getDatabase } from '$lib/db/database.ts'
import { dispatchDatabaseChangedEvent } from '$lib/db/events.ts'
import { createUIAction } from '$lib/helpers/ui-action.ts'
import type { PlayHistoryEntry } from './types.ts'
const PLAY_HISTORY_LIMIT = 100
const notifyPlayHistoryChange = () => {
dispatchDatabaseChangedEvent({
storeName: 'playHistory',
})
}
export const dbAddToPlayHistory = async (trackId: number): Promise => {
const db = await getDatabase()
const tx = db.transaction(['tracks', 'playHistory'], 'readwrite')
const tracksStore = tx.objectStore('tracks')
const store = tx.objectStore('playHistory')
// Don't add orphaned history records for tracks that are no longer in library.
const trackExists = (await tracksStore.count(trackId)) > 0
if (!trackExists) {
await tx.done
return
}
const newEntry: Omit = {
trackId,
playedAt: Date.now(),
}
const existingKey = await store.index('trackId').getKey(trackId)
if (existingKey === undefined) {
await store.add(newEntry as PlayHistoryEntry)
} else {
await store.put({
...newEntry,
id: existingKey,
})
}
// Keep only the most recent PLAY_HISTORY_LIMIT entries.
// Start at newest and jump over the records we keep, then delete the tail.
let cursor = await store.index('playedAt').openCursor(null, 'prev')
if (cursor !== null) {
cursor = await cursor.advance(PLAY_HISTORY_LIMIT)
}
while (cursor !== null) {
await cursor.delete()
cursor = await cursor.continue()
}
await tx.done
notifyPlayHistoryChange()
}
export const dbRemoveFromPlayHistory = async (trackId: number): Promise => {
const db = await getDatabase()
const tx = db.transaction('playHistory', 'readwrite')
const store = tx.objectStore('playHistory')
const historyIds = await store.index('trackId').getAllKeys(trackId)
await Promise.all(historyIds.map((id) => store.delete(id)))
await tx.done
notifyPlayHistoryChange()
}
const dbClearPlayHistory = async (): Promise => {
const db = await getDatabase()
await db.clear('playHistory')
notifyPlayHistoryChange()
}
export const clearPlayHistory = createUIAction(false, dbClearPlayHistory)
================================================
FILE: src/lib/library/playlists-actions.ts
================================================
import type { IDBPObjectStore } from 'idb'
import { type AppDB, getDatabase } from '$lib/db/database.ts'
import { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts'
import { createUIAction } from '$lib/helpers/ui-action.ts'
import { truncate } from '$lib/helpers/utils/text.ts'
import type { Playlist, PlaylistEntry } from '$lib/library/types.ts'
import { FAVORITE_PLAYLIST_ID } from './types.ts'
export { FAVORITE_PLAYLIST_ID } from './types.ts'
export const dbCreatePlaylist = async (
name: string,
description: string,
createdAt = Date.now(),
): Promise => {
const db = await getDatabase()
const newPlaylist: Omit = {
name,
description,
uuid: crypto.randomUUID(),
createdAt,
}
const id = await db.add('playlists', newPlaylist as Playlist)
dispatchDatabaseChangedEvent({
operation: 'add',
storeName: 'playlists',
key: id,
})
return id
}
export const createPlaylist = async (name: string, description: string): Promise => {
try {
await dbCreatePlaylist(name, description)
snackbar(
m.libraryPlaylistCreated({
playlistName: truncate(name, 20),
}),
)
} catch (error) {
snackbar.unexpectedError(error)
}
}
export interface UpdatePlaylistOptions {
id: number
name: string
description: string
}
const dbUpdatePlaylist = async (options: UpdatePlaylistOptions): Promise => {
const db = await getDatabase()
const id = options.id
const tx = db.transaction('playlists', 'readwrite')
const existingPlaylist = await tx.store.get(id)
invariant(existingPlaylist, 'Playlist not found')
const updatedPlaylist: Playlist = {
...existingPlaylist,
name: options.name,
description: options.description,
}
await tx.store.put(updatedPlaylist)
dispatchDatabaseChangedEvent({
operation: 'update',
storeName: 'playlists',
key: id,
})
}
export const updatePlaylist = async (options: UpdatePlaylistOptions): Promise => {
try {
await dbUpdatePlaylist(options)
snackbar({
id: `playlist-updated-${options.id}`,
message: m.libraryPlaylistUpdated(truncate(options.name, 20)),
})
return true
} catch (error) {
snackbar.unexpectedError(error)
return false
}
}
export const dbRemovePlaylist = async (playlistId: number): Promise => {
const db = await getDatabase()
const tx = db.transaction(['playlists', 'playlistEntries'], 'readwrite')
const entriesStore = tx.objectStore('playlistEntries')
const entriesIds = await entriesStore
.index('playlistTrack')
.getAllKeys(IDBKeyRange.bound([playlistId], [playlistId + 1], false, true))
await Promise.all([
...entriesIds.map((id) => entriesStore.delete(id)),
tx.objectStore('playlists').delete(playlistId),
tx.done,
])
// We are not notifying about individual tracks removals
// because we are removing the whole playlist
dispatchDatabaseChangedEvent({
operation: 'delete',
storeName: 'playlists',
key: playlistId,
})
}
export type DbPlaylistEntriesStore = IDBPObjectStore<
AppDB,
['playlistEntries'],
'playlistEntries',
'readwrite'
>
export const getPlaylistEntriesDatabaseStore = async (): Promise => {
const db = await getDatabase()
const tx = db.transaction('playlistEntries', 'readwrite')
const store = tx.objectStore('playlistEntries')
return store
}
export interface AddTracksToPlaylistOptions {
playlistIds: readonly number[]
trackIds: readonly number[]
}
export const dbAddTracksToPlaylistsWithTx = (
store: DbPlaylistEntriesStore,
options: AddTracksToPlaylistOptions,
) => {
const promises = options.trackIds.flatMap((trackId) =>
options.playlistIds.map(async (playlistId) => {
const playlistEntry: Omit = {
playlistId,
trackId,
addedAt: Date.now(),
}
const playlistEntryId = await store.add(playlistEntry as PlaylistEntry)
const change: DatabaseChangeDetails = {
storeName: 'playlistEntries',
key: playlistEntryId,
operation: 'add',
value: {
...playlistEntry,
id: playlistEntryId,
},
}
return change
}),
)
return Promise.all(promises)
}
interface RemoveTracksFromPlaylistOptions {
playlistIds: readonly number[]
trackIds: readonly number[]
}
export const dbRemoveTracksFromPlaylistsWithTx = async (
store: DbPlaylistEntriesStore,
options: RemoveTracksFromPlaylistOptions,
) => {
const { playlistIds, trackIds } = options
const trackIdIndex = store.index('trackId')
const changes: DatabaseChangeDetails[] = []
for (const trackId of trackIds) {
for await (const cursor of trackIdIndex.iterate(trackId)) {
if (playlistIds.includes(cursor.value.playlistId)) {
await cursor.delete()
changes.push({
storeName: 'playlistEntries',
operation: 'delete',
key: cursor.primaryKey,
value: cursor.value,
})
}
}
}
return changes
}
interface BatchModifyPlaylistSelectionOptions {
trackIds: readonly number[]
playlistsIdsAddTo: readonly number[]
playlistsIdsRemoveFrom: readonly number[]
}
export const dbBatchModifyPlaylistsSelection = async (
options: BatchModifyPlaylistSelectionOptions,
): Promise => {
const store = await getPlaylistEntriesDatabaseStore()
const { trackIds, playlistsIdsAddTo, playlistsIdsRemoveFrom } = options
const allChanges: DatabaseChangeDetails[] = []
if (playlistsIdsRemoveFrom.length > 0) {
const changes = await dbRemoveTracksFromPlaylistsWithTx(store, {
playlistIds: playlistsIdsRemoveFrom,
trackIds,
})
allChanges.push(...changes)
}
if (playlistsIdsAddTo.length > 0) {
const changes = await dbAddTracksToPlaylistsWithTx(store, {
playlistIds: playlistsIdsAddTo,
trackIds,
})
allChanges.push(...changes)
}
dispatchDatabaseChangedEvent(allChanges)
return allChanges.length > 0
}
const dbRemoveTrackEntryFromPlaylist = async (playlistEntryId: number): Promise => {
const db = await getDatabase()
const entry = await db.get('playlistEntries', playlistEntryId)
invariant(entry)
await db.delete('playlistEntries', entry.id)
dispatchDatabaseChangedEvent({
operation: 'delete',
storeName: 'playlistEntries',
key: entry.id,
value: entry,
})
}
export const removeTrackEntryFromPlaylist = createUIAction(
m.libraryTrackRemovedFromPlaylist(),
(playlistEntryId: number) => dbRemoveTrackEntryFromPlaylist(playlistEntryId),
)
const dbAddTrackToFavorites = async (trackId: number): Promise => {
const db = await getDatabase()
// Check if already exists to prevent duplicates
const existing = await db.getFromIndex('playlistEntries', 'playlistTrack', [
FAVORITE_PLAYLIST_ID,
trackId,
])
if (existing) {
return
}
const playlistEntry: Omit = {
playlistId: FAVORITE_PLAYLIST_ID,
trackId,
addedAt: Date.now(),
}
const key = await db.add('playlistEntries', playlistEntry as PlaylistEntry)
dispatchDatabaseChangedEvent({
operation: 'add',
storeName: 'playlistEntries',
key,
value: {
...playlistEntry,
id: key,
},
})
}
const dbRemoveTrackFromFavorites = async (trackId: number): Promise => {
const db = await getDatabase()
const entry = await db.getFromIndex('playlistEntries', 'playlistTrack', [
FAVORITE_PLAYLIST_ID,
trackId,
])
invariant(entry)
await db.delete('playlistEntries', entry.id)
dispatchDatabaseChangedEvent({
operation: 'delete',
storeName: 'playlistEntries',
key: entry.id,
value: entry,
})
}
export const toggleFavoriteTrack = async (
shouldBeRemoved: boolean,
trackId: number,
): Promise => {
try {
if (shouldBeRemoved) {
await dbRemoveTrackFromFavorites(trackId)
} else {
await dbAddTrackToFavorites(trackId)
}
return true
} catch (error) {
snackbar.unexpectedError(error)
return false
}
}
================================================
FILE: src/lib/library/remove.ts
================================================
import type { IDBPTransaction } from 'idb'
import { type AppDB, getDatabase } from '$lib/db/database.ts'
import { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts'
import type { Track } from './types.ts'
type TrackOperationsTransaction = IDBPTransaction<
AppDB,
('tracks' | 'albums' | 'artists' | 'playlistEntries' | 'playHistory')[],
'readwrite'
>
const dedupe = (values: readonly T[]): readonly T[] => {
if (values.length < 2) {
return values
}
return [...new Set(values)]
}
const dbRemoveTracksFromPlayHistoryWithTx = async (
tx: TrackOperationsTransaction,
trackIds: readonly number[],
): Promise => {
const store = tx.objectStore('playHistory')
const trackIdIndex = store.index('trackId')
let removedAny = false
for (const trackId of trackIds) {
const historyId = await trackIdIndex.getKey(trackId)
if (historyId === undefined) {
continue
}
await store.delete(historyId)
removedAny = true
}
if (!removedAny) {
return
}
return { storeName: 'playHistory' }
}
const dbRemoveTracksFromAllPlaylistsWithTx = async (
tx: TrackOperationsTransaction,
trackIds: readonly number[],
) => {
const store = tx.objectStore('playlistEntries')
const trackIdIndex = store.index('trackId')
const changes: DatabaseChangeDetails[] = []
for (const trackId of trackIds) {
const entries = await trackIdIndex.getAll(trackId)
await Promise.all(entries.map((entry) => store.delete(entry.id)))
changes.push(
...entries.map(
(entry): DatabaseChangeDetails => ({
operation: 'delete',
storeName: 'playlistEntries',
key: entry.id,
value: entry,
}),
),
)
}
return changes
}
const dbRemoveUnusedAlbumsWithTx = async (
tx: TrackOperationsTransaction,
albumNames: readonly Track['album'][],
) => {
const tracksByAlbum = tx.objectStore('tracks').index('album')
const albumsStore = tx.objectStore('albums')
const changes: DatabaseChangeDetails[] = []
for (const albumName of dedupe(albumNames)) {
const albumNameKey = IDBKeyRange.only(albumName)
const tracksWithAlbumCount = await tracksByAlbum.count(albumNameKey)
if (tracksWithAlbumCount > 0) {
continue
}
const album = await albumsStore.index('name').get(albumNameKey)
if (!album) {
continue
}
await albumsStore.delete(album.id)
changes.push({
storeName: 'albums',
key: album.id,
operation: 'delete',
})
}
return changes
}
const dbRemoveUnusedArtistsWithTx = async (
tx: TrackOperationsTransaction,
artistNames: readonly string[],
) => {
const tracksByArtist = tx.objectStore('tracks').index('artists')
const artistsStore = tx.objectStore('artists')
const changes: DatabaseChangeDetails[] = []
for (const artistName of dedupe(artistNames)) {
const artistNameKey = IDBKeyRange.only(artistName)
const tracksWithArtistCount = await tracksByArtist.count(artistNameKey)
if (tracksWithArtistCount > 0) {
continue
}
const artist = await artistsStore.index('name').get(artistNameKey)
if (!artist) {
continue
}
await artistsStore.delete(artist.id)
changes.push({
storeName: 'artists',
key: artist.id,
operation: 'delete',
})
}
return changes
}
export const dbRemoveTracks = async (trackIds: readonly number[]): Promise => {
if (trackIds.length === 0) {
return
}
const db = await getDatabase()
const tx = db.transaction(
['tracks', 'albums', 'artists', 'playlistEntries', 'playHistory'],
'readwrite',
)
const tracksStore = tx.objectStore('tracks')
const existingTracks = (
await Promise.all(dedupe(trackIds).map((trackId) => tracksStore.get(trackId)))
).filter((track) => track !== undefined)
if (existingTracks.length === 0) {
await tx.done
return
}
const existingTrackIds = await Promise.all(
existingTracks.map((track) => tracksStore.delete(track.id).then(() => track.id)),
)
const [albumChanges, playlistChanges, historyChange, artistChanges] = await Promise.all([
dbRemoveUnusedAlbumsWithTx(
tx,
existingTracks.map((track) => track.album),
),
dbRemoveTracksFromAllPlaylistsWithTx(tx, existingTrackIds),
dbRemoveTracksFromPlayHistoryWithTx(tx, existingTrackIds),
dbRemoveUnusedArtistsWithTx(
tx,
existingTracks.flatMap((track) => track.artists),
),
])
const changes = [
...existingTrackIds.map(
(trackId): DatabaseChangeDetails => ({
storeName: 'tracks',
operation: 'delete',
key: trackId,
}),
),
historyChange,
...albumChanges,
...artistChanges,
...playlistChanges,
].filter((change) => change !== undefined)
await tx.done
if (changes.length > 0) {
dispatchDatabaseChangedEvent(changes)
}
}
export const dbRemoveAlbum = async (albumId: number): Promise => {
const db = await getDatabase()
const tx = db.transaction(['albums', 'tracks'], 'readonly')
const album = await tx.objectStore('albums').get(albumId)
if (!album) {
await tx.done
return
}
const tracksIds = await tx.objectStore('tracks').index('album').getAllKeys(album.name)
await tx.done
// If no tracks references it, it will be deleted automatically
await dbRemoveTracks(tracksIds)
}
export const dbRemoveArtist = async (artistId: number): Promise => {
const db = await getDatabase()
const tx = db.transaction(['artists', 'tracks'], 'readonly')
const artist = await tx.objectStore('artists').get(artistId)
if (!artist) {
await tx.done
return
}
// Artists is an array, we want to remove all tracks that reference this artist, artist can have other names as well
const tracksIds = await tx
.objectStore('tracks')
.index('artists')
.getAllKeys(IDBKeyRange.only(artist.name))
await tx.done
// If no tracks references it, it will be deleted automatically
await dbRemoveTracks(tracksIds)
}
================================================
FILE: src/lib/library/scan-actions/directories.ts
================================================
import { getDatabase } from '$lib/db/database.ts'
import { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts'
import { lockDatabase } from '$lib/db/lock-database.ts'
import { dbRemoveTracks } from '$lib/library/remove.ts'
import type { Directory } from '$lib/library/types.ts'
import { scanTracks } from './scan-tracks.ts'
export interface DirectoryStatus {
status: 'child' | 'existing' | 'parent'
existingDir: Directory
newDirHandle: FileSystemDirectoryHandle
}
export const checkNewDirectoryStatus = async (
existingDir: Directory,
newDirHandle: FileSystemDirectoryHandle,
): Promise => {
const paths = await existingDir.handle.resolve(newDirHandle)
let status: 'child' | 'existing' | 'parent' | undefined
if (paths) {
status = paths.length === 0 ? 'existing' : 'child'
} else {
const parent = await newDirHandle.resolve(existingDir.handle)
if (parent) {
status = 'parent'
}
}
if (status) {
return {
status,
existingDir,
newDirHandle,
}
}
return undefined
}
const dbImportNewDirectory = async (dirHandle: FileSystemDirectoryHandle): Promise => {
const db = await getDatabase()
const id = await db.add('directories', {
handle: dirHandle,
} as Directory)
dispatchDatabaseChangedEvent({
key: id,
storeName: 'directories',
operation: 'add',
})
await scanTracks({
action: 'directory-add',
dirId: id,
dirHandle,
})
}
export const importNewDirectory = async (handle: FileSystemDirectoryHandle): Promise => {
try {
await lockDatabase(() => dbImportNewDirectory(handle))
} catch (error) {
snackbar.unexpectedError(error)
}
}
export const rescanDirectory = async (
dirId: number,
dirHandle: FileSystemDirectoryHandle,
): Promise => {
let permission = await dirHandle.queryPermission()
if (permission === 'prompt') {
permission = await dirHandle.requestPermission()
}
if (permission !== 'granted') {
snackbar(m.settingsGrantDirectoryAccess())
return
}
try {
await lockDatabase(() =>
scanTracks({
action: 'directory-rescan',
dirId,
dirHandle,
}),
)
} catch (error) {
snackbar.unexpectedError(error)
}
}
const dbReplaceDirectories = async (
parentDirHandle: FileSystemDirectoryHandle,
directoriesIds: readonly number[],
): Promise => {
const dirIds = [...directoriesIds]
const directoryId = dirIds.pop()
// We pick last id and make it the parents new id.
invariant(directoryId)
const db = await getDatabase()
const tx = db.transaction(['directories', 'tracks'], 'readwrite')
const newDir: Directory = {
id: directoryId,
handle: parentDirHandle,
}
const replaceHandlePromise = tx
.objectStore('directories')
.put(newDir)
.then(
(): DatabaseChangeDetails => ({
key: directoryId,
storeName: 'directories',
operation: 'update',
}),
)
const promises = dirIds.map(async (existingDirId): Promise => {
// Update all tracks to point to the new directory.
const updatedTracksPromise = tx
.objectStore('tracks')
.index('directory')
.openCursor(existingDirId)
.then(async (c) => {
let cursor = c
const trackChangeRecords: DatabaseChangeDetails[] = []
while (cursor) {
const track = cursor.value
track.directory = directoryId
await cursor.update(track)
cursor = await cursor.continue()
trackChangeRecords.push({
key: track.id,
storeName: 'tracks',
operation: 'update',
})
}
return trackChangeRecords
})
const removedDirectoryPromise = tx
.objectStore('directories')
.delete(existingDirId)
.then(
(): DatabaseChangeDetails => ({
key: existingDirId,
storeName: 'directories',
operation: 'delete',
}),
)
const result = await Promise.all([removedDirectoryPromise, updatedTracksPromise])
return result.flat()
})
const [_, ...changes] = await Promise.all([tx.done, replaceHandlePromise, ...promises])
dispatchDatabaseChangedEvent(changes.flat())
await scanTracks({
action: 'directory-rescan',
dirId: directoryId,
dirHandle: parentDirHandle,
})
}
export const replaceDirectories = async (
parentDirHandle: FileSystemDirectoryHandle,
dirsIds: number[],
): Promise