Implement persistence layer

This commit is contained in:
Brendan Szymanski 2026-06-10 23:57:58 -04:00
parent 76efb1af72
commit 2411a220fe
12 changed files with 303 additions and 42 deletions

View file

@ -10,6 +10,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
.package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"),
],
targets: [
.target(
@ -17,6 +18,7 @@ let package = Package(
dependencies: [
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
.product(name: "SQLite", package: "SQLite.swift"),
],
plugins: [
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")

View file

@ -11,10 +11,24 @@ struct Luminate: App {
let app = AdwaitaApp(id: "dev.bscubed.Luminate")
@State private var client: JellyfinClient?
@State private var userId = ""
@State private var isLaunchLoading = true
init() {
if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) {
DIContainer.shared.register(\.persistence, value: store)
}
}
var scene: Scene {
Window(id: "main") { window in
if let client {
if isLaunchLoading {
VStack {
Spinner()
}
.onAppear {
loadSavedSession()
}
} else if let client {
ContentView(
client: client,
userId: userId
@ -24,12 +38,13 @@ struct Luminate: App {
DIContainer.shared.register(\.client, value: client)
DIContainer.shared.register(\.userId, value: id)
DIContainer.shared.register(\.imageService, value: ImageService())
self.client = client
self.userId = id
}
}
}
.defaultSize(width: 1100, height: 700)
.defaultSize(width: 1280, height: 800)
.quitShortcut()
.closeShortcut()
.keyboardShortcut("f".ctrl()) { _ in
@ -37,6 +52,31 @@ struct Luminate: App {
.keyboardShortcut("r".ctrl()) { _ in
}
}
private func loadSavedSession() {
Task {
do {
defer { isLaunchLoading = false }
let store = try SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL())
let auth = try await store.loadAuth()
guard let serverURL = URL(string: auth.serverURL) else { return }
let client = JellyfinClient(serverURL: serverURL, token: auth.token, userId: auth.userId)
let isValid = await client.validateToken()
guard isValid else { return }
DIContainer.shared.register(\.client, value: client)
DIContainer.shared.register(\.userId, value: auth.userId)
DIContainer.shared.register(\.imageService, value: ImageService())
self.client = client
self.userId = auth.userId
} catch {
isLaunchLoading = false
}
}
}
}
struct ContentView: View {
@ -59,20 +99,7 @@ struct ContentView: View {
} else {
HomeView()
.topToolbar {
HeaderBar.end {
Menu(icon: .default(icon: .openMenu)) {
MenuButton("Search", window: false) {
}
MenuSection {
MenuButton("About", window: false) {
}
}
}
.primary()
.tooltip("Main Menu")
}
ToolbarView()
}
}
}

View file

@ -63,6 +63,14 @@ public struct ServerSetupView: View {
let result = try await client.authenticate(username: username, password: password)
let userId = result.user?.value1.id ?? ""
let auth = AuthData(
serverURL: url.absoluteString,
token: result.accessToken ?? "",
userId: userId,
username: username
)
try? await DIContainer.shared.values.persistence?.saveAuth(auth)
isLoading = false
onLogin(client, userId)
} catch (JellyfinError.httpError(let code)) {

View file

@ -4,8 +4,6 @@ struct ToolbarView: View {
@State private var about = false
@State private var shortcuts = false
var app: AdwaitaApp
var window: AdwaitaWindow
var view: Body {
HeaderBar.end {
@ -28,14 +26,13 @@ struct ToolbarView: View {
.shortcutsItem(Loc.keyboardShortcuts, accelerator: "question".ctrl())
}
}
.titleWidget {
WindowTitle(subtitle: "", title: "Luminate")
}
}
var content: AnyView {
Menu(icon: .default(icon: .openMenu)) {
MenuButton(Loc.newWindow, window: false) {
app.addWindow("main")
}
.keyboardShortcut("n".ctrl())
MenuSection {
MenuButton(Loc.keyboardShortcuts, window: false) {
shortcuts = true

View file

@ -6,6 +6,7 @@ public struct InjectionValues {
public var userId: String?
public var imageService: ImageService?
public var webSocketClient: WebSocketClient?
public var persistence: PersistenceService?
public init() {}

View file

@ -85,6 +85,29 @@ public actor JellyfinClient {
)
}
public init(serverURL: URL, token: String, userId: String) {
self.serverURL = serverURL
self.token = token
self.userId = userId
self.clientConfig = Configuration(dateTranscoder: JellyfinDateTranscoder())
self.client = Client(
serverURL: serverURL,
configuration: clientConfig,
transport: URLSessionTransport(),
middlewares: [AuthMiddleware(token: token)]
)
}
public func validateToken() async -> Bool {
guard let userId else { return false }
do {
_ = try await getUserViews(userId: userId)
return true
} catch {
return false
}
}
private func makeClient(token: String) -> Client {
Client(
serverURL: serverURL,

View file

@ -0,0 +1,35 @@
import Foundation
public final class MockPersistenceService: @unchecked Sendable, PersistenceService {
private var authData: AuthData?
private var preferences: [String: String] = [:]
public init() {}
public func loadAuth() async throws -> AuthData {
guard let authData else { throw PersistenceError.notFound }
return authData
}
public func saveAuth(_ auth: AuthData) async throws {
authData = auth
}
public func clearAuth() async throws {
authData = nil
}
public func getPreference(key: String) async throws -> String? {
preferences[key]
}
public func setPreference(key: String, value: String) async throws {
preferences[key] = value
}
public func clearAll() async throws {
authData = nil
preferences = [:]
}
}

View file

@ -0,0 +1,33 @@
import Foundation
public struct AuthData: Codable, Sendable {
public let serverURL: String
public let token: String
public let userId: String
public let username: String
public init(serverURL: String, token: String, userId: String, username: String) {
self.serverURL = serverURL
self.token = token
self.userId = userId
self.username = username
}
}
public enum PersistenceError: Error {
case migrationFailed(Error)
case ioFailed(Error)
case encodingFailed
case decodingFailed
case notFound
}
public protocol PersistenceService: Sendable {
func loadAuth() async throws -> AuthData
func saveAuth(_ auth: AuthData) async throws
func clearAuth() async throws
func getPreference(key: String) async throws -> String?
func setPreference(key: String, value: String) async throws
func clearAll() async throws
}

View file

@ -0,0 +1,127 @@
import Foundation
import SQLite
extension Connection: @retroactive @unchecked Sendable {}
public actor SQLiteStore: PersistenceService {
private let db: Connection
public init(dbURL: URL) throws {
let directory = dbURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
db = try Connection(dbURL.path)
db.busyTimeout = 5
try db.execute("PRAGMA journal_mode = WAL")
try migrate()
}
private func migrate() throws {
let version = db.userVersion
switch version {
case 0:
try db.execute(
"""
CREATE TABLE IF NOT EXISTS auth (
id INTEGER PRIMARY KEY CHECK (id = 1),
server_url TEXT NOT NULL,
token TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT NOT NULL
)
"""
)
try db.execute(
"""
CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
db.userVersion = 1
fallthrough
default:
break
}
}
public static func defaultDatabaseURL() -> URL {
if #available(macOS 13, *) {
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!
return appSupport
.appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite")
} else {
#if os(Linux)
let xdgData = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]
?? "\(FileManager.default.homeDirectoryForCurrentUser.path).local/share"
return URL(fileURLWithPath: xdgData)
.appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite")
#else
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!
return appSupport
.appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite")
#endif
}
}
public func loadAuth() async throws -> AuthData {
let stmt = try db.prepare("SELECT server_url, token, user_id, username FROM auth WHERE id = 1")
for row in stmt {
guard let serverURL = row[0] as? String,
let token = row[1] as? String,
let userId = row[2] as? String,
let username = row[3] as? String
else {
throw PersistenceError.decodingFailed
}
return AuthData(
serverURL: serverURL,
token: token,
userId: userId,
username: username
)
}
throw PersistenceError.notFound
}
public func saveAuth(_ auth: AuthData) async throws {
try db.run(
"INSERT OR REPLACE INTO auth (id, server_url, token, user_id, username) VALUES (1, ?, ?, ?, ?)",
auth.serverURL, auth.token, auth.userId, auth.username
)
}
public func clearAuth() async throws {
try db.run("DELETE FROM auth WHERE id = 1")
}
public func getPreference(key: String) async throws -> String? {
let stmt = try db.prepare("SELECT value FROM preferences WHERE key = ?", key)
for row in stmt {
return row[0] as? String
}
return nil
}
public func setPreference(key: String, value: String) async throws {
try db.run(
"INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)",
key, value
)
}
public func clearAll() async throws {
try db.run("DELETE FROM auth")
try db.run("DELETE FROM preferences")
}
}

View file

@ -25,19 +25,25 @@ struct HomePosterCell: View {
.card()
}
VStack(spacing: 0) {
Text(item.name ?? "")
.ellipsize()
.heading()
.halign(.center)
.frame(maxWidth: 200)
Text(item.yearString)
.ellipsize()
.caption()
.dimLabel()
.halign(.center)
.frame(maxWidth: 200)
if let title = item.name, !title.isEmpty {
Text(item.name ?? "")
.ellipsize()
.heading()
.halign(.center)
.frame(maxWidth: 200)
}
let subtitle = item.yearString
if !subtitle.isEmpty {
Text(subtitle)
.ellipsize()
.caption()
.dimLabel()
.halign(.center)
.frame(maxWidth: 200)
}
}
.padding(4)
.padding(6, .vertical)
.padding(12, .horizontal)
}
.onAppear {
loadImage()
@ -48,13 +54,13 @@ struct HomePosterCell: View {
private func loadImage() {
guard let tag = item.primaryImageTag,
let itemId = item.id else { return }
let itemId = item.seriesId ?? item.id else { return }
Task {
guard let url = await client.imageURL(
itemId: itemId,
imageType: .primary,
tag: tag,
maxWidth: 300
maxWidth: 400
) else { return }
let service = ImageService()
imageData = try? await service.loadImage(url: url)

View file

@ -34,14 +34,14 @@ public struct HomeView: View {
title: "Continue Watching",
items: resumeItems
)
.padding(16, .bottom)
.padding(32, .bottom)
}
if !nextUpItems.isEmpty {
MediaRow(
title: "Next Up",
items: nextUpItems
)
.padding(16, .bottom)
.padding(32, .bottom)
}
if !latestItems.isEmpty {
MediaRow(
@ -49,18 +49,21 @@ public struct HomeView: View {
items: latestItems,
onSeeAll: {}
)
.padding(16, .bottom)
.padding(32, .bottom)
}
LibraryGrid(
libraries: libraries
)
.padding(32, .bottom)
}
}
.padding(8, .horizontal)
.padding(32, .bottom)
}
}
.hscrollbarPolicy(.never)
.propagateNaturalHeight()
.navigationTitle("Luminate")
.onAppear {
loadHomeData()
}

View file

@ -9,15 +9,14 @@ struct MediaRow: View {
var onSeeAll: (() -> Void)?
var view: Body {
VStack(spacing: 8) {
HStack {
VStack(spacing: 16) {
HStack(spacing: 16) {
Text(title)
.style("title-3")
if onSeeAll != nil {
Button("See All") {
onSeeAll?()
}
.style("flat")
}
}
ScrollView {