Implement persistence layer
This commit is contained in:
parent
76efb1af72
commit
2411a220fe
12 changed files with 303 additions and 42 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
35
Sources/LuminateCore/PersistenceService+Mock.swift
Normal file
35
Sources/LuminateCore/PersistenceService+Mock.swift
Normal 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 = [:]
|
||||
}
|
||||
}
|
||||
33
Sources/LuminateCore/PersistenceService.swift
Normal file
33
Sources/LuminateCore/PersistenceService.swift
Normal 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
|
||||
}
|
||||
127
Sources/LuminateCore/SQLiteStore.swift
Normal file
127
Sources/LuminateCore/SQLiteStore.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue