diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 4fc99eee8..0c74b0314 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -818,6 +818,7 @@ C3FA301E20E0024900393DDA /* BPArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FA301D20E0024900393DDA /* BPArtworkView.swift */; }; C3FE3F8220A090880055B9C6 /* limitPanAngle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FE3F8120A090880055B9C6 /* limitPanAngle.swift */; }; C451596D6866C856F3E5F7D1 /* IntegrationConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D61F68F0A174C7CA44FE8A /* IntegrationConnectionView.swift */; }; + B14881000000000000000001 /* IntegrationCustomHeadersSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */; }; C53864BEFAE4D1CEC668A6B3 /* IntegrationLibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F0D505E060FDAEB6DB68E3 /* IntegrationLibraryListView.swift */; }; CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */; }; D080B0A77D9844C3A0737170 /* IntegrationConnectionFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */; }; @@ -1226,6 +1227,7 @@ 41FCA32625E87EC600BFB9E6 /* Audiobook Player 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Audiobook Player 4.xcdatamodel"; sourceTree = ""; }; 424AE6DFF6B641DB69DF3D78 /* IntegrationAudiobookDetailsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationAudiobookDetailsView.swift; sourceTree = ""; }; 43D61F68F0A174C7CA44FE8A /* IntegrationConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionView.swift; sourceTree = ""; }; + B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationCustomHeadersSectionView.swift; sourceTree = ""; }; 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeInlineTip.swift; sourceTree = ""; }; 465D87512D3195D600A4AA47 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; @@ -1835,6 +1837,7 @@ 35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */, A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */, 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */, + B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */, ); path = "Connection Screen"; sourceTree = ""; @@ -4312,6 +4315,7 @@ buildActionMask = 2147483647; files = ( C451596D6866C856F3E5F7D1 /* IntegrationConnectionView.swift in Sources */, + B14881000000000000000001 /* IntegrationCustomHeadersSectionView.swift in Sources */, 98BA9BA4D6A94BC8BCCE5F8B /* IntegrationSettingsView.swift in Sources */, 66DF1F3E6AFECB623A558F04 /* IntegrationDisconnectedView.swift in Sources */, 17239057BBE31E405AFFBBCD /* IntegrationServerFoundView.swift in Sources */, @@ -5527,7 +5531,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = BookPlayer; @@ -5536,7 +5540,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; - WATCHOS_DEPLOYMENT_TARGET = 7.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -5584,7 +5588,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = BookPlayer; SDKROOT = iphoneos; @@ -5593,7 +5597,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 7.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; @@ -6132,7 +6136,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = BookPlayer; SDKROOT = iphoneos; @@ -6141,7 +6145,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 7.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Beta; }; diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index 5bd4e372c..21ead5284 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -374,13 +374,12 @@ private struct AudiobookShelfTabRoot: View { .tint(theme.linkColor) .sheet(isPresented: $showConnectionDetails) { NavigationStack { - IntegrationSettingsView( - viewModel: AudiobookShelfConnectionViewModel( + IntegrationSettingsView(integrationName: "AudiobookShelf") { + AudiobookShelfConnectionViewModel( connectionService: connectionService, mode: .viewDetails - ), - integrationName: "AudiobookShelf" - ) + ) + } .toolbar { if connectionService.connection == nil { ToolbarItemGroup(placement: .cancellationAction) { diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift index 7e906e4c7..f97772060 100644 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift +++ b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift @@ -29,7 +29,12 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro let form = IntegrationConnectionFormViewModel() if let data = connectionService.connection { - form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + form.setValues( + url: data.url.absoluteString, + serverName: data.serverName, + userName: data.userName, + customHeaders: data.customHeaders + ) self._connectionState = .init(initialValue: .connected) } else { self._connectionState = .init(initialValue: .disconnected) @@ -40,7 +45,10 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro @MainActor func handleConnectAction() async throws { - let serverName = try await connectionService.pingServer(at: form.serverUrl) + let serverName = try await connectionService.pingServer( + at: form.serverUrl, + customHeaders: form.customHeadersDictionary() + ) connectionState = .foundServer form.serverName = serverName } @@ -52,7 +60,8 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro username: form.username, password: form.password, serverUrl: form.serverUrl, - serverName: form.serverName + serverName: form.serverName, + customHeaders: form.customHeadersDictionary() ) connectionState = .connected @@ -69,4 +78,9 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro form = IntegrationConnectionFormViewModel() connectionState = .disconnected } + + @MainActor + func handleCustomHeadersUpdate() { + connectionService.updateCustomHeaders(form.customHeadersDictionary()) + } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift index 37bc42c3d..409334f52 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift @@ -21,6 +21,7 @@ struct AudiobookShelfLibraryItemImageView: View { AudiobookShelfLibraryItemImageViewWrapper( item: item, url: connectionService.createItemImageURL(item, size: imageSize), + customHeaders: connectionService.connection?.customHeaders ?? [:], imageSize: imageSize ) .cornerRadius(max(3, min(proxy.size.width, proxy.size.height) * 0.02)) @@ -33,6 +34,7 @@ struct AudiobookShelfLibraryItemImageView: View { fileprivate struct AudiobookShelfLibraryItemImageViewWrapper: View, Equatable { let item: AudiobookShelfLibraryItem let url: URL? + let customHeaders: [String: String] let imageSize: CGSize @EnvironmentObject var themeViewModel: ThemeViewModel @@ -40,6 +42,13 @@ fileprivate struct AudiobookShelfLibraryItemImageViewWrapper: View, Equatable { var body: some View { KFImage .url(url) + .requestModifier(AnyModifier { request in + var request = request + for (key, value) in customHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + return request + }) .cancelOnDisappear(true) .cacheMemoryOnly() .resizable() @@ -48,7 +57,9 @@ fileprivate struct AudiobookShelfLibraryItemImageViewWrapper: View, Equatable { } static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.item.id == rhs.item.id && lhs.url == rhs.url + return lhs.item.id == rhs.item.id + && lhs.url == rhs.url + && lhs.customHeaders == rhs.customHeaders } @ViewBuilder diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift index 9844ed8b4..b7cbce1b4 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift @@ -225,18 +225,18 @@ final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, self.items.first(where: { $0.id == id && $0.isDownloadable }) } - var urls = [URL]() + var requests = [URLRequest]() for item in items { do { - let url = try connectionService.createItemDownloadUrl(item) - urls.append(url) + let request = try connectionService.createItemDownloadRequest(item) + requests.append(request) } catch { self.error = error } } - guard !urls.isEmpty else { return } - singleFileDownloadService.handleDownload(urls) + guard !requests.isEmpty else { return } + singleFileDownloadService.handleDownload(requests) navigation.dismiss?() } diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift index d5d620912..1eb2d503e 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift @@ -62,7 +62,7 @@ class AudiobookShelfAudiobookDetailsViewModel: IntegrationDetailsViewModelProtoc @MainActor func beginDownloadAudiobook(_ item: AudiobookShelfLibraryItem) throws { - let url = try connectionService.createItemDownloadUrl(item) - singleFileDownloadService.handleDownload(url) + let request = try connectionService.createItemDownloadRequest(item) + singleFileDownloadService.handleDownload(request) } } diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift index 30669cc00..b301a8b64 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift @@ -15,6 +15,40 @@ struct AudiobookShelfConnectionData: Codable { let userName: String let apiToken: String var selectedLibraryId: String? + var customHeaders: [String: String] = [:] + + enum CodingKeys: String, CodingKey { + case url, serverName, userID, userName, apiToken, selectedLibraryId, customHeaders + } + + init( + url: URL, + serverName: String, + userID: String, + userName: String, + apiToken: String, + selectedLibraryId: String? = nil, + customHeaders: [String: String] = [:] + ) { + self.url = url + self.serverName = serverName + self.userID = userID + self.userName = userName + self.apiToken = apiToken + self.selectedLibraryId = selectedLibraryId + self.customHeaders = customHeaders + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.url = try container.decode(URL.self, forKey: .url) + self.serverName = try container.decode(String.self, forKey: .serverName) + self.userID = try container.decode(String.self, forKey: .userID) + self.userName = try container.decode(String.self, forKey: .userName) + self.apiToken = try container.decode(String.self, forKey: .apiToken) + self.selectedLibraryId = try container.decodeIfPresent(String.self, forKey: .selectedLibraryId) + self.customHeaders = try container.decodeIfPresent([String: String].self, forKey: .customHeaders) ?? [:] + } } extension AudiobookShelfConnectionData: CustomDebugStringConvertible { diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift index 493353358..5dd90b963 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift @@ -29,7 +29,10 @@ class AudiobookShelfConnectionService: BPLogger { } /// Pings the server to verify it exists and returns the server version - public func pingServer(at absolutePath: String) async throws -> String { + public func pingServer( + at absolutePath: String, + customHeaders: [String: String] = [:] + ) async throws -> String { guard let url = URL(string: absolutePath) else { throw IntegrationError.urlMalformed(nil) } @@ -39,6 +42,7 @@ class AudiobookShelfConnectionService: BPLogger { var request = URLRequest(url: pingURL) request.httpMethod = "GET" request.timeoutInterval = 10 + applyCustomHeaders(to: &request, headers: customHeaders) let (data, response) = try await urlSession.data(for: request) @@ -68,7 +72,8 @@ class AudiobookShelfConnectionService: BPLogger { username: String, password: String, serverUrl: String, - serverName: String + serverName: String, + customHeaders: [String: String] = [:] ) async throws { guard let url = URL(string: serverUrl) else { throw IntegrationError.urlMalformed(nil) @@ -77,6 +82,7 @@ class AudiobookShelfConnectionService: BPLogger { let loginURL = url.appendingPathComponent("login") var request = URLRequest(url: loginURL) request.httpMethod = "POST" + applyCustomHeaders(to: &request, headers: customHeaders) request.setValue("application/json", forHTTPHeaderField: "Content-Type") let credentials = ["username": username, "password": password] @@ -109,7 +115,8 @@ class AudiobookShelfConnectionService: BPLogger { serverName: serverName, userID: userID, userName: username, - apiToken: apiToken + apiToken: apiToken, + customHeaders: customHeaders ) try keychainService.set( @@ -120,6 +127,13 @@ class AudiobookShelfConnectionService: BPLogger { self.connection = connectionData } + func updateCustomHeaders(_ headers: [String: String]) { + guard var data = connection else { return } + data.customHeaders = headers + connection = data + try? keychainService.set(data, key: .audiobookshelfConnection) + } + func saveSelectedLibrary(id: String?) { guard var data = connection else { return } data.selectedLibraryId = id @@ -146,7 +160,7 @@ class AudiobookShelfConnectionService: BPLogger { .appendingPathComponent("api") .appendingPathComponent("libraries") var request = URLRequest(url: url) - request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + applyAuthenticatedHeaders(to: &request, connection: connection) let (data, response) = try await urlSession.data(for: request) @@ -215,7 +229,7 @@ class AudiobookShelfConnectionService: BPLogger { } var request = URLRequest(url: url) - request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + applyAuthenticatedHeaders(to: &request, connection: connection) let (data, response) = try await urlSession.data(for: request) @@ -247,7 +261,7 @@ class AudiobookShelfConnectionService: BPLogger { .appendingPathComponent("filterdata") var request = URLRequest(url: url) - request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + applyAuthenticatedHeaders(to: &request, connection: connection) let (data, response) = try await urlSession.data(for: request) @@ -290,7 +304,7 @@ class AudiobookShelfConnectionService: BPLogger { } var request = URLRequest(url: url) - request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + applyAuthenticatedHeaders(to: &request, connection: connection) let (data, response) = try await urlSession.data(for: request) @@ -318,7 +332,7 @@ class AudiobookShelfConnectionService: BPLogger { .appendingPathComponent(id) var request = URLRequest(url: url) - request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + applyAuthenticatedHeaders(to: &request, connection: connection) let (data, response) = try await urlSession.data(for: request) @@ -371,7 +385,7 @@ class AudiobookShelfConnectionService: BPLogger { } var request = URLRequest(url: url) - request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + applyAuthenticatedHeaders(to: &request, connection: connection) let (data, response) = try await urlSession.data(for: request) @@ -401,7 +415,7 @@ class AudiobookShelfConnectionService: BPLogger { .appending(queryItems: [URLQueryItem(name: "expanded", value: "1")]) var request = URLRequest(url: url) - request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + applyAuthenticatedHeaders(to: &request, connection: connection) let (data, response) = try await urlSession.data(for: request) @@ -432,6 +446,24 @@ class AudiobookShelfConnectionService: BPLogger { .appending(queryItems: [URLQueryItem(name: "token", value: connection.apiToken)]) } + /// Returns a URLRequest for downloading a library item, carrying the user-defined + /// custom HTTP headers (needed for servers behind Cloudflare Access etc.). + public func createItemDownloadRequest(_ item: AudiobookShelfLibraryItem) throws -> URLRequest { + guard connection != nil else { + throw URLError(.userAuthenticationRequired) + } + let url = try createItemDownloadUrl(item) + return wrapWithCustomHeaders(url) + } + + /// Wraps an arbitrary URL (e.g. a cover image or stream URL) in a URLRequest that carries + /// the current connection's custom HTTP headers. + public func wrapWithCustomHeaders(_ url: URL) -> URLRequest { + var request = URLRequest(url: url) + applyCustomHeaders(to: &request, headers: connection?.customHeaders ?? [:]) + return request + } + private func reloadConnection() { guard let storedConnection: AudiobookShelfConnectionData = try? keychainService.get(.audiobookshelfConnection), @@ -448,6 +480,23 @@ class AudiobookShelfConnectionService: BPLogger { return !data.userID.isEmpty && !data.apiToken.isEmpty } + /// Apply user-defined custom headers (e.g. Cloudflare Access Service Tokens) to an outgoing request. + /// Called before integration-specific headers (Authorization, Content-Type) so the integration's + /// own values always win on conflict. + private func applyCustomHeaders(to request: inout URLRequest, headers: [String: String]) { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + + private func applyAuthenticatedHeaders( + to request: inout URLRequest, + connection: AudiobookShelfConnectionData + ) { + applyCustomHeaders(to: &request, headers: connection.customHeaders) + request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + } + /// Creates an image URL for a library item public func createItemImageURL(_ item: AudiobookShelfLibraryItem, size: CGSize) -> URL? { guard let connection = connection else { return nil } diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index 32f5f20f8..c22bbf52c 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -348,6 +348,11 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_username_placeholder" = "Username"; "integration_password_placeholder" = "Password"; "integration_password_remember_me_label" = "Remember Me"; +"integration_custom_headers_title" = "Custom HTTP Headers"; +"integration_custom_headers_footer" = "Headers added here are attached to every request sent to this server."; +"integration_custom_headers_key_placeholder" = "Header name"; +"integration_custom_headers_value_placeholder" = "Header value"; +"integration_custom_headers_add_button" = "Add Header"; "settings_integration_manage_connection_title" = "Manage Connection"; "integration_internal_error_invalid_url" = "Internal error: Request URL is invalid: %@"; "integration_internal_error_build_url" = "Internal error: Failed to build request URL"; diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift index 9784e2e23..af5e6ffa5 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift +++ b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift @@ -31,7 +31,12 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, let form = IntegrationConnectionFormViewModel() if let data = connectionService.connection { - form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + form.setValues( + url: data.url.absoluteString, + serverName: data.serverName, + userName: data.userName, + customHeaders: data.customHeaders + ) self._connectionState = .init(initialValue: .connected) } else { self._connectionState = .init(initialValue: .disconnected) @@ -42,7 +47,10 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, @MainActor func handleConnectAction() async throws { - let serverName = try await connectionService.findServer(at: form.serverUrl) + let serverName = try await connectionService.findServer( + at: form.serverUrl, + customHeaders: form.customHeadersDictionary() + ) connectionState = .foundServer form.serverName = serverName } @@ -53,7 +61,8 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, try await connectionService.signIn( username: form.username, password: form.password, - serverName: form.serverName + serverName: form.serverName, + customHeaders: form.customHeadersDictionary() ) connectionState = .connected @@ -75,4 +84,9 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, form = IntegrationConnectionFormViewModel() connectionState = .disconnected } + + @MainActor + func handleCustomHeadersUpdate() { + connectionService.updateCustomHeaders(form.customHeadersDictionary()) + } } diff --git a/BookPlayer/Jellyfin/JellyfinRootView.swift b/BookPlayer/Jellyfin/JellyfinRootView.swift index 07c94aee7..9d501f36b 100644 --- a/BookPlayer/Jellyfin/JellyfinRootView.swift +++ b/BookPlayer/Jellyfin/JellyfinRootView.swift @@ -371,13 +371,12 @@ private struct JellyfinTabRoot: View { private var connectionDetailsSheet: some View { NavigationStack { - IntegrationSettingsView( - viewModel: JellyfinConnectionViewModel( + IntegrationSettingsView(integrationName: "Jellyfin") { + JellyfinConnectionViewModel( connectionService: connectionService, mode: .viewDetails - ), - integrationName: "Jellyfin" - ) + ) + } .toolbar { if connectionService.connection == nil { ToolbarItemGroup(placement: .cancellationAction) { @@ -589,13 +588,12 @@ extension JellyfinTabRoot { dismissAll: DismissAction? = nil ) -> some View { NavigationStack { - IntegrationSettingsView( - viewModel: JellyfinConnectionViewModel( + IntegrationSettingsView(integrationName: "Jellyfin") { + JellyfinConnectionViewModel( connectionService: connectionService, mode: .viewDetails - ), - integrationName: "Jellyfin" - ) + ) + } .toolbar { if connectionService.connection == nil { ToolbarItemGroup(placement: .cancellationAction) { diff --git a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift index 242c37730..8e6d430e0 100644 --- a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift +++ b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift @@ -92,7 +92,7 @@ class JellyfinAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { @MainActor func beginDownloadAudiobook(_ item: JellyfinLibraryItem) throws { - let url = try connectionService.createItemDownloadUrl(item) - singleFileDownloadService.handleDownload(url) + let request = try connectionService.createItemDownloadRequest(item) + singleFileDownloadService.handleDownload(request) } } diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift index 4480b93b8..762b8e33e 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift @@ -23,6 +23,7 @@ struct JellyfinLibraryItemImageView: View { JellyfinLibraryItemImageViewWrapper( item: item, url: try? connectionService.createItemImageURL(item, size: imageSize), + customHeaders: connectionService.connection?.customHeaders ?? [:], imageSize: imageSize, aspectRatio: aspectRatio ) @@ -36,6 +37,7 @@ struct JellyfinLibraryItemImageView: View { fileprivate struct JellyfinLibraryItemImageViewWrapper: View, Equatable { let item: JellyfinLibraryItem let url: URL? + let customHeaders: [String: String] let imageSize: CGSize let aspectRatio: CGFloat? @@ -44,6 +46,14 @@ fileprivate struct JellyfinLibraryItemImageViewWrapper: View, Equatable { var body: some View { KFImage .url(url) + .requestModifier(AnyModifier { request in + var request = request + for (key, value) in customHeaders + where key.caseInsensitiveCompare("Authorization") != .orderedSame { + request.setValue(value, forHTTPHeaderField: key) + } + return request + }) .cancelOnDisappear(true) .cacheMemoryOnly() .resizable() @@ -52,7 +62,9 @@ fileprivate struct JellyfinLibraryItemImageViewWrapper: View, Equatable { } static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.item.id == rhs.item.id && lhs.url == rhs.url + return lhs.item.id == rhs.item.id + && lhs.url == rhs.url + && lhs.customHeaders == rhs.customHeaders } @ViewBuilder diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift index 3f4bbbe1f..21be5af3c 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift @@ -286,16 +286,16 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog self.items.first(where: { $0.id == id }) }) - var urls = [URL]() + var requests = [URLRequest]() for item in items { do { - let url = try connectionService.createItemDownloadUrl(item) - urls.append(url) + let request = try connectionService.createItemDownloadRequest(item) + requests.append(request) } catch { self.error = error } } - singleFileDownloadService.handleDownload(urls) + singleFileDownloadService.handleDownload(requests) navigation.dismiss?() } @@ -303,7 +303,7 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog func onDownloadFolderTapped() { showingDownloadConfirmation = true } - + @MainActor func confirmDownloadFolder() { guard let folderID else { return } @@ -312,24 +312,24 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog guard let self else { return } do { - let urls = try await self.getAllAudiobookDownloadURLs(for: folderID) - self.singleFileDownloadService.handleDownload(urls, folderName: self.navigationTitle) + let requests = try await self.getAllAudiobookDownloadRequests(for: folderID) + self.singleFileDownloadService.handleDownload(requests, folderName: self.navigationTitle) self.navigation.dismiss?() } catch { self.error = error } } } - + @MainActor - private func getAllAudiobookDownloadURLs(for folderID: String) async throws -> [URL] { + private func getAllAudiobookDownloadRequests(for folderID: String) async throws -> [URLRequest] { if items.count == totalItems { let audiobooks = items.filter { $0.kind == .audiobook } return audiobooks.compactMap { audiobook in - try? connectionService.createItemDownloadUrl(audiobook) + try? connectionService.createItemDownloadRequest(audiobook) } } else { - return try await connectionService.fetchAudiobookDownloadURLs(for: folderID) + return try await connectionService.fetchAudiobookDownloadRequests(for: folderID) } } } @@ -453,17 +453,17 @@ final class JellyfinAuthorBooksViewModel: IntegrationLibraryViewModelProtocol, B items.first(where: { $0.id == id && $0.isDownloadable }) } guard !downloadItems.isEmpty else { return } - var urls = [URL]() + var requests = [URLRequest]() for item in downloadItems { do { - let url = try connectionService.createItemDownloadUrl(item) - urls.append(url) + let request = try connectionService.createItemDownloadRequest(item) + requests.append(request) } catch { self.error = error } } - guard !urls.isEmpty else { return } - singleFileDownloadService.handleDownload(urls) + guard !requests.isEmpty else { return } + singleFileDownloadService.handleDownload(requests) navigation.dismiss?() } @@ -601,17 +601,17 @@ final class JellyfinNarratorBooksViewModel: IntegrationLibraryViewModelProtocol, items.first(where: { $0.id == id && $0.isDownloadable }) } guard !downloadItems.isEmpty else { return } - var urls = [URL]() + var requests = [URLRequest]() for item in downloadItems { do { - let url = try connectionService.createItemDownloadUrl(item) - urls.append(url) + let request = try connectionService.createItemDownloadRequest(item) + requests.append(request) } catch { self.error = error } } - guard !urls.isEmpty else { return } - singleFileDownloadService.handleDownload(urls) + guard !requests.isEmpty else { return } + singleFileDownloadService.handleDownload(requests) navigation.dismiss?() } diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift index 90e66d844..264336f98 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift @@ -15,6 +15,40 @@ struct JellyfinConnectionData: Codable { let userName: String let accessToken: String var selectedLibraryId: String? + var customHeaders: [String: String] = [:] + + enum CodingKeys: String, CodingKey { + case url, serverName, userID, userName, accessToken, selectedLibraryId, customHeaders + } + + init( + url: URL, + serverName: String, + userID: String, + userName: String, + accessToken: String, + selectedLibraryId: String? = nil, + customHeaders: [String: String] = [:] + ) { + self.url = url + self.serverName = serverName + self.userID = userID + self.userName = userName + self.accessToken = accessToken + self.selectedLibraryId = selectedLibraryId + self.customHeaders = customHeaders + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.url = try container.decode(URL.self, forKey: .url) + self.serverName = try container.decode(String.self, forKey: .serverName) + self.userID = try container.decode(String.self, forKey: .userID) + self.userName = try container.decode(String.self, forKey: .userName) + self.accessToken = try container.decode(String.self, forKey: .accessToken) + self.selectedLibraryId = try container.decodeIfPresent(String.self, forKey: .selectedLibraryId) + self.customHeaders = try container.decodeIfPresent([String: String].self, forKey: .customHeaders) ?? [:] + } } extension JellyfinConnectionData: CustomDebugStringConvertible { diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift index ac8f9cbab..42a18d232 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift @@ -9,6 +9,33 @@ import BookPlayerKit import Get import JellyfinAPI +import os + +/// Applies user-defined custom HTTP headers (e.g. Cloudflare Access Service Tokens) +/// to every outgoing `JellyfinClient` request. Skips `Authorization` so the Jellyfin +/// client's own MediaBrowser token is never overwritten. +/// +/// `willSendRequest` is invoked on `Get.APIClient`'s actor executor, while +/// `setCustomHeaders(_:)` is typically called from `@MainActor`. The dictionary +/// is therefore guarded by `OSAllocatedUnfairLock`. +final class JellyfinHeaderInjector: APIClientDelegate, @unchecked Sendable { + private let lockedHeaders: OSAllocatedUnfairLock<[String: String]> + + init(customHeaders: [String: String] = [:]) { + self.lockedHeaders = OSAllocatedUnfairLock(initialState: customHeaders) + } + + func setCustomHeaders(_ headers: [String: String]) { + lockedHeaders.withLock { $0 = headers } + } + + func client(_ client: APIClient, willSendRequest request: inout URLRequest) async throws { + let headers = lockedHeaders.withLock { $0 } + for (key, value) in headers where key.caseInsensitiveCompare("Authorization") != .orderedSame { + request.setValue(value, forHTTPHeaderField: key) + } + } +} @Observable class JellyfinConnectionService: BPLogger { @@ -16,6 +43,7 @@ class JellyfinConnectionService: BPLogger { var connection: JellyfinConnectionData? var client: JellyfinClient? + private var headerInjector: JellyfinHeaderInjector? init(keychainService: KeychainServiceProtocol = KeychainService()) { @@ -27,8 +55,11 @@ class JellyfinConnectionService: BPLogger { } /// Finds and creates the api-client for the specified server - public func findServer(at absolutePath: String) async throws -> String { - guard let client = createClient(serverUrlString: absolutePath) else { + public func findServer( + at absolutePath: String, + customHeaders: [String: String] = [:] + ) async throws -> String { + guard let client = createClient(serverUrlString: absolutePath, customHeaders: customHeaders) else { throw IntegrationError.noClient("Jellyfin") } @@ -43,7 +74,8 @@ class JellyfinConnectionService: BPLogger { public func signIn( username: String, password: String, - serverName: String + serverName: String, + customHeaders: [String: String] = [:] ) async throws { guard let client else { throw IntegrationError.noClient("Jellyfin") @@ -63,7 +95,8 @@ class JellyfinConnectionService: BPLogger { serverName: serverName, userID: userID, userName: username, - accessToken: accessToken + accessToken: accessToken, + customHeaders: customHeaders ) try keychainService.set( @@ -73,6 +106,15 @@ class JellyfinConnectionService: BPLogger { self.connection = data self.client = client + headerInjector?.setCustomHeaders(customHeaders) + } + + func updateCustomHeaders(_ headers: [String: String]) { + guard var data = connection else { return } + data.customHeaders = headers + connection = data + try? keychainService.set(data, key: .jellyfinConnection) + headerInjector?.setCustomHeaders(headers) } func saveSelectedLibrary(id: String?) { @@ -385,7 +427,7 @@ class JellyfinConnectionService: BPLogger { ) } - public func fetchAudiobookDownloadURLs(for folderID: String) async throws -> [URL] { + public func fetchAudiobookDownloadRequests(for folderID: String) async throws -> [URLRequest] { let parameters = Paths.GetItemsParameters( isRecursive: false, parentID: folderID, @@ -401,16 +443,14 @@ class JellyfinConnectionService: BPLogger { return JellyfinLibraryItem(apiItem: item) } - let downloadURLs = audiobooks.compactMap { audiobook in + return audiobooks.compactMap { audiobook in do { - return try createItemDownloadUrl(audiobook) + return try createItemDownloadRequest(audiobook) } catch { - Self.logger.warning("Failed to create download URL for audiobook \(audiobook.id): \(error)") + Self.logger.warning("Failed to create download request for audiobook \(audiobook.id): \(error)") return nil } } - - return downloadURLs } private func send( @@ -434,7 +474,8 @@ class JellyfinConnectionService: BPLogger { client = createClient( serverUrlString: storedConnection.url.absoluteString, - accessToken: storedConnection.accessToken + accessToken: storedConnection.accessToken, + customHeaders: storedConnection.customHeaders ) connection = storedConnection } @@ -443,7 +484,11 @@ class JellyfinConnectionService: BPLogger { return !data.userID.isEmpty && !data.accessToken.isEmpty } - private func createClient(serverUrlString: String, accessToken: String? = nil) -> JellyfinClient? { + private func createClient( + serverUrlString: String, + accessToken: String? = nil, + customHeaders: [String: String] = [:] + ) -> JellyfinClient? { let mainBundleInfo = Bundle.main.infoDictionary let clientName = mainBundleInfo?[kCFBundleNameKey as String] as? String let clientVersion = mainBundleInfo?[kCFBundleVersionKey as String] as? String @@ -461,7 +506,13 @@ class JellyfinConnectionService: BPLogger { deviceID: "\(deviceID.uuidString)-\(clientName)", version: clientVersion ) - return JellyfinClient(configuration: configuration, accessToken: accessToken) + let injector = JellyfinHeaderInjector(customHeaders: customHeaders) + self.headerInjector = injector + return JellyfinClient( + configuration: configuration, + delegate: injector, + accessToken: accessToken + ) } func createItemDownloadUrl(_ item: JellyfinLibraryItem) throws -> URL { @@ -483,6 +534,24 @@ class JellyfinConnectionService: BPLogger { return url } + /// Returns a URLRequest for downloading a library item, carrying the user-defined + /// custom HTTP headers (needed for servers behind Cloudflare Access etc.). + func createItemDownloadRequest(_ item: JellyfinLibraryItem) throws -> URLRequest { + let url = try createItemDownloadUrl(item) + return wrapWithCustomHeaders(url) + } + + /// Wraps an arbitrary URL (e.g. a cover image) in a URLRequest carrying the current + /// connection's custom HTTP headers. Skips `Authorization` so the Jellyfin token is preserved. + func wrapWithCustomHeaders(_ url: URL) -> URLRequest { + var request = URLRequest(url: url) + for (key, value) in connection?.customHeaders ?? [:] + where key.caseInsensitiveCompare("Authorization") != .orderedSame { + request.setValue(value, forHTTPHeaderField: key) + } + return request + } + func createItemImageURL(_ item: JellyfinLibraryItem, size: CGSize?) throws -> URL { var parameters = Paths.GetItemImageParameters() diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFormViewModel.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFormViewModel.swift index 127f9269d..65afef4c9 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFormViewModel.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFormViewModel.swift @@ -13,10 +13,19 @@ class IntegrationConnectionFormViewModel: ObservableObject, IntegrationConnectio @Published var serverName: String = "" @Published var username: String = "" @Published var password: String = "" + @Published var customHeaders: [CustomHeaderEntry] = [] - func setValues(url: String, serverName: String, userName: String) { + func setValues( + url: String, + serverName: String, + userName: String, + customHeaders: [String: String] = [:] + ) { self.serverUrl = url self.serverName = serverName self.username = userName + self.customHeaders = customHeaders + .sorted(by: { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }) + .map { CustomHeaderEntry(key: $0.key, value: $0.value) } } } diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift index bdce2f870..33d3572e3 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift @@ -31,6 +31,9 @@ struct IntegrationConnectionView: Vi integrationName: integrationName, onCommit: onConnect ) + IntegrationCustomHeadersSectionView( + customHeaders: $viewModel.form.customHeaders + ) case .foundServer: IntegrationServerInformationSectionView( serverName: viewModel.form.serverName, @@ -41,11 +44,18 @@ struct IntegrationConnectionView: Vi password: $viewModel.form.password, onCommit: onSignIn ) + IntegrationCustomHeadersSectionView( + customHeaders: $viewModel.form.customHeaders + ) case .connected: IntegrationServerInformationSectionView( serverName: viewModel.form.serverName, serverUrl: viewModel.form.serverUrl ) + IntegrationCustomHeadersSectionView( + customHeaders: $viewModel.form.customHeaders, + onCommit: { viewModel.handleCustomHeadersUpdate() } + ) IntegrationConnectedView(viewModel: viewModel) } } diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationCustomHeadersSectionView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationCustomHeadersSectionView.swift new file mode 100644 index 000000000..0f4a87ddb --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationCustomHeadersSectionView.swift @@ -0,0 +1,125 @@ +// +// IntegrationCustomHeadersSectionView.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/20/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +struct IntegrationCustomHeadersSectionView: View { + @Binding var customHeaders: [CustomHeaderEntry] + + /// Called when the user commits an edit — on Return key, when focus leaves the + /// field, when a row is deleted, or when the section disappears. The caller + /// persists the current state. `nil` means "don't persist yet" (e.g. during + /// initial connect/sign-in, where the full form state is written once at sign-in). + var onCommit: (() -> Void)? + + @EnvironmentObject var theme: ThemeViewModel + + private enum FocusedField: Hashable { + case key(UUID) + case value(UUID) + } + + @FocusState private var focusedField: FocusedField? + + /// IDs of rows that `customHeadersDictionary()` will drop — either because + /// the entry is rejected by `CustomHeaderEntry.normalized`, or because a + /// later row with the same normalized key will overwrite it. Used to strike + /// through the key field as a hint. + private var droppedEntryIDs: Set { + var dropped: Set = [] + var lastWinner: [String: UUID] = [:] + for entry in customHeaders { + guard let pair = entry.normalized else { + dropped.insert(entry.id) + continue + } + if let priorID = lastWinner[pair.key] { + dropped.insert(priorID) + } + lastWinner[pair.key] = entry.id + } + return dropped + } + + /// True when the row should visually strike through its key. Suppressed while + /// the row is being edited so the user doesn't see "crossed-out" text mid-typing. + private func shouldStrikethrough(_ entry: CustomHeaderEntry) -> Bool { + guard droppedEntryIDs.contains(entry.id) else { return false } + return focusedField != .key(entry.id) && focusedField != .value(entry.id) + } + + var body: some View { + ThemedSection { + ForEach($customHeaders) { $entry in + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + TextField( + "integration_custom_headers_key_placeholder".localized, + text: $entry.key + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .key(entry.id)) + .onSubmit { onCommit?() } + .strikethrough(shouldStrikethrough(entry)) + + TextField( + "integration_custom_headers_value_placeholder".localized, + text: $entry.value + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .value(entry.id)) + .onSubmit { onCommit?() } + } + + Button { + let removedID = entry.id + customHeaders.removeAll { $0.id == removedID } + onCommit?() + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + .padding(.horizontal, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .accessibilityLabel("delete_button".localized) + } + .padding(.vertical, 2) + } + + Button { + let entry = CustomHeaderEntry() + customHeaders.append(entry) + focusedField = .key(entry.id) + } label: { + Label( + "integration_custom_headers_add_button".localized, + systemImage: "plus.circle" + ) + .foregroundStyle(theme.linkColor) + } + } header: { + Text("integration_custom_headers_title".localized) + .foregroundStyle(theme.secondaryColor) + } footer: { + Text("integration_custom_headers_footer".localized) + .foregroundStyle(theme.secondaryColor) + } + .onChange(of: focusedField) { oldValue, _ in + // Focus left a field — persist whatever was being typed. + if oldValue != nil { + onCommit?() + } + } + .onDisappear { + onCommit?() + } + } +} diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationSettingsView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationSettingsView.swift index 1763b5145..a13f06ff4 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationSettingsView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationSettingsView.swift @@ -9,10 +9,17 @@ import SwiftUI struct IntegrationSettingsView: View { - @ObservedObject var viewModel: VM + /// Owned by this view so transient state (pending custom header edits, etc.) survives + /// parent re-renders that would otherwise rebuild an `@ObservedObject`-passed viewmodel. + @StateObject private var viewModel: VM let integrationName: String + init(integrationName: String, initViewModel: @escaping () -> VM) { + self._viewModel = .init(wrappedValue: initViewModel()) + self.integrationName = integrationName + } + var body: some View { IntegrationConnectionView(viewModel: viewModel, integrationName: integrationName) .navigationBarTitleDisplayMode(.inline) diff --git a/BookPlayer/MediaServerIntegration/IntegrationConnectionFormViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationConnectionFormViewModelProtocol.swift index 68a7b5377..2591b8386 100644 --- a/BookPlayer/MediaServerIntegration/IntegrationConnectionFormViewModelProtocol.swift +++ b/BookPlayer/MediaServerIntegration/IntegrationConnectionFormViewModelProtocol.swift @@ -8,9 +8,60 @@ import Foundation +struct CustomHeaderEntry: Identifiable, Equatable { + let id: UUID + var key: String + var value: String + + init(id: UUID = UUID(), key: String = "", value: String = "") { + self.id = id + self.key = key + self.value = value + } + + /// Returns the `(key, value)` pair this entry contributes to an outgoing request, + /// or `nil` if it should be dropped. Single source of truth for header validation, + /// consumed by both `customHeadersDictionary()` (persistence/send) and the UI + /// strikethrough indicator. Rules: + /// - Key and value are trimmed of surrounding whitespace and newlines. + /// - Empty key or empty value → dropped. + /// - Key containing newlines or `:` → dropped (would crash `URLRequest.setValue`). + /// - Value containing newlines → dropped (same reason). + /// - Key of `Authorization` → dropped; owned by the integration itself + /// (Jellyfin's MediaBrowser scheme / AudiobookShelf's Bearer token). + var normalized: (key: String, value: String)? { + let trimmedKey = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty, + trimmedKey.rangeOfCharacter(from: .newlines) == nil, + !trimmedKey.contains(":"), + trimmedKey.caseInsensitiveCompare("Authorization") != .orderedSame + else { return nil } + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty, + trimmedValue.rangeOfCharacter(from: .newlines) == nil + else { return nil } + return (trimmedKey, trimmedValue) + } +} + protocol IntegrationConnectionFormViewModelProtocol: ObservableObject { var serverUrl: String { get set } var serverName: String { get set } var username: String { get set } var password: String { get set } + var customHeaders: [CustomHeaderEntry] { get set } +} + +extension IntegrationConnectionFormViewModelProtocol { + /// Serialize the header entries into a dictionary, dropping anything + /// `CustomHeaderEntry.normalized` rejects. Later duplicates of the same + /// (trimmed) key overwrite earlier values. + func customHeadersDictionary() -> [String: String] { + var result: [String: String] = [:] + for entry in customHeaders { + guard let pair = entry.normalized else { continue } + result[pair.key] = pair.value + } + return result + } } diff --git a/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift index 4f83e41db..8acfd8d31 100644 --- a/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift +++ b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift @@ -30,4 +30,7 @@ protocol IntegrationConnectionViewModelProtocol: ObservableObject { func handleConnectAction() async throws func handleSignInAction() async throws func handleSignOutAction() + /// Persist any changes made to the custom-headers list while the connection is already live. + /// Called by the headers-editor UI in the `.connected` state. + func handleCustomHeadersUpdate() } diff --git a/BookPlayer/Services/SingleFileDownloadService.swift b/BookPlayer/Services/SingleFileDownloadService.swift index b37485d07..8f8b3678b 100644 --- a/BookPlayer/Services/SingleFileDownloadService.swift +++ b/BookPlayer/Services/SingleFileDownloadService.swift @@ -29,7 +29,7 @@ final class SingleFileDownloadService: ObservableObject { private var disposeBag = Set() public var isDownloading: Bool { !downloadQueue.isEmpty || currentTask != nil } - public private(set) var downloadQueue: [(url: URL, folderName: String?)] = [] + public private(set) var downloadQueue: [(request: URLRequest, folderName: String?)] = [] private var currentTask: (task: URLSessionTask, folderName: String?)? private lazy var downloadSession: URLSession = { @@ -51,22 +51,38 @@ final class SingleFileDownloadService: ObservableObject { } public func handleDownload(_ url: URL) { - downloadQueue.append((url: url, folderName: nil)) - processNextDownload() + handleDownload(URLRequest(url: url)) } public func handleDownload(_ urls: [URL]) { - downloadQueue.append(contentsOf: urls.map { (url: $0, folderName: nil) }) - processNextDownload() + handleDownload(urls.map { URLRequest(url: $0) }) } public func handleDownload(_ url: URL, folderName: String) { - downloadQueue.append((url: url, folderName: folderName)) - processNextDownload() + handleDownload(URLRequest(url: url), folderName: folderName) } public func handleDownload(_ urls: [URL], folderName: String) { - downloadQueue.append(contentsOf: urls.map { (url: $0, folderName: folderName) }) + handleDownload(urls.map { URLRequest(url: $0) }, folderName: folderName) + } + + public func handleDownload(_ request: URLRequest) { + downloadQueue.append((request: request, folderName: nil)) + processNextDownload() + } + + public func handleDownload(_ requests: [URLRequest]) { + downloadQueue.append(contentsOf: requests.map { (request: $0, folderName: nil) }) + processNextDownload() + } + + public func handleDownload(_ request: URLRequest, folderName: String) { + downloadQueue.append((request: request, folderName: folderName)) + processNextDownload() + } + + public func handleDownload(_ requests: [URLRequest], folderName: String) { + downloadQueue.append(contentsOf: requests.map { (request: $0, folderName: folderName) }) processNextDownload() } @@ -75,11 +91,14 @@ final class SingleFileDownloadService: ObservableObject { guard currentTask == nil, !downloadQueue.isEmpty else { return } let downloadItem = downloadQueue.removeFirst() - sendEvent(.starting(url: downloadItem.url)) + /// Every `handleDownload` overload wraps a known URL, so `request.url` is non-nil. + /// A nil here would break dedupe (task description collides) — crash early instead. + let url = downloadItem.request.url! + sendEvent(.starting(url: url)) let task = await networkClient.download( - url: downloadItem.url, - taskDescription: "SingleFileDownload-\(downloadItem.url.absoluteString)", + request: downloadItem.request, + taskDescription: "SingleFileDownload-\(url.absoluteString)", session: downloadSession ) currentTask = (task: task, folderName: downloadItem.folderName) diff --git a/BookPlayer/Settings/SettingsView.swift b/BookPlayer/Settings/SettingsView.swift index 49115a6ef..aa6942ce5 100644 --- a/BookPlayer/Settings/SettingsView.swift +++ b/BookPlayer/Settings/SettingsView.swift @@ -115,23 +115,21 @@ struct SettingsView: View { ) case .jellyfin: view = AnyView( - IntegrationSettingsView( - viewModel: JellyfinConnectionViewModel( + IntegrationSettingsView(integrationName: "Jellyfin") { + JellyfinConnectionViewModel( connectionService: jellyfinService, mode: .viewDetails - ), - integrationName: "Jellyfin" - ) + ) + } ) case .audiobookshelf: view = AnyView( - IntegrationSettingsView( - viewModel: AudiobookShelfConnectionViewModel( + IntegrationSettingsView(integrationName: "AudiobookShelf") { + AudiobookShelfConnectionViewModel( connectionService: audiobookshelfService, mode: .viewDetails - ), - integrationName: "AudiobookShelf" - ) + ) + } ) case .hardcover: view = AnyView( diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 878826ad8..76f713336 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -347,6 +347,11 @@ "integration_username_placeholder" = "اسم المستخدم"; "integration_password_placeholder" = "كلمة المرور"; "integration_password_remember_me_label" = "تذكرني"; +"integration_custom_headers_title" = "رؤوس HTTP مخصصة"; +"integration_custom_headers_footer" = "تُرفق الرؤوس المضافة هنا بكل طلب يُرسل إلى هذا الخادم."; +"integration_custom_headers_key_placeholder" = "اسم الرأس"; +"integration_custom_headers_value_placeholder" = "قيمة الرأس"; +"integration_custom_headers_add_button" = "إضافة رأس"; "settings_integration_manage_connection_title" = "إدارة الاتصال"; "integration_internal_error_invalid_url" = "خطأ داخلي: عنوان URL للطلب غير صالح: %@"; "integration_internal_error_build_url" = "خطأ داخلي: فشل في بناء عنوان URL للطلب"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index a1d09993c..d172ef860 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -347,6 +347,11 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "integration_username_placeholder" = "Nom d'usuari"; "integration_password_placeholder" = "Contrasenya"; "integration_password_remember_me_label" = "Recorda'm"; +"integration_custom_headers_title" = "Capçaleres HTTP personalitzades"; +"integration_custom_headers_footer" = "Les capçaleres afegides aquí s'adjunten a cada petició enviada a aquest servidor."; +"integration_custom_headers_key_placeholder" = "Nom de la capçalera"; +"integration_custom_headers_value_placeholder" = "Valor de la capçalera"; +"integration_custom_headers_add_button" = "Afegir capçalera"; "settings_integration_manage_connection_title" = "Gestionar la connexió"; "integration_internal_error_invalid_url" = "Error intern: l'URL de sol·licitud no és vàlid: %@"; "integration_internal_error_build_url" = "Error intern: no s'ha pogut crear l'URL de la sol·licitud"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index a2c4aaa72..bec744e6a 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Uživatelské jméno"; "integration_password_placeholder" = "Heslo"; "integration_password_remember_me_label" = "Pamatuj si mě"; +"integration_custom_headers_title" = "Vlastní HTTP hlavičky"; +"integration_custom_headers_footer" = "Hlavičky přidané zde se připojí ke každému požadavku odeslanému na tento server."; +"integration_custom_headers_key_placeholder" = "Název hlavičky"; +"integration_custom_headers_value_placeholder" = "Hodnota hlavičky"; +"integration_custom_headers_add_button" = "Přidat hlavičku"; "settings_integration_manage_connection_title" = "Správa připojení"; "integration_internal_error_invalid_url" = "Interní chyba: Adresa URL požadavku je neplatná: %@"; "integration_internal_error_build_url" = "Interní chyba: Nepodařilo se vytvořit adresu URL požadavku"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 79dfea027..48d75ddce 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Brugernavn"; "integration_password_placeholder" = "Adgangskode"; "integration_password_remember_me_label" = "Husk mig"; +"integration_custom_headers_title" = "Brugerdefinerede HTTP-headers"; +"integration_custom_headers_footer" = "Headers tilføjet her vedhæftes til hver anmodning, der sendes til denne server."; +"integration_custom_headers_key_placeholder" = "Header-navn"; +"integration_custom_headers_value_placeholder" = "Header-værdi"; +"integration_custom_headers_add_button" = "Tilføj header"; "settings_integration_manage_connection_title" = "Administrer forbindelse"; "integration_internal_error_invalid_url" = "Intern fejl: Anmodnings-URL er ugyldig: %@"; "integration_internal_error_build_url" = "Intern fejl: Forespørgsels-URL kunne ikke oprettes"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index 716e7bafe..f577952bb 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Benutzername"; "integration_password_placeholder" = "Passwort"; "integration_password_remember_me_label" = "Erinnere dich an mich"; +"integration_custom_headers_title" = "Benutzerdefinierte HTTP-Header"; +"integration_custom_headers_footer" = "Die hier hinzugefügten Header werden an jede Anfrage an diesen Server angehängt."; +"integration_custom_headers_key_placeholder" = "Header-Name"; +"integration_custom_headers_value_placeholder" = "Header-Wert"; +"integration_custom_headers_add_button" = "Header hinzufügen"; "settings_integration_manage_connection_title" = "Verbindung verwalten"; "integration_internal_error_invalid_url" = "Interner Fehler: Anforderungs-URL ist ungültig: %@"; "integration_internal_error_build_url" = "Interner Fehler: Anforderungs-URL konnte nicht erstellt werden"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 9abce12bc..801d1fc1c 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -347,6 +347,11 @@ "integration_username_placeholder" = "Όνομα χρήστη"; "integration_password_placeholder" = "Σύνθημα"; "integration_password_remember_me_label" = "Να με θυμάσαι"; +"integration_custom_headers_title" = "Προσαρμοσμένες κεφαλίδες HTTP"; +"integration_custom_headers_footer" = "Οι κεφαλίδες που προστίθενται εδώ επισυνάπτονται σε κάθε αίτημα που αποστέλλεται σε αυτόν τον διακομιστή."; +"integration_custom_headers_key_placeholder" = "Όνομα κεφαλίδας"; +"integration_custom_headers_value_placeholder" = "Τιμή κεφαλίδας"; +"integration_custom_headers_add_button" = "Προσθήκη κεφαλίδας"; "settings_integration_manage_connection_title" = "Διαχείριση σύνδεσης"; "integration_internal_error_invalid_url" = "Εσωτερικό σφάλμα: Η διεύθυνση URL αιτήματος δεν είναι έγκυρη: %@"; "integration_internal_error_build_url" = "Εσωτερικό σφάλμα: Απέτυχε η δημιουργία διεύθυνσης URL αιτήματος"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index f00d4a5d1..8134ea17e 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -349,6 +349,11 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_username_placeholder" = "Username"; "integration_password_placeholder" = "Password"; "integration_password_remember_me_label" = "Remember Me"; +"integration_custom_headers_title" = "Custom HTTP Headers"; +"integration_custom_headers_footer" = "Headers added here are attached to every request sent to this server."; +"integration_custom_headers_key_placeholder" = "Header name"; +"integration_custom_headers_value_placeholder" = "Header value"; +"integration_custom_headers_add_button" = "Add Header"; "settings_integration_manage_connection_title" = "Manage Connection"; "integration_internal_error_invalid_url" = "Internal error: Request URL is invalid: %@"; "integration_internal_error_build_url" = "Internal error: Failed to build request URL"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 62ada2312..3ee58831d 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Nombre de usuario"; "integration_password_placeholder" = "Contraseña"; "integration_password_remember_me_label" = "Acuérdate de mí"; +"integration_custom_headers_title" = "Encabezados HTTP personalizados"; +"integration_custom_headers_footer" = "Los encabezados añadidos aquí se adjuntan a cada solicitud enviada a este servidor."; +"integration_custom_headers_key_placeholder" = "Nombre del encabezado"; +"integration_custom_headers_value_placeholder" = "Valor del encabezado"; +"integration_custom_headers_add_button" = "Añadir encabezado"; "settings_integration_manage_connection_title" = "Administrar conexión"; "integration_internal_error_invalid_url" = "Error interno: La URL de solicitud no es válida: %@"; "integration_internal_error_build_url" = "Error interno: No se pudo crear la URL de solicitud"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index 02d4b902d..214b3f55a 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Käyttäjätunnus"; "integration_password_placeholder" = "Salasana"; "integration_password_remember_me_label" = "Muista minut"; +"integration_custom_headers_title" = "Mukautetut HTTP-otsakkeet"; +"integration_custom_headers_footer" = "Tähän lisätyt otsakkeet liitetään jokaiseen tälle palvelimelle lähetettävään pyyntöön."; +"integration_custom_headers_key_placeholder" = "Otsakkeen nimi"; +"integration_custom_headers_value_placeholder" = "Otsakkeen arvo"; +"integration_custom_headers_add_button" = "Lisää otsake"; "settings_integration_manage_connection_title" = "Hallitse yhteyttä"; "integration_internal_error_invalid_url" = "Sisäinen virhe: Pyynnön URL-osoite on virheellinen: %@"; "integration_internal_error_build_url" = "Sisäinen virhe: Pyynnön URL-osoitteen luominen epäonnistui"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 4b4244379..6dffa89ab 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Nom d'utilisateur"; "integration_password_placeholder" = "Mot de passe"; "integration_password_remember_me_label" = "Souviens-toi de moi"; +"integration_custom_headers_title" = "En-têtes HTTP personnalisés"; +"integration_custom_headers_footer" = "Les en-têtes ajoutés ici sont joints à chaque requête envoyée à ce serveur."; +"integration_custom_headers_key_placeholder" = "Nom de l'en-tête"; +"integration_custom_headers_value_placeholder" = "Valeur de l'en-tête"; +"integration_custom_headers_add_button" = "Ajouter un en-tête"; "settings_integration_manage_connection_title" = "Gérer la connexion"; "integration_internal_error_invalid_url" = "Erreur interne : l'URL de la demande n'est pas valide : %@"; "integration_internal_error_build_url" = "Erreur interne : échec de la création de l'URL de la demande"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 0190ae622..f036e37aa 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -347,6 +347,11 @@ "integration_username_placeholder" = "Felhasználónév"; "integration_password_placeholder" = "Jelszó"; "integration_password_remember_me_label" = "Emlékezzen rám"; +"integration_custom_headers_title" = "Egyéni HTTP fejlécek"; +"integration_custom_headers_footer" = "Az itt hozzáadott fejlécek minden, erre a kiszolgálóra küldött kéréshez csatolódnak."; +"integration_custom_headers_key_placeholder" = "Fejléc neve"; +"integration_custom_headers_value_placeholder" = "Fejléc értéke"; +"integration_custom_headers_add_button" = "Fejléc hozzáadása"; "settings_integration_manage_connection_title" = "Kapcsolat kezelése"; "integration_internal_error_invalid_url" = "Belső hiba: A kérés URL-je érvénytelen: %@"; "integration_internal_error_build_url" = "Belső hiba: Nem sikerült létrehozni a kérés URL-jét"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index 05709211a..578bd57c5 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Nome utente"; "integration_password_placeholder" = "Password"; "integration_password_remember_me_label" = "Ricordati di me"; +"integration_custom_headers_title" = "Intestazioni HTTP personalizzate"; +"integration_custom_headers_footer" = "Le intestazioni aggiunte qui vengono allegate a ogni richiesta inviata a questo server."; +"integration_custom_headers_key_placeholder" = "Nome intestazione"; +"integration_custom_headers_value_placeholder" = "Valore intestazione"; +"integration_custom_headers_add_button" = "Aggiungi intestazione"; "settings_integration_manage_connection_title" = "Gestisci connessione"; "integration_internal_error_invalid_url" = "Errore interno: l'URL della richiesta non è valido: %@"; "integration_internal_error_build_url" = "Errore interno: Impossibile creare l'URL della richiesta"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index d38cd9303..dd313839e 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -347,6 +347,11 @@ "integration_username_placeholder" = "ユーザ名"; "integration_password_placeholder" = "パスワード"; "integration_password_remember_me_label" = "ログイン状態を保持する"; +"integration_custom_headers_title" = "カスタムHTTPヘッダー"; +"integration_custom_headers_footer" = "ここで追加したヘッダーは、このサーバーへのすべてのリクエストに付加されます。"; +"integration_custom_headers_key_placeholder" = "ヘッダー名"; +"integration_custom_headers_value_placeholder" = "ヘッダーの値"; +"integration_custom_headers_add_button" = "ヘッダーを追加"; "settings_integration_manage_connection_title" = "接続を管理"; "integration_internal_error_invalid_url" = "内部エラー: リクエストURLが無効です: %@"; "integration_internal_error_build_url" = "内部エラー: リクエストURLの構築に失敗しました"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index bd342bf3c..b9bd50679 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -346,6 +346,11 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "integration_username_placeholder" = "Brukernavn"; "integration_password_placeholder" = "Passord"; "integration_password_remember_me_label" = "Husk meg"; +"integration_custom_headers_title" = "Egendefinerte HTTP-headere"; +"integration_custom_headers_footer" = "Headere som legges til her, vedlegges hver forespørsel som sendes til denne serveren."; +"integration_custom_headers_key_placeholder" = "Header-navn"; +"integration_custom_headers_value_placeholder" = "Header-verdi"; +"integration_custom_headers_add_button" = "Legg til header"; "settings_integration_manage_connection_title" = "Administrer tilkobling"; "integration_internal_error_invalid_url" = "Intern feil: Forespørsels-URL er ugyldig: %@"; "integration_internal_error_build_url" = "Intern feil: Kunne ikke lage forespørsels-URL"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index e3c845e19..94cbc9682 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Gebruikersnaam"; "integration_password_placeholder" = "Wachtwoord"; "integration_password_remember_me_label" = "Onthoud mij"; +"integration_custom_headers_title" = "Aangepaste HTTP-headers"; +"integration_custom_headers_footer" = "Headers die hier worden toegevoegd, worden bij elk verzoek naar deze server meegestuurd."; +"integration_custom_headers_key_placeholder" = "Headernaam"; +"integration_custom_headers_value_placeholder" = "Headerwaarde"; +"integration_custom_headers_add_button" = "Header toevoegen"; "settings_integration_manage_connection_title" = "Verbinding beheren"; "integration_internal_error_invalid_url" = "Interne fout: Verzoek-URL is ongeldig: %@"; "integration_internal_error_build_url" = "Interne fout: het is niet gelukt om de URL van het verzoek te bouwen"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 766d64fad..57786a83c 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Nazwa użytkownika"; "integration_password_placeholder" = "Hasło"; "integration_password_remember_me_label" = "Pamiętaj mnie"; +"integration_custom_headers_title" = "Niestandardowe nagłówki HTTP"; +"integration_custom_headers_footer" = "Nagłówki dodane tutaj są dołączane do każdego żądania wysyłanego do tego serwera."; +"integration_custom_headers_key_placeholder" = "Nazwa nagłówka"; +"integration_custom_headers_value_placeholder" = "Wartość nagłówka"; +"integration_custom_headers_add_button" = "Dodaj nagłówek"; "settings_integration_manage_connection_title" = "Zarządzaj połączeniem"; "integration_internal_error_invalid_url" = "Błąd wewnętrzny: Adres URL żądania jest nieprawidłowy: %@"; "integration_internal_error_build_url" = "Błąd wewnętrzny: Nie udało się zbudować adresu URL żądania"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index 3aba37f1d..fe86dd0cd 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Nome de usuário"; "integration_password_placeholder" = "Senha"; "integration_password_remember_me_label" = "Lembre de mim"; +"integration_custom_headers_title" = "Cabeçalhos HTTP personalizados"; +"integration_custom_headers_footer" = "Os cabeçalhos adicionados aqui são anexados a cada requisição enviada a este servidor."; +"integration_custom_headers_key_placeholder" = "Nome do cabeçalho"; +"integration_custom_headers_value_placeholder" = "Valor do cabeçalho"; +"integration_custom_headers_add_button" = "Adicionar cabeçalho"; "settings_integration_manage_connection_title" = "Gerenciar conexão"; "integration_internal_error_invalid_url" = "Erro interno: URL da solicitação é inválida: %@"; "integration_internal_error_build_url" = "Erro interno: Falha ao criar URL de solicitação"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index 1ed04bdbd..86e30bb54 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Nome de usuário"; "integration_password_placeholder" = "Senha"; "integration_password_remember_me_label" = "Lembre de mim"; +"integration_custom_headers_title" = "Cabeçalhos HTTP personalizados"; +"integration_custom_headers_footer" = "Os cabeçalhos adicionados aqui são anexados a cada pedido enviado para este servidor."; +"integration_custom_headers_key_placeholder" = "Nome do cabeçalho"; +"integration_custom_headers_value_placeholder" = "Valor do cabeçalho"; +"integration_custom_headers_add_button" = "Adicionar cabeçalho"; "settings_integration_manage_connection_title" = "Gerenciar conexão"; "integration_internal_error_invalid_url" = "Erro interno: URL da solicitação é inválida: %@"; "integration_internal_error_build_url" = "Erro interno: Falha ao criar URL de solicitação"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index 3486cb9e9..6ab92affa 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Nume de utilizator"; "integration_password_placeholder" = "Parolă"; "integration_password_remember_me_label" = "Ține-mă minte"; +"integration_custom_headers_title" = "Anteturi HTTP personalizate"; +"integration_custom_headers_footer" = "Anteturile adăugate aici sunt atașate fiecărei solicitări trimise către acest server."; +"integration_custom_headers_key_placeholder" = "Numele antetului"; +"integration_custom_headers_value_placeholder" = "Valoarea antetului"; +"integration_custom_headers_add_button" = "Adaugă antet"; "settings_integration_manage_connection_title" = "Gestionați conexiunea"; "integration_internal_error_invalid_url" = "Eroare internă: adresa URL a solicitării este nevalidă: %@"; "integration_internal_error_build_url" = "Eroare internă: nu s-a putut crea adresa URL a solicitării"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index 7c6e7c55d..2687508c6 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Имя пользователя"; "integration_password_placeholder" = "Пароль"; "integration_password_remember_me_label" = "Запомнить меня"; +"integration_custom_headers_title" = "Пользовательские HTTP-заголовки"; +"integration_custom_headers_footer" = "Заголовки, добавленные здесь, прикрепляются к каждому запросу, отправляемому на этот сервер."; +"integration_custom_headers_key_placeholder" = "Имя заголовка"; +"integration_custom_headers_value_placeholder" = "Значение заголовка"; +"integration_custom_headers_add_button" = "Добавить заголовок"; "settings_integration_manage_connection_title" = "Управление соединением"; "integration_internal_error_invalid_url" = "Внутренняя ошибка: недопустимый URL-адрес запроса: %@"; "integration_internal_error_build_url" = "Внутренняя ошибка: не удалось создать URL-адрес запроса"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 20e8bb112..517122d9e 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -347,6 +347,11 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "integration_username_placeholder" = "Používateľské meno"; "integration_password_placeholder" = "Heslo"; "integration_password_remember_me_label" = "Zapamätať si mňa"; +"integration_custom_headers_title" = "Vlastné HTTP hlavičky"; +"integration_custom_headers_footer" = "Hlavičky pridané tu sa pripoja ku každej požiadavke odoslanej na tento server."; +"integration_custom_headers_key_placeholder" = "Názov hlavičky"; +"integration_custom_headers_value_placeholder" = "Hodnota hlavičky"; +"integration_custom_headers_add_button" = "Pridať hlavičku"; "settings_integration_manage_connection_title" = "Správa pripojenia"; "integration_internal_error_invalid_url" = "Interná chyba: URL požiadavky je neplatná: %@"; "integration_internal_error_build_url" = "Interná chyba: Nepodarilo sa vytvoriť adresu URL požiadavky"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index ad2af9bea..5417bb1a6 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Användarnamn"; "integration_password_placeholder" = "Lösenord"; "integration_password_remember_me_label" = "Kom ihåg mig"; +"integration_custom_headers_title" = "Anpassade HTTP-headrar"; +"integration_custom_headers_footer" = "Headrar som läggs till här bifogas varje förfrågan som skickas till denna server."; +"integration_custom_headers_key_placeholder" = "Headernamn"; +"integration_custom_headers_value_placeholder" = "Headervärde"; +"integration_custom_headers_add_button" = "Lägg till header"; "settings_integration_manage_connection_title" = "Hantera anslutning"; "integration_internal_error_invalid_url" = "Internt fel: URL för begäran är ogiltig: %@"; "integration_internal_error_build_url" = "Internt fel: Det gick inte att skapa webbadress för begäran"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 848be7414..5f15d7cdc 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Kullanıcı adı"; "integration_password_placeholder" = "Şifre"; "integration_password_remember_me_label" = "Beni Hatırla"; +"integration_custom_headers_title" = "Özel HTTP başlıkları"; +"integration_custom_headers_footer" = "Buraya eklenen başlıklar bu sunucuya gönderilen her isteğe eklenir."; +"integration_custom_headers_key_placeholder" = "Başlık adı"; +"integration_custom_headers_value_placeholder" = "Başlık değeri"; +"integration_custom_headers_add_button" = "Başlık ekle"; "settings_integration_manage_connection_title" = "Bağlantıyı Yönet"; "integration_internal_error_invalid_url" = "Dahili hata: İstek URL'si geçersiz: %@"; "integration_internal_error_build_url" = "Dahili hata: İstek URL'si oluşturulamadı"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index debda2a68..54f0cf305 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "Ім'я користувача"; "integration_password_placeholder" = "Пароль"; "integration_password_remember_me_label" = "Пам'ятай мене"; +"integration_custom_headers_title" = "Користувацькі HTTP-заголовки"; +"integration_custom_headers_footer" = "Заголовки, додані тут, додаються до кожного запиту, який надсилається на цей сервер."; +"integration_custom_headers_key_placeholder" = "Назва заголовка"; +"integration_custom_headers_value_placeholder" = "Значення заголовка"; +"integration_custom_headers_add_button" = "Додати заголовок"; "settings_integration_manage_connection_title" = "Керувати підключенням"; "integration_internal_error_invalid_url" = "Внутрішня помилка: URL-адреса запиту недійсна: %@"; "integration_internal_error_build_url" = "Внутрішня помилка: не вдалося створити URL-адресу запиту"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index fa11937b0..9def356a3 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -346,6 +346,11 @@ "integration_username_placeholder" = "用户名"; "integration_password_placeholder" = "密码"; "integration_password_remember_me_label" = "记住账号"; +"integration_custom_headers_title" = "自定义 HTTP 请求头"; +"integration_custom_headers_footer" = "此处添加的请求头将附加到发送至此服务器的每个请求中。"; +"integration_custom_headers_key_placeholder" = "请求头名称"; +"integration_custom_headers_value_placeholder" = "请求头值"; +"integration_custom_headers_add_button" = "添加请求头"; "settings_integration_manage_connection_title" = "管理连接"; "integration_internal_error_invalid_url" = "内部错误:请求 URL 无效: %@"; "integration_internal_error_build_url" = "内部错误:无法构建请求 URL"; diff --git a/BookPlayerTests/Mocks/NetworkClientMock.swift b/BookPlayerTests/Mocks/NetworkClientMock.swift index 6b080fe12..5444fda0f 100644 --- a/BookPlayerTests/Mocks/NetworkClientMock.swift +++ b/BookPlayerTests/Mocks/NetworkClientMock.swift @@ -61,4 +61,8 @@ class NetworkClientMock: NetworkClientProtocol { func download(url: URL, taskDescription: String?, session: URLSession) async -> URLSessionTask { return URLSession.shared.downloadTask(with: URLRequest(url: URL(string: "https://google.com")!)) } + + func download(request: URLRequest, taskDescription: String?, session: URLSession) async -> URLSessionTask { + return URLSession.shared.downloadTask(with: request) + } } diff --git a/Shared/Network/NetworkClient.swift b/Shared/Network/NetworkClient.swift index fc08a864a..42e74dfd7 100644 --- a/Shared/Network/NetworkClient.swift +++ b/Shared/Network/NetworkClient.swift @@ -40,6 +40,14 @@ public protocol NetworkClientProtocol { taskDescription: String?, session: URLSession ) async -> URLSessionTask + + /// Managed download using a fully-built URLRequest (e.g. to carry custom HTTP headers + /// for integrations behind Cloudflare Access / Authelia / reverse-proxy auth). + func download( + request: URLRequest, + taskDescription: String?, + session: URLSession + ) async -> URLSessionTask } public class NetworkClient: NetworkClientProtocol, BPLogger { @@ -92,6 +100,18 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { url: URL, taskDescription: String?, session: URLSession + ) async -> URLSessionTask { + await download( + request: URLRequest(url: url), + taskDescription: taskDescription, + session: session + ) + } + + public func download( + request: URLRequest, + taskDescription: String?, + session: URLSession ) async -> URLSessionTask { let allTasks = await session.allTasks @@ -99,12 +119,12 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { if let existingTask = allTasks.first(where: { task in task.taskDescription == taskDescription }) { - Self.logger.trace("Existing request for: \(url.path)") + Self.logger.trace("Existing request for: \(request.url?.path ?? "")") return existingTask } else { - Self.logger.trace("[Request] Download \(url.path)") + Self.logger.trace("[Request] Download \(request.url?.path ?? "")") - let task = session.downloadTask(with: url) + let task = session.downloadTask(with: request) task.taskDescription = taskDescription task.resume()