0.25.0
New Features
-
.cuesheet playback — virtual split (#273) — opening a.cuefile (via File → Open, drag-and-drop, or double-click), or opening an audio file that has a sibling.cuenext to it, now plays the single backing file virtually split into its cue tracks: one row per track in the now-playing playlist, with the title, performer, and duration taken from the cue. Prev/Next move per cue track and seeking stays within the current track, while playback crosses track boundaries gaplessly — the backing file is scheduled as one continuous stream and a boundary detector advances the playlist row (updating title, seek bar, Now Playing, and history) without touching the audio. Gapless applies with shuffle and repeat-single off; in those modes boundaries still advance correctly but a small gap is expected. Nothing is written to disk and nothing is added to the Local Library; a missing/renamed backing file simply shows its rows as unplayable. The parser reads the firstFILEentry (extra ones warn), prefersINDEX 01(falling back toINDEX 00), and is the exact inverse of the Stream Ripper's chapter-.cuewriter, so a rip's own cue round-trips..cueis now also offered in the File → Open panel's file-type filter. -
Split
.cuealbums on import (library, off by default) — a new Library → Split .cue Albums on Import toggle (default off) makes the Local Library scan physically split a single-file album into per-track files when it finds a.cuenext to it. When on, each track is cut withffmpegand re-encoded to FLAC (re-encoding, not stream-copy, so cuts are sample-accurate) into a per-album subfolder named from the source file's ownALBUM/ARTISTtags (e.g.Artist - Album/), falling back to the cue's performer/title, then the cue filename. Each track inherits the source's metadata (date, genre, embedded cover art) with the title/track-number and album/album-artist set from the source tags; the split tracks are added to the library in the same scan and the original backing file is excluded. The split is idempotent (a re-scan does no work if the per-track files already exist) and filenames are sanitized and de-duplicated so they can't collide or escape the album folder. Ifffmpegisn't installed — or a write fails (permissions, read-only volume, out of space) — splitting is skipped with a one-time notice and the original file imports normally as a single track (it's only hidden once real split tracks exist). When the toggle is off,.cuefiles are ignored by the scan entirely and the backing file imports as one normal track. Direct-play (above) is unaffected by this toggle. Changing the toggle takes effect on the next scan. -
Local library reads FLAC/M4A album-artist and track/disc numbers — metadata parsing previously read album-artist and track/disc numbers only from MP3 ID3 frames (
TPE2/TRCK/TPOS), so FLAC/OGG (VorbisALBUMARTIST/TRACKNUMBER/DISCNUMBER) and M4A (aART/trkn/disk) tracks came back without them — causing single albums to fragment by per-track artist and lose their track ordering. These tags are now read across all containers (handling the1/10track form). -
Stream Ripper — download a URL to FLAC/MP3 or a video file — a new Output → Streaming → Rip URL… action opens a dialog where you paste a URL (auto-filled from the clipboard when it holds a web link) and choose an output type: Audio — FLAC (lossless), Audio — MP3, or video at a resolution/bitrate profile you pick (720p/2.5 Mbps, 1080p/4 Mbps recommended, 1080p/8 Mbps high quality, 1440p/16 Mbps, 4K/35 Mbps, Full/50 Mbps max). Ripping shells out to a system-installed
yt-dlp(+ffmpeg); if either isn't found it shows an install hint (brew install yt-dlp ffmpeg) rather than failing silently. Quality is prioritized for audio (bestaudio, then lossless FLAC encode or top-VBR MP3). Video grabs the best source streams within the selected height cap (or no height cap for Full), then ffmpeg creates a playback-safe H.264/AAC MP4 withyuv420ppixels and fast-start metadata so the app and cast targets do not receive VLC-only files. The video source file is temporary ([source]) and is removed after the compatible MP4 is written; existing MP4s are not overwritten. Output is tagged with the source's metadata (title/artist/album/date) and, for audio, the thumbnail is embedded as cover art; the final file is namedArtist - Titlefrom that metadata into a folder you pick. If the source has chapter timestamps (common on album/mix uploads), a matching.cuesheet is written alongside the audio — one TRACK per chapter. Progress shows as a spinner + message band at the top of the main window for the duration of the rip (works in both classic and modern UI). When it finishes, a dialog offers Play Now (audio loads into the player; video opens in the video player window, cast-aware), Reveal in Finder, or Done. -
Local
.m3u/.plsplaylists in the Plists tab (#269) — the local library browser's Plists tab now lists.m3u,.m3u8, and.plsplaylist files found on disk, matching what the Plex/Subsonic/Jellyfin/Emby sources already show there (previously the tab was always empty for the local source). Playlist files are discovered during the normal library scan and their locations persisted in a smalllibrary_playliststable — the track contents are not stored, but parsed lazily the first time you expand a playlist. Expanding shows each entry as a row: entries that match a file already in your library carry its metadata and duration, while unmatched paths still appear and remain playable. Double-clicking a playlist row loads and plays the whole list; double-clicking a single entry plays just that track; the disclosure triangle expands as usual. Removing a playlist file from disk drops it from the tab on the next scan, while a transiently unreachable network folder leaves the list intact (the same offline-volume safety guard used for tracks). Implemented identically in both the modern and classic library browsers. Pairs with the earlier "browse by folder structure" work under the same "organize by what's actually on disk" philosophy. -
Remove Orphaned Entries (library maintenance) — new Library → Clear… → Remove Orphaned Entries… action removes library entries whose files are no longer inside any watched folder. These orphans are typically left behind by an older buggy removal that deleted the watch folder but not its entries, so they can't be cleared by removing a folder (none owns them). The action previews the count, auto-creates a backup first, deletes from both memory and the SQLite store (tracks, movies, episodes, playlists), and never touches files on disk. Path matching uses the resolved
url.pathso it stays fast on large libraries. -
Browse local library by folder structure — the local library can now be browsed by its actual on-disk folder hierarchy instead of by Artist/Album/Playlist metadata. Rather than adding a ninth tab, the existing Plists tab slot doubles as a toggle: double-click it (local source only) to flip between Plists and Folders; single-click selects whichever the slot currently shows, and the choice persists across launches. The Folders view reflects what is actually on disk right now — including files that haven't been scanned into the library yet — read lazily one directory level at a time as folders are expanded; library metadata (title, duration) enriches a file row only when that file is in the database. Folders sort first, then files, case-insensitively; symlinked directories are skipped to avoid loops. Right-click a folder for Play / Play and Replace Queue / Play Next / Add to Queue / Show in Finder, which recursively collect every supported audio file beneath it. Filesystem enumeration and database lookups run off the main thread (with per-click cancellation and a loading spinner) so large network/NAS folders don't stall the UI. Implemented independently in both the modern and classic library browsers.
Improvements
- Keychain credentials hardened (#253) — saved server credentials (Plex, Subsonic, Jellyfin, Emby) are now stored with the
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyaccessibility class, so they are only readable while the Mac is unlocked and never sync off the device. The previous permissive per-item ACL has been removed. Entries written by earlier versions are upgraded automatically and lazily the first time each one is read.
Bug Fixes
- Main-window right-click menu simplified — the main window context menu no longer shows Sleep Timer or Remember State controls. Those settings remain available from the macOS menu bar, keeping the right-click menu focused on playback/window actions.
- Removing a watch folder now actually deletes its tracks and persists — removing a watched folder removed its tracks/movies/episodes from the in-memory arrays but never deleted the rows from the SQLite store, and the folder row itself often failed to delete too. Because the library browser reads from the store (not the in-memory arrays), removed tracks kept appearing and never went away, and the "removed" folders reappeared on next launch. Two underlying bugs: (1)
removeWatchFolder(removeEntries:)only updated memory and calledstore.deleteWatchFolderfor the folder — it now also deletes the matching track/movie/episode rows via new chunked, transactional bulk deletes; (2)MediaLibraryStore.deleteWatchFolderreconstructed the folder URL withURL(fileURLWithPath:), which on an offline network volume can't stat the path to add the trailing slash that stored directory URLs carry (file:///Volumes/home/MUSIC/), so theWHEREclause matched nothing — it now matches the trailing-slash, no-slash, and raw-path forms so offline folders delete correctly. (Pre-existing orphans left by the old behavior aren't retroactively removed by removing a folder — use the new Remove Orphaned Entries… action to clean them.) - Watch-folder removal confirmation was invisible — the "Remove Watched Folder?" confirmation opened as a free-floating alert at the normal window level, below the Manage Watch Folders window (which sits at
.modalPanelfor issue #254) and below any always-on-top windows, so it was hidden off-screen-behind and could never be confirmed — clicking Remove… appeared to do nothing. It's now a window-modal sheet attached to the manager window: always visible, and it blocks the folder table beneath it (you can no longer select another row while it's open). This was the root cause behind "removing folders does nothing." - Removing a watch folder took ~20 seconds on large libraries —
removalCountsForWatchFolder()andremoveWatchFolder()calledresolvingSymlinksInPath()once per track to normalize paths — roughly one filesystem call per library item (~60k on a large library). They now comparetrack.url.pathdirectly (already resolved at scan time), the same optimizationwatchFolderSummaries()received, making removal near-instant. - Removing a watch folder no longer beachballs the app — in the Manage Watch Folders window, clicking Remove… ran
removalCountsForWatchFolder()andremoveWatchFolder()directly on the main thread. Both block onMediaLibrary's internaldataQueue.sync, and a running import scan (e.g. right after adding/rescanning a folder) holds that queue for many seconds — so the app froze with a spinning beachball until the scan finished. These calls now run off the main thread (only the confirmation alert stays on main, where it must), matching the pattern the window's folder-list reload already used. Also hardened the modern Library Browser's Folders view, which made the same blockingwatchFolderSummaries()call on the main thread just before handing off to its background walk — that snapshot now happens inside the background task. - Popup dialogs no longer hide behind always-on-top windows (#254) — with Always on Top enabled, opening a popup dialog (e.g. Add Radio Station) appeared to do nothing: the dialog opened at the normal window level, below the main window which had been raised to the floating level, so it was completely obscured until the main window was dragged aside. These transient dialogs now open at the
.modalPanellevel so they always sit above the app's floating windows, matching the tag-editor and Plex link dialogs that already did this. Covers Add/Edit Radio Station, the Subsonic/Jellyfin/Emby link and server-list sheets, the watch-folder manager, and the auto-tag album candidate picker. - Non-Retina classic skin colors fixed (#256) — removed the blanket blue→grayscale conversion in
SkinLoader.processForNonRetina()that ran on 1× displays, converting every blue-dominant pixel to gray across all classic skin sprites and stripping legitimate blue tones from every skin. The conversion never ran on Retina, which had masked the bug. - Non-Retina Data tab text/chart blur fixed (#257) — the modern Library Browser "Data" tab hosting view is now opaque (
isOpaque = truewith an opaque skin background, kept in sync on skin change). A clear, non-opaque layer had disabled AppKit font smoothing, blurring text and charts on 1× displays; it now mirrors the classic PlexBrowser twin. - Local library expand re-sort fixed (#262) — with a column sort active, double-clicking an Artist or Album to expand it no longer reshuffles the top-level list. The list shown before expanding came from the in-memory column sort (
LibraryTextSorter: diacritic-insensitive, numeric, leading-article-aware), but expanding a row rebuilt it from the store's SQLiteBINARYcollation order and skipped re-sorting once nested rows were present — so the order silently snapped to the raw store order, most visibly around names with special characters, mixed case, or leading articles. The local-library views inModernLibraryBrowserViewandPlexBrowserViewnow re-sort top-level groups (each leader plus its expanded children) with the same comparator, keeping visible order stable through expand/collapse. - HTTP-only internet radio streams now play (#255) — adding a station whose stream URL is plain
http://(e.g. many Icecast/SHOUTcast servers on custom ports) silently failed to play: it sat buffering forever and never started. Internet radio plays through the AudioStreaming library, which fetches overURLSession, but the app's App Transport Security config only declaredNSAllowsArbitraryLoadsForMedia— a key that exempts AVFoundation media loads, notURLSession— so cleartext connections were blocked by ATS. The reported ".mp3links work, others don't" pattern was a coincidence: the working stations happened to behttps://, and the real distinction was scheme, not file extension or audio format.Info.plistnow setsNSAllowsArbitraryLoadsso http stations connect.