Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
46 changes: 46 additions & 0 deletions electron/libraryScan.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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))
}
58 changes: 58 additions & 0 deletions electron/libraryScanTest.ts
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 2 additions & 17 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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[]) => {
Expand Down
Loading