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
37 changes: 37 additions & 0 deletions Sources/AdaptiveSound/PlaylistsModel+DeadFiles.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import LibraryStore

// MARK: - PlaylistsModel + dead/missing-file resolution (F)

/// Missing-file resolution helpers, split from `PlaylistsModel` for type-body length. The mutating
/// verbs (`removeMissingEntries` / `relocateEntry`) stay in the main file (they set `actionError`,
/// whose setter is file-private); these are stateless / read-only.
extension PlaylistsModel {
/// Off-main file-existence resolution (F): entry ids whose track resolved AND whose file exists.
/// `PlaylistEntry` / `LibraryTrackDisplay` are `Sendable`, so the `fileExists` stat loop runs
/// detached at `.utility` off the main actor (a large playlist mustn't jank the open).
static func availableEntryIDs(entries: [PlaylistEntry],
displays: [Int64: LibraryTrackDisplay]) async -> Set<Int64> {
await Task.detached(priority: .utility) {
var available = Set<Int64>()
for entry in entries {
if let url = displays[entry.trackID]?.url, FileManager.default.fileExists(atPath: url.path) {
available.insert(entry.id)
}
}
return available
}.value
}

/// Count of unavailable (missing-file) entries in the open detail — drives the "Remove missing"
/// affordance + the count-line suffix.
var missingEntryCount: Int {
detail.count { !$0.isAvailable }
}

/// Whether a scan / metadata pass / reconcile is populating the library — availability is a live
/// `fileExists` snapshot that flickers "missing" mid-scan, so bulk "Remove missing" is gated off it.
var isLibraryPopulating: Bool {
library.scanProgress != nil || library.metadataProgress != nil || library.isReconciling
}
}
66 changes: 62 additions & 4 deletions Sources/AdaptiveSound/PlaylistsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ final class PlaylistsModel {
private(set) var detailState: LoadState = .idle
private var detailEpoch = 0

/// A transient per-ACTION error (Locate / Remove-missing) shown as an alert — never routed through
/// `detailState` (a failed row-action mustn't blow the whole pane into load-error; F review).
private(set) var actionError: String?

/// The `library.libraryRevision` last folded in, so `reloadOnLibraryChange` refreshes exactly
/// once per library-content change (a track deletion CASCADE-drops entries → counts move).
private var lastLoadedRevision = 0
Expand Down Expand Up @@ -213,8 +217,14 @@ final class PlaylistsModel {
do {
let entries = try await store.entries(inPlaylist: id)
let byID = try await store.tracksDisplay(ids: entries.map(\.trackID))
// File-existence is a disk stat per row — resolve it OFF the main actor so a large
// playlist doesn't jank (F). A resolved track whose file is gone = "unavailable".
let availableIDs = await Self.availableEntryIDs(entries: entries, displays: byID)
guard epoch == detailEpoch else { return } // a newer open/reload superseded this one
detail = entries.map { PlaylistDetailEntry(entry: $0, display: byID[$0.trackID]) }
detail = entries.map {
PlaylistDetailEntry(entry: $0, display: byID[$0.trackID],
isAvailable: availableIDs.contains($0.id))
}
detailState = detail.isEmpty ? .empty : .loaded
} catch {
guard epoch == detailEpoch else { return }
Expand All @@ -240,10 +250,11 @@ final class PlaylistsModel {

// MARK: - Play verbs (delegate to AudioViewModel; C)

/// Resolved (playable) entries of the open playlist, in order. Unresolved (missing) entries are
/// dropped — that IS skip-on-play for C; Chunk F adds the unavailable badge + Locate.
/// Playable entries of the open playlist, in order — the AVAILABLE ones (track resolved AND file
/// on disk). Unavailable (missing-file) entries are SKIPPED on play, never halting (F); the UI
/// still shows them, badged, with Locate / Remove.
private var resolvedEntries: [PlaylistDetailEntry] {
detail.filter { $0.display != nil }
detail.filter(\.isAvailable)
}

private func playableFiles() -> [AudioFile] {
Expand Down Expand Up @@ -322,6 +333,49 @@ final class PlaylistsModel {
detailState = .failed(error.localizedDescription)
}
}

// MARK: - Dead/missing-file handling (F)

/// Remove ALL unavailable (missing-file) entries from the open playlist (bulk "Remove missing").
/// Returns the count removed; reloads detail + tree.
@discardableResult
func removeMissingEntries() async -> Int {
guard let store, let playlistID = openPlaylistID else { return 0 }
let missing = detail.filter { !$0.isAvailable }.map(\.entry.id)
guard !missing.isEmpty else { return 0 }
do {
try await store.removeEntries(ids: missing, playlistID: playlistID)
await loadDetail(id: playlistID)
await loadTree()
return missing.count
} catch {
actionError = "Couldn’t remove the missing tracks: \(error.localizedDescription)"
return 0
}
}

func clearActionError() {
actionError = nil
}

/// Locate: re-point a missing entry's TRACK to a user-chosen file, preserving the track id (so
/// every playlist referencing it is fixed at once), then reload so it resolves. Reuses the
/// id-preserving `moveTrack` seam; the file becomes loose (`folder_id = nil`) until a rescan
/// re-associates it — a full metadata re-scan is out of F scope. A URL already owned by another
/// track throws `URLConflict` (caught → surfaced via `detailState`).
func relocateEntry(_ entryID: Int64, to url: URL) async {
guard let store, let playlistID = openPlaylistID,
let trackID = detail.first(where: { $0.id == entryID })?.entry.trackID else { return }
do {
try await store.moveTrack(id: trackID, newURL: url, newFolderID: nil, newRelativePath: "")
await loadDetail(id: playlistID)
} catch is URLConflict {
// The picked file is already another track's — a per-row failure, NOT a pane-wide error.
actionError = "That file is already in your library under a different track."
} catch {
actionError = "Couldn’t relocate the file: \(error.localizedDescription)"
}
}
}

// MARK: - Detail row model
Expand All @@ -333,6 +387,10 @@ final class PlaylistsModel {
struct PlaylistDetailEntry: Identifiable {
let entry: PlaylistEntry
let display: LibraryTrackDisplay?
/// True when the track resolved AND its file exists on disk (S10.3 F). False = "unavailable":
/// the file moved/was deleted (a loose file gone, or a move not yet reconciled) or the track row
/// is absent — the row is badged, SKIPPED on play (never halts), and offered Locate / Remove.
let isAvailable: Bool

var id: Int64 {
entry.id
Expand Down
1 change: 0 additions & 1 deletion Sources/AdaptiveSound/UI/Library/SongsTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import SwiftUI
/// (show/hide/reorder/resize + persistence, §11) via `columnCustomization`.
struct SongsTable: View {
@Environment(LibraryBrowseModel.self) private var model
@Environment(PlaylistsModel.self) private var playlists
@State private var selection = Set<LibraryTrackDisplay.ID>()
/// The track whose Info popover is open (mirrors the album-detail affordance).
@State private var infoTarget: LibraryTrackDisplay?
Expand Down
94 changes: 94 additions & 0 deletions Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView+Actions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import SwiftUI

// MARK: - PlaylistDetailView + actions (split out for type-body length)

/// Play / reorder / keyboard-nav / restore-toast actions for the playlist detail, plus the
/// missing-file (F) helpers. A same-type extension, split from `PlaylistDetailView` for type-body
/// length; reaches its `internal` state.
extension PlaylistDetailView {
/// Locate / Remove for a missing entry — shared by the badge menu, the row context menu, and the
/// VoiceOver rotor so all three stay in sync.
@ViewBuilder
func missingRowActions(_ row: PlaylistDetailEntry) -> some View {
Button("Locate…") { locatingEntryID = row.id }
Button("Remove from Playlist", role: .destructive) {
Task { await model.removeEntry(row.id) }
}
}

/// The "N file(s) missing" help string (real pluralization) for the header affordance.
var missingHelp: String {
let count = model.missingEntryCount
return "\(count) \(count == 1 ? "file" : "files") missing"
}

/// Play the playlist (replace queue, undoable), optionally from a tapped row, and raise the
/// transient restore-queue affordance — ONLY if a replace actually happened (an all-unavailable
/// playlist no-ops, and must not resurface a stale toast from an earlier real Play).
func playNow(startingAt entryID: Int64? = nil) {
if model.playPlaylist(startingAt: entryID) { raiseRestoreToast() }
}

func raiseRestoreToast() {
let token = (restoreToastToken ?? 0) &+ 1
restoreToastToken = token
restoreDismissTask?.cancel()
restoreDismissTask = Task { [token] in
try? await Task.sleep(for: .seconds(6))
guard !Task.isCancelled, restoreToastToken == token else { return }
restoreToastToken = nil
}
}

func dismissRestoreToast() {
restoreDismissTask?.cancel()
restoreToastToken = nil
}

/// Move `fromID` to land AT `toEntryID`'s row and persist the new order — matching the queue's
/// convention (`AudioViewModel.moveByDrop`): dragging DOWN inserts AFTER the target, UP before
/// it, so the last slot is reachable and the two lists behave identically.
func moveEntry(fromID: Int64, toEntryID: Int64) -> Bool {
var ids = model.detail.map(\.id)
guard let from = ids.firstIndex(of: fromID), let to = ids.firstIndex(of: toEntryID),
from != to else { return false }
ids.move(fromOffsets: IndexSet(integer: from), toOffset: from < to ? to + 1 : to)
Task { await model.reorderEntries(ids) }
return true
}

/// ↑/↓ traverse only the AVAILABLE (playable) rows — the "unavailable" (missing-file) rows are
/// non-interactive except via their context menu (Locate / Remove), so selection skips them (F).
func moveSelection(by delta: Int) -> KeyPress.Result {
let ids = model.detail.filter(\.isAvailable).map(\.id)
guard !ids.isEmpty else { return .ignored }
guard let current = selectedEntryID, let index = ids.firstIndex(of: current) else {
selectedEntryID = ids.first
return .handled
}
let next = index + delta
guard next >= 0, next < ids.count else { return .ignored }
selectedEntryID = ids[next]
return .handled
}

func playSelected() -> KeyPress.Result {
guard let id = selectedEntryID else { return .ignored }
playNow(startingAt: id)
return .handled
}

func removeSelected() -> KeyPress.Result {
guard let id = selectedEntryID else { return .ignored }
// Pre-select the neighbor (next, else previous) so selection lands there — not back at the
// top — once the async remove + reload lands. Use the SAME available-row set `moveSelection`
// traverses, so the neighbor is a selectable row (not an unavailable placeholder).
let ids = model.detail.filter(\.isAvailable).map(\.id)
if let index = ids.firstIndex(of: id) {
selectedEntryID = index + 1 < ids.count ? ids[index + 1]
: (index - 1 >= 0 ? ids[index - 1] : nil)
}
Task { await model.removeEntry(id) }
return .handled
}
}
Loading
Loading