From 97662bf4bffaed18d9971700ad6f21012e0645e3 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 11:16:29 +0000 Subject: [PATCH] importer chnges --- KJMix Pro.xcodeproj/project.pbxproj | 8 ++--- KJamsClone/Hosting.swift | 3 +- KJamsClone/KJMixProApp.swift | 12 +++---- KJamsClone/Metadata.swift | 14 +++++++- KJamsCloneTests/KJamsCloneTests.swift | 19 ++++++++++ README.md | 8 ++++- docs/REINTERPRETING.md | 50 +++++++++++++++++++++++++++ docs/VERIFICATION.md | 16 +++++++++ 8 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 docs/REINTERPRETING.md diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index 3bb355d..75b4f34 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -245,7 +245,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = 39R6U3437D; ENABLE_APP_SANDBOX = NO; ENABLE_TESTABILITY = YES; @@ -255,7 +255,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.6.7; + MARKETING_VERSION = 0.6.8; OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; PRODUCT_MODULE_NAME = KJamsClone; @@ -273,7 +273,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = 39R6U3437D; ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; @@ -282,7 +282,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.6.7; + MARKETING_VERSION = 0.6.8; OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; PRODUCT_MODULE_NAME = KJamsClone; diff --git a/KJamsClone/Hosting.swift b/KJamsClone/Hosting.swift index b9515aa..121a6cb 100644 --- a/KJamsClone/Hosting.swift +++ b/KJamsClone/Hosting.swift @@ -184,7 +184,7 @@ struct PlaylistWorkspace: View { Menu { Button("Rename") { if let selected { model.rename("playlists",id:selected,name:name) } }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty);Button("Delete…",role:.destructive) { delete = true } } label: { Image(systemName:"ellipsis.circle") }.disabled(selected == nil) } HStack { - Picker("Sort",selection:$sort) { ForEach(["Saved Order","Song ID","Title","Artist","Album","Track","Rendition","Duration","Rating"],id:\.self) { Text($0) } }.frame(width:200) + Picker("Sort",selection:$sort) { ForEach(["Saved Order","Song ID","Title","Artist","Album","Track","Rendition","Duration","Rating"],id:\.self) { Text($0 == "Saved Order" ? "Saved Order (PLI)" : $0).tag($0) } }.frame(width:200) Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.disabled(sort == "Saved Order").help("Reverse sort") Text("\(rows.count) songs").font(.caption).foregroundStyle(.secondary);Spacer();Button(nameSelection.isEmpty ? "Reinterpret Visible Songs…" : "Reinterpret Selected…") { reinterpretIDs = nameSelection.isEmpty ? Set(rows.map(\.id)) : nameSelection.intersection(Set(rows.map(\.id)));reinterpreting = true }.disabled(rows.isEmpty);Button("Add Songs…") { adding = true }.disabled(selected == nil) } @@ -203,6 +203,7 @@ struct PlaylistWorkspace: View { Text("Use toolbar search for this playlist. Quoted phrases, artist:, album:, title:, id: and exact = searches are supported. Saved order can be changed with the arrow buttons when search is empty.").font(.caption).foregroundStyle(.secondary) }.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(isPresented:$adding) { addSheet } .sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) } + .focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{ reinterpretIDs = nameSelection.isEmpty ? Set(rows.map(\.id)) : nameSelection.intersection(Set(rows.map(\.id)));reinterpreting = true },enabled:!rows.isEmpty)) .onChange(of:query) { _ in nameSelection = [] } .confirmationDialog("Delete this playlist? Library songs are kept.",isPresented:$delete) { Button("Delete Playlist",role:.destructive) { if let selected { model.delete("playlists",id:selected);self.selected = nil } } } .onChange(of:sort) { _ in saveSort() }.onChange(of:ascending) { _ in saveSort() } diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index a2944ae..56b5ba0 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -484,8 +484,6 @@ struct SongDragReference: Codable, Sendable { } private struct LibraryCommands { var importMusic: () -> Void - var reinterpret: () -> Void - var canReinterpret: Bool var songInfo: () -> Void var canEdit: Bool var play: () -> Void @@ -504,6 +502,7 @@ private extension FocusedValues { @StateObject private var model = AppModel(storageURL:ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil ? nil : FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-TestHost-"+String(ProcessInfo.processInfo.processIdentifier))) @AppStorage("studioAppearance") private var appearance = "system" @FocusedValue(\.libraryCommands) private var actions + @FocusedValue(\.nameReinterpretation) private var nameActions var body: some Scene { Window("KJMix Pro", id: "main") { LibraryView(model: model).frame(minWidth: 1060, minHeight: 760) @@ -524,7 +523,7 @@ private extension FocusedValues { Button("Add Drive or Folder…") { model.chooseMediaLocation() } } CommandMenu("Advanced") { - Button("Reinterpret Names…") { actions?.reinterpret() }.disabled(actions?.canReinterpret != true || model.namesBusy) + Button("Reinterpret Names…") { nameActions?.perform() }.disabled(nameActions?.enabled != true || model.namesBusy) Button("Undo Last Name Reinterpretation") { Task { do { try await model.undoNameChanges() } catch { model.error = error.localizedDescription } } }.disabled(!model.canUndoNames || model.namesBusy) } CommandMenu("Playback") { @@ -727,7 +726,8 @@ struct LibraryView: View { } .background(StudioTheme.frame) .tint(.accentColor) - .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},reinterpret:{beginReinterpretation()},canReinterpret:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty),songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"})) + .focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty))) + .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"})) .navigationTitle(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source) .navigationSubtitle(model.currentVenueName) .searchable(text:$search,placement:.toolbar,prompt:"Search Music") @@ -958,10 +958,10 @@ struct LibraryView: View { Text(song.title).fontWeight(.medium).lineLimit(1) }.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() } }.width(min:180,ideal:220,max:600) - TableColumn("Track",value:\Song.trackOrder) { song in Text(song.trackNumber.isEmpty ? "—" : song.trackNumber).monospacedDigit().foregroundStyle(.secondary) }.width(45) - TableColumn("Rendition",value:\Song.rendition) { song in Text(song.rendition.isEmpty ? "—" : song.rendition).foregroundStyle(.secondary) }.width(min:70,ideal:100,max:220) TableColumn("Artist",value:\Song.artist) { song in Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary) }.width(min:90,ideal:110,max:240) TableColumn("Album",value:\Song.album) { song in Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary) }.width(min:85,ideal:95,max:220) + TableColumn("Track",value:\Song.trackOrder) { song in Text(song.trackNumber.isEmpty ? "—" : song.trackNumber).monospacedDigit().foregroundStyle(.secondary) }.width(45) + TableColumn("Rendition",value:\Song.rendition) { song in Text(song.rendition.isEmpty ? "—" : song.rendition).foregroundStyle(.secondary) }.width(min:70,ideal:100,max:220) TableColumn("Time",value:\Song.duration) { song in Text(durationLabel(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(50) TableColumn("Key") { song in HStack(spacing:2) { diff --git a/KJamsClone/Metadata.swift b/KJamsClone/Metadata.swift index 692c047..b82b85a 100644 --- a/KJamsClone/Metadata.swift +++ b/KJamsClone/Metadata.swift @@ -438,7 +438,7 @@ struct ReinterpretNamesSheet: View { private var previewTable: some View { Table(Array(previews.prefix(200))) { TableColumn("ID") { Text("\($0.id)").monospacedDigit().foregroundStyle(.secondary) }.width(40) - TableColumn("Source / Result") { row in VStack(alignment:.leading,spacing:3) { Text(row.input).foregroundStyle(.secondary).lineLimit(1);Text(row.after?.title ?? row.issue ?? "No match").foregroundStyle(row.after == nil ? Color.orange : Color.primary).lineLimit(2) } }.width(min:220,ideal:300) + TableColumn("Source / Result") { row in VStack(alignment:.leading,spacing:3) { Text(row.input).foregroundStyle(.secondary).lineLimit(1).help(row.input);Text(row.after?.title ?? row.issue ?? "No match").foregroundStyle(row.after == nil ? Color.orange : Color.primary).lineLimit(2) } }.width(min:220,ideal:300) TableColumn("Artist") { Text($0.after?.artist ?? "—") }.width(min:110,ideal:170) TableColumn("Album") { Text($0.after?.album ?? "—") }.width(min:75,ideal:110) TableColumn("Track") { Text($0.after?.track ?? "—").monospacedDigit() }.width(45) @@ -470,3 +470,15 @@ struct ReinterpretNamesSheet: View { Task { do { try await model.applyNameChanges(plan);dismiss() } catch { issue = error.localizedDescription } } } } + +struct NameReinterpretationCommands { + var perform: () -> Void + var enabled: Bool +} +private struct NameReinterpretationKey: FocusedValueKey { typealias Value = NameReinterpretationCommands } +extension FocusedValues { + var nameReinterpretation: NameReinterpretationCommands? { + get { self[NameReinterpretationKey.self] } + set { self[NameReinterpretationKey.self] = newValue } + } +} diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index d0b1d74..196f951 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -387,6 +387,25 @@ final class KJamsCloneTests: XCTestCase { try await model.undoNameChanges();XCTAssertFalse(model.canUndoNames);XCTAssertEqual(model.songs.first?.title,"Artist | Title [Clean]") XCTAssertEqual(model.songs.first?.id,id) } + @MainActor func testActualReinterpretationApplyButtonUpdatesSelectedSong() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let id = try XCTUnwrap(model.library).add(title:"SF285-01 - Artist - Title",audio:"/demo.mp3",cdg:nil);try model.refresh() + let host = NSHostingView(rootView:ReinterpretNamesSheet(model:model,ids:[id]).frame(width:1000,height:800).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.light)) + host.frame = NSRect(x:0,y:0,width:1000,height:800) + let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false) + window.isReleasedWhenClosed = false;window.appearance = NSAppearance(named:.aqua);window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil) + defer { window.orderOut(nil);window.contentView = nil } + try await Task.sleep(nanoseconds:500_000_000);host.layoutSubtreeIfNeeded() + if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-reinterpret-ui-test.png")) } + for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { + let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:910,y:800-678),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event) + } + for _ in 0..<100 { if model.songs.first?.title == "Title" { break };try await Task.sleep(nanoseconds:20_000_000) } + XCTAssertEqual(model.songs.first?.title,"Title");XCTAssertEqual(model.songs.first?.artist,"Artist");XCTAssertEqual(model.songs.first?.trackNumber,"01");XCTAssertTrue(model.canUndoNames) + try await Task.sleep(nanoseconds:100_000_000) + } func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } func testPresetTileXORAndInvalidCoordinates() { var d = CDGDecoder() diff --git a/README.md b/README.md index caaec22..44725b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# KJMix Pro 0.6.4 — karaoke hosting studio +# KJMix Pro 0.6.8 — karaoke hosting studio Open **KJMix Pro.xcodeproj**, choose the shared **KJMix Pro** scheme and **My Mac**, then Run. The project contains a real project.pbxproj, SwiftUI macOS app, SQLite library, XCTest target, complete icon assets and build/test/release scripts. No project generator or third-party package is required. @@ -150,3 +150,9 @@ Generate reuses valid saved waveforms. Regenerate forces fresh analysis and repl ### Import from an SMB/NAS server Open the Import Music toolbar menu → Import from SMB/NAS. Enter `smb://server/share`, click Connect in Finder, and complete macOS sign-in. Click Choose Server Folder and select the connected share under Locations. Imports copy supported files recursively into the configured managed media folder, using the normal duplicate checks, MP3/CDG pairing, progress and cancellation. Already connected shares can be selected without reconnecting. Keep the share connected until import finishes; connection or read errors stop the job and retain completed tracks. Reconnect and retry to skip completed imports. Credentials are handled by macOS, not saved by KJMix Pro. + +## Reinterpret Names + +Select songs and use **Advanced → Reinterpret Names…** to split combined names using templates such as `L-T - R - S`. Preview album, track, artist, title and rendition before applying; save custom templates and undo completed batches. Playlists provide selection/visible-song actions and stable Saved Order (PLI) or Song ID sorting. + +See [the Reinterpret Names guide](docs/REINTERPRETING.md) for folder templates, repeated fields, dash handling and original filename recovery for older imports. diff --git a/docs/REINTERPRETING.md b/docs/REINTERPRETING.md new file mode 100644 index 0000000..6b78e73 --- /dev/null +++ b/docs/REINTERPRETING.md @@ -0,0 +1,50 @@ +# Reinterpret Names + +Use this when a song name contains combined information, such as: + +`SF285-16 - Michael Bublé - Haven't Met You Yet` + +The template `L-T - R - S` extracts Album `SF285`, Track `16`, Artist `Michael Bublé`, and Title `Haven't Met You Yet`. + +## Use it + +1. Select songs in Library, then choose **Advanced → Reinterpret Names…** or use the context menu. In Playlists, select rows and choose **Reinterpret Selected…**, or choose **Reinterpret Visible Songs…** to process the current playlist/search results. +2. Choose **File Name** or **Song Name**, and select a template. +3. Review the preview. It stays in Song ID order, independently of the library or playlist sort. Large selections show the first 200 preview rows and check all selected songs. +4. A mismatch keeps Apply disabled. Choose a different template, or explicitly enable **Apply matching songs only** to update the matching subset. +5. Apply the changes. **Advanced → Undo Last Name Reinterpretation** restores the preceding batch; undo is saved across restarts. The last ten changed batches are retained. Later edits are protected from being overwritten by undo. + +The command updates library metadata. It preserves media paths, ratings, singer preferences, Tonight lists and playlist order. Embedded file tags are handled separately by Write File Tags for supported tags. + +## Fields and templates + +| Letter | Field | +| --- | --- | +| L | Album | +| T | Track number, displayed with at least two digits | +| R | Artist | +| S | Song title | +| V | Rendition/version, such as backing vocals or explicit | +| I | Ignore that part | + +Templates accept those uppercase letters, spaces, `-`, `|`, `(`, `)`, `[`, `]`, `{`, `}`, `.`, and `/`. Other characters are rejected with an explanation. Spaces around dashes are optional when matching; other spaces are separators. Extensions such as `.mp3` are omitted from File Name input. + +Examples: + +- `L-T - R - S`: disc, track, artist and title. +- `L-T - R - S (V)`: also extracts a rendition in parentheses. +- `I | T. R - S [V]`: ignores the first part and extracts a bracketed rendition. +- `/R/S`: artist from the containing folder, title from the filename. +- `/L/R/S`: album and artist from two containing folders. +- `L-T - R-R - S`: combines two artist parts with a space, for names such as Olivia Newton-John. +- `L-L-T-R-S`: combines series/disc parts into the album field. + +**Edit Templates…** saves custom templates, one per line. Built-in templates remain available. The em-dash option lets `—`, `–` and `−` act as regular dashes. The underscore option converts underscores in source names to spaces. + +## Original filenames + +New imports retain their original name and source path, including member paths inside ZIPs. This remains available after copying or moving managed media to hash-based filenames. + +For older imports whose original names were not recorded, choose **Song Name** when it still contains the combined metadata. Reimporting the original files/ZIPs backfills missing source names while retaining duplicate protection. An unchanged older ZIP may need one extraction to recover those names; its audio is not duplicated. + +Track and Rendition are visible/sortable in Library and editable in Song Info. Search with `track:`, `version:` or `rendition:`. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index dfc0c9a..be0ca6c 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -1,3 +1,19 @@ +# KJMix Pro 0.6.8 (build 19) — Reinterpret Names + +Verified on 5 November 2026. + +- Advanced → Reinterpret Names follows the active library or playlist selection. Library/playlist context menus and playlist buttons also open the dialog. Song Name and original File Name sources, built-in/custom templates, album/track/artist/title/rendition/ignore fields, repeated fields, folder templates, literal delimiters, em-dash handling and underscore handling are supported. +- All selected songs are checked asynchronously; the first 200 are shown in stable Song ID order. Unmatched rows are reported. Applying a matching subset requires an explicit toggle. Metadata updates use one SQLite transaction and one UI refresh, preserving playlist order and media files. +- Schema 11 adds track number, rendition, original-name/source-path records and persistent undo. The last ten changed batches are retained. Stale previews and undo conflicts roll back atomically rather than overwriting later edits. Ratings, singer preferences, media locations, file tags and playlist memberships are preserved. +- New imports retain original names, including ZIP member paths. Reimporting older sources can backfill missing provenance through duplicate-safe imports; older unchanged ZIPs may be extracted once for this backfill. +- Track and Rendition are visible/sortable in Library, editable in Song Info and searchable with track:/version:/rendition:. Playlist sorting includes Saved Order (PLI), Song ID, Track and Rendition. +- 101 XCTest cases passed, zero failures (`/tmp/kjmix-reinterpret-final-101-tests.log`). New tests cover provided template examples, Unicode, punctuation, repeated/ignored fields, rendition, normalization toggles, invalid templates, folder metadata, managed filename fallback, original-name backfill, persisted undo, stale preview/undo rollback and preserved singer/playlist data. A real native mouse click on Apply verified the dialog updates the selected song. +- The dialog was rendered in Light appearance with isolated sample metadata. Test-host app storage is isolated from the production library when XCTest supplies its configuration environment. +- Release signing, Apple notarization, stapling and Gatekeeper results are stored under `build-release/release-verification/KJMixPro-0.6.8-macOS/`. Only the current DMG is retained in `dist`. +- User instructions: `docs/REINTERPRETING.md`. + +--- + # KJMix Pro 0.6.7 (build 18) — external media storage and legacy ZIP filenames Verified on 4 November 2026.