Implement local song project store
This commit is contained in:
@@ -1,6 +1,81 @@
|
|||||||
public protocol ProjectStore: Sendable {
|
import Foundation
|
||||||
associatedtype Project
|
|
||||||
|
|
||||||
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]
|
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
@@ -32,7 +32,7 @@ requirement is missing and blocks implementation, record it in
|
|||||||
|
|
||||||
## Phase 2 --- Project Persistence & Versions
|
## Phase 2 --- Project Persistence & Versions
|
||||||
|
|
||||||
- [ ] Create new/open/save song projects locally.
|
- [x] Create new/open/save song projects locally.
|
||||||
- [ ] Implement project list.
|
- [ ] Implement project list.
|
||||||
- [ ] Implement immutable or snapshot-based version history.
|
- [ ] Implement immutable or snapshot-based version history.
|
||||||
- [ ] Restore a previous version without destroying later versions.
|
- [ ] Restore a previous version without destroying later versions.
|
||||||
|
|||||||
Reference in New Issue
Block a user