refactor: type server identifiers

This commit is contained in:
edde746
2026-06-01 11:06:03 +02:00
parent 7b59dac26b
commit 74b8dc4561
258 changed files with 2296 additions and 2080 deletions
+5 -4
View File
@@ -1,4 +1,5 @@
import '../media/media_item.dart';
import '../media/ids.dart';
import 'app_logger.dart';
import 'base_notifier.dart';
import 'global_key_utils.dart';
@@ -16,7 +17,7 @@ class DeletionEvent with HierarchicalEventMixin {
/// Server this item belongs to
@override
final String serverId;
final ServerId serverId;
/// Parent chain for hierarchical invalidation
/// For an episode: [seasonId, showId]
@@ -43,7 +44,7 @@ class DeletionEvent with HierarchicalEventMixin {
required this.mediaType,
this.leafCount = 1,
this.isDownloadOnly = false,
}) : globalKey = buildGlobalKey(serverId, itemId);
}) : globalKey = buildGlobalKey(ServerId(serverId), itemId);
@override
String toString() => 'DeletionEvent(deleted: $globalKey, type: $mediaType, parents: $parentChain)';
@@ -60,7 +61,7 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
DeletionNotifier._internal();
Stream<DeletionEvent> forServer(String serverId) => stream.where((e) => e.serverId == serverId);
Stream<DeletionEvent> forServer(ServerId serverId) => stream.where((e) => e.serverId == serverId);
Stream<DeletionEvent> forItem(String itemId) => stream.where((e) => e.affectsItem(itemId));
@@ -75,7 +76,7 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
notify(
DeletionEvent(
itemId: item.id,
serverId: item.serverId ?? '',
serverId: ServerId(item.serverId ?? ''),
parentChain: item.parentChain,
mediaType: item.kind.id,
leafCount: item.leafCount ?? 1,
+3 -3
View File
@@ -78,7 +78,7 @@ class DesktopAppBarHelper {
final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft;
final paddedWidget = Padding(
padding: EdgeInsets.only(left: leftPadding),
padding: .only(left: leftPadding),
child: leading,
);
@@ -167,7 +167,7 @@ class DesktopTitleBarPadding extends StatelessWidget {
return child;
}
return Padding(
padding: EdgeInsets.only(right: right),
padding: .only(right: right),
child: child,
);
}
@@ -186,7 +186,7 @@ class DesktopTitleBarPadding extends StatelessWidget {
}
return Padding(
padding: EdgeInsets.only(left: left, right: right),
padding: .only(left: left, right: right),
child: child,
);
},
+5 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../media/ids.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
@@ -139,11 +140,11 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
bool syncRuleUpdated = false;
if (keepSynced) {
final syncCount = maxCount ?? 0; // 0 means "all unwatched" for the rule
final ruleKey = downloadProvider.syncRuleKeyFor(metadata.serverId ?? client.serverId, metadata.id);
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(metadata.serverId ?? client.serverId), metadata.id);
syncRuleUpdated = downloadProvider.hasSyncRule(ruleKey);
await downloadProvider.createSyncRule(
serverId: metadata.serverId ?? client.serverId,
serverId: ServerId(metadata.serverId ?? client.serverId),
ratingKey: metadata.id,
targetType: metadata.kind.id.isNotEmpty ? metadata.kind.id : ContentTypes.show,
episodeCount: syncCount,
@@ -212,13 +213,13 @@ Future<DownloadResult?> showListDownloadOptionsAndQueue(
bool syncRuleUpdated = false;
if (syncChoice == _SyncChoice.keepSynced) {
final ruleKey = downloadProvider.syncRuleKeyFor(serverId, rootMetadata.id);
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id);
if (downloadProvider.hasSyncRule(ruleKey)) {
await downloadProvider.updateSyncRuleFilter(ruleKey, filterString);
syncRuleUpdated = true;
} else {
await downloadProvider.createSyncRule(
serverId: serverId,
serverId: ServerId(serverId),
ratingKey: rootMetadata.id,
targetType: targetType,
episodeCount: 0,
+1 -1
View File
@@ -114,7 +114,7 @@ String formatSyncOffset(double offsetMs) {
final absMs = offsetMs.abs().round();
final durationLocale = _getDurationLocale();
if (absMs >= 10000) {
if (absMs >= 10_000) {
final seconds = (offsetMs.abs() / 1000).toStringAsFixed(1);
final unit = durationLocale.second(1, true);
return '$sign$seconds$unit';
+7 -5
View File
@@ -1,4 +1,6 @@
String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKey';
import '../media/ids.dart';
String buildGlobalKey(ServerId serverId, String ratingKey) => '$serverId:$ratingKey';
/// Separator used by profile-owned rows whose public media identity is still
/// [buildGlobalKey]. Profile ids are generated by the app and do not contain
@@ -6,7 +8,7 @@ String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKe
const String profileScopedGlobalKeySeparator = '|';
/// Builds a profile-owned sync-rule key from [profileId] and public media id.
String buildProfileScopedGlobalKey(String profileId, String serverId, String ratingKey) {
String buildProfileScopedGlobalKey(String profileId, ServerId serverId, String ratingKey) {
return '$profileId$profileScopedGlobalKeySeparator${buildGlobalKey(serverId, ratingKey)}';
}
@@ -14,14 +16,14 @@ String buildProfileScopedGlobalKey(String profileId, String serverId, String rat
///
/// Returns `null` if the key does not contain a colon separator.
/// Uses [indexOf] so ratingKeys containing colons are handled correctly.
({String serverId, String ratingKey})? parseGlobalKey(String globalKey) {
({ServerId serverId, String ratingKey})? parseGlobalKey(String globalKey) {
final idx = globalKey.indexOf(':');
if (idx < 0) return null;
return (serverId: globalKey.substring(0, idx), ratingKey: globalKey.substring(idx + 1));
return (serverId: ServerId(globalKey.substring(0, idx)), ratingKey: globalKey.substring(idx + 1));
}
/// Parses a profile-owned sync-rule key, returning `null` for legacy public keys.
({String profileId, String serverId, String ratingKey})? parseProfileScopedGlobalKey(String globalKey) {
({String profileId, ServerId serverId, String ratingKey})? parseProfileScopedGlobalKey(String globalKey) {
final idx = globalKey.indexOf(profileScopedGlobalKeySeparator);
if (idx < 0) return null;
final publicKey = parseGlobalKey(globalKey.substring(idx + 1));
+2 -1
View File
@@ -1,4 +1,5 @@
import 'global_key_utils.dart';
import '../media/ids.dart';
/// Mixin providing hierarchical event matching methods.
///
@@ -11,7 +12,7 @@ mixin HierarchicalEventMixin {
String get globalKey;
String get serverId;
ServerId get serverId;
/// Parent chain for hierarchical matching.
/// For an episode: [seasonId, showId]
+1 -1
View File
@@ -7,7 +7,7 @@
/// across mappers, the client, and the playback bundle.
library;
const int _ticksPerMs = 10000;
const int _ticksPerMs = 10_000;
/// Jellyfin ticks → milliseconds. Returns `null` for non-numeric input.
int? jellyfinTicksToMs(Object? ticks) {
+3 -2
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import '../media/ids.dart';
import 'package:flutter/material.dart';
@@ -92,7 +93,7 @@ Future<void> tuneAndNavigateToLiveTv(
return;
}
final genericClient = multiServer.getClientForServer(serverInfo.serverId);
final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (genericClient == null) {
showErrorSnackBar(context, 'Live TV server is not connected.');
return;
@@ -112,7 +113,7 @@ Future<void> tuneAndNavigateToLiveTv(
return;
}
final plexClient = multiServer.getPlexClientForServer(serverInfo.serverId);
final plexClient = multiServer.getPlexClientForServer(ServerId(serverInfo.serverId));
if (plexClient == null) {
appLogger.w('Failed to resolve live stream URL for ${channel.displayName} on ${genericClient.backend.id}');
showErrorSnackBar(context, 'Unable to start this live TV channel.');
+5 -4
View File
@@ -1,4 +1,5 @@
import '../media/media_hub.dart';
import '../media/ids.dart';
import '../media/media_library.dart';
import 'global_key_utils.dart';
@@ -35,13 +36,13 @@ bool sortMediaHubsByLibraryOrder(List<MediaHub> hubs, List<MediaLibrary> library
}
int? _hubLibraryOrderIndex(MediaHub hub, Map<String, int> orderByGlobalKey) {
final hubLibraryKey = _globalKey(hub.serverId, hub.libraryId);
final hubLibraryKey = _globalKey(serverIdOrNull(hub.serverId), hub.libraryId);
final hubIndex = hubLibraryKey == null ? null : orderByGlobalKey[hubLibraryKey];
if (hubIndex != null) return hubIndex;
int? bestIndex;
for (final item in hub.items) {
final key = _globalKey(item.serverId ?? hub.serverId, item.libraryId);
final key = _globalKey(serverIdOrNull(item.serverId ?? hub.serverId), item.libraryId);
final index = key == null ? null : orderByGlobalKey[key];
if (index != null && (bestIndex == null || index < bestIndex)) {
bestIndex = index;
@@ -50,7 +51,7 @@ int? _hubLibraryOrderIndex(MediaHub hub, Map<String, int> orderByGlobalKey) {
return bestIndex;
}
String? _globalKey(String? serverId, String? libraryId) {
String? _globalKey(ServerId? serverId, String? libraryId) {
if (serverId == null || libraryId == null) return null;
return buildGlobalKey(serverId, libraryId);
return buildGlobalKey(ServerId(serverId), libraryId);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../media/ids.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_playlist.dart';
@@ -72,7 +73,7 @@ Future<MediaNavigationResult> navigateToMediaItem(
if (mi.isLibrarySection) {
final sectionKey = mi.librarySectionKey;
if (sectionKey != null && mi.serverId != null) {
final libraryGlobalKey = buildGlobalKey(mi.serverId!, sectionKey);
final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), sectionKey);
MainScreenFocusScope.of(context, listen: false)?.selectLibrary?.call(libraryGlobalKey);
return MediaNavigationResult.librarySelected;
}
+1 -1
View File
@@ -399,7 +399,7 @@ class MediaServerHttpClient {
void add(String key, Object? value) {
if (value == null) return;
if (value is Iterable && value is! String) {
if (value is Iterable) {
for (final item in value) {
add(key, item);
}
+17 -16
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../media/ids.dart';
import 'package:provider/provider.dart';
import '../media/media_item.dart';
import '../media/media_library.dart';
@@ -24,7 +25,7 @@ extension ProviderExtensions on BuildContext {
/// Plex-only flows that have no neutral equivalent (DVR tuning, match).
/// Backend-agnostic flows use the [_resolveMediaClient]
/// helpers below.
PlexClient? _resolveClient(String? serverId) {
PlexClient? _resolveClient(ServerId? serverId) {
final provider = Provider.of<MultiServerProvider>(this, listen: false);
return _resolvePrioritized(serverId, provider.onlineServerIds, provider.getPlexClientForServer);
}
@@ -32,7 +33,7 @@ extension ProviderExtensions on BuildContext {
/// Internal: like [_resolveClient] but throws a localized exception when
/// no client is available. The thrown message is the canonical
/// `t.errors.noClientAvailable` so callers can surface it directly.
PlexClient _requireClient(String? serverId, {bool fallback = true}) {
PlexClient _requireClient(ServerId? serverId, {bool fallback = true}) {
final provider = Provider.of<MultiServerProvider>(this, listen: false);
if (serverId != null) {
final client = provider.getPlexClientForServer(serverId);
@@ -49,17 +50,17 @@ extension ProviderExtensions on BuildContext {
return client;
}
PlexClient getPlexClientForServer(String serverId) => _requireClient(serverId, fallback: false);
PlexClient getPlexClientForServer(ServerId serverId) => _requireClient(serverId, fallback: false);
PlexClient? tryGetPlexClientForServer(String? serverId) {
PlexClient? tryGetPlexClientForServer(ServerId? serverId) {
if (serverId == null) return null;
final provider = Provider.of<MultiServerProvider>(this, listen: false);
return provider.getPlexClientForServer(serverId);
}
PlexClient getPlexClientForLibrary(MediaLibrary library) => _requireClient(library.serverId);
PlexClient getPlexClientForLibrary(MediaLibrary library) => _requireClient(serverIdOrNull(library.serverId));
PlexClient getPlexClientWithFallback(String? serverId) => _requireClient(serverId);
PlexClient getPlexClientWithFallback(ServerId? serverId) => _requireClient(serverId);
// ── Backend-neutral helpers ──────────────────────────────────────
// These return [MediaServerClient] regardless of backend kind so callers
@@ -68,12 +69,12 @@ extension ProviderExtensions on BuildContext {
// when you specifically need a [PlexClient] (Plex-only flows like Live TV or
// match/fix-match).
MediaServerClient? _resolveMediaClient(String? serverId) {
MediaServerClient? _resolveMediaClient(ServerId? serverId) {
final provider = Provider.of<MultiServerProvider>(this, listen: false);
return _resolvePrioritized(serverId, provider.onlineServerIds, provider.getClientForServer);
}
MediaServerClient? tryGetMediaClientForServer(String? serverId) {
MediaServerClient? tryGetMediaClientForServer(ServerId? serverId) {
if (serverId == null) return null;
final provider = Provider.of<MultiServerProvider>(this, listen: false);
return provider.getClientForServer(serverId);
@@ -82,14 +83,14 @@ extension ProviderExtensions on BuildContext {
/// Get a [MediaServerClient] for the given serverId. Throws when the
/// server isn't registered or is offline. Mirrors the throwing variant of
/// the Plex-typed [getPlexClientForServer] helpers.
MediaServerClient getMediaClientForServer(String serverId) {
MediaServerClient getMediaClientForServer(ServerId serverId) {
final c = tryGetMediaClientForServer(serverId);
if (c == null) throw Exception(t.errors.noClientAvailable);
return c;
}
MediaServerClient getMediaClientForLibrary(MediaLibrary library) {
final c = _resolveMediaClient(library.serverId);
final c = _resolveMediaClient(serverIdOrNull(library.serverId));
if (c == null) throw Exception(t.errors.noClientAvailable);
return c;
}
@@ -98,12 +99,12 @@ extension ProviderExtensions on BuildContext {
/// when the server isn't online.
MediaServerClient? getMediaClientForItemOrNull(MediaItem item, {bool isOffline = false}) {
if (isOffline) return null;
return tryGetMediaClientForServer(item.serverId);
return tryGetMediaClientForServer(serverIdOrNull(item.serverId));
}
/// Get a [MediaServerClient] for [serverId], falling back to the first
/// online server when not found. Throws if no client is available.
MediaServerClient getMediaClientWithFallback(String? serverId) {
MediaServerClient getMediaClientWithFallback(ServerId? serverId) {
final c = _resolveMediaClient(serverId);
if (c == null) throw Exception(t.errors.noClientAvailable);
return c;
@@ -113,19 +114,19 @@ extension ProviderExtensions on BuildContext {
/// when no client is registered. Use this for non-critical surfaces (image
/// loaders, list cards) that can render a fallback when the client isn't
/// available — throwing during `build` would crash the widget instead.
MediaServerClient? tryGetMediaClientWithFallback(String? serverId) => _resolveMediaClient(serverId);
MediaServerClient? tryGetMediaClientWithFallback(ServerId? serverId) => _resolveMediaClient(serverId);
}
/// Try [preferred] first, then fall back through [fallbacks] in order. Returns
/// the first non-null result from [resolve], or `null` if every candidate
/// resolves to null.
T? _resolvePrioritized<T>(String? preferred, Iterable<String> fallbacks, T? Function(String) resolve) {
T? _resolvePrioritized<T>(String? preferred, Iterable<String> fallbacks, T? Function(ServerId) resolve) {
if (preferred != null) {
final c = resolve(preferred);
final c = resolve(ServerId(preferred));
if (c != null) return c;
}
for (final id in fallbacks) {
final c = resolve(id);
final c = resolve(ServerId(id));
if (c != null) return c;
}
return null;
+2 -2
View File
@@ -34,8 +34,8 @@ double mediaSearchRelevanceScore(MediaItem item, String query) {
(value: item.title, weight: 1.0),
(value: item.titleSort, weight: 0.98),
(value: item.originalTitle, weight: 0.96),
(value: item.grandparentTitle, weight: 0.90),
(value: item.parentTitle, weight: 0.80),
(value: item.grandparentTitle, weight: 0.9),
(value: item.parentTitle, weight: 0.8),
];
var best = 0.0;
+1 -1
View File
@@ -45,7 +45,7 @@ class SmartDeletionHandler {
if (progress == null) {
return AlertDialog(
content: Row(
mainAxisSize: MainAxisSize.min,
mainAxisSize: .min,
children: [const CircularProgressIndicator(), const SizedBox(width: 20), Text(t.downloads.deleting)],
),
);
+2 -2
View File
@@ -21,8 +21,8 @@ Future<void> showUpdateAvailableDialog(
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
t.update.versionAvailable(version: latestVersion),
+5 -4
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import '../media/ids.dart';
import 'package:flutter/material.dart';
@@ -65,8 +66,8 @@ Future<bool?> navigateToVideoPlayer(
final manager = context.read<MultiServerProvider>().serverManager;
final offlineWatchService = context.read<OfflineWatchSyncService>();
final serverId = metadata.serverId ?? '';
final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(serverId))
? manager.getClient(serverId)
final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(ServerId(serverId)))
? manager.getClient(ServerId(serverId))
: null;
int mediaIndex = selectedMediaIndex ?? 0;
@@ -203,11 +204,11 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
Future<void> navigateToWatchTogetherPlayback(
BuildContext context, {
required String ratingKey,
required String serverId,
required ServerId serverId,
VoidCallback? onBeforeNavigate,
}) async {
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(serverId);
final client = multiServer.getClientForServer(ServerId(serverId));
if (client == null) {
throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable');
+9 -7
View File
@@ -1,4 +1,5 @@
import '../media/media_item.dart';
import '../media/ids.dart';
import 'app_logger.dart';
import 'base_notifier.dart';
import 'global_key_utils.dart';
@@ -18,7 +19,7 @@ class WatchStateEvent with HierarchicalEventMixin {
/// Server this item belongs to
@override
final String serverId;
final ServerId serverId;
/// Optional backend-private cache namespace for user-scoped servers.
///
@@ -60,12 +61,13 @@ class WatchStateEvent with HierarchicalEventMixin {
this.viewOffset,
this.isNowWatched,
this.librarySectionID,
}) : globalKey = buildGlobalKey(serverId, itemId);
}) : globalKey = buildGlobalKey(ServerId(serverId), itemId);
/// `serverId:librarySectionID`, matching [MediaLibrary.globalKey]. Null when
/// the library section is unknown; tracker filters treat unknown as allowed
/// only when no filter is configured.
String? get librarySectionGlobalKey => librarySectionID != null ? buildGlobalKey(serverId, librarySectionID!) : null;
String? get librarySectionGlobalKey =>
librarySectionID != null ? buildGlobalKey(ServerId(serverId), librarySectionID!) : null;
@override
String toString() => 'WatchStateEvent($changeType, $globalKey, parents: $parentChain)';
@@ -82,7 +84,7 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
WatchStateNotifier._internal();
Stream<WatchStateEvent> forServer(String serverId) => stream.where((e) => e.serverId == serverId);
Stream<WatchStateEvent> forServer(ServerId serverId) => stream.where((e) => e.serverId == serverId);
Stream<WatchStateEvent> forItem(String itemId) => stream.where((e) => e.affectsItem(itemId));
@@ -98,7 +100,7 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
notify(
WatchStateEvent(
itemId: item.id,
serverId: item.serverId ?? '',
serverId: ServerId(item.serverId ?? ''),
cacheServerId: cacheServerId,
changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched,
parentChain: item.parentChain,
@@ -123,7 +125,7 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
notify(
WatchStateEvent(
itemId: item.id,
serverId: item.serverId ?? '',
serverId: ServerId(item.serverId ?? ''),
changeType: WatchStateChangeType.progressUpdate,
parentChain: item.parentChain,
mediaType: item.kind.id,
@@ -139,7 +141,7 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
notify(
WatchStateEvent(
itemId: item.id,
serverId: item.serverId ?? '',
serverId: ServerId(item.serverId ?? ''),
changeType: WatchStateChangeType.removedFromContinueWatching,
parentChain: item.parentChain,
mediaType: item.kind.id,