.cue Sheet Support
SkillFiles & storage.cue sheet support — direct-play virtual split (open a .cue or an audio file with a sibling .cue → N playlist rows from one backing file, gapless) and library physical split-on-import (ffmpeg per-track files, off by default). Use when working on cue parsing, the AudioEngine cue boundary detector, in-file offset playback, or CueAlbumSplitter.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the .cue Sheet Support skill
What this skill tells your AI
The instructions your AI receives, as published by ad-repo/nullplayer in skills/cue-sheets/SKILL.md and read by ahel’s review.
Two independent code paths that consume .cue sheets. The Stream Ripper writes cues (one TRACK per chapter — see the stream-ripper skill); this feature reads them.
- Part A — Direct play (virtual split): open a
.cue, or an audio file with a sibling.cue, and the single backing file is played virtually split into its cue tracks → N rows in the now-playing playlist. Nothing is written to disk; nothing is added to the Local Library. Satisfies issue #273. - Part B — Library split-on-import (off by default): when the toggle is on, a
.cueencountered by the Local Library scan causes the backing file to be physically split into per-track FLACs via ffmpeg; those are added to the library and the original is excluded. When off, the scan ignores.cuefiles entirely and imports the backing file as one normal track.
The two paths share only the parser. Part A is unaffected by the Part B toggle.
Key files
| Area | File |
|---|---|
| Shared parser | Data/Models/CueSheet.swift |
| Track cue fields | Data/Models/Track.swift (cueStartOffset, cueEndOffset, cueSourceURL, isCueTrack) |
| Playback + boundary detector + entry helper | Audio/AudioEngine.swift |
| Part B discovery | Utilities/LocalFileDiscovery.swift (cueExtensions / cueFiles bucket) |
| Part B splitter | Utilities/CueAlbumSplitter.swift |
| Part B scan pre-pass + exclusion + pref threading | Data/Models/MediaLibrary.swift |
| Part B toggle UI | App/ContextMenuBuilder.swift (cueSplitOnImportEnabled) |
| Entry points (Part A) | App/AppDelegate.swift, Windows/MainWindow/MainWindowView.swift, Windows/ModernMainWindow/ModernMainWindowView.swift, Windows/Playlist/PlaylistView.swift, Windows/ModernPlaylist/ModernPlaylistView.swift, Windows/ModernLibraryBrowser/ModernLibraryBrowserView.swift, Windows/PlexBrowser/PlexBrowserView.swift |
| Tests | Tests/NullPlayerAppTests/CueSheetTests.swift, AudioEngineCueBoundaryDetectorTests.swift, CueAlbumSplitterTests.swift |
Shared parser — CueSheet.swift
CueSheet holds top-level performer (→ artist fallback), title (→ album), fileName (first FILE only), and [Entry] (number, title, performer?, startTime).
parse(from:) throws— line scan; top-level vs track-levelPERFORMER/TITLEare disambiguated by whetherFILEhas been seen yet.INDEX 01preferred,INDEX 00as fallback. MultipleFILEentries → warn, use first only. Throws if noFILE, or ifentries.count > CueSheet.maxEntries(10 000 — DoS guard against a pathological cue spawning unbounded jobs/rows).parseCueTimestamp(_:)— inverse of the writer'scueTimestamp:MM:SS:FF@ 75 fps → seconds. Must round-trip withStreamRipper.cueTimestamp.resolveBackingFile(for:fileName:)— absolute paths honored; relative resolved against the cue's directory. Untrusted-input guard: a relative path that escapes the cue's own directory (../../…) is treated as missing (returns a non-existent sentinel) rather than reading an arbitrary file. A.cuetravels with downloaded media — treat it as untrusted.siblingCue(for:)— returns<basename>.cuenext to an audio file if present.expandToTracks(cue:cueFileURL:)— virtual[Track]for Part A. Empty cue →[]. For entry i:cueStartOffset = entries[i].startTime,cueEndOffset = entries[i+1].startTime(guardedi+1 < count;nilfor the last entry → play to EOF). A missing backing file is just a cue track whoseurldoesn't exist → the engine's load-failure skip shows it as an unplayable row.
Part A — playback (AudioEngine.swift)
-
Entry helper
tracksForCueOrSibling(url:) -> [Track]?— every entry point calls this before the normal Track-from-URL path: returns expanded tracks for a.cue, or for an audio file with a sibling cue, elsenil. -
Load: a cue track schedules
scheduleSegmentfromcueStart*srto EOF (not tocueEnd),.dataPlayedBack, generation-guarded — the continuous schedule is what makes gapless possible._currentTime/lastReportedTimereset to 0 so the clock is 0-relative; the boundary base index resets. -
durationgetter:cueEnd - cueStart, orfileDuration - cueStartwhencueEnd == nil. -
Seek: clamps to
[0, duration], then offsets bycueStartand reschedules to EOF. -
Gapless boundary detector
advanceCueTrackIfBoundaryCrossed()(called from the 0.1 s time-update timer) wraps the pure, unit-tested decisionshouldAdvanceCueTrackAtBoundary(...). WhencurrentTime >= cueEndand the next playlist entry is a same-cueSourceURLcue track, it advancescurrentIndex/currentTrack, resets_currentTime/lastReportedTime, and resetsplaybackStartDate = Date()(without this the 0-relative clock keeps climbing). Audio is untouched → no gap;currentTrackdidSet posts the change so title/seek-bar/Now Playing update.Gapless is provided only with shuffle off and repeat-single off. The detector returns early under shuffle or repeat-single (it assumes the next track is
playlist[currentIndex+1], which is false under shuffle) and guardscurrentIndex+1 < playlist.count. In those modes the schedule runs to real EOF and the normaltrackDidFinishpath advances (a gap is acceptable — documented limitation).
Part B — library split (CueAlbumSplitter.swift, off by default)
Gated by UserDefaults bool cueSplitOnImportEnabled (default false, mirrors the includeLegacyWMA scan-flag pattern). When off, LocalFileDiscovery does not collect .cue files and MediaLibrary excludes nothing.
LocalFileDiscovery exposes a separate cueExtensions = ["cue"] / cueFiles bucket — do not reuse playlistExtensions (that would make cues into LocalPlaylist browser nodes). The MediaLibrary scan runs a pre-pass before the cleanup loop: for each cue, CueAlbumSplitter.splitIfNeeded(cueURL:) returns a SplitOutcome { backingFileToExclude, trackFiles }:
shouldPerformSplitcompares the deterministic expected output paths (computeOutputPath(..., checkFilesystem: false)) against disk. All present → idempotent skip, returns the backing file + existing track files. Any missing → split.- Per-album subdirectory: outputs go into
<cueDir>/<Artist - Album>/— named from the source file's ownALBUM/ARTISTtags read viaffprobe(sourceTags), falling back to the cue'sPERFORMER/TITLE, then the cue filename. (The cue'sTITLEfrom the Stream Ripper is the video/show title — the track name — not the album, so the file tags are preferred.)sourceTagsis deterministic, soexpectedOutputPathsandperformSplitcompute the same folder → idempotency holds. - Output is always re-encoded FLAC (
-c:a flac, not-c copy— copy isn't sample-accurate at cut points) for both lossless and lossy sources, asNN - <sanitized title>.flac. - Filenames/folder are sanitized via
sanitizeFilenameComponent: replace/ \ : * ? " < > |+ control chars with_, collapse whitespace, trim leading/trailing spaces+dots, NFC-normalize, truncate ~200 UTF-8 bytes; track filenames also de-dup with(2),(3). - ffmpeg args (mirroring
StreamRipper's Process/[String]pattern — never a shell string):-ss/-to(last track omits-to),-map_metadata 0(inherit the source's date/genre/cover-art tags), then override per-tracktitle+track=N/total, andartist/album/album_artistfrom the source tags (sourceTags), falling back to cue values, only writing non-empty values so an inherited field is never blanked. Cover art is conditional — the-map 0:v:0 -c:v copy -disposition:v attached_picgroup is added only ifffprobefinds a video/attached-pic stream. - Skip+warn when ffmpeg is absent, or on any write/permission/space failure: do not split, do not exclude the original, post a one-time notice. The original is excluded iff its split tracks actually exist.
MediaLibrary then: (a) removes any backing file in the "successfully split" set from tracks/tracksByPath/store and from audioMetadataTasks (critical — the enrichment pass would otherwise re-insert the backing under its own tags), and (b) adds the returned trackFiles to the library in-scan (they're written into a subdir created after discovery enumerated audio, so discovery didn't see them). Cue files are never upserted as tracks or playlists.
Reading FLAC/M4A tags (MediaLibrary.parseMetadata): AVFoundation exposes title/artist/album via common keys, but album-artist and track/disc number are not common keys — they were only read from ID3 (TPE2/TRCK/TPOS), so FLAC/M4A came back nil. parseMetadata now scans all metadata formats for Vorbis ALBUMARTIST/TRACKNUMBER/DISCNUMBER (and iTunes aART/trkn/disk), parsing the leading int from "1/10". Without these, cue-split FLAC albums fragmented by per-track artist and lost their track ordering.
Gotchas
- Idempotency seeding:
performSplitseeds the de-dup set with the cue's full deterministic canonical path set up front, so an already-split track on a partial re-run is recognized as our own output and reused — never re-encoded into a(2)duplicate. (Trade-off: two different cues in the same folder producing identically-named tracks will overwrite rather than(2); idempotency is the priority.) - Outputs land in a subdir of the cue's directory (
<cueDir>/<Artist - Album>/), never relative to the backing file — there is no write-side path traversal. - MAS sandbox: writing split files next to a scanned
.cueneeds the containing folder covered by a writable security-scoped bookmark. If watch folders aren't writable under the sandbox, Part B no-ops via skip+warn. Confirm before relying on Part B in the MAS build, or treat it as DMG-only. Sources/NullPlayerCore/Models/Track.swiftis NOT modified — the cue fields live only on the Data/ModelsTrackused byAudioEngine.playlist.- Out of scope: embedded FLAC
CUESHEET/Vorbis tags; multipleFILEentries per cue (first only);INDEX 00pregap beyond the start-time fallback.
Signals
- GitHub stars
- 118
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
cue-sheets- Source
- github.com/ad-repo/nullplayer