import 'dart:convert'; import 'package:drift/drift.dart'; import '../database/app_database.dart'; typedef JellyfinItemCacheKey = ({String scopeId, String machineId, String userId, String itemId}); typedef JellyfinCacheItem = ({ApiCacheData cacheRow, JellyfinItemCacheKey key}); typedef ResolvedJellyfinCacheItem = ({ApiCacheData cacheRow, ConnectionRow connection, JellyfinItemCacheKey key}); /// Canonical MediaBrowser connection and item-cache key resolution. class JellyfinCacheResolver { JellyfinCacheResolver(this.database); final AppDatabase database; static const _likeEscape = r'\'; static const _usersMarker = ':/Users/'; static const _itemsMarker = '/Items/'; static Expression _mediaBrowserKind(GeneratedColumn kind) => kind.equals('jellyfin') | kind.equals('emby'); Expression itemKeyPredicate(GeneratedColumn column, String serverOrScopeId, String itemId) { final scope = _splitScope(serverOrScopeId); final escapedItemId = _escapeLike(itemId); final scopedUser = scope.userId == null ? '%' : _escapeLike(scope.userId!); final scopedPattern = '${_escapeLike(serverOrScopeId)}:/Users/$scopedUser/Items/$escapedItemId'; var predicate = column.like(scopedPattern, escapeChar: _likeEscape); if (scope.userId == null) { final compoundPattern = '${_escapeLike(scope.machineId)}/%:/Users/%/Items/$escapedItemId'; predicate = predicate | column.like(compoundPattern, escapeChar: _likeEscape); } else { final legacyPattern = '${_escapeLike(scope.machineId)}:/Users/${_escapeLike(scope.userId!)}/Items/$escapedItemId'; predicate = predicate | column.like(legacyPattern, escapeChar: _likeEscape); } return predicate; } Future findItem(String serverOrScopeId, String itemId) async { final matches = await _findItems(serverOrScopeId, itemId); return matches.isEmpty ? null : matches.first; } Future findResolvedItem(String serverOrScopeId, String itemId) async { final matches = await _findItems(serverOrScopeId, itemId); for (final match in matches) { final connection = await findConnection(match.key.scopeId, userId: match.key.userId); if (connection != null) return (cacheRow: match.cacheRow, connection: connection, key: match.key); } return null; } Future> _findItems(String serverOrScopeId, String itemId) async { final rows = await (database.select(database.apiCache) ..where((t) => itemKeyPredicate(t.cacheKey, serverOrScopeId, itemId)) ..orderBy([(t) => OrderingTerm.asc(t.cacheKey)])) .get(); final requested = _splitScope(serverOrScopeId); final matches = []; for (final row in rows) { final key = parseItemKey(row.cacheKey); if (key == null || key.itemId != itemId) continue; if (key.machineId != requested.machineId) continue; if (requested.userId != null && key.userId != requested.userId) continue; matches.add((cacheRow: row, key: key)); } if (requested.userId != null) { matches.sort((a, b) => a.key.scopeId == serverOrScopeId ? -1 : (b.key.scopeId == serverOrScopeId ? 1 : 0)); } return matches; } Future> findPinnedItems() async { final rows = await (database.select(database.apiCache) ..where((t) => t.pinned.equals(true)) ..orderBy([(t) => OrderingTerm.asc(t.cacheKey)])) .get(); if (rows.isEmpty) return const []; final connections = await (database.select(database.connections)..where((t) => _mediaBrowserKind(t.kind))).get(); final connectionById = {for (final connection in connections) connection.id: connection}; final bindings = await database.select(database.profileConnections).get(); final bindingsByConnection = >{}; for (final binding in bindings) { bindingsByConnection.putIfAbsent(binding.connectionId, () => []).add(binding); } bool matchesBinding(String connectionId, String userId) { final connectionBindings = bindingsByConnection[connectionId]; return connectionBindings == null || connectionBindings.isEmpty || connectionBindings.any((binding) => binding.userIdentifier == userId); } final matches = []; for (final row in rows) { final key = parseItemKey(row.cacheKey); if (key == null) continue; final compoundId = '${key.machineId}/${key.userId}'; final compound = connectionById[compoundId]; if (compound != null && matchesBinding(compound.id, key.userId)) { matches.add((cacheRow: row, connection: compound, key: key)); continue; } final legacy = connectionById[key.machineId]; if (legacy != null && matchesBinding(legacy.id, key.userId)) { matches.add((cacheRow: row, connection: legacy, key: key)); } } return matches; } /// Resolves the exact persisted MediaBrowser cache namespace owned by /// [profileId] for [serverOrScopeId]. /// /// The physical download row is deliberately not consulted: it is shared /// across profiles and may have been created by a different MediaBrowser user. Future findProfileScopeId(String serverOrScopeId, String profileId) async { if (profileId.isEmpty) return null; final requested = _splitScope(serverOrScopeId); final bindings = await (database.select(database.profileConnections) ..where((t) => t.profileId.equals(profileId)) ..orderBy([ (t) => OrderingTerm.desc(t.isDefault), (t) => OrderingTerm.desc(t.lastUsedAt), (t) => OrderingTerm.asc(t.connectionId), ])) .get(); for (final binding in bindings) { if (binding.userIdentifier.isEmpty) continue; final connection = await (database.select( database.connections, )..where((t) => t.id.equals(binding.connectionId) & _mediaBrowserKind(t.kind))).getSingleOrNull(); if (connection == null) continue; final connectionScope = _splitScope(connection.id); var machineId = connectionScope.machineId; String? configuredUserId = connectionScope.userId; try { final config = jsonDecode(connection.configJson); if (config is Map) { final configuredMachineId = config['serverMachineId']; final configuredUser = config['userId']; if (configuredMachineId is String && configuredMachineId.isNotEmpty) { machineId = configuredMachineId; } if (configuredUser is String && configuredUser.isNotEmpty) { configuredUserId = configuredUser; } } } on FormatException { // Legacy rows can still be resolved from their canonical id. } if (machineId != requested.machineId) continue; if (configuredUserId != null && configuredUserId != binding.userIdentifier) continue; return '$machineId/${binding.userIdentifier}'; } return null; } Future findConnection(String serverOrScopeId, {String? userId}) async { final scope = _splitScope(serverOrScopeId); if (scope.userId != null && userId != null && scope.userId != userId) return null; final expectedUserId = userId ?? scope.userId; if (expectedUserId != null) { final compoundId = '${scope.machineId}/$expectedUserId'; final compound = await (database.select( database.connections, )..where((t) => t.id.equals(compoundId) & _mediaBrowserKind(t.kind))).getSingleOrNull(); if (compound != null && await _matchesProfileBinding(compound.id, expectedUserId)) return compound; final legacy = await (database.select( database.connections, )..where((t) => t.id.equals(scope.machineId) & _mediaBrowserKind(t.kind))).getSingleOrNull(); if (legacy != null && await _matchesProfileBinding(legacy.id, expectedUserId)) return legacy; return null; } final exact = await (database.select( database.connections, )..where((t) => t.id.equals(scope.machineId))).getSingleOrNull(); if (exact != null) return exact; final plex = await _findPlexConnectionForServer(scope.machineId); if (plex != null) return plex; final prefix = '${scope.machineId}/'; return (database.select(database.connections) ..where((t) => t.id.substr(1, prefix.length).equals(prefix) & _mediaBrowserKind(t.kind)) ..orderBy([(t) => OrderingTerm.asc(t.id)]) ..limit(1)) .getSingleOrNull(); } Future _findPlexConnectionForServer(String serverId) async { final accounts = await (database.select(database.connections)..where((t) => t.kind.equals('plex'))).get(); for (final account in accounts) { try { final config = jsonDecode(account.configJson); if (config is! Map) continue; final servers = config['servers']; if (servers is! List) continue; for (final server in servers) { if (server is Map && server['clientIdentifier'] == serverId) { return account; } } } on FormatException { // Ignore malformed persisted accounts and continue deterministically. } } return null; } Future _matchesProfileBinding(String connectionId, String userId) async { final bindings = await (database.select( database.profileConnections, )..where((t) => t.connectionId.equals(connectionId))).get(); return bindings.isEmpty || bindings.any((binding) => binding.userIdentifier == userId); } static JellyfinItemCacheKey? parseItemKey(String cacheKey) { final usersMarker = cacheKey.indexOf(_usersMarker); if (usersMarker <= 0) return null; final scopeId = cacheKey.substring(0, usersMarker); final userStart = usersMarker + _usersMarker.length; final itemsMarker = cacheKey.indexOf(_itemsMarker, userStart); if (itemsMarker <= userStart) return null; final userId = cacheKey.substring(userStart, itemsMarker); final itemId = cacheKey.substring(itemsMarker + _itemsMarker.length); if (itemId.isEmpty || itemId.contains('/') || itemId.contains('?')) return null; final scope = _splitScope(scopeId); if (scope.userId != null && scope.userId != userId) return null; return (scopeId: scopeId, machineId: scope.machineId, userId: userId, itemId: itemId); } static ({String machineId, String? userId}) _splitScope(String scopeId) { final slash = scopeId.indexOf('/'); if (slash <= 0 || slash == scopeId.length - 1) return (machineId: scopeId, userId: null); return (machineId: scopeId.substring(0, slash), userId: scopeId.substring(slash + 1)); } static String _escapeLike(String value) { return value.replaceAll(_likeEscape, '$_likeEscape$_likeEscape').replaceAll('%', r'\%').replaceAll('_', r'\_'); } }