refactor: pin the look-alike code paths that must not be merged
Several pairs of near-identical code paths differ in one load-bearing line. Each site now carries a comment naming the invariant that forces it apart, backed by a characterization test so a future deduplication fails loudly instead of silently changing behaviour. Pinned: focusable wrapper vs. chip D-pad activation policy, profile connection cleanup's raw-id vs. ServerId-typed server projections, live TV tab loaders, video player display matching and playback service wiring, track selection container ordering, tracker HTTP client status ladder, and the MediaServerHttpClient shutdown/cancellation contract versus ManagedHttpClient's closing guard. New tests: test/focus/dpad_activation_policy_test.dart test/services/track_selection_container_ordinal_test.dart test/services/trackers/tracker_status_ladder_test.dart test/utils/media_server_http_client_shutdown_test.dart
This commit is contained in:
@@ -102,6 +102,12 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
/// Returns [KeyEventResult.handled] if the event was consumed,
|
||||
/// [KeyEventResult.ignored] otherwise.
|
||||
///
|
||||
/// Runs the same activation sequence as `_FocusableWrapperState._handleKeyEvent`
|
||||
/// but is deliberately kept separate: a chip leaves the context-menu key
|
||||
/// unconsumed when [ChipKeyCallbacks.onLongPress] is null and traps RIGHT/DOWN
|
||||
/// so focus cannot escape the strip, where a wrapper does the opposite on both
|
||||
/// counts.
|
||||
KeyEventResult handleChipKeyEvent(FocusNode _, KeyEvent event, ChipKeyCallbacks callbacks) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
|
||||
@@ -410,6 +410,11 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
});
|
||||
}
|
||||
|
||||
// Runs the same activation sequence as FocusableChipStateMixin.handleChipKeyEvent
|
||||
// but is deliberately kept separate: a wrapper always consumes the context-menu
|
||||
// key (even with no onLongPress, so a card never leaks it upward) and passes
|
||||
// every unmapped arrow through to framework traversal, where a chip does the
|
||||
// opposite on both counts.
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
final diagnosticsEnabled = TextInputDiagnostics.enabled;
|
||||
|
||||
@@ -372,6 +372,11 @@ class ActiveProfileBinder {
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Server ids the profile should reach once bound: its join rows plus the
|
||||
/// implicit Plex Home parent, which normally has no row. Not shared with
|
||||
/// `_serverIdsForProfile` (profile_connection_cleanup.dart) — that one is
|
||||
/// join-rows-only and [ServerId]-typed, while this set keeps growing with
|
||||
/// bind results and is compared against the manager's raw string ids.
|
||||
Set<String> _expectedServerIdsForProfile(
|
||||
Profile profile, {
|
||||
required List<ProfileConnection> joinRows,
|
||||
|
||||
@@ -283,6 +283,10 @@ Future<void> _clearProfileServerPrefsNoLongerReferenced({
|
||||
}
|
||||
}
|
||||
|
||||
/// Server ids reachable through this profile's join rows. Narrower than
|
||||
/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
|
||||
/// parent is not counted here, so folding the two together would change which
|
||||
/// per-profile prefs survive an unlink.
|
||||
Future<Set<ServerId>> _serverIdsForProfile(
|
||||
String profileId, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
@@ -316,6 +320,9 @@ Future<bool> _isServerReferenced(
|
||||
return false;
|
||||
}
|
||||
|
||||
// [ServerId]-typed for the preference APIs, which drops ids that fail to
|
||||
// parse; the twin in profile_detail_screen.dart stays raw so it can be
|
||||
// differenced against download keys.
|
||||
Set<ServerId> _serverIdsForConnection(Connection connection) {
|
||||
return switch (connection) {
|
||||
PlexAccountConnection(:final servers) => {
|
||||
|
||||
@@ -192,6 +192,9 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
|
||||
});
|
||||
}
|
||||
|
||||
// Not the gated data-refresh timer the other tabs run: pause/resume drive the
|
||||
// per-minute UI ticker, and pause has to stamp _hiddenSince on both a section
|
||||
// hide and an app background so _catchUpIfStale can measure the absence.
|
||||
void pauseRefresh() {
|
||||
_hiddenSince ??= DateTime.now();
|
||||
_timeIndicatorTimer?.cancel();
|
||||
|
||||
@@ -125,6 +125,9 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
|
||||
}
|
||||
}
|
||||
|
||||
// Same three gates as WhatsOnTab (tab selected, subtree visible, app
|
||||
// foregrounded), but resume also reloads: a recording scheduled from the
|
||||
// guide has to show up on arrival, not on the next 30s tick.
|
||||
void pauseRefresh() {
|
||||
_refreshRequested = false;
|
||||
_syncRefreshTimer();
|
||||
|
||||
@@ -80,6 +80,9 @@ class WhatsOnTabState extends State<WhatsOnTab>
|
||||
}
|
||||
}
|
||||
|
||||
// Refreshes only while all three gates hold: tab selected, subtree visible,
|
||||
// app foregrounded. Resume just re-arms the tick — unlike RecordingsTab there
|
||||
// is no immediate reload, since nothing done on the other tabs changes hubs.
|
||||
void pauseRefresh() {
|
||||
_refreshRequested = false;
|
||||
_syncRefreshTimer();
|
||||
|
||||
@@ -228,7 +228,10 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
|
||||
/// Server ids the profile keeps after removing [excludingConnectionId]:
|
||||
/// its other join rows plus, for Plex Home profiles, the implicit parent
|
||||
/// account.
|
||||
/// account. Raw ids, matching the download keys this is differenced
|
||||
/// against; `_serverIdsForProfile` in profile_connection_cleanup.dart is
|
||||
/// ServerId-typed and ignores the parent, so the two are not the same
|
||||
/// projection.
|
||||
Future<Set<String>> _retainedServerIds({
|
||||
required String excludingConnectionId,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
@@ -260,6 +263,9 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(_profile.id));
|
||||
}
|
||||
|
||||
// Raw machine ids rather than the ServerId-typed twin in
|
||||
// profile_connection_cleanup.dart: these are differenced against retained
|
||||
// ids and matched to download global keys, which carry the unparsed id.
|
||||
Set<String> _serverIdsForConnection(Connection conn) {
|
||||
return switch (conn) {
|
||||
PlexAccountConnection(:final servers) => servers.map((s) => s.clientIdentifier).toSet(),
|
||||
|
||||
@@ -143,7 +143,9 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore Windows display mode to original state.
|
||||
/// Restore Windows display mode to original state. Fullscreen-exit only:
|
||||
/// `dispose()` runs its own fire-and-forget variant because it cannot await
|
||||
/// the HDR settle below.
|
||||
Future<void> _restoreWindowsDisplayMode() async {
|
||||
if (_displayModeService == null || !_displayModeService!.anyChangeApplied) return;
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
required SettingsService settingsService,
|
||||
required bool useExoPlayer,
|
||||
}) async {
|
||||
// Re-wire scope: exactly the nine subscriptions re-created below. The
|
||||
// media-controls listeners belong to _setupMediaControls and the
|
||||
// sleep-timer/Apple TV ones to initState; both outlive a re-wire.
|
||||
await Future.wait<void>([
|
||||
if (_playingSubscription != null) _playingSubscription!.cancel(),
|
||||
if (_completedSubscription != null) _completedSubscription!.cancel(),
|
||||
@@ -190,10 +193,21 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
});
|
||||
}
|
||||
|
||||
/// Roll the screen back to a re-runnable state after a failed player
|
||||
/// attempt. The player is gone but the screen stays mounted and
|
||||
/// [_retryPlayerInitialization] may run again, so every collaborator is
|
||||
/// released *and* nulled so it can be built once more. Kept separate from
|
||||
/// `dispose()`, which instead destroys the notifiers, focus nodes and
|
||||
/// player, and cannot await any of this.
|
||||
Future<void> _tearDownFailedPlayerAttempt(Player attemptPlayer) async {
|
||||
final activePlayer = player;
|
||||
if (activePlayer != null && !identical(activePlayer, attemptPlayer)) return;
|
||||
|
||||
// Rollback scope: the nine player streams plus the five media-controls
|
||||
// ones. _sleepTimerSubscription and _appleTvPlayPauseSubscription are
|
||||
// initState-owned and never re-created — cancelling them here would kill
|
||||
// the sleep-timer prompt and the Apple TV remote for the rest of the
|
||||
// screen's life.
|
||||
final cancellationFutures = <Future<void>>[
|
||||
if (_playingSubscription != null) _playingSubscription!.cancel(),
|
||||
if (_completedSubscription != null) _completedSubscription!.cancel(),
|
||||
|
||||
@@ -1509,6 +1509,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_chromeController.dispose();
|
||||
_toastController.dispose();
|
||||
|
||||
// The release sequence below mirrors _tearDownFailedPlayerAttempt but is
|
||||
// deliberately separate: dispose() cannot await, and it destroys the
|
||||
// notifiers, focus nodes and player that the rollback path keeps alive
|
||||
// for a retry on a still-mounted screen.
|
||||
//
|
||||
// Stop progress tracking and send final state. Normal back navigation
|
||||
// awaits this before popping; dispose keeps a fallback for externally
|
||||
// removed routes where dispose() cannot await.
|
||||
@@ -1531,6 +1536,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
SleepTimerService().markNeedsRestart();
|
||||
}
|
||||
|
||||
// Teardown scope: every subscription the screen ever owns, including the
|
||||
// initState-owned sleep-timer and Apple TV ones that the rollback path
|
||||
// must leave alive.
|
||||
_playingSubscription?.cancel();
|
||||
_completedSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
@@ -1577,6 +1585,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
FullscreenStateManager().removeListener(_onFullscreenChanged);
|
||||
_fullscreenListenerAttached = false;
|
||||
}
|
||||
// Not _restoreWindowsDisplayMode(): that helper waits 200ms after clearing
|
||||
// the HDR hint before restoring, which dispose() cannot do. Fire the hint
|
||||
// clear at the still-live player and restore immediately.
|
||||
if (!isReplacingWithVideo &&
|
||||
Platform.isWindows &&
|
||||
_displayModeService != null &&
|
||||
|
||||
@@ -127,6 +127,8 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(
|
||||
|
||||
// A container track has no stable native ID. Its source-container ordinal
|
||||
// is authoritative; a metadata-identical earlier track is not a match.
|
||||
// Narrower than the guard in [findPlexTrackForMpvSubtitle]: a Plex stream
|
||||
// carrying no container ordinal still falls back to metadata scoring.
|
||||
if (mpvTrack.isContainer && plexOrdinal >= 0 && !ordinalMatches) continue;
|
||||
|
||||
final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||
@@ -191,6 +193,8 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle(
|
||||
final ordinalMatches =
|
||||
containerPlexTracks != null && mpvOrdinal >= 0 && containerPlexTracks.indexOf(plexTrack) == mpvOrdinal;
|
||||
|
||||
// The probe fixes isContainer here, so once a container ordinal list exists
|
||||
// a container track matches at its own ordinal or not at all.
|
||||
if (mpvTrack.isContainer && containerPlexTracks != null && !ordinalMatches) continue;
|
||||
|
||||
final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||
@@ -217,6 +221,8 @@ AudioTrack? findMpvTrackForPlexAudio(
|
||||
|
||||
AudioTrack? bestMatch;
|
||||
int bestScore = 0;
|
||||
// Ordinal identity is cross-side: the probe's index in the Plex list against
|
||||
// the candidate's index in the MPV list.
|
||||
final plexOrdinal = allPlexTracks?.indexOf(plexTrack) ?? -1;
|
||||
|
||||
for (final mpvTrack in mpvTracks) {
|
||||
@@ -244,6 +250,8 @@ MediaAudioTrack? findPlexTrackForMpvAudio(
|
||||
|
||||
MediaAudioTrack? bestMatch;
|
||||
int bestScore = 0;
|
||||
// Same cross-side ordinal rule as [findMpvTrackForPlexAudio] with the two
|
||||
// lists swapped; the score arguments stay MPV-first either way.
|
||||
final mpvOrdinal = allMpvTracks?.indexOf(mpvTrack) ?? -1;
|
||||
|
||||
for (final plexTrack in plexTracks) {
|
||||
|
||||
@@ -408,6 +408,8 @@ class AnilistClient implements DisposableTrackerClient {
|
||||
|
||||
final res = await send();
|
||||
|
||||
// Rate limits are typed here and in Trakt only; MAL and Simkl surface a 429
|
||||
// as a plain TrackerApiException.
|
||||
if (res.statusCode == 429) {
|
||||
throw TrackerRateLimitException(
|
||||
service: TrackerService.anilist,
|
||||
|
||||
@@ -193,6 +193,8 @@ class MalClient implements DisposableTrackerClient {
|
||||
try {
|
||||
await _refresh();
|
||||
} catch (_) {
|
||||
// Reported as an API 401, not as the TrackerAuthException Trakt
|
||||
// propagates from the same path.
|
||||
throw const TrackerApiException(service: TrackerService.mal, statusCode: 401);
|
||||
}
|
||||
res = await _send(method, path, body: body, formBody: formBody);
|
||||
|
||||
@@ -154,6 +154,8 @@ class SimklClient implements DisposableTrackerClient {
|
||||
allowedMethods: const {'GET', 'POST'},
|
||||
);
|
||||
|
||||
// Only the authenticated host may invalidate: the data host is called
|
||||
// without a token, so its 401s say nothing about the session.
|
||||
if (mainApiHost && response.statusCode == 401) {
|
||||
onSessionInvalidated();
|
||||
throw const TrackerAuthException(
|
||||
|
||||
@@ -9,6 +9,14 @@ import '../../utils/platform_http_client_stub.dart'
|
||||
as platform;
|
||||
import 'tracker_constants.dart';
|
||||
|
||||
/// Transport shared by the tracker clients: builds, times and logs a request,
|
||||
/// then hands back the raw response.
|
||||
///
|
||||
/// Status handling stays with each client because the rules genuinely differ:
|
||||
/// MAL and Simkl accept any 2xx, Trakt a per-call set (200/201/204, plus 409
|
||||
/// for scrobble), AniList only 200 (GraphQL errors ride a 200 body); and a 401
|
||||
/// means refresh-and-retry for Trakt/MAL but a terminal session for AniList
|
||||
/// and Simkl.
|
||||
class TrackerHttpClient {
|
||||
static const Set<String> allMethods = {'GET', 'POST', 'PATCH', 'PUT', 'DELETE'};
|
||||
|
||||
|
||||
@@ -307,6 +307,8 @@ class TraktClient implements DisposableTrackerClient {
|
||||
var res = await _send(method, path, body: body);
|
||||
|
||||
if (res.statusCode == 401) {
|
||||
// A failed refresh propagates its TrackerAuthException; MAL's equivalent
|
||||
// path flattens the same failure into TrackerApiException(401).
|
||||
await refresh();
|
||||
res = await _send(method, path, body: body);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ Future<http.Response> sendAbortableHttpRequest(
|
||||
Future<void>? abortTrigger,
|
||||
String? operation,
|
||||
}) {
|
||||
// Deliberately not `AbortController`: that type lives with the media-server
|
||||
// client and throws `MediaServerHttpException`, which the tracker/Seerr
|
||||
// callers of this helper must stay independent of.
|
||||
final abort = Completer<void>();
|
||||
void abortRequest() {
|
||||
if (!abort.isCompleted) abort.complete();
|
||||
|
||||
@@ -240,6 +240,9 @@ class _ManagedStreamedResponseWithUrl extends http.StreamedResponse implements h
|
||||
final Uri url;
|
||||
}
|
||||
|
||||
/// Deliberately not `AbortController`: this layer stays a plain [http.Client]
|
||||
/// with no media-server dependency, and it needs two independent latches
|
||||
/// (aborted vs. drained) plus the response canceller.
|
||||
class _TrackedRequest {
|
||||
_TrackedRequest(this.url);
|
||||
|
||||
|
||||
@@ -78,7 +78,16 @@ class AbortController {
|
||||
/// timeouts, logging, and optional endpoint failover.
|
||||
class MediaServerHttpClient {
|
||||
final http.Client _client;
|
||||
|
||||
/// Requests owned by this client, aborted at the transport on shutdown so an
|
||||
/// in-flight body raises [http.RequestAbortedException] instead of truncating.
|
||||
final Set<AbortController> _activeAborts = <AbortController>{};
|
||||
|
||||
/// Not delegated to [ManagedHttpClient]'s own closing guard: that reports
|
||||
/// shutdown as an [http.ClientException], which maps to
|
||||
/// [MediaServerHttpErrorType.connectionError] and so reads as transient.
|
||||
/// Failover, pagination and download retry all branch on
|
||||
/// [MediaServerHttpException.isCancellation].
|
||||
bool _closing = false;
|
||||
|
||||
MediaServerHttpClient({
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/focusable_wrapper.dart';
|
||||
import 'package:plezy/widgets/focusable_tab_chip.dart';
|
||||
|
||||
void main() {
|
||||
// FocusableWrapper and FocusableChipStateMixin run the same d-pad activation
|
||||
// sequence under opposite consume policies. These pin the two differences that
|
||||
// keep the handlers separate.
|
||||
group('d-pad activation policies', () {
|
||||
Future<List<LogicalKeyboardKey>> escapedKeysFor(
|
||||
WidgetTester tester,
|
||||
FocusNode node,
|
||||
Widget child,
|
||||
LogicalKeyboardKey key,
|
||||
) async {
|
||||
final escaped = <LogicalKeyboardKey>[];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Focus(
|
||||
onKeyEvent: (_, event) {
|
||||
if (event is KeyDownEvent) escaped.add(event.logicalKey);
|
||||
return KeyEventResult.handled;
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(key);
|
||||
await tester.pump();
|
||||
return escaped;
|
||||
}
|
||||
|
||||
testWidgets('wrapper consumes the context menu key with no onLongPress', (tester) async {
|
||||
final node = FocusNode(debugLabel: 'card');
|
||||
addTearDown(node.dispose);
|
||||
|
||||
final escaped = await escapedKeysFor(
|
||||
tester,
|
||||
node,
|
||||
FocusableWrapper(focusNode: node, onSelect: () {}, child: const SizedBox(width: 10, height: 10)),
|
||||
LogicalKeyboardKey.contextMenu,
|
||||
);
|
||||
|
||||
expect(escaped, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('chip leaves the context menu key to its ancestors with no onLongPress', (tester) async {
|
||||
final node = FocusNode(debugLabel: 'chip');
|
||||
addTearDown(node.dispose);
|
||||
|
||||
final escaped = await escapedKeysFor(
|
||||
tester,
|
||||
node,
|
||||
FocusableTabChip(label: 'Tab', isSelected: true, focusNode: node, onSelect: () {}),
|
||||
LogicalKeyboardKey.contextMenu,
|
||||
);
|
||||
|
||||
expect(escaped, [LogicalKeyboardKey.contextMenu]);
|
||||
});
|
||||
|
||||
testWidgets('wrapper passes unmapped RIGHT/DOWN through to the framework', (tester) async {
|
||||
final node = FocusNode(debugLabel: 'card');
|
||||
addTearDown(node.dispose);
|
||||
Widget card() => FocusableWrapper(focusNode: node, onSelect: () {}, child: const SizedBox(width: 10, height: 10));
|
||||
|
||||
expect(await escapedKeysFor(tester, node, card(), LogicalKeyboardKey.arrowRight), [
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
]);
|
||||
expect(await escapedKeysFor(tester, node, card(), LogicalKeyboardKey.arrowDown), [LogicalKeyboardKey.arrowDown]);
|
||||
});
|
||||
|
||||
testWidgets('chip traps unmapped RIGHT/DOWN so focus cannot escape the strip', (tester) async {
|
||||
final node = FocusNode(debugLabel: 'chip');
|
||||
addTearDown(node.dispose);
|
||||
Widget chip() => FocusableTabChip(label: 'Tab', isSelected: true, focusNode: node, onSelect: () {});
|
||||
|
||||
expect(await escapedKeysFor(tester, node, chip(), LogicalKeyboardKey.arrowRight), isEmpty);
|
||||
expect(await escapedKeysFor(tester, node, chip(), LogicalKeyboardKey.arrowDown), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/track_selection_service.dart';
|
||||
|
||||
// The container-ordinal guards in `findMpvTrackForPlexSubtitle` and
|
||||
// `findPlexTrackForMpvSubtitle` look like mirrors but are not: when the probe
|
||||
// has no ordinal in the container list, the Plex->MPV direction still scores by
|
||||
// metadata while the MPV->Plex direction refuses to match at all. These tests
|
||||
// pin that difference so the two guards are not "symmetrised".
|
||||
|
||||
MediaSubtitleTrack _plexSub(int id, {int? index, String? languageCode}) =>
|
||||
MediaSubtitleTrack(id: id, index: index, languageCode: languageCode, selected: false, forced: false);
|
||||
|
||||
SubtitleTrack _containerSub(String id, {String? lang}) =>
|
||||
SubtitleTrack(id: id, language: lang, isExternal: true, isContainer: true);
|
||||
|
||||
void main() {
|
||||
group('container-ordinal guard asymmetry', () {
|
||||
test('Plex->MPV keeps metadata scoring when the Plex stream has no container ordinal', () {
|
||||
final probe = _plexSub(40, index: 0, languageCode: 'eng');
|
||||
final otherPlexTracks = [_plexSub(41, index: 1, languageCode: 'eng')];
|
||||
final nativeTracks = [_containerSub('2_0', lang: 'eng')];
|
||||
|
||||
expect(findMpvTrackForPlexSubtitle(probe, nativeTracks, allPlexTracks: otherPlexTracks), nativeTracks.first);
|
||||
});
|
||||
|
||||
test('MPV->Plex refuses to match when the container track has no ordinal', () {
|
||||
final probe = _containerSub('2_0', lang: 'eng');
|
||||
final otherNativeTracks = [_containerSub('2_1', lang: 'eng')];
|
||||
final plexTracks = [_plexSub(40, index: 0, languageCode: 'eng')];
|
||||
|
||||
expect(findPlexTrackForMpvSubtitle(probe, plexTracks, allMpvTracks: otherNativeTracks), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/models/trakt/trakt_ids.dart';
|
||||
import 'package:plezy/models/trakt/trakt_scrobble_request.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_client.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
|
||||
TrackerSession _session({String refreshToken = 'refresh-old'}) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return TrackerSession(
|
||||
accessToken: 'access-old',
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: now + 86400,
|
||||
createdAt: now,
|
||||
username: 'alice',
|
||||
);
|
||||
}
|
||||
|
||||
const _scrobble = TraktScrobbleRequest.movie(ids: TraktIds(trakt: 1));
|
||||
|
||||
void main() {
|
||||
group('Trakt status ladder', () {
|
||||
test('accepts 409 on scrobble but not on other requests', () async {
|
||||
final client = TraktClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('409 should not invalidate the session'),
|
||||
httpClient: MockClient((_) async => http.Response('conflict', 409)),
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await client.scrobbleStart(_scrobble);
|
||||
|
||||
await expectLater(
|
||||
client.getUserSettings(),
|
||||
throwsA(isA<TrackerApiException>().having((e) => e.statusCode, 'statusCode', 409)),
|
||||
);
|
||||
});
|
||||
|
||||
test('propagates the refresh TrackerAuthException after a 401', () async {
|
||||
var invalidated = 0;
|
||||
final client = TraktClient(
|
||||
_session(refreshToken: 'refresh-ladder'),
|
||||
onSessionInvalidated: () => invalidated++,
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/oauth/token') {
|
||||
return http.Response(json.encode({'error': 'invalid_grant'}), 400);
|
||||
}
|
||||
return http.Response('unauthorized', 401);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await expectLater(
|
||||
client.getUserSettings(),
|
||||
throwsA(isA<TrackerAuthException>().having((e) => e.isPermanent, 'isPermanent', isTrue)),
|
||||
);
|
||||
expect(invalidated, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Simkl status ladder', () {
|
||||
test('only the authenticated host invalidates on 401', () async {
|
||||
var invalidated = 0;
|
||||
final client = SimklClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => invalidated++,
|
||||
httpClient: MockClient((_) async => http.Response('unauthorized', 401)),
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await expectLater(client.getTrending(SimklCatalogType.tv), throwsA(isA<TrackerApiException>()));
|
||||
expect(invalidated, 0);
|
||||
|
||||
await expectLater(
|
||||
client.getUserSettings(),
|
||||
throwsA(isA<TrackerAuthException>().having((e) => e.isPermanent, 'isPermanent', isTrue)),
|
||||
);
|
||||
expect(invalidated, 1);
|
||||
});
|
||||
|
||||
test('surfaces 429 as a plain API failure', () async {
|
||||
final client = SimklClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('429 should not invalidate the session'),
|
||||
httpClient: MockClient((_) async => http.Response('slow down', 429, headers: {'retry-after': '23'})),
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await expectLater(
|
||||
client.getUserSettings(),
|
||||
throwsA(
|
||||
allOf(
|
||||
isA<TrackerApiException>().having((e) => e.statusCode, 'statusCode', 429),
|
||||
isNot(isA<TrackerRateLimitException>()),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/utils/managed_http_client.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
void main() {
|
||||
group('MediaServerHttpClient shutdown', () {
|
||||
test('rejects new requests as a cancellation, not a transient failure', () async {
|
||||
final client = MediaServerHttpClient(
|
||||
client: ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'),
|
||||
baseUrl: 'https://example.test/',
|
||||
);
|
||||
|
||||
client.close();
|
||||
|
||||
await expectLater(
|
||||
client.get('library/sections'),
|
||||
throwsA(
|
||||
isA<MediaServerHttpException>()
|
||||
.having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)
|
||||
.having((e) => e.isTransient, 'isTransient', isFalse),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the layer beneath reports the same shutdown as a transient connection error', () async {
|
||||
final managed = ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test');
|
||||
await managed.closeGracefully(drainTimeout: Duration.zero);
|
||||
|
||||
await expectLater(
|
||||
managed.send(http.Request('GET', Uri.parse('https://example.test/library/sections'))),
|
||||
throwsA(
|
||||
isA<http.ClientException>()
|
||||
.having(
|
||||
(e) => MediaServerHttpException.from(e).type,
|
||||
'mapped type',
|
||||
MediaServerHttpErrorType.connectionError,
|
||||
)
|
||||
.having((e) => MediaServerHttpException.from(e).isTransient, 'mapped isTransient', isTrue),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('aborts requests already in flight at the transport', () async {
|
||||
final transport = _AbortAwareClient();
|
||||
final client = MediaServerHttpClient(client: transport, baseUrl: 'https://example.test/');
|
||||
|
||||
final pending = client.get('library/sections');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
client.close();
|
||||
|
||||
await expectLater(transport.abortTrigger, completes);
|
||||
await expectLater(
|
||||
pending,
|
||||
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _AbortAwareClient extends http.BaseClient {
|
||||
final _response = Completer<http.StreamedResponse>();
|
||||
late final Future<void> abortTrigger;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
final trigger = (request as http.Abortable).abortTrigger!;
|
||||
abortTrigger = trigger;
|
||||
unawaited(
|
||||
trigger.whenComplete(() {
|
||||
if (!_response.isCompleted) _response.completeError(http.RequestAbortedException(request.url));
|
||||
}),
|
||||
);
|
||||
return _response.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
Reference in New Issue
Block a user