diff --git a/Package.swift b/Package.swift index 194f86d..83c8f30 100644 --- a/Package.swift +++ b/Package.swift @@ -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") diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 0b87dea..168656a 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -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() } } } diff --git a/Sources/Luminate/ServerSetupView.swift b/Sources/Luminate/ServerSetupView.swift index 962e88c..7d0b367 100644 --- a/Sources/Luminate/ServerSetupView.swift +++ b/Sources/Luminate/ServerSetupView.swift @@ -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)) { diff --git a/Sources/Luminate/ToolbarView.swift b/Sources/Luminate/ToolbarView.swift index d5a3667..b5aaf79 100644 --- a/Sources/Luminate/ToolbarView.swift +++ b/Sources/Luminate/ToolbarView.swift @@ -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 diff --git a/Sources/LuminateCore/InjectionValues.swift b/Sources/LuminateCore/InjectionValues.swift index dc5d933..b00f6be 100644 --- a/Sources/LuminateCore/InjectionValues.swift +++ b/Sources/LuminateCore/InjectionValues.swift @@ -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() {} diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift index 169d969..24b7ec6 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -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, diff --git a/Sources/LuminateCore/PersistenceService+Mock.swift b/Sources/LuminateCore/PersistenceService+Mock.swift new file mode 100644 index 0000000..63cbf10 --- /dev/null +++ b/Sources/LuminateCore/PersistenceService+Mock.swift @@ -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 = [:] + } +} diff --git a/Sources/LuminateCore/PersistenceService.swift b/Sources/LuminateCore/PersistenceService.swift new file mode 100644 index 0000000..24796eb --- /dev/null +++ b/Sources/LuminateCore/PersistenceService.swift @@ -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 +} diff --git a/Sources/LuminateCore/SQLiteStore.swift b/Sources/LuminateCore/SQLiteStore.swift new file mode 100644 index 0000000..3851081 --- /dev/null +++ b/Sources/LuminateCore/SQLiteStore.swift @@ -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") + } +} diff --git a/Sources/LuminateHome/HomePosterCell.swift b/Sources/LuminateHome/HomePosterCell.swift index c912441..6cc5a35 100644 --- a/Sources/LuminateHome/HomePosterCell.swift +++ b/Sources/LuminateHome/HomePosterCell.swift @@ -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) diff --git a/Sources/LuminateHome/HomeView.swift b/Sources/LuminateHome/HomeView.swift index aa890fd..2ec1345 100644 --- a/Sources/LuminateHome/HomeView.swift +++ b/Sources/LuminateHome/HomeView.swift @@ -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() } diff --git a/Sources/LuminateHome/MediaRow.swift b/Sources/LuminateHome/MediaRow.swift index f18d8f5..9efba0c 100644 --- a/Sources/LuminateHome/MediaRow.swift +++ b/Sources/LuminateHome/MediaRow.swift @@ -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 {