diff --git a/Dockerfile b/Dockerfile index 2f5619c71..1af363ed6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,18 @@ FROM nginx:alpine +RUN apk add --no-cache jq + EXPOSE 80 ENV BASE_URL="" +ENV SEERR_URL="" +ENV HIDE_PASSWORD_LOGIN="" +ENV SEERR_CUSTOM_HEADERS="" +ENV PORT=80 COPY build/web /usr/share/nginx/html +COPY docker/docker-entrypoint.sh /docker-entrypoint.sh -RUN echo '{"baseUrl": "${BASE_URL}"}' > /usr/share/nginx/html/assets/config/config.json +RUN chmod +x /docker-entrypoint.sh -CMD /bin/sh -c 'sed -i "s|\${BASE_URL}|${BASE_URL}|g" /usr/share/nginx/html/assets/config/config.json && nginx -g "daemon off;"' +CMD ["/docker-entrypoint.sh"] diff --git a/Dockerfile-rootless b/Dockerfile-rootless index 040d9da3a..c3820400e 100644 --- a/Dockerfile-rootless +++ b/Dockerfile-rootless @@ -3,14 +3,19 @@ FROM ghcr.io/nginxinc/nginx-unprivileged:stable-alpine-slim EXPOSE 8080 ENV BASE_URL="" +ENV SEERR_URL="" +ENV HIDE_PASSWORD_LOGIN="" +ENV SEERR_CUSTOM_HEADERS="" +ENV PORT=8080 USER root +RUN apk add --no-cache jq COPY build/web /usr/share/nginx/html -RUN chown -R nginx:nginx /usr/share/nginx/html +COPY docker/docker-entrypoint.sh /docker-entrypoint.sh -USER nginx -RUN mkdir -p /usr/share/nginx/html/assets/config && \ - echo '{"baseUrl": "${BASE_URL}"}' > /usr/share/nginx/html/assets/config/config.json +RUN chmod +x /docker-entrypoint.sh && \ + chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /etc/nginx/conf.d -USER root -CMD /bin/sh -c 'sed -i "s|\${BASE_URL}|${BASE_URL}|g" /usr/share/nginx/html/assets/config/config.json && nginx -g "daemon off;"' \ No newline at end of file +USER nginx +CMD ["/docker-entrypoint.sh"] diff --git a/config/config.json b/config/config.json index 78ec77596..232dc0567 100644 --- a/config/config.json +++ b/config/config.json @@ -1,3 +1,6 @@ { - "baseUrl": null -} \ No newline at end of file + "baseUrl": null, + "seerrUrl": null, + "hidePasswordLogin": null, + "seerrProxyPath": null +} diff --git a/docker-compose.yml b/docker-compose.yml index 70a3660ce..41013089a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,3 +5,6 @@ services: - 80:80 environment: - BASE_URL=https://server-url #OPTIONAL: Locks the Fladder front-end to a certain jellyfin server + - SEERR_URL= #OPTIONAL: Pre-fills and locks the Seerr URL in settings + - HIDE_PASSWORD_LOGIN= #OPTIONAL: Set to "true" to hide password fields and only show QuickConnect + - SEERR_CUSTOM_HEADERS= #OPTIONAL: JSON object of headers injected server-side via nginx proxy, e.g. '{"X-Auth": "secret"}' diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100755 index 000000000..7aa823f54 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -e + +CONFIG="/usr/share/nginx/html/assets/config/config.json" +NGINX_CONF="/etc/nginx/conf.d/default.conf" + +# --- Build config.json --- + +HIDE_PW_VAL=$([ "$HIDE_PASSWORD_LOGIN" = "true" ] && echo true || echo null) + +# Determine seerrProxyPath: set when both SEERR_URL and SEERR_CUSTOM_HEADERS are provided +if [ -n "$SEERR_URL" ] && [ -n "$SEERR_CUSTOM_HEADERS" ]; then + SEERR_PROXY_PATH="/seerr-proxy" +else + SEERR_PROXY_PATH="" +fi + +SEERR_PROXY_JSON=$([ -n "$SEERR_PROXY_PATH" ] && echo "\"$SEERR_PROXY_PATH\"" || echo null) + +cat > "$CONFIG" < "$NGINX_CONF" < { ServerLoginModel? serverLoginModel, String? errorMessage, bool hasBaseUrl, + bool hasSeerrUrl, + bool hidePasswordLogin, bool loading, String? tempSeerrUrl, String? tempSeerrSessionCookie}); @@ -74,6 +78,8 @@ class _$LoginScreenModelCopyWithImpl<$Res> Object? serverLoginModel = freezed, Object? errorMessage = freezed, Object? hasBaseUrl = null, + Object? hasSeerrUrl = null, + Object? hidePasswordLogin = null, Object? loading = null, Object? tempSeerrUrl = freezed, Object? tempSeerrSessionCookie = freezed, @@ -99,6 +105,14 @@ class _$LoginScreenModelCopyWithImpl<$Res> ? _self.hasBaseUrl : hasBaseUrl // ignore: cast_nullable_to_non_nullable as bool, + hasSeerrUrl: null == hasSeerrUrl + ? _self.hasSeerrUrl + : hasSeerrUrl // ignore: cast_nullable_to_non_nullable + as bool, + hidePasswordLogin: null == hidePasswordLogin + ? _self.hidePasswordLogin + : hidePasswordLogin // ignore: cast_nullable_to_non_nullable + as bool, loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable @@ -228,6 +242,8 @@ extension LoginScreenModelPatterns on LoginScreenModel { ServerLoginModel? serverLoginModel, String? errorMessage, bool hasBaseUrl, + bool hasSeerrUrl, + bool hidePasswordLogin, bool loading, String? tempSeerrUrl, String? tempSeerrSessionCookie)? @@ -243,6 +259,8 @@ extension LoginScreenModelPatterns on LoginScreenModel { _that.serverLoginModel, _that.errorMessage, _that.hasBaseUrl, + _that.hasSeerrUrl, + _that.hidePasswordLogin, _that.loading, _that.tempSeerrUrl, _that.tempSeerrSessionCookie); @@ -272,6 +290,8 @@ extension LoginScreenModelPatterns on LoginScreenModel { ServerLoginModel? serverLoginModel, String? errorMessage, bool hasBaseUrl, + bool hasSeerrUrl, + bool hidePasswordLogin, bool loading, String? tempSeerrUrl, String? tempSeerrSessionCookie) @@ -286,6 +306,8 @@ extension LoginScreenModelPatterns on LoginScreenModel { _that.serverLoginModel, _that.errorMessage, _that.hasBaseUrl, + _that.hasSeerrUrl, + _that.hidePasswordLogin, _that.loading, _that.tempSeerrUrl, _that.tempSeerrSessionCookie); @@ -314,6 +336,8 @@ extension LoginScreenModelPatterns on LoginScreenModel { ServerLoginModel? serverLoginModel, String? errorMessage, bool hasBaseUrl, + bool hasSeerrUrl, + bool hidePasswordLogin, bool loading, String? tempSeerrUrl, String? tempSeerrSessionCookie)? @@ -328,6 +352,8 @@ extension LoginScreenModelPatterns on LoginScreenModel { _that.serverLoginModel, _that.errorMessage, _that.hasBaseUrl, + _that.hasSeerrUrl, + _that.hidePasswordLogin, _that.loading, _that.tempSeerrUrl, _that.tempSeerrSessionCookie); @@ -346,6 +372,8 @@ class _LoginScreenModel implements LoginScreenModel { this.serverLoginModel, this.errorMessage, this.hasBaseUrl = false, + this.hasSeerrUrl = false, + this.hidePasswordLogin = false, this.loading = false, this.tempSeerrUrl, this.tempSeerrSessionCookie}) @@ -372,6 +400,12 @@ class _LoginScreenModel implements LoginScreenModel { final bool hasBaseUrl; @override @JsonKey() + final bool hasSeerrUrl; + @override + @JsonKey() + final bool hidePasswordLogin; + @override + @JsonKey() final bool loading; @override final String? tempSeerrUrl; @@ -388,7 +422,7 @@ class _LoginScreenModel implements LoginScreenModel { @override String toString() { - return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, loading: $loading, tempSeerrUrl: $tempSeerrUrl, tempSeerrSessionCookie: $tempSeerrSessionCookie)'; + return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, hasSeerrUrl: $hasSeerrUrl, hidePasswordLogin: $hidePasswordLogin, loading: $loading, tempSeerrUrl: $tempSeerrUrl, tempSeerrSessionCookie: $tempSeerrSessionCookie)'; } } @@ -406,6 +440,8 @@ abstract mixin class _$LoginScreenModelCopyWith<$Res> ServerLoginModel? serverLoginModel, String? errorMessage, bool hasBaseUrl, + bool hasSeerrUrl, + bool hidePasswordLogin, bool loading, String? tempSeerrUrl, String? tempSeerrSessionCookie}); @@ -432,6 +468,8 @@ class __$LoginScreenModelCopyWithImpl<$Res> Object? serverLoginModel = freezed, Object? errorMessage = freezed, Object? hasBaseUrl = null, + Object? hasSeerrUrl = null, + Object? hidePasswordLogin = null, Object? loading = null, Object? tempSeerrUrl = freezed, Object? tempSeerrSessionCookie = freezed, @@ -457,6 +495,14 @@ class __$LoginScreenModelCopyWithImpl<$Res> ? _self.hasBaseUrl : hasBaseUrl // ignore: cast_nullable_to_non_nullable as bool, + hasSeerrUrl: null == hasSeerrUrl + ? _self.hasSeerrUrl + : hasSeerrUrl // ignore: cast_nullable_to_non_nullable + as bool, + hidePasswordLogin: null == hidePasswordLogin + ? _self.hidePasswordLogin + : hidePasswordLogin // ignore: cast_nullable_to_non_nullable + as bool, loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/settings/client_settings_model.dart b/lib/models/settings/client_settings_model.dart index ecbbf51cc..a0b5568a9 100644 --- a/lib/models/settings/client_settings_model.dart +++ b/lib/models/settings/client_settings_model.dart @@ -89,6 +89,7 @@ abstract class ClientSettingsModel with _$ClientSettingsModel { @Default(false) bool usePosterForLibrary, @Default(false) bool useSystemIME, @Default(false) bool useTVExpandedLayout, + @Default(false) bool hidePasswordLogin, String? lastViewedUpdate, int? libraryPageSize, @Default({}) Map shortcuts, diff --git a/lib/models/settings/client_settings_model.freezed.dart b/lib/models/settings/client_settings_model.freezed.dart index 5f5913736..a8fa6015e 100644 --- a/lib/models/settings/client_settings_model.freezed.dart +++ b/lib/models/settings/client_settings_model.freezed.dart @@ -43,6 +43,7 @@ mixin _$ClientSettingsModel implements DiagnosticableTreeMixin { bool get usePosterForLibrary; bool get useSystemIME; bool get useTVExpandedLayout; + bool get hidePasswordLogin; String? get lastViewedUpdate; int? get libraryPageSize; Map get shortcuts; @@ -93,6 +94,7 @@ mixin _$ClientSettingsModel implements DiagnosticableTreeMixin { ..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary)) ..add(DiagnosticsProperty('useSystemIME', useSystemIME)) ..add(DiagnosticsProperty('useTVExpandedLayout', useTVExpandedLayout)) + ..add(DiagnosticsProperty('hidePasswordLogin', hidePasswordLogin)) ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)) ..add(DiagnosticsProperty('shortcuts', shortcuts)); @@ -100,7 +102,7 @@ mixin _$ClientSettingsModel implements DiagnosticableTreeMixin { @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, updateNotificationsInterval: $updateNotificationsInterval, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, expandSideBar: $expandSideBar, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, enableBlurEffects: $enableBlurEffects, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, useSystemIME: $useSystemIME, useTVExpandedLayout: $useTVExpandedLayout, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; + return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, updateNotificationsInterval: $updateNotificationsInterval, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, expandSideBar: $expandSideBar, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, enableBlurEffects: $enableBlurEffects, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, useSystemIME: $useSystemIME, useTVExpandedLayout: $useTVExpandedLayout, hidePasswordLogin: $hidePasswordLogin, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; } } @@ -139,6 +141,7 @@ abstract mixin class $ClientSettingsModelCopyWith<$Res> { bool usePosterForLibrary, bool useSystemIME, bool useTVExpandedLayout, + bool hidePasswordLogin, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts}); @@ -185,6 +188,7 @@ class _$ClientSettingsModelCopyWithImpl<$Res> Object? usePosterForLibrary = null, Object? useSystemIME = null, Object? useTVExpandedLayout = null, + Object? hidePasswordLogin = null, Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, Object? shortcuts = null, @@ -302,6 +306,10 @@ class _$ClientSettingsModelCopyWithImpl<$Res> ? _self.useTVExpandedLayout : useTVExpandedLayout // ignore: cast_nullable_to_non_nullable as bool, + hidePasswordLogin: null == hidePasswordLogin + ? _self.hidePasswordLogin + : hidePasswordLogin // ignore: cast_nullable_to_non_nullable + as bool, lastViewedUpdate: freezed == lastViewedUpdate ? _self.lastViewedUpdate : lastViewedUpdate // ignore: cast_nullable_to_non_nullable @@ -440,6 +448,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { bool usePosterForLibrary, bool useSystemIME, bool useTVExpandedLayout, + bool hidePasswordLogin, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts)? @@ -478,6 +487,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { _that.usePosterForLibrary, _that.useSystemIME, _that.useTVExpandedLayout, + _that.hidePasswordLogin, _that.lastViewedUpdate, _that.libraryPageSize, _that.shortcuts); @@ -530,6 +540,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { bool usePosterForLibrary, bool useSystemIME, bool useTVExpandedLayout, + bool hidePasswordLogin, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts) @@ -567,6 +578,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { _that.usePosterForLibrary, _that.useSystemIME, _that.useTVExpandedLayout, + _that.hidePasswordLogin, _that.lastViewedUpdate, _that.libraryPageSize, _that.shortcuts); @@ -618,6 +630,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { bool usePosterForLibrary, bool useSystemIME, bool useTVExpandedLayout, + bool hidePasswordLogin, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts)? @@ -655,6 +668,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { _that.usePosterForLibrary, _that.useSystemIME, _that.useTVExpandedLayout, + _that.hidePasswordLogin, _that.lastViewedUpdate, _that.libraryPageSize, _that.shortcuts); @@ -697,6 +711,7 @@ class _ClientSettingsModel extends ClientSettingsModel this.usePosterForLibrary = false, this.useSystemIME = false, this.useTVExpandedLayout = false, + this.hidePasswordLogin = false, this.lastViewedUpdate, this.libraryPageSize, final Map shortcuts = const {}}) @@ -787,6 +802,9 @@ class _ClientSettingsModel extends ClientSettingsModel @JsonKey() final bool useTVExpandedLayout; @override + @JsonKey() + final bool hidePasswordLogin; + @override final String? lastViewedUpdate; @override final int? libraryPageSize; @@ -850,6 +868,7 @@ class _ClientSettingsModel extends ClientSettingsModel ..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary)) ..add(DiagnosticsProperty('useSystemIME', useSystemIME)) ..add(DiagnosticsProperty('useTVExpandedLayout', useTVExpandedLayout)) + ..add(DiagnosticsProperty('hidePasswordLogin', hidePasswordLogin)) ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)) ..add(DiagnosticsProperty('shortcuts', shortcuts)); @@ -857,7 +876,7 @@ class _ClientSettingsModel extends ClientSettingsModel @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, updateNotificationsInterval: $updateNotificationsInterval, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, expandSideBar: $expandSideBar, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, enableBlurEffects: $enableBlurEffects, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, useSystemIME: $useSystemIME, useTVExpandedLayout: $useTVExpandedLayout, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; + return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, updateNotificationsInterval: $updateNotificationsInterval, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, expandSideBar: $expandSideBar, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, enableBlurEffects: $enableBlurEffects, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, useSystemIME: $useSystemIME, useTVExpandedLayout: $useTVExpandedLayout, hidePasswordLogin: $hidePasswordLogin, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; } } @@ -898,6 +917,7 @@ abstract mixin class _$ClientSettingsModelCopyWith<$Res> bool usePosterForLibrary, bool useSystemIME, bool useTVExpandedLayout, + bool hidePasswordLogin, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts}); @@ -944,6 +964,7 @@ class __$ClientSettingsModelCopyWithImpl<$Res> Object? usePosterForLibrary = null, Object? useSystemIME = null, Object? useTVExpandedLayout = null, + Object? hidePasswordLogin = null, Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, Object? shortcuts = null, @@ -1061,6 +1082,10 @@ class __$ClientSettingsModelCopyWithImpl<$Res> ? _self.useTVExpandedLayout : useTVExpandedLayout // ignore: cast_nullable_to_non_nullable as bool, + hidePasswordLogin: null == hidePasswordLogin + ? _self.hidePasswordLogin + : hidePasswordLogin // ignore: cast_nullable_to_non_nullable + as bool, lastViewedUpdate: freezed == lastViewedUpdate ? _self.lastViewedUpdate : lastViewedUpdate // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/settings/client_settings_model.g.dart b/lib/models/settings/client_settings_model.g.dart index 507790c3c..2504a1b22 100644 --- a/lib/models/settings/client_settings_model.g.dart +++ b/lib/models/settings/client_settings_model.g.dart @@ -55,6 +55,7 @@ _ClientSettingsModel _$ClientSettingsModelFromJson(Map json) => usePosterForLibrary: json['usePosterForLibrary'] as bool? ?? false, useSystemIME: json['useSystemIME'] as bool? ?? false, useTVExpandedLayout: json['useTVExpandedLayout'] as bool? ?? false, + hidePasswordLogin: json['hidePasswordLogin'] as bool? ?? false, lastViewedUpdate: json['lastViewedUpdate'] as String?, libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(), shortcuts: (json['shortcuts'] as Map?)?.map( @@ -96,6 +97,7 @@ Map _$ClientSettingsModelToJson( 'usePosterForLibrary': instance.usePosterForLibrary, 'useSystemIME': instance.useSystemIME, 'useTVExpandedLayout': instance.useTVExpandedLayout, + 'hidePasswordLogin': instance.hidePasswordLogin, 'lastViewedUpdate': instance.lastViewedUpdate, 'libraryPageSize': instance.libraryPageSize, 'shortcuts': instance.shortcuts diff --git a/lib/providers/api_provider.dart b/lib/providers/api_provider.dart index 896ff6751..a159c7e6c 100644 --- a/lib/providers/api_provider.dart +++ b/lib/providers/api_provider.dart @@ -130,6 +130,23 @@ String normalizeUrl(String url) { } } +/// Probes a Seerr server URL by hitting /api/v1/status. +/// Returns the working URL (with scheme) or null. +Future probeSeerrUrl(String baseUrl) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + try { + final request = await client.getUrl(Uri.parse('$baseUrl/api/v1/status')); + final response = await request.close(); + if (response.statusCode >= 200 && response.statusCode < 400) { + return baseUrl; + } + } catch (_) { + } finally { + client.close(); + } + return null; +} + Uri? tryParseServerBaseUri(String? url) { if (url == null) return null; final trimmed = url.trim(); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index ad5cd29df..d4a4df00d 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -50,6 +50,13 @@ class AuthNotifier extends StateNotifier { await setServer(url); } } + if (FladderConfig.seerrUrl != null) { + state = state.copyWith(hasSeerrUrl: true); + setTempSeerrUrl(FladderConfig.seerrUrl); + } + if (FladderConfig.hidePasswordLogin == true) { + state = state.copyWith(hidePasswordLogin: true); + } state = state.copyWith( accounts: currentAccounts, screen: currentAccounts.isEmpty ? LoginScreenType.login : LoginScreenType.users, @@ -189,9 +196,21 @@ class AuthNotifier extends StateNotifier { } Future setServer(String server) async { - final url = (state.hasBaseUrl ? FladderConfig.baseUrl : server); - if (url == null || server.isEmpty) return; - await _fetchServerInfo(url); + if (state.hasBaseUrl) { + await _fetchServerInfo(FladderConfig.baseUrl!); + return; + } + final trimmed = server.trim(); + if (trimmed.isEmpty) return; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + await _fetchServerInfo(trimmed); + } else { + // Try https first, then http + await _fetchServerInfo('https://$trimmed'); + if (state.errorMessage != null) { + await _fetchServerInfo('http://$trimmed'); + } + } } List getSavedAccounts() { diff --git a/lib/providers/seerr/seerr_request_provider.g.dart b/lib/providers/seerr/seerr_request_provider.g.dart index 15a0d5144..b901cc25b 100644 --- a/lib/providers/seerr/seerr_request_provider.g.dart +++ b/lib/providers/seerr/seerr_request_provider.g.dart @@ -6,7 +6,7 @@ part of 'seerr_request_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$seerrRequestHash() => r'89604c3fa7ae8c3cd68218b8fe89c1df284aea5d'; +String _$seerrRequestHash() => r'5c36189f4c33f2b035b6dc23ddd53e134276b0f5'; /// See also [SeerrRequest]. @ProviderFor(SeerrRequest) diff --git a/lib/providers/seerr_api_provider.dart b/lib/providers/seerr_api_provider.dart index 3c542963f..e610c6e83 100644 --- a/lib/providers/seerr_api_provider.dart +++ b/lib/providers/seerr_api_provider.dart @@ -2,14 +2,18 @@ import 'dart:developer'; import 'dart:io'; import 'package:chopper/chopper.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:fladder/providers/connectivity_provider.dart'; +import 'package:fladder/util/fladder_config.dart'; import 'package:fladder/providers/seerr_service_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/seerr/seerr_chopper_service.dart'; import 'package:fladder/seerr/seerr_json_converter.dart'; +import 'package:fladder/util/seerr_http_client.dart' + if (dart.library.html) 'package:fladder/util/seerr_http_client_web.dart'; part 'seerr_api_provider.g.dart'; @@ -20,6 +24,7 @@ class SeerrApi extends _$SeerrApi { ref.watch(userProvider.select((u) => u?.seerrCredentials)); final chopperClient = ChopperClient( + client: createSeerrHttpClient(), converter: const SeerrJsonConverter(), interceptors: [ SeerrRequest(ref), @@ -56,7 +61,14 @@ class SeerrRequest implements Interceptor { final authHeaders = _authHeaders(apiKey: apiKey, cookie: cookie); final customHeaders = creds?.customHeaders ?? {}; final headers = {...authHeaders, ...customHeaders}; - final apiBaseUri = Uri.parse(serverUrl); + + final proxyPath = FladderConfig.seerrProxyPath; + final Uri apiBaseUri; + if (kIsWeb && proxyPath != null) { + apiBaseUri = Uri.base.resolve(proxyPath); + } else { + apiBaseUri = Uri.parse(serverUrl); + } Uri resolvedRequestUri; try { @@ -85,7 +97,8 @@ class SeerrRequest implements Interceptor { Map _authHeaders({required String apiKey, required String cookie}) { if (apiKey.isNotEmpty) return {'X-Api-Key': apiKey}; - if (cookie.isNotEmpty) return {'Cookie': cookie}; + if (cookie.isNotEmpty && cookie != kBrowserManagedCookie) return {'Cookie': cookie}; + if (cookie == kBrowserManagedCookie) return const {}; return const {}; } diff --git a/lib/providers/seerr_service_provider.dart b/lib/providers/seerr_service_provider.dart index 48e244bcc..511e4a844 100644 --- a/lib/providers/seerr_service_provider.dart +++ b/lib/providers/seerr_service_provider.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:chopper/chopper.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/items/images_models.dart'; @@ -11,6 +12,7 @@ import 'package:fladder/seerr/seerr_chopper_service.dart'; import 'package:fladder/seerr/seerr_models.dart'; const tmbdUrl = 'https://image.tmdb.org/t/p/original'; +const kBrowserManagedCookie = '__browser_managed__'; class SeerrService { SeerrService(this.ref, this._api); @@ -231,7 +233,9 @@ class SeerrService { ); } else { final movieResponse = await movieDetails(tmdbId: tmdbId, language: language); - if (!movieResponse.isSuccessful || movieResponse.body == null) return null; + if (!movieResponse.isSuccessful || movieResponse.body == null) { + return null; + } final details = movieResponse.body!; String? releaseYear; final releaseDate = details.releaseDate; @@ -632,7 +636,8 @@ class SeerrService { SeerrDashboardPosterModel? posterFromDiscoverItem(SeerrDiscoverItem item) => _posterFromDiscoverItem(item); - Future authenticateLocal({required String email, required String password, Map? headers}) async { + Future authenticateLocal( + {required String email, required String password, Map? headers}) async { final response = await _api.authenticateLocal( SeerrAuthLocalBody(email: email, password: password), headers: headers, @@ -647,14 +652,35 @@ class SeerrService { return cookie; } - Future authenticateJellyfin({required String username, required String password, Map? headers}) async { + Future authenticateJellyfin( + {required String username, required String password, Map? headers}) async { final response = await _authenticateJellyfin(username: username, password: password, headers: headers); return _requireSessionCookie(response, label: 'Jellyfin'); } + Future quickConnectInitiate() async { + final response = await _api.quickConnectInitiate(); + if (!response.isSuccessful) return null; + return response.body; + } + + Future quickConnectCheck(String secret) async { + final response = await _api.quickConnectCheck(secret); + return response.body?.authenticated ?? false; + } + + Future quickConnectAuthenticate(String secret) async { + final response = await _api.quickConnectAuthenticate( + SeerrQuickConnectAuthBody(secret: secret), + ); + if (!response.isSuccessful) return null; + return _extractSessionCookie(response); + } + Future logout() async => await _api.logout(); - Future> _authenticateJellyfin({required String username, required String password, Map? headers}) async { + Future> _authenticateJellyfin( + {required String username, required String password, Map? headers}) async { var response = await _api.authenticateJellyfin( SeerrAuthJellyfinBody(username: username, password: password), headers: headers, @@ -697,7 +723,9 @@ class SeerrService { String? _extractSessionCookie(Response response) { final setCookie = response.base.headers['set-cookie']; - if (setCookie == null || setCookie.isEmpty) return null; + if (setCookie == null || setCookie.isEmpty) { + return kIsWeb ? kBrowserManagedCookie : null; + } return setCookie.split(';').first.trim(); } } diff --git a/lib/screens/login/login_code_dialog.dart b/lib/screens/login/login_code_dialog.dart index 3d93b999a..107c2af2c 100644 --- a/lib/screens/login/login_code_dialog.dart +++ b/lib/screens/login/login_code_dialog.dart @@ -4,10 +4,14 @@ import 'package:async/async.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/auth_provider.dart'; +import 'package:fladder/screens/shared/media/external_urls.dart' as ext; +import 'package:fladder/util/clipboard_helper.dart'; +import 'package:fladder/util/fladder_config.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; Future openLoginCodeDialog( BuildContext context, { @@ -39,7 +43,9 @@ class LoginCodeDialog extends ConsumerStatefulWidget { class _LoginCodeDialogState extends ConsumerState { late QuickConnectResult quickConnectInfo = widget.quickConnectInfo; + static const _maxPollAttempts = 300; // ~5 minutes at 1s interval RestartableTimer? timer; + int _pollAttempts = 0; @override void initState() { @@ -55,16 +61,24 @@ class _LoginCodeDialogState extends ConsumerState { void createTimer() { timer?.cancel(); + _pollAttempts = 0; timer = RestartableTimer(const Duration(seconds: 1), () async { - final result = await ref.read(jellyApiProvider).quickConnectConnectGet( - secret: quickConnectInfo.secret, - ); - final newSecret = result.body?.secret; - if (result.isSuccessful && - result.body?.authenticated == true && - newSecret != null) { - widget.onAuthenticated.call(context, newSecret); - } else { + if (_pollAttempts >= _maxPollAttempts) { + timer?.cancel(); + return; + } + _pollAttempts++; + try { + final result = await ref.read(jellyApiProvider).quickConnectConnectGet( + secret: quickConnectInfo.secret, + ); + final newSecret = result.body?.secret; + if (result.isSuccessful && result.body?.authenticated == true && newSecret != null) { + widget.onAuthenticated.call(context, newSecret); + } else { + timer?.reset(); + } + } catch (_) { timer?.reset(); } }); @@ -73,8 +87,7 @@ class _LoginCodeDialogState extends ConsumerState { @override Widget build(BuildContext context) { final code = quickConnectInfo.code; - final serverName = ref.watch(authProvider - .select((value) => value.serverLoginModel?.tempCredentials.serverName)); + final serverName = ref.watch(authProvider.select((value) => value.serverLoginModel?.tempCredentials.serverName)); return Dialog( constraints: const BoxConstraints( maxWidth: 500, @@ -102,29 +115,42 @@ class _LoginCodeDialogState extends ConsumerState { style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), - IntrinsicWidth( - child: Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - code, - style: - Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - wordSpacing: 8, - letterSpacing: 8, - ), - textAlign: TextAlign.center, - semanticsLabel: code, + GestureDetector( + onTap: () => context.copyToClipboard(code), + child: IntrinsicWidth( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + code, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + wordSpacing: 8, + letterSpacing: 8, + ), + textAlign: TextAlign.center, + semanticsLabel: code, + ), ), ), ), ), + TextButton.icon( + onPressed: () async { + final baseUrl = + FladderConfig.baseUrl ?? ref.read(authProvider).serverLoginModel?.tempCredentials.url; + if (baseUrl != null && baseUrl.isNotEmpty) { + await ext.launchUrl(context, '$baseUrl/web/#/quickconnect'); + timer?.reset(); + } + }, + icon: const Icon(IconsaxPlusLinear.export_1), + label: Text(context.localized.openJellyfinQuickConnect), + ), ], FilledButton( onPressed: () async { - final response = - await ref.read(jellyApiProvider).quickConnectInitiate(); + final response = await ref.read(jellyApiProvider).quickConnectInitiate(); if (response.isSuccessful && response.body != null) { setState(() { quickConnectInfo = response.body!; diff --git a/lib/screens/login/login_screen_credentials.dart b/lib/screens/login/login_screen_credentials.dart index f0fc3050a..5be45f2d8 100644 --- a/lib/screens/login/login_screen_credentials.dart +++ b/lib/screens/login/login_screen_credentials.dart @@ -10,6 +10,7 @@ import 'package:fladder/models/account_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/auth_provider.dart'; import 'package:fladder/providers/seerr_api_provider.dart'; +import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; @@ -60,6 +61,11 @@ class _LoginScreenCredentialsState extends ConsumerState final hasBaseUrl = ref.watch(authProvider.select((value) => value.hasBaseUrl)); final urlError = ref.watch(authProvider.select((value) => value.errorMessage)); final hasQuickConnect = ref.watch(authProvider.select((value) => value.serverLoginModel?.hasQuickConnect ?? false)); + // Note: hidePasswordLogin is a UI preference, not a security control. + // It hides the password fields but does not disable password-based authentication on the server. + final hidePasswordLogin = ref.watch(authProvider.select((value) => value.hidePasswordLogin)) || + ref.watch(clientSettingsProvider.select((value) => value.hidePasswordLogin)); + final hasSeerrUrl = ref.watch(authProvider.select((value) => value.hasSeerrUrl)); ref.listen( authProvider.select((value) => value.serverLoginModel), @@ -76,7 +82,7 @@ class _LoginScreenCredentialsState extends ConsumerState spacing: 16, children: [ Row( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: hasBaseUrl ? MainAxisAlignment.center : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ @@ -145,82 +151,88 @@ class _LoginScreenCredentialsState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 8, children: [ - Flexible( - child: AutofillGroup( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 8, - children: [ - Flexible( - child: OutlinedTextField( - controller: usernameController, - autoFillHints: const [AutofillHints.username], - textInputAction: TextInputAction.next, - autocorrect: false, - onChanged: (value) => setState(() {}), - label: context.localized.userName, + if (!hidePasswordLogin) + Flexible( + child: AutofillGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Flexible( + child: OutlinedTextField( + controller: usernameController, + autoFillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + autocorrect: false, + onChanged: (value) => setState(() {}), + label: context.localized.userName, + ), ), - ), - Flexible( - child: OutlinedTextField( - controller: passwordController, - autoFillHints: const [AutofillHints.password], - keyboardType: TextInputType.visiblePassword, - focusNode: focusNode, - autocorrect: false, - textInputAction: TextInputAction.send, - onSubmitted: (value) => enterCredentialsTryLogin?.call(), - onChanged: (value) => setState(() {}), - label: context.localized.password, + Flexible( + child: OutlinedTextField( + controller: passwordController, + autoFillHints: const [AutofillHints.password], + keyboardType: TextInputType.visiblePassword, + focusNode: focusNode, + autocorrect: false, + textInputAction: TextInputAction.send, + onSubmitted: (value) => enterCredentialsTryLogin?.call(), + onChanged: (value) => setState(() {}), + label: context.localized.password, + ), ), - ), - ], + ], + ), ), ), - ), - const Divider( - indent: 32, - endIndent: 32, - ), - Row( - spacing: 8, - children: [ - Expanded( - child: FilledButton( - onPressed: enterCredentialsTryLogin, - child: loggingIn - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.inversePrimary, strokeCap: StrokeCap.round), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.localized.login), - const SizedBox(width: 8), - const Icon(IconsaxPlusBold.send_1), - ], - ), + if (!hidePasswordLogin) ...[ + const Divider( + indent: 32, + endIndent: 32, + ), + Row( + spacing: 8, + children: [ + Expanded( + child: FilledButton( + onPressed: enterCredentialsTryLogin, + child: loggingIn + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.inversePrimary, + strokeCap: StrokeCap.round), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.localized.login), + const SizedBox(width: 8), + const Icon(IconsaxPlusBold.send_1), + ], + ), + ), + ), + if (!hasSeerrUrl) + IconButton.filledTonal( + onPressed: _openAdvancedLoginOptions, + icon: const Icon(IconsaxPlusLinear.setting_3), + ), + ], + ), + ], + if (hidePasswordLogin && !hasSeerrUrl) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.filledTonal( + onPressed: _openAdvancedLoginOptions, + icon: const Icon(IconsaxPlusLinear.setting_3), ), - ), - IconButton.filledTonal( - onPressed: () async { - final tempSeerrUrl = ref.read(authProvider.select((value) => value.tempSeerrUrl)); - final result = await showAdvancedLoginOptionsDialog( - context, - initialSeerrUrl: tempSeerrUrl, - ); - if (result != null) { - ref.read(authProvider.notifier).setTempSeerrUrl(result); - } - }, - icon: const Icon(IconsaxPlusLinear.setting_3), - ), - ], - ), + ], + ), if (hasQuickConnect) FilledButton( onPressed: () async { @@ -266,6 +278,19 @@ class _LoginScreenCredentialsState extends ConsumerState ); } + Future _openAdvancedLoginOptions() async { + final tempSeerrUrl = ref.read(authProvider.select((value) => value.tempSeerrUrl)); + final hasSeerrUrl = ref.read(authProvider.select((value) => value.hasSeerrUrl)); + final result = await showAdvancedLoginOptionsDialog( + context, + initialSeerrUrl: tempSeerrUrl, + hasSeerrUrl: hasSeerrUrl, + ); + if (result != null) { + ref.read(authProvider.notifier).setTempSeerrUrl(result); + } + } + Future Function()? get enterCredentialsTryLogin => emptyFields() ? null : () => loginUsingCredentials(); Future loginUsingCredentials() async { @@ -307,25 +332,32 @@ class _LoginScreenCredentialsState extends ConsumerState Future _tryAuthenticateSeerr(String seerrUrl) async { try { - final username = usernameController.text.trim(); - final password = passwordController.text; - ref.read(userProvider.notifier).setSeerrServerUrl(seerrUrl); final tempCookie = ref.read(authProvider.select((value) => value.tempSeerrSessionCookie)); - final cookie = tempCookie ?? - await ref.read(seerrApiProvider).authenticateJellyfin( - username: username, - password: password, - ); - - ref.read(userProvider.notifier).setSeerrSessionCookie(cookie); - ref.read(userProvider.notifier).setSeerrApiKey(''); - ref.read(authProvider.notifier).setTempSeerrSessionCookie(null); + if (tempCookie != null) { + ref.read(userProvider.notifier).setSeerrSessionCookie(tempCookie); + ref.read(userProvider.notifier).setSeerrApiKey(''); + ref.read(authProvider.notifier).setTempSeerrSessionCookie(null); + if (context.mounted) { + FladderSnack.show(context.localized.seerrLoggedIn, context: context); + } + return; + } - if (context.mounted) { - FladderSnack.show(context.localized.seerrLoggedIn, context: context); + final password = passwordController.text; + if (password.isNotEmpty) { + final cookie = await ref.read(seerrApiProvider).authenticateJellyfin( + username: usernameController.text.trim(), + password: password, + ); + ref.read(userProvider.notifier).setSeerrSessionCookie(cookie); + ref.read(userProvider.notifier).setSeerrApiKey(''); + if (context.mounted) { + FladderSnack.show(context.localized.seerrLoggedIn, context: context); + } } + // No password (QC login) → skip auto-auth, user can authenticate via settings } catch (e) { if (context.mounted) { FladderSnack.show( @@ -344,7 +376,13 @@ class _LoginScreenCredentialsState extends ConsumerState ref.read(authProvider.notifier).authenticateUsingSecret(secret), ); if (response.isSuccess && context.mounted) { - loggedInGoToHome(context, ref); + final tempSeerrUrl = ref.read(authProvider.select((value) => value.tempSeerrUrl)); + if (tempSeerrUrl != null && tempSeerrUrl.isNotEmpty) { + await _tryAuthenticateSeerr(tempSeerrUrl); + } + if (context.mounted) { + loggedInGoToHome(context, ref); + } } setState(() { loggingIn = false; diff --git a/lib/screens/login/widgets/advanced_login_options_dialog.dart b/lib/screens/login/widgets/advanced_login_options_dialog.dart index 6c2373154..31286bfad 100644 --- a/lib/screens/login/widgets/advanced_login_options_dialog.dart +++ b/lib/screens/login/widgets/advanced_login_options_dialog.dart @@ -3,21 +3,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/screens/shared/outlined_text_field.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/localization_helper.dart'; -Future showAdvancedLoginOptionsDialog(BuildContext context, {String? initialSeerrUrl}) async { +Future showAdvancedLoginOptionsDialog( + BuildContext context, { + String? initialSeerrUrl, + bool hasSeerrUrl = false, +}) async { return await showDialog( context: context, - builder: (context) => _AdvancedLoginOptionsDialog(initialSeerrUrl: initialSeerrUrl), + builder: (context) => _AdvancedLoginOptionsDialog( + initialSeerrUrl: initialSeerrUrl, + hasSeerrUrl: hasSeerrUrl, + ), ); } class _AdvancedLoginOptionsDialog extends ConsumerStatefulWidget { final String? initialSeerrUrl; + final bool hasSeerrUrl; - const _AdvancedLoginOptionsDialog({this.initialSeerrUrl}); + const _AdvancedLoginOptionsDialog({this.initialSeerrUrl, this.hasSeerrUrl = false}); @override ConsumerState<_AdvancedLoginOptionsDialog> createState() => _AdvancedLoginOptionsDialogState(); @@ -25,6 +34,7 @@ class _AdvancedLoginOptionsDialog extends ConsumerStatefulWidget { class _AdvancedLoginOptionsDialogState extends ConsumerState<_AdvancedLoginOptionsDialog> { late final TextEditingController seerrUrlController = TextEditingController(text: widget.initialSeerrUrl ?? ''); + bool _probing = false; @override void dispose() { @@ -55,6 +65,7 @@ class _AdvancedLoginOptionsDialogState extends ConsumerState<_AdvancedLoginOptio textInputAction: TextInputAction.done, autoFillHints: const [AutofillHints.url], autocorrect: false, + enabled: !widget.hasSeerrUrl, label: context.localized.seerrServer, onSubmitted: (_) => _save(), ), @@ -68,14 +79,36 @@ class _AdvancedLoginOptionsDialogState extends ConsumerState<_AdvancedLoginOptio child: Text(context.localized.cancel), ), FilledButton( - onPressed: _save, - child: Text(context.localized.save), + onPressed: _probing ? null : _save, + child: _probing + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : Text(context.localized.save), ), ], ); } - void _save() { - Navigator.of(context).pop(seerrUrlController.text.trim()); + Future _save() async { + final url = seerrUrlController.text.trim(); + if (url.isEmpty) { + Navigator.of(context).pop(url); + return; + } + final hasScheme = url.startsWith('http://') || url.startsWith('https://'); + if (!hasScheme) { + setState(() => _probing = true); + final httpsUrl = normalizeUrl('https://$url'); + final httpUrl = normalizeUrl('http://$url'); + final result = await probeSeerrUrl(httpsUrl) ?? await probeSeerrUrl(httpUrl); + if (!mounted) return; + setState(() => _probing = false); + if (result != null) { + Navigator.of(context).pop(result); + } else { + Navigator.of(context).pop(normalizeUrl(url)); + } + } else { + Navigator.of(context).pop(normalizeUrl(url)); + } } } diff --git a/lib/screens/settings/client_sections/client_settings_advanced.dart b/lib/screens/settings/client_sections/client_settings_advanced.dart index b0e04ed08..1e59b9567 100644 --- a/lib/screens/settings/client_sections/client_settings_advanced.dart +++ b/lib/screens/settings/client_sections/client_settings_advanced.dart @@ -5,6 +5,7 @@ import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/home_settings_provider.dart'; +import 'package:fladder/util/fladder_config.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart'; import 'package:fladder/screens/settings/widgets/settings_label_divider.dart'; import 'package:fladder/screens/settings/widgets/settings_list_group.dart'; @@ -115,6 +116,24 @@ List buildClientSettingsAdvanced(BuildContext context, WidgetRef ref) { onChanged: (value) => ref.read(clientSettingsProvider.notifier).useSystemIME(value), ), ), + SettingsListTile( + label: Text(context.localized.hidePasswordLogin), + subLabel: Text(context.localized.hidePasswordLoginDescription), + onTap: FladderConfig.hidePasswordLogin != null + ? null + : () => ref + .read(clientSettingsProvider.notifier) + .update((current) => current.copyWith(hidePasswordLogin: !current.hidePasswordLogin)), + trailing: Switch( + value: FladderConfig.hidePasswordLogin ?? + ref.watch(clientSettingsProvider.select((value) => value.hidePasswordLogin)), + onChanged: FladderConfig.hidePasswordLogin != null + ? null + : (value) => ref + .read(clientSettingsProvider.notifier) + .update((current) => current.copyWith(hidePasswordLogin: value)), + ), + ), ], ); } diff --git a/lib/screens/settings/widgets/seerr_connection_dialog.dart b/lib/screens/settings/widgets/seerr_connection_dialog.dart index ccb2d54cc..59b48dcac 100644 --- a/lib/screens/settings/widgets/seerr_connection_dialog.dart +++ b/lib/screens/settings/widgets/seerr_connection_dialog.dart @@ -1,19 +1,26 @@ +import 'dart:async'; + +import 'package:async/async.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/seerr_api_provider.dart'; import 'package:fladder/providers/seerr_dashboard_provider.dart'; import 'package:fladder/providers/seerr_user_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/screens/shared/adaptive_dialog.dart'; +import 'package:fladder/screens/shared/media/external_urls.dart' as ext; import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; import 'package:fladder/screens/shared/focused_outlined_text_field.dart'; import 'package:fladder/screens/shared/outlined_text_field.dart'; import 'package:fladder/seerr/seerr_models.dart'; +import 'package:fladder/util/clipboard_helper.dart'; +import 'package:fladder/util/fladder_config.dart'; import 'package:fladder/util/localization_helper.dart'; Future showSeerrConnectionDialog(BuildContext context) { @@ -26,12 +33,14 @@ Future showSeerrConnectionDialog(BuildContext context) { enum SeerrAuthTab { jellyfin, local, - apiKey; + apiKey, + quickConnect; String label(BuildContext context) => switch (this) { SeerrAuthTab.apiKey => context.localized.seerrAuthApiKey, SeerrAuthTab.local => context.localized.seerrAuthLocal, SeerrAuthTab.jellyfin => context.localized.seerrAuthJellyfin, + SeerrAuthTab.quickConnect => context.localized.seerrAuthQuickConnect, }; } @@ -58,12 +67,19 @@ class _SeerrConnectionDialogState extends ConsumerState { bool processing = false; String? error; + // QuickConnect state + static const _maxPollAttempts = 150; // ~5 minutes at 2s interval + String? _qcCode; + String? _qcSecret; + RestartableTimer? _qcTimer; + int _qcPollAttempts = 0; + @override void initState() { super.initState(); final creds = ref.read(userProvider)?.seerrCredentials; apiKeyController = TextEditingController(text: creds?.apiKey ?? ''); - serverController = TextEditingController(text: creds?.serverUrl ?? ''); + serverController = TextEditingController(text: FladderConfig.seerrUrl ?? creds?.serverUrl ?? ''); localEmailController = TextEditingController(); localPasswordController = TextEditingController(); jfUsernameController = TextEditingController(); @@ -76,6 +92,9 @@ class _SeerrConnectionDialogState extends ConsumerState { @override void dispose() { + _qcTimer?.cancel(); + _qcSecret = null; + _qcCode = null; apiKeyController.dispose(); serverController.dispose(); localEmailController.dispose(); @@ -154,9 +173,9 @@ class _SeerrConnectionDialogState extends ConsumerState { } } - bool _applyServerUrl({bool showError = true}) { - final serverUrl = serverController.text.trim(); - if (serverUrl.isEmpty) { + Future _applyServerUrl({bool showError = true}) async { + final rawUrl = FladderConfig.seerrUrl ?? serverController.text.trim(); + if (rawUrl.isEmpty) { if (showError && mounted) { setState(() { error = context.localized.seerrEnterServerUrlFirst; @@ -164,12 +183,36 @@ class _SeerrConnectionDialogState extends ConsumerState { } return false; } + + String serverUrl; + final hasScheme = rawUrl.startsWith('http://') || rawUrl.startsWith('https://'); + if (!hasScheme) { + // Probe https first, then http + final httpsUrl = normalizeUrl('https://$rawUrl'); + final httpUrl = normalizeUrl('http://$rawUrl'); + final result = await probeSeerrUrl(httpsUrl) ?? await probeSeerrUrl(httpUrl); + if (result == null) { + if (showError && mounted) { + setState(() { + error = context.localized.seerrEnterServerUrlFirst; + }); + } + return false; + } + serverUrl = result; + } else { + serverUrl = normalizeUrl(rawUrl); + } + + if (serverUrl != rawUrl) { + serverController.text = serverUrl; + } ref.read(userProvider.notifier).setSeerrServerUrl(serverUrl); return true; } Future _useApiKey() async { - if (!_applyServerUrl()) return; + if (!await _applyServerUrl()) return; setState(() { processing = true; error = null; @@ -196,7 +239,7 @@ class _SeerrConnectionDialogState extends ConsumerState { } Future _loginLocal() async { - if (!_applyServerUrl()) return; + if (!await _applyServerUrl()) return; setState(() { processing = true; error = null; @@ -230,7 +273,7 @@ class _SeerrConnectionDialogState extends ConsumerState { } Future _loginJellyfin() async { - if (!_applyServerUrl()) return; + if (!await _applyServerUrl()) return; setState(() { processing = true; error = null; @@ -263,8 +306,110 @@ class _SeerrConnectionDialogState extends ConsumerState { } } + Future _quickConnectInitiate() async { + if (!await _applyServerUrl()) return; + setState(() { + processing = true; + error = null; + _qcCode = null; + _qcSecret = null; + }); + _qcTimer?.cancel(); + + try { + final result = await ref.read(seerrApiProvider).quickConnectInitiate(); + if (!mounted) return; + if (result == null) { + setState(() { + error = context.localized.quickConnectPostFailed; + processing = false; + }); + return; + } + setState(() { + _qcCode = result.code; + _qcSecret = result.secret; + processing = false; + }); + _startQcPolling(); + } catch (e) { + if (mounted) { + setState(() { + error = e.toString(); + processing = false; + }); + } + } + } + + void _startQcPolling() { + _qcTimer?.cancel(); + _qcPollAttempts = 0; + final secret = _qcSecret; + if (secret == null) return; + _qcTimer = RestartableTimer(const Duration(seconds: 2), () async { + if (_qcPollAttempts >= _maxPollAttempts) { + _qcTimer?.cancel(); + return; + } + _qcPollAttempts++; + try { + final authenticated = await ref.read(seerrApiProvider).quickConnectCheck(secret); + if (!mounted) return; + if (authenticated) { + await _quickConnectAuthenticate(secret); + } else { + _qcTimer?.reset(); + } + } catch (_) { + if (mounted) _qcTimer?.reset(); + } + }); + } + + Future _quickConnectAuthenticate(String secret) async { + _qcTimer?.cancel(); + setState(() { + processing = true; + error = null; + }); + + try { + final cookie = await ref.read(seerrApiProvider).quickConnectAuthenticate(secret); + if (!mounted) return; + if (cookie == null || cookie.isEmpty) { + setState(() { + error = context.localized.seerrUserFetchFailed; + processing = false; + }); + return; + } + ref.read(userProvider.notifier).setSeerrSessionCookie(cookie); + ref.read(userProvider.notifier).setSeerrApiKey(''); + await _refreshSession(); + if (mounted) { + FladderSnack.show(context.localized.seerrLoggedIn, context: context); + } + } catch (e) { + if (mounted) { + setState(() { + error = e.toString(); + }); + } + } finally { + if (mounted) { + setState(() { + processing = false; + _qcCode = null; + _qcSecret = null; + }); + ref.read(seerrDashboardProvider.notifier).clear(); + } + } + } + Future _logout() async { - _applyServerUrl(showError: false); + await _applyServerUrl(showError: false); setState(() { processing = true; error = null; @@ -386,8 +531,9 @@ class _SeerrConnectionDialogState extends ConsumerState { controller: serverController, keyboardType: TextInputType.url, textInputAction: TextInputAction.next, - onSubmitted: (_) { - _applyServerUrl(); + enabled: FladderConfig.seerrUrl == null, + onSubmitted: (_) async { + await _applyServerUrl(); _refreshSession(); }, ), @@ -444,22 +590,25 @@ class _SeerrConnectionDialogState extends ConsumerState { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: SegmentedButton( - segments: SeerrAuthTab.values - .map( - (tab) => ButtonSegment( - value: tab, - label: Text(tab.label(context)), - ), - ) - .toList(), - selected: {selectedTab}, - onSelectionChanged: (value) { - setState(() { - selectedTab = value.first; - }); - }, - showSelectedIcon: false, + child: SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: SeerrAuthTab.values + .map( + (tab) => ButtonSegment( + value: tab, + label: Text(tab.label(context)), + ), + ) + .toList(), + selected: {selectedTab}, + onSelectionChanged: (value) { + setState(() { + selectedTab = value.first; + }); + }, + showSelectedIcon: false, + ), ), ), AnimatedFadeSize(child: _authForm()), @@ -570,6 +719,63 @@ class _SeerrConnectionDialogState extends ConsumerState { ), ], ); + case SeerrAuthTab.quickConnect: + return Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + if (_qcCode != null) ...[ + Text( + context.localized.quickConnectEnterCodeDescription, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + GestureDetector( + onTap: () => context.copyToClipboard(_qcCode!), + child: IntrinsicWidth( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + _qcCode!, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + wordSpacing: 8, + letterSpacing: 8, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + TextButton.icon( + onPressed: () async { + final baseUrl = FladderConfig.baseUrl ?? ref.read(userProvider)?.credentials.url; + if (baseUrl != null && baseUrl.isNotEmpty) { + await ext.launchUrl(context, '$baseUrl/web/#/quickconnect'); + _qcTimer?.reset(); + } + }, + icon: const Icon(IconsaxPlusLinear.export_1), + label: Text(context.localized.openJellyfinQuickConnect), + ), + ], + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: FilledButton( + onPressed: processing ? null : _quickConnectInitiate, + child: processing + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator()) + : Text(_qcCode != null ? context.localized.refresh : context.localized.quickConnectTitle), + ), + ), + ], + ), + ], + ); } } diff --git a/lib/seerr/seerr_chopper_service.chopper.dart b/lib/seerr/seerr_chopper_service.chopper.dart index 2fdb09ed5..0c33846cb 100644 --- a/lib/seerr/seerr_chopper_service.chopper.dart +++ b/lib/seerr/seerr_chopper_service.chopper.dart @@ -72,6 +72,43 @@ final class _$SeerrChopperService extends SeerrChopperService { return client.send($request); } + @override + Future> quickConnectInitiate() { + final Uri $url = Uri.parse('/api/v1/auth/jellyfin/quickconnect/initiate'); + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + ); + return client.send($request); + } + + @override + Future> quickConnectCheck(String secret) { + final Uri $url = Uri.parse('/api/v1/auth/jellyfin/quickconnect/check'); + final Map $params = {'secret': secret}; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + return client.send($request); + } + + @override + Future> quickConnectAuthenticate(SeerrQuickConnectAuthBody body) { + final Uri $url = Uri.parse('/api/v1/auth/jellyfin/quickconnect/authenticate'); + final $body = body; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + return client.send($request); + } + @override Future> logout() { final Uri $url = Uri.parse('/api/v1/auth/logout'); @@ -102,8 +139,7 @@ final class _$SeerrChopperService extends SeerrChopperService { $url, client.baseUrl, ); - return client - .send($request); + return client.send($request); } @override @@ -125,8 +161,7 @@ final class _$SeerrChopperService extends SeerrChopperService { $url, client.baseUrl, ); - return client - .send($request); + return client.send($request); } @override @@ -156,9 +191,7 @@ final class _$SeerrChopperService extends SeerrChopperService { String? language, }) { final Uri $url = Uri.parse('/api/v1/movie/${movieId}'); - final Map $params = { - 'language': language - }; + final Map $params = {'language': language}; final Request $request = Request( 'GET', $url, @@ -174,9 +207,7 @@ final class _$SeerrChopperService extends SeerrChopperService { String? language, }) { final Uri $url = Uri.parse('/api/v1/tv/${tvId}'); - final Map $params = { - 'language': language - }; + final Map $params = {'language': language}; final Request $request = Request( 'GET', $url, @@ -193,9 +224,7 @@ final class _$SeerrChopperService extends SeerrChopperService { String? language, }) { final Uri $url = Uri.parse('/api/v1/tv/${tvId}/season/${seasonNumber}'); - final Map $params = { - 'language': language - }; + final Map $params = {'language': language}; final Request $request = Request( 'GET', $url, @@ -264,8 +293,7 @@ final class _$SeerrChopperService extends SeerrChopperService { } @override - Future> createRequest( - SeerrCreateRequestBody body) { + Future> createRequest(SeerrCreateRequestBody body) { final Uri $url = Uri.parse('/api/v1/request'); final $body = body; final Request $request = Request( @@ -531,9 +559,7 @@ final class _$SeerrChopperService extends SeerrChopperService { String? language, }) { final Uri $url = Uri.parse('/api/v1/movie/${movieId}/similar'); - final Map $params = { - 'language': language - }; + final Map $params = {'language': language}; final Request $request = Request( 'GET', $url, @@ -549,9 +575,7 @@ final class _$SeerrChopperService extends SeerrChopperService { String? language, }) { final Uri $url = Uri.parse('/api/v1/tv/${tvId}/similar'); - final Map $params = { - 'language': language - }; + final Map $params = {'language': language}; final Request $request = Request( 'GET', $url, @@ -567,9 +591,7 @@ final class _$SeerrChopperService extends SeerrChopperService { String? language, }) { final Uri $url = Uri.parse('/api/v1/movie/${movieId}/recommendations'); - final Map $params = { - 'language': language - }; + final Map $params = {'language': language}; final Request $request = Request( 'GET', $url, @@ -607,9 +629,7 @@ final class _$SeerrChopperService extends SeerrChopperService { String? language, }) { final Uri $url = Uri.parse('/api/v1/tv/${tvId}/recommendations'); - final Map $params = { - 'language': language - }; + final Map $params = {'language': language}; final Request $request = Request( 'GET', $url, @@ -656,8 +676,7 @@ final class _$SeerrChopperService extends SeerrChopperService { client.baseUrl, parameters: $params, ); - return client - .send($request); + return client.send($request); } @override @@ -683,12 +702,9 @@ final class _$SeerrChopperService extends SeerrChopperService { } @override - Future>> getMovieWatchProviders( - {String? watchRegion}) { + Future>> getMovieWatchProviders({String? watchRegion}) { final Uri $url = Uri.parse('/api/v1/watchproviders/movies'); - final Map $params = { - 'watchRegion': watchRegion - }; + final Map $params = {'watchRegion': watchRegion}; final Request $request = Request( 'GET', $url, @@ -699,12 +715,9 @@ final class _$SeerrChopperService extends SeerrChopperService { } @override - Future>> getTvWatchProviders( - {String? watchRegion}) { + Future>> getTvWatchProviders({String? watchRegion}) { final Uri $url = Uri.parse('/api/v1/watchproviders/tv'); - final Map $params = { - 'watchRegion': watchRegion - }; + final Map $params = {'watchRegion': watchRegion}; final Request $request = Request( 'GET', $url, @@ -722,8 +735,7 @@ final class _$SeerrChopperService extends SeerrChopperService { $url, client.baseUrl, ); - return client.send, - SeerrWatchProviderRegion>($request); + return client.send, SeerrWatchProviderRegion>($request); } @override @@ -734,8 +746,7 @@ final class _$SeerrChopperService extends SeerrChopperService { $url, client.baseUrl, ); - return client.send($request); + return client.send($request); } @override @@ -746,7 +757,6 @@ final class _$SeerrChopperService extends SeerrChopperService { $url, client.baseUrl, ); - return client.send($request); + return client.send($request); } } diff --git a/lib/seerr/seerr_chopper_service.dart b/lib/seerr/seerr_chopper_service.dart index 0f489476e..a7ac1c901 100644 --- a/lib/seerr/seerr_chopper_service.dart +++ b/lib/seerr/seerr_chopper_service.dart @@ -28,6 +28,21 @@ abstract class SeerrChopperService extends ChopperService { Map? headers, }); + @POST(path: '/auth/jellyfin/quickconnect/initiate') + Future> quickConnectInitiate(); + + // Security note: secret is passed as a query parameter per the Seerr API spec. + // Ensure the Seerr server URL uses HTTPS in production to protect this value in transit. + @GET(path: '/auth/jellyfin/quickconnect/check') + Future> quickConnectCheck( + @Query('secret') String secret, + ); + + @POST(path: '/auth/jellyfin/quickconnect/authenticate') + Future> quickConnectAuthenticate( + @Body() SeerrQuickConnectAuthBody body, + ); + @POST(path: '/auth/logout') Future> logout(); diff --git a/lib/seerr/seerr_json_converter.dart b/lib/seerr/seerr_json_converter.dart index 945e15cb5..e1ba0b714 100644 --- a/lib/seerr/seerr_json_converter.dart +++ b/lib/seerr/seerr_json_converter.dart @@ -40,6 +40,9 @@ class SeerrJsonConverter extends JsonConverter { SeerrUsersResponse: SeerrUsersResponse.fromJson, SeerrAuthLocalBody: SeerrAuthLocalBody.fromJson, SeerrAuthJellyfinBody: SeerrAuthJellyfinBody.fromJson, + SeerrQuickConnectInitResponse: SeerrQuickConnectInitResponse.fromJson, + SeerrQuickConnectCheckResponse: SeerrQuickConnectCheckResponse.fromJson, + SeerrQuickConnectAuthBody: SeerrQuickConnectAuthBody.fromJson, SeerrGenreResponse: SeerrGenreResponse.fromJson, SeerrWatchProvider: SeerrWatchProvider.fromJson, SeerrWatchProviderRegion: SeerrWatchProviderRegion.fromJson, diff --git a/lib/seerr/seerr_models.dart b/lib/seerr/seerr_models.dart index 1a36286d9..84122d31c 100644 --- a/lib/seerr/seerr_models.dart +++ b/lib/seerr/seerr_models.dart @@ -1418,6 +1418,46 @@ class SeerrAuthJellyfinBody { Map toJson() => _$SeerrAuthJellyfinBodyToJson(this); } +@JsonSerializable() +class SeerrQuickConnectInitResponse { + final String code; + final String secret; + + SeerrQuickConnectInitResponse({ + required this.code, + required this.secret, + }); + + factory SeerrQuickConnectInitResponse.fromJson(Map json) => + _$SeerrQuickConnectInitResponseFromJson(json); + Map toJson() => _$SeerrQuickConnectInitResponseToJson(this); +} + +@JsonSerializable() +class SeerrQuickConnectCheckResponse { + final bool authenticated; + + SeerrQuickConnectCheckResponse({ + required this.authenticated, + }); + + factory SeerrQuickConnectCheckResponse.fromJson(Map json) => + _$SeerrQuickConnectCheckResponseFromJson(json); + Map toJson() => _$SeerrQuickConnectCheckResponseToJson(this); +} + +@JsonSerializable() +class SeerrQuickConnectAuthBody { + final String secret; + + SeerrQuickConnectAuthBody({ + required this.secret, + }); + + factory SeerrQuickConnectAuthBody.fromJson(Map json) => _$SeerrQuickConnectAuthBodyFromJson(json); + Map toJson() => _$SeerrQuickConnectAuthBodyToJson(this); +} + class SeerrCompany { final int id; final String name; @@ -1498,21 +1538,27 @@ extension SeerrMovieDetailsExtension on SeerrMovieDetails { } String? get backdropUrl { - if (internalBackdropPath == null || internalBackdropPath!.isEmpty) return null; + if (internalBackdropPath == null || internalBackdropPath!.isEmpty) { + return null; + } return '$_tmdbImageBaseUrl$internalBackdropPath'; } } extension SeerrCastExtension on SeerrCast { String? get profileUrl { - if (internalProfilePath == null || internalProfilePath!.isEmpty) return null; + if (internalProfilePath == null || internalProfilePath!.isEmpty) { + return null; + } return '$_tmdbProfileBaseUrl$internalProfilePath'; } } extension SeerrCrewExtension on SeerrCrew { String? get profileUrl { - if (internalProfilePath == null || internalProfilePath!.isEmpty) return null; + if (internalProfilePath == null || internalProfilePath!.isEmpty) { + return null; + } return '$_tmdbProfileBaseUrl$internalProfilePath'; } } @@ -1524,7 +1570,9 @@ extension SeerrTvDetailsExtension on SeerrTvDetails { } String? get backdropUrl { - if (internalBackdropPath == null || internalBackdropPath!.isEmpty) return null; + if (internalBackdropPath == null || internalBackdropPath!.isEmpty) { + return null; + } return '$_tmdbImageBaseUrl$internalBackdropPath'; } } @@ -1550,7 +1598,9 @@ extension SeerrDiscoverItemExtension on SeerrDiscoverItem { } String? get backdropUrl { - if (internalBackdropPath == null || internalBackdropPath!.isEmpty) return null; + if (internalBackdropPath == null || internalBackdropPath!.isEmpty) { + return null; + } return '$_tmdbImageBaseUrl$internalBackdropPath'; } } diff --git a/lib/seerr/seerr_models.g.dart b/lib/seerr/seerr_models.g.dart index a788b38a3..28be27cab 100644 --- a/lib/seerr/seerr_models.g.dart +++ b/lib/seerr/seerr_models.g.dart @@ -844,6 +844,44 @@ Map _$SeerrAuthJellyfinBodyToJson( if (instance.hostname case final value?) 'hostname': value, }; +SeerrQuickConnectInitResponse _$SeerrQuickConnectInitResponseFromJson( + Map json) => + SeerrQuickConnectInitResponse( + code: json['code'] as String, + secret: json['secret'] as String, + ); + +Map _$SeerrQuickConnectInitResponseToJson( + SeerrQuickConnectInitResponse instance) => + { + 'code': instance.code, + 'secret': instance.secret, + }; + +SeerrQuickConnectCheckResponse _$SeerrQuickConnectCheckResponseFromJson( + Map json) => + SeerrQuickConnectCheckResponse( + authenticated: json['authenticated'] as bool, + ); + +Map _$SeerrQuickConnectCheckResponseToJson( + SeerrQuickConnectCheckResponse instance) => + { + 'authenticated': instance.authenticated, + }; + +SeerrQuickConnectAuthBody _$SeerrQuickConnectAuthBodyFromJson( + Map json) => + SeerrQuickConnectAuthBody( + secret: json['secret'] as String, + ); + +Map _$SeerrQuickConnectAuthBodyToJson( + SeerrQuickConnectAuthBody instance) => + { + 'secret': instance.secret, + }; + _SeerrUserModel _$SeerrUserModelFromJson(Map json) => _SeerrUserModel( id: (json['id'] as num?)?.toInt(), diff --git a/lib/util/fladder_config.dart b/lib/util/fladder_config.dart index e54040fda..d2ffdef45 100644 --- a/lib/util/fladder_config.dart +++ b/lib/util/fladder_config.dart @@ -6,12 +6,29 @@ class FladderConfig { static set baseUrl(String? value) => _instance._baseUrl = value; String? _baseUrl; + static String? get seerrUrl => _instance._seerrUrl; + static set seerrUrl(String? value) => _instance._seerrUrl = value; + String? _seerrUrl; + + static bool? get hidePasswordLogin => _instance._hidePasswordLogin; + static set hidePasswordLogin(bool? value) => _instance._hidePasswordLogin = value; + bool? _hidePasswordLogin; + + static String? get seerrProxyPath => _instance._seerrProxyPath; + static set seerrProxyPath(String? value) => _instance._seerrProxyPath = value; + String? _seerrProxyPath; + static void fromJson(Map json) => _instance = FladderConfig._fromJson(json); factory FladderConfig._fromJson(Map json) { final config = FladderConfig._(); final newUrl = json['baseUrl'] as String?; config._baseUrl = newUrl?.isEmpty == true ? null : newUrl; + final newSeerrUrl = json['seerrUrl'] as String?; + config._seerrUrl = newSeerrUrl?.isEmpty == true ? null : newSeerrUrl; + config._hidePasswordLogin = json['hidePasswordLogin'] as bool?; + final proxyPath = json['seerrProxyPath'] as String?; + config._seerrProxyPath = proxyPath?.isEmpty == true ? null : proxyPath; return config; } } diff --git a/lib/util/seerr_http_client.dart b/lib/util/seerr_http_client.dart new file mode 100644 index 000000000..29bf93bc8 --- /dev/null +++ b/lib/util/seerr_http_client.dart @@ -0,0 +1,3 @@ +import 'package:http/http.dart' as http; + +http.Client createSeerrHttpClient() => http.Client(); diff --git a/lib/util/seerr_http_client_web.dart b/lib/util/seerr_http_client_web.dart new file mode 100644 index 000000000..c7c8b9ab0 --- /dev/null +++ b/lib/util/seerr_http_client_web.dart @@ -0,0 +1,6 @@ +import 'package:http/browser_client.dart'; +import 'package:http/http.dart' as http; + +http.Client createSeerrHttpClient() { + return BrowserClient()..withCredentials = true; +}