From 45a0e4467404dbf875108f1a85087aab853c57b4 Mon Sep 17 00:00:00 2001 From: Jon Drews Date: Wed, 11 Nov 2026 22:02:45 -0500 Subject: [PATCH] Fix library scan skipping symlinked MIDI files --- docs/CHANGELOG.md | 6 ++++ electron/libraryScan.ts | 46 +++++++++++++++++++++++++++++ electron/libraryScanTest.ts | 58 +++++++++++++++++++++++++++++++++++++ electron/main.ts | 19 ++---------- 4 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 electron/libraryScan.ts create mode 100644 electron/libraryScanTest.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5dee0af..7faca65 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -47,6 +47,12 @@ - **Settings › MIDI Files & Library** is laid out afresh, and Chord Transcription's description now says what it does: when it's on, a song's file icon makes the chord chart. +### Fixed +- **Symlinked songs appear in the Library.** A Library folder whose `.mid` files are symbolic + links — for example an artist index linking into one flat store — came up empty: only folders + of real files loaded. Links are now followed, broken ones are skipped, and a folder linked + back into its own tree can no longer hang the scan. + ## [1.7.3] — 11. 11. 2026 — The Explorers hear your keyboard, a bigger Settings window **In short** diff --git a/electron/libraryScan.ts b/electron/libraryScan.ts new file mode 100644 index 0000000..499ad5f --- /dev/null +++ b/electron/libraryScan.ts @@ -0,0 +1,46 @@ +import { readdirSync, statSync, realpathSync } from 'fs' +import { join } from 'path' +import type { LibraryFile } from '../src/types' + +// ── Library scan — the recursive walk behind the Library folder picker. +// Follows symlinks: Dirent reports the entry's own type, never its target, +// so a linked .mid is neither a file nor a directory and would be skipped +// without the statSync below. Broken links are skipped silently, as +// unreadable directories are. ───────────────────────────────────────────── +const LIBRARY_EXT = /\.(mid|midi|kar|musicxml|xml|mxl|gp|gp3|gp4|gp5|gpx|cap)$/i + +export function scanMidiFolder(folderPath: string): LibraryFile[] { + // Directories already visited, by real path — a symlinked subfolder that + // points back at an ancestor (or a symlink cycle) would otherwise recurse + // forever. It also dedups a directory reached twice (real path and link): + // its files are listed once, under the first path reached. + const visited = new Set() + function scanDir(dir: string): LibraryFile[] { + let real: string + try { real = realpathSync(dir) } catch { return [] } + if (visited.has(real)) return [] + visited.add(real) + try { + const results: LibraryFile[] = [] + const entries = readdirSync(dir, { withFileTypes: true }) + for (const e of entries) { + let isDir = e.isDirectory() + let isFile = e.isFile() + if (e.isSymbolicLink()) { + try { + const st = statSync(join(dir, e.name)) + isDir = st.isDirectory() + isFile = st.isFile() + } catch { continue } + } + if (isDir) { + results.push(...scanDir(join(dir, e.name))) + } else if (isFile && LIBRARY_EXT.test(e.name)) { + results.push({ name: e.name, path: join(dir, e.name) }) + } + } + return results + } catch { return [] } + } + return scanDir(folderPath).sort((a, b) => a.name.localeCompare(b.name)) +} diff --git a/electron/libraryScanTest.ts b/electron/libraryScanTest.ts new file mode 100644 index 0000000..9fd66d6 --- /dev/null +++ b/electron/libraryScanTest.ts @@ -0,0 +1,58 @@ +import { mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { scanMidiFolder } from './libraryScan' + +// A temporary tree with regular files and — where the OS allows it — +// symlinks. Making symlinks needs a privilege on Windows, so when creation +// fails the link checks are dropped, not counted as failures (the SKIP +// table in runAllTests.ts works per file; this is the per-check version). +// No assertion depends on readdir order: which surface a deduped directory +// is reported under is filesystem-dependent, so only the counts are checked. +export function runLibraryScanTest(): number { + console.group('[libraryScan] self-check') + let pass = 0, fail = 0 + const check = (c: boolean, m: string) => { c ? pass++ : (fail++, console.error('FAIL:', m)) } + + const root = join(tmpdir(), `orfeo-scan-test-${process.pid}-${Date.now()}`) + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, 'real', 'sub'), { recursive: true }) + mkdirSync(join(root, 'outside'), { recursive: true }) + writeFileSync(join(root, 'real', 'a.mid'), '') + writeFileSync(join(root, 'real', 'sub', 'b.mid'), '') + writeFileSync(join(root, 'real', 'note.txt'), '') + writeFileSync(join(root, 'outside', 'd.mid'), '') + + let links = true + try { + symlinkSync(join(root, 'real', 'a.mid'), join(root, 'linkfile.mid')) // linked file + symlinkSync(join(root, 'real'), join(root, 'linkdir')) // linked dir → dedup + symlinkSync(join(root, 'outside'), join(root, 'real', 'sub', 'out')) // link out of the tree + symlinkSync('nowhere', join(root, 'broken.mid')) // broken link + symlinkSync(root, join(root, 'self')) // cycle: link to own root + } catch { links = false } + + try { + const files = scanMidiFolder(root) + const count = (n: string) => files.filter(f => f.name === n).length + + check(count('a.mid') === 1, 'regular file found') + check(count('note.txt') === 0, 'non-library extension ignored') + if (links) { + check(count('linkfile.mid') === 1, 'symlinked file found (Dirent says neither file nor dir)') + check(count('b.mid') === 1, 'dir reached as real path and as link → listed once') + check(count('d.mid') === 1, 'dir out of the tree, reached real and via link → listed once') + check(count('broken.mid') === 0, 'broken symlink skipped') + check(files.length === 4, `a, linkfile, b, d in all — got ${files.length}`) + } else { + check(files.length === 3, `a, b, d without links — got ${files.length}`) + } + check(files.every(f => f.name && f.path.startsWith(root)), 'names and absolute paths only') + } finally { + rmSync(root, { recursive: true, force: true }) + } + + console.log(`libraryScan: ${pass} passed, ${fail} failed${links ? '' : ' (symlink checks skipped: not permitted here)'}`) + console.groupEnd() + return fail +} diff --git a/electron/main.ts b/electron/main.ts index 9b92c9e..3385f34 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,6 +11,7 @@ import { assignHands } from '../src/utils/handAssignment' import { buildHandExportHint, withHandSuffix, nameHandFromSuffix, parseHandMapMeta, RESTORED_CONFIDENCE } from '../src/utils/handMetadata' import { getGMGroup } from '../src/utils/gmInstruments' import { exportsDir, nextFreeName, safeExportBaseName } from './exportName' +import { scanMidiFolder } from './libraryScan' import { fileFromArgs } from './openWith' import { fileHasLyrics } from '../src/utils/lyricsCheck' import { KEYBOARD_GROUPS } from '../src/utils/keyboardGroups' @@ -642,23 +643,7 @@ ipcMain.handle('dialog:openFolder', async () => { } return folderPath }) -ipcMain.handle('fs:scanMidiFolder', async (_e, folderPath: string) => { - function scanDir(dir: string): { name: string; path: string }[] { - try { - const results: { name: string; path: string }[] = [] - const entries = readdirSync(dir, { withFileTypes: true }) - for (const e of entries) { - if (e.isDirectory()) { - results.push(...scanDir(join(dir, e.name))) - } else if (e.isFile() && /\.(mid|midi|kar|musicxml|xml|mxl|gp|gp3|gp4|gp5|gpx|cap)$/i.test(e.name)) { - results.push({ name: e.name, path: join(dir, e.name) }) - } - } - return results - } catch { return [] } - } - return scanDir(folderPath).sort((a, b) => a.name.localeCompare(b.name)) -}) +ipcMain.handle('fs:scanMidiFolder', (_e, folderPath: string) => scanMidiFolder(folderPath)) // ── Size + modified time for many files at once (no reads) — lets the // tempo-warning Library scan skip files it has already checked. ───────── ipcMain.handle('fs:statFiles', async (_e, paths: string[]) => {