refactor(profiles): delete dead client accessors, the write-only focus aspect, and test-only registry API

getServerBoundPlexClient/getClientForLibrary existed only for their own unit tests; MetadataEditAdapter.backend and Plex's unreachable prefKey fallback and music-kind branches had no production path (edit is double-gated on supportsKind); MainScreenFocusScope's focus aspect, isSidebarFocused, and focusContent were write-only; resolveActivePlexIdentity.preferredAccount was never passed; ProfileRegistry.get was unused outside tests — its removal exposed a stale test contract, now pinned correctly: list() deliberately never serves plex_home rows.
This commit is contained in:
edde746
2026-08-17 19:02:24 +02:00
parent a33c423985
commit 6eb0805f8d
23 changed files with 26 additions and 248 deletions
@@ -1,5 +1,4 @@
import '../i18n/strings.g.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
@@ -12,9 +11,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
JellyfinMetadataEditAdapter(this.client);
@override
MediaBackend get backend => client.backend;
@override
MediaServerClient get mediaClient => client;
@@ -1,5 +1,4 @@
import '../i18n/strings.g.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
@@ -119,7 +118,6 @@ class MetadataEditDraft {
}
abstract class MetadataEditAdapter {
MediaBackend get backend;
MediaServerClient get mediaClient;
bool supportsKind(MediaKind kind);
@@ -1,6 +1,5 @@
import '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
@@ -14,9 +13,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
PlexMetadataEditAdapter(this.client);
@override
MediaBackend get backend => MediaBackend.plex;
@override
MediaServerClient get mediaClient => client;
@@ -103,8 +99,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
@override
Future<bool> saveImmediateField(MetadataEditDraft draft, MetadataEditField field, Object? value) async {
final prefKey = _prefKey(field.id);
if (prefKey == null) return super.saveImmediateField(draft, field, value);
final prefKey = _prefKey(field.id)!;
final success = await client.updateMetadataPrefs(draft.sourceItem.id, {prefKey: (value as String?) ?? ''});
if (success) {
draft.originalValues[field.id] = value;
@@ -221,19 +216,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
tag('label', t.metadataEdit.label),
],
MediaKind.episode => [tag('director', t.metadataEdit.director), tag('writer', t.metadataEdit.writer)],
MediaKind.artist => [
tag('genre', t.metadataEdit.genre),
tag('style', t.metadataEdit.style),
tag('mood', t.metadataEdit.mood),
tag('country', t.metadataEdit.country),
tag('collection', t.metadataEdit.collection),
],
MediaKind.album => [
tag('genre', t.metadataEdit.genre),
tag('style', t.metadataEdit.style),
tag('mood', t.metadataEdit.mood),
tag('collection', t.metadataEdit.collection),
],
_ => const [],
};
}
@@ -352,9 +334,6 @@ int _plexTypeNumberForKind(MediaKind kind) => switch (kind) {
MediaKind.show => 2,
MediaKind.season => 3,
MediaKind.episode => 4,
MediaKind.artist => 8,
MediaKind.album => 9,
MediaKind.track => 10,
_ => 0,
};
+1 -9
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../media/media_library.dart';
import '../media/media_server_client.dart';
import '../services/plex_client.dart';
import '../utils/provider_extensions.dart';
/// Mixin providing common functionality for library tab screens
@@ -9,14 +8,7 @@ import '../utils/provider_extensions.dart';
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
MediaLibrary get library;
/// Get the [PlexClient] for this library's server. Throws if unavailable.
/// Use [getMediaClientForLibrary] in code paths that work for both Plex
/// and Jellyfin via the [MediaServerClient] interface — this getter is
/// for Plex-only methods (collections, metadata edit, etc.).
PlexClient getClientForLibrary() => context.getPlexClientForLibrary(library);
/// Get a backend-neutral [MediaServerClient] for this library's server.
/// Throws if unavailable. Prefer this over [getClientForLibrary] for any
/// flow that doesn't strictly need Plex-only APIs.
/// Throws if unavailable.
MediaServerClient getMediaClientForLibrary() => context.getMediaClientForLibrary(library);
}
-9
View File
@@ -3,7 +3,6 @@ import '../media/ids.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../services/plex_client.dart';
import '../utils/global_key_utils.dart';
import '../utils/provider_extensions.dart';
@@ -23,14 +22,6 @@ mixin ServerBoundMediaMixin<T extends StatefulWidget> on State<T> {
return buildGlobalKey(resolved, ratingKey);
}
/// Returns the [PlexClient] for the bound server, or null when offline /
/// the server is Jellyfin / not registered. Use [getServerBoundMediaClient]
/// for backend-neutral flows.
PlexClient? getServerBoundPlexClient(BuildContext context) {
if (isServerBoundOffline) return null;
return context.tryGetPlexClientForServer(serverIdOrNull(serverBoundMetadata.serverId));
}
/// Returns a backend-neutral [MediaServerClient] for the bound server, or
/// null when offline / not registered.
MediaServerClient? getServerBoundMediaClient(BuildContext context) =>
+1 -12
View File
@@ -12,9 +12,6 @@ enum MainScreenScopeAspect {
/// widgets (e.g. [SideNavigationBleedBuilder] call sites).
offset,
/// `isSidebarFocused`.
focus,
/// `foregroundWidth` / `viewportWidth` / `reservedSideNavigationWidth` —
/// stable across sidebar flips (only change with window geometry).
layout,
@@ -22,8 +19,6 @@ enum MainScreenScopeAspect {
class MainScreenFocusScope extends InheritedModel<MainScreenScopeAspect> {
final VoidCallback focusSidebar;
final VoidCallback focusContent;
final bool isSidebarFocused;
final double sideNavigationWidth;
final double? reservedSideNavigationWidth;
final double? foregroundLeft;
@@ -35,8 +30,6 @@ class MainScreenFocusScope extends InheritedModel<MainScreenScopeAspect> {
const MainScreenFocusScope({
super.key,
required this.focusSidebar,
required this.focusContent,
required this.isSidebarFocused,
required this.sideNavigationWidth,
this.reservedSideNavigationWidth,
this.foregroundLeft,
@@ -98,8 +91,7 @@ class MainScreenFocusScope extends InheritedModel<MainScreenScopeAspect> {
@override
bool updateShouldNotify(MainScreenFocusScope oldWidget) {
return isSidebarFocused != oldWidget.isSidebarFocused ||
sideNavigationWidth != oldWidget.sideNavigationWidth ||
return sideNavigationWidth != oldWidget.sideNavigationWidth ||
reservedSideNavigationWidth != oldWidget.reservedSideNavigationWidth ||
foregroundLeft != oldWidget.foregroundLeft ||
foregroundWidth != oldWidget.foregroundWidth ||
@@ -112,9 +104,6 @@ class MainScreenFocusScope extends InheritedModel<MainScreenScopeAspect> {
(foregroundLeft != oldWidget.foregroundLeft || sideNavigationWidth != oldWidget.sideNavigationWidth)) {
return true;
}
if (dependencies.contains(MainScreenScopeAspect.focus) && isSidebarFocused != oldWidget.isSidebarFocused) {
return true;
}
if (dependencies.contains(MainScreenScopeAspect.layout) &&
(foregroundWidth != oldWidget.foregroundWidth ||
viewportWidth != oldWidget.viewportWidth ||
-28
View File
@@ -14,38 +14,10 @@ Future<ActivePlexIdentity?> resolveActivePlexIdentity({
required ActiveProfileProvider activeProfile,
required ConnectionRegistry connections,
required ProfileConnectionRegistry profileConnections,
PlexAccountConnection? preferredAccount,
}) async {
await activeProfile.initialize();
final profile = activeProfile.active;
String? userUuidForPreferred() {
if (profile == null) return null;
if (profile.parentConnectionId == preferredAccount?.id) {
return profile.plexHomeUserUuid;
}
return null;
}
if (preferredAccount != null) {
final preferredUserUuid = userUuidForPreferred();
if (preferredUserUuid != null) {
return ActivePlexIdentity(account: preferredAccount, userUuid: preferredUserUuid);
}
if (profile != null) {
final pcs = await profileConnections.listForProfile(profile.id);
for (final pc in pcs) {
if (pc.connectionId == preferredAccount.id) {
return ActivePlexIdentity(
account: preferredAccount,
userUuid: pc.userIdentifier.isEmpty ? null : pc.userIdentifier,
);
}
}
}
return ActivePlexIdentity(account: preferredAccount);
}
final parentId = profile?.parentConnectionId;
if (parentId != null) {
final account = await connections.getPlexAccount(parentId);
+1 -9
View File
@@ -421,15 +421,7 @@ class PlexHomeService {
PlexHome? materializePlexHome(String connectionId) {
final users = _byConnection[connectionId];
if (users == null || users.isEmpty) return null;
return PlexHome(
id: 0,
name: '',
guestUserID: null,
guestUserUUID: '',
guestEnabled: false,
subscription: false,
users: users,
);
return PlexHome(id: 0, users: users);
}
/// Await startup cache hydration, then materialize the home attached to
-5
View File
@@ -36,11 +36,6 @@ class ProfileRegistry {
return rows.map(_rowToProfile).whereType<Profile>().toList();
}
Future<Profile?> get(String id) async {
final row = await (_db.select(_db.profiles)..where((t) => t.id.equals(id))).getSingleOrNull();
return row == null ? null : _rowToProfile(row);
}
Future<void> upsert(Profile profile) async {
await _db.runIdentityMutation(() async {
final row = ProfilesCompanion(
-2
View File
@@ -1752,8 +1752,6 @@ class _MainScreenState extends State<MainScreen>
);
return MainScreenFocusScope(
focusSidebar: _focusSidebar,
focusContent: _focusContent,
isSidebarFocused: _isSidebarFocused,
sideNavigationWidth: targetContentOffset,
reservedSideNavigationWidth: reservedContentOffset,
foregroundLeft: contentLayout.left,
+1 -1
View File
@@ -1073,7 +1073,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}
/// Backend-neutral counterpart of [getServerBoundPlexClient]. Returns a
/// Returns a
/// [MediaServerClient] for Jellyfin items too, so image URLs use the
/// right server's transcoder.
MediaServerClient? _getMediaClientForMetadata(BuildContext context) {
@@ -84,20 +84,6 @@ void main() {
{'Name': 'archive'},
]);
});
test("the adapter reports the dialect's backend", () {
final embyClient = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: <String>[]);
final jellyfinClient = _clientForDto(
connection: _jellyfinConnection(),
dto: _jellyfinItem(),
postedBodies: <String>[],
);
addTearDown(embyClient.close);
addTearDown(jellyfinClient.close);
expect(JellyfinMetadataEditAdapter(embyClient).backend, MediaBackend.emby);
expect(JellyfinMetadataEditAdapter(jellyfinClient).backend, MediaBackend.jellyfin);
});
}
JellyfinClient _clientForDto({
@@ -45,9 +45,6 @@ void main() {
}
class _TestMetadataEditAdapter extends MetadataEditAdapter {
@override
MediaBackend get backend => MediaBackend.plex;
@override
MediaServerClient get mediaClient => throw UnimplementedError();
-68
View File
@@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/mixins/library_tab_state.dart';
import 'package:provider/provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/multi_server_manager.dart';
import '../test_helpers/multi_server_fixtures.dart';
class _Probe extends StatefulWidget {
const _Probe({required this.library, required this.onState});
final MediaLibrary library;
final void Function(_ProbeState state) onState;
@override
State<_Probe> createState() => _ProbeState();
}
class _ProbeState extends State<_Probe> with LibraryTabStateMixin<_Probe> {
@override
MediaLibrary get library => widget.library;
@override
Widget build(BuildContext context) {
// Surface state after the first frame so tests receive a mounted probe.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onState(this);
});
return const SizedBox.shrink();
}
}
MediaLibrary _lib({ServerId? serverId, String key = '1'}) =>
MediaLibrary(id: key, backend: MediaBackend.plex, title: 'Movies', kind: MediaKind.movie, serverId: serverId);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('LibraryTabStateMixin', () {
testWidgets('getClientForLibrary throws when no server matches and no fallback online', (tester) async {
late _ProbeState state;
final manager = MultiServerManager();
final provider = testMultiServerProvider(manager);
// provider.dispose() cascades to manager.dispose() — only register
// the outer teardown to avoid a double-close on the manager's stream.
addTearDown(provider.dispose);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: _Probe(
library: _lib(serverId: ServerId('srv-missing')),
onState: (s) => state = s,
),
),
);
await tester.pump();
expect(() => state.getClientForLibrary(), throwsA(isA<Exception>()));
});
});
}
@@ -82,25 +82,5 @@ void main() {
expect(() => state.toServerBoundGlobalKey('rk-1'), throwsStateError);
});
testWidgets('getServerBoundPlexClient returns null in offline mode regardless of providers', (tester) async {
late _ProbeState state;
late BuildContext ctx;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: ServerId('srv-A')),
offline: true,
onState: (s, c) {
state = s;
ctx = c;
},
),
);
await tester.pump();
// The provider extension short-circuits to null when isOffline is true,
// so no MultiServerProvider is required to exercise this branch.
expect(state.getServerBoundPlexClient(ctx), isNull);
});
});
}
+1 -2
View File
@@ -57,8 +57,7 @@ WatchStateEvent _ev({
required String itemId,
List<String> parentChain = const [],
WatchStateChangeType type = WatchStateChangeType.watched,
}) =>
WatchStateEvent(itemId: itemId, serverId: serverId, changeType: type, parentChain: parentChain, mediaType: 'movie');
}) => WatchStateEvent(itemId: itemId, serverId: serverId, changeType: type, parentChain: parentChain);
/// Drain microtasks the broadcast stream uses to deliver events.
Future<void> _settle(WidgetTester tester) async {
+3 -7
View File
@@ -11,8 +11,6 @@ void main() {
MaterialApp(
home: MainScreenFocusScope(
focusSidebar: () => focusCalls++,
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: Builder(
builder: (context) {
@@ -47,7 +45,7 @@ void main() {
});
testWidgets('focusSidebarOf does not register an inherited dependency', (tester) async {
var isSidebarFocused = false;
var sideNavigationWidth = 0.0;
var childBuilds = 0;
var focusCalls = 0;
late StateSetter rebuildScope;
@@ -66,9 +64,7 @@ void main() {
rebuildScope = setState;
return MainScreenFocusScope(
focusSidebar: () => focusCalls++,
focusContent: () {},
isSidebarFocused: isSidebarFocused,
sideNavigationWidth: 0,
sideNavigationWidth: sideNavigationWidth,
child: child,
);
},
@@ -79,7 +75,7 @@ void main() {
expect(childBuilds, 1);
expect(focusCalls, 1);
rebuildScope(() => isSidebarFocused = true);
rebuildScope(() => sideNavigationWidth = 100);
await tester.pump();
expect(childBuilds, 1);
@@ -1121,7 +1121,6 @@ PlexServer _server({
),
],
owned: owned,
presence: true,
);
}
+17 -13
View File
@@ -22,7 +22,7 @@ void main() {
expect(await registry.list(), isEmpty);
});
test('upsert + get round-trips a local profile', () async {
test('upsert + list round-trips a local profile', () async {
final profile = Profile.local(
id: 'local-1',
displayName: 'Owner',
@@ -31,14 +31,14 @@ void main() {
);
await registry.upsert(profile);
final fetched = await registry.get('local-1');
final fetched = await _profileById(registry, 'local-1');
expect(fetched, isNotNull);
expect(fetched!.kind, ProfileKind.local);
expect(fetched.displayName, 'Owner');
expect(fetched.pinHash, profile.pinHash);
});
test('upsert + get round-trips a plex_home profile', () async {
test('list never serves plex_home rows (Plex Home users are not persisted profiles)', () async {
final profile = Profile.plexHome(
id: 'plex-home-acct-uuid',
displayName: 'Admin',
@@ -50,13 +50,10 @@ void main() {
);
await registry.upsert(profile);
final fetched = await registry.get(profile.id);
expect(fetched, isNotNull);
expect(fetched!.kind, ProfileKind.plexHome);
expect(fetched.avatarThumbUrl, profile.avatarThumbUrl);
expect(fetched.parentConnectionId, 'acct');
expect(fetched.plexAdmin, isTrue);
expect(fetched.plexProtected, isTrue);
// The read surface is deliberately local-only: Plex Home users are
// materialized live by PlexHomeService and any leftover persisted rows
// are dropped by [ProfileRegistry.dropAllPlexHomeRows].
expect(await _profileById(registry, profile.id), isNull);
});
test('list orders by sortOrder then createdAt', () async {
@@ -69,21 +66,21 @@ void main() {
test('remove deletes a profile', () async {
await registry.upsert(Profile.local(id: 'p', displayName: 'P', createdAt: DateTime(2026, 1, 1)));
await registry.remove('p');
expect(await registry.get('p'), isNull);
expect(await _profileById(registry, 'p'), isNull);
});
test('markUsed updates lastUsedAt', () async {
await registry.upsert(Profile.local(id: 'p', displayName: 'P', createdAt: DateTime(2026, 1, 1)));
final ts = DateTime(2026, 1, 5, 12, 0);
await registry.markUsed('p', ts);
final fetched = await registry.get('p');
final fetched = await _profileById(registry, 'p');
expect(fetched!.lastUsedAt, ts);
});
test('upsert is idempotent (replaces existing row)', () async {
await registry.upsert(Profile.local(id: 'p', displayName: 'Original', createdAt: DateTime(2026, 1, 1)));
await registry.upsert(Profile.local(id: 'p', displayName: 'Renamed', createdAt: DateTime(2026, 1, 1)));
final fetched = await registry.get('p');
final fetched = await _profileById(registry, 'p');
expect(fetched!.displayName, 'Renamed');
});
@@ -107,3 +104,10 @@ void main() {
});
});
}
Future<Profile?> _profileById(ProfileRegistry registry, String id) async {
for (final profile in await registry.list()) {
if (profile.id == id) return profile;
}
return null;
}
-10
View File
@@ -153,8 +153,6 @@ void main() {
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: targetSidebarOffset,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: currentForegroundLeft,
@@ -321,8 +319,6 @@ void main() {
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
@@ -458,8 +454,6 @@ void main() {
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
@@ -570,8 +564,6 @@ void main() {
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
@@ -722,8 +714,6 @@ Future<void> _pumpDiscoverShell(WidgetTester tester, {required bool isTv}) async
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
@@ -335,8 +335,6 @@ Future<void> _pumpScreen(
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.macOS),
home: MainScreenFocusScope(
focusSidebar: onSidebar ?? () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: DownloadsScreen(key: screenKey),
),
@@ -555,9 +555,6 @@ class _ArtworkAdapter extends MetadataEditAdapter {
int applyCalls = 0;
int uploadCalls = 0;
@override
MediaBackend get backend => MediaBackend.plex;
@override
MediaServerClient get mediaClient => _client;
@@ -32,8 +32,6 @@ Future<void> pumpLibraryTab(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: focusSidebar ?? () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: Scaffold(
body: NestedScrollView(