Implement local song project store

This commit is contained in:
Codex
2026-09-13 13:56:59 +02:00
parent 55235ba90d
commit 60b4f1b916
3 changed files with 154 additions and 4 deletions
@@ -1,6 +1,81 @@
public protocol ProjectStore: Sendable {
associatedtype Project
import Foundation
func save(_ project: Project) async throws
public protocol ProjectStore: Sendable {
func create(_ project: SongProject) async throws
func save(_ project: SongProject) async throws
func open(id: String) async throws -> SongProject
func loadProjectIDs() async throws -> [String]
}
public actor LocalSongProjectStore: ProjectStore {
private let directoryURL: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(directoryURL: URL) {
self.directoryURL = directoryURL
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
}
public func create(_ project: SongProject) async throws {
try ensureDirectoryExists()
let url = projectURL(for: project.id)
guard !FileManager.default.fileExists(atPath: url.path) else {
throw LocalSongProjectStoreError.projectAlreadyExists(project.id)
}
try saveEncoded(project, to: url)
}
public func save(_ project: SongProject) async throws {
try ensureDirectoryExists()
try saveEncoded(project, to: projectURL(for: project.id))
}
public func open(id: String) async throws -> SongProject {
let url = projectURL(for: id)
guard FileManager.default.fileExists(atPath: url.path) else {
throw LocalSongProjectStoreError.projectNotFound(id)
}
let data = try Data(contentsOf: url)
return try decoder.decode(SongProject.self, from: data)
}
public func loadProjectIDs() async throws -> [String] {
try ensureDirectoryExists()
let fileURLs = try FileManager.default.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: nil
)
return fileURLs
.filter { $0.pathExtension == Self.fileExtension }
.map { $0.deletingPathExtension().lastPathComponent }
.sorted()
}
private func ensureDirectoryExists() throws {
try FileManager.default.createDirectory(
at: directoryURL,
withIntermediateDirectories: true
)
}
private func saveEncoded(_ project: SongProject, to url: URL) throws {
let data = try encoder.encode(project)
try data.write(to: url, options: [.atomic])
}
private func projectURL(for id: String) -> URL {
directoryURL
.appendingPathComponent(id)
.appendingPathExtension(Self.fileExtension)
}
private static let fileExtension = "json"
}
public enum LocalSongProjectStoreError: Error, Equatable, Sendable {
case projectAlreadyExists(String)
case projectNotFound(String)
}
@@ -0,0 +1,75 @@
import Foundation
import MusicAssistantCore
import XCTest
final class LocalSongProjectStoreTests: XCTestCase {
private var temporaryDirectoryURL: URL!
override func setUpWithError() throws {
temporaryDirectoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("music-assistant-store-tests")
.appendingPathComponent(UUID().uuidString)
}
override func tearDownWithError() throws {
if let temporaryDirectoryURL {
try? FileManager.default.removeItem(at: temporaryDirectoryURL)
}
temporaryDirectoryURL = nil
}
func testCreatesOpensSavesAndListsSongProjectsLocally() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
var project = SongProject(
id: "local-project",
title: "Local Project",
idea: "Persist this song locally",
genres: [GenreStyle(id: "pop", name: "Pop")]
)
try await store.create(project)
let createdProjectIDs = try await store.loadProjectIDs()
let openedProject = try await store.open(id: project.id)
XCTAssertEqual(createdProjectIDs, ["local-project"])
XCTAssertEqual(openedProject, project)
project.title = "Updated Local Project"
project.setBPMManually(100)
try await store.save(project)
let updatedProject = try await store.open(id: project.id)
XCTAssertEqual(updatedProject, project)
}
func testCreateDoesNotOverwriteExistingProject() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let project = SongProject(
id: "duplicate-project",
title: "Duplicate",
idea: "Do not overwrite"
)
try await store.create(project)
do {
try await store.create(project)
XCTFail("Expected duplicate project creation to fail.")
} catch let error as LocalSongProjectStoreError {
XCTAssertEqual(error, .projectAlreadyExists(project.id))
}
}
func testOpenMissingProjectThrowsNotFound() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
do {
_ = try await store.open(id: "missing")
XCTFail("Expected missing project open to fail.")
} catch let error as LocalSongProjectStoreError {
XCTAssertEqual(error, .projectNotFound("missing"))
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ requirement is missing and blocks implementation, record it in
## Phase 2 --- Project Persistence & Versions
- [ ] Create new/open/save song projects locally.
- [x] Create new/open/save song projects locally.
- [ ] Implement project list.
- [ ] Implement immutable or snapshot-based version history.
- [ ] Restore a previous version without destroying later versions.