Files
plezy/lib/services/seerr/seerr_auth_service.dart
T
edde746 369c6279d6 fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:

A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.

English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.

Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.

Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.

Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.

All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.

scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.

One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.

close #1856
2026-08-10 15:32:43 +02:00

216 lines
8.0 KiB
Dart

import 'package:http/http.dart' as http;
import '../../i18n/strings.g.dart';
import '../../models/seerr/seerr_public_settings.dart';
import '../../models/seerr/seerr_session.dart';
import '../../models/seerr/seerr_user.dart';
import '../../utils/app_logger.dart';
import 'seerr_constants.dart';
import 'seerr_exceptions.dart';
import 'seerr_http_client.dart';
/// Sign-in flows against a Seerr instance. Every flow ends with a captured
/// `connect.sid` cookie and the Seerr-side [SeerrUser], packed into a
/// [SeerrSession].
class SeerrAuthService {
final http.Client Function()? httpClientFactory;
SeerrAuthService({this.httpClientFactory});
SeerrHttpClient _client(String baseUrl, {String? cookie}) =>
SeerrHttpClient(baseUrl: baseUrl, httpClient: httpClientFactory?.call(), cookie: cookie);
/// Validate that [baseUrl] points at a running, initialized Seerr and
/// collect the metadata the connect flow needs. Throws [SeerrUrlException]
/// when unreachable or not set up.
Future<SeerrPublicSettings> probe(String baseUrl) async {
final client = _client(baseUrl);
try {
final SeerrResponse res;
try {
res = await client.send('GET', '/settings/public', timeout: SeerrConstants.probeTimeout, authenticated: false);
} catch (e) {
throw SeerrUrlException(
'Could not reach $baseUrl: $e',
display: t.seerr.couldNotReach(url: baseUrl, error: e),
);
}
final data = res.data;
if (res.statusCode >= 400 || data is! Map<String, dynamic>) {
throw SeerrUrlException(
'No Seerr instance at $baseUrl (HTTP ${res.statusCode})',
display: t.seerr.noInstanceAtUrl(url: baseUrl, status: res.statusCode),
);
}
final settings = SeerrPublicSettings.fromJson(data);
if (!settings.initialized) {
throw SeerrUrlException('Seerr instance has not completed first-run setup', display: t.seerr.notInitialized);
}
return settings;
} finally {
client.dispose();
}
}
/// `POST /auth/plex` with a Plex account token.
Future<SeerrSession> signInWithPlex({required String baseUrl, required String plexToken}) => _signIn(
baseUrl: baseUrl,
method: SeerrAuthMethod.plex,
path: '/auth/plex',
body: {'authToken': plexToken},
identifier: '',
secret: '',
);
/// `POST /auth/jellyfin` with Jellyfin or Emby credentials.
Future<SeerrSession> signInWithJellyfin({
required String baseUrl,
required String username,
required String password,
bool emby = false,
}) => _signIn(
baseUrl: baseUrl,
method: emby ? SeerrAuthMethod.emby : SeerrAuthMethod.jellyfin,
path: '/auth/jellyfin',
body: {
'username': username,
'password': password,
'serverType': emby ? SeerrMediaServerType.emby : SeerrMediaServerType.jellyfin,
},
identifier: username,
secret: password,
);
/// `POST /auth/local` with a Seerr local account.
Future<SeerrSession> signInWithLocal({required String baseUrl, required String email, required String password}) =>
_signIn(
baseUrl: baseUrl,
method: SeerrAuthMethod.local,
path: '/auth/local',
body: {'email': email, 'password': password},
identifier: email,
secret: password,
);
/// Silent re-login using the credentials carried by [session]
/// ([plexToken] for plex-method sessions). Returns the refreshed session.
Future<SeerrSession> reauth(SeerrSession session, {String? plexToken}) async {
final fresh = await switch (session.method) {
SeerrAuthMethod.plex when plexToken != null && plexToken.isNotEmpty => signInWithPlex(
baseUrl: session.baseUrl,
plexToken: plexToken,
),
// No token RIGHT NOW is a degraded state (identity not hydrated yet,
// vault decrypt hiccup), not a server rejection — retryable, so it
// must not unlink the session. An empty stored secret below is the
// opposite: those credentials are gone for good, so re-linking is the
// only way forward and unlinking is honest.
SeerrAuthMethod.plex => throw SeerrReauthUnavailableException(
'No Plex token available for silent re-auth',
display: t.seerr.noPlexTokenForReauth,
),
SeerrAuthMethod.jellyfin || SeerrAuthMethod.emby when session.secret.isNotEmpty => signInWithJellyfin(
baseUrl: session.baseUrl,
username: session.identifier,
password: session.secret,
emby: session.method == SeerrAuthMethod.emby,
),
SeerrAuthMethod.local when session.secret.isNotEmpty => signInWithLocal(
baseUrl: session.baseUrl,
email: session.identifier,
password: session.secret,
),
_ => throw SeerrAuthException('No stored credentials for silent re-auth', display: t.seerr.noStoredCredentials),
};
return session.copyWith(cookie: fresh.cookie, permissions: fresh.permissions, displayName: fresh.displayName);
}
/// Best-effort server-side sign-out; local cleanup must not depend on it.
Future<void> signOut(SeerrSession session) async {
final client = _client(session.baseUrl, cookie: session.cookie);
try {
await client.send('POST', '/auth/logout', timeout: SeerrConstants.authTimeout);
} catch (e) {
appLogger.d('Seerr: sign-out best-effort failed', error: e);
} finally {
client.dispose();
}
}
Future<SeerrSession> _signIn({
required String baseUrl,
required SeerrAuthMethod method,
required String path,
required Map<String, Object?> body,
required String identifier,
required String secret,
}) async {
final client = _client(baseUrl);
try {
final res = await client.send(
'POST',
path,
body: body,
timeout: SeerrConstants.authTimeout,
authenticated: false,
);
if (res.statusCode == 401 || res.statusCode == 403) {
final message = res.data is Map<String, dynamic>
? (res.data as Map<String, dynamic>)['message'] as String?
: null;
throw SeerrAuthException(
message ?? 'Sign-in rejected',
statusCode: res.statusCode,
display: t.seerr.signInRejected,
);
}
SeerrHttpClient.throwForStatus(res);
if (!client.captureSessionCookie(res.response)) {
throw SeerrAuthException('Seerr did not issue a session cookie', display: t.seerr.noSessionCookie);
}
final user = await _resolveUser(client, res.data);
return SeerrSession(
baseUrl: client.baseUrl,
method: method,
identifier: identifier,
secret: secret,
cookie: client.cookie!,
userId: user.id,
permissions: user.permissions ?? 0,
displayName: user.displayName ?? identifier,
instanceLabel: '',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
} finally {
client.dispose();
}
}
/// The login endpoints return the [SeerrUser] directly; fall back to
/// `GET /auth/me` with the fresh cookie if that shape ever changes.
Future<SeerrUser> _resolveUser(SeerrHttpClient client, dynamic loginData) async {
if (loginData is Map<String, dynamic>) {
try {
return SeerrUser.fromJson(loginData);
} catch (_) {
// fall through to /auth/me
}
}
final res = await client.send('GET', '/auth/me', timeout: SeerrConstants.authTimeout);
// throwForStatus passes 401 through (it's normally the re-auth signal);
// here it means the fresh cookie was rejected — an auth failure, not a
// malformed-user-payload crash further down.
if (res.statusCode == 401 || res.statusCode == 403) {
throw SeerrAuthException(
'Seerr rejected the fresh session cookie',
statusCode: res.statusCode,
display: t.seerr.freshCookieRejected,
);
}
SeerrHttpClient.throwForStatus(res);
final data = res.data;
if (data is Map<String, dynamic>) return SeerrUser.fromJson(data);
throw SeerrAuthException('Seerr did not return user information', display: t.seerr.noUserInformation);
}
}