refactor: remove unreachable code paths and unused members

Drops dead code across services, models, utils and widgets, including the
connection auth service, which had no implementer, and the Live TV DVR
provisioning models, which had no caller.

Tests that only covered deleted behaviour are removed or trimmed. No
behaviour change.
This commit is contained in:
edde746
2026-07-26 06:09:47 +02:00
parent 9a6f48a4cb
commit 4307c49cd2
53 changed files with 59 additions and 1118 deletions
@@ -1,20 +0,0 @@
import 'connection.dart';
/// Backend-neutral auth service interface. Each backend's implementation
/// (`PlexConnectionAuthService`, `JellyfinConnectionAuthService`) drives its
/// own UX (PIN flow vs. password) but produces the same opaque
/// [Connection] record at the end.
abstract class ConnectionAuthService {
/// Best-effort check that an existing token still works. Returns false on
/// 401/403; throws on transport failures the caller should retry.
Future<bool> validate(Connection connection);
/// Refresh whatever side-channel state belongs to a connection — for Plex
/// that's the discovered server list and Home users; for Jellyfin it's a
/// no-op once auth has succeeded. Returns the updated connection.
Future<Connection> refresh(Connection connection);
/// Revoke the token server-side and forget local credentials. The caller
/// is responsible for removing the row from [ConnectionRegistry].
Future<void> signOut(Connection connection);
}
+2
View File
@@ -950,6 +950,7 @@ class AppDatabase extends _$AppDatabase {
} }
/// Get pending watch actions for a specific server /// Get pending watch actions for a specific server
@visibleForTesting
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) { Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) {
return (select(offlineWatchProgress) return (select(offlineWatchProgress)
..where( ..where(
@@ -979,6 +980,7 @@ class AppDatabase extends _$AppDatabase {
} }
/// Get the latest action for a specific item /// Get the latest action for a specific item
@visibleForTesting
Future<OfflineWatchProgressItem?> getLatestWatchAction( Future<OfflineWatchProgressItem?> getLatestWatchAction(
String globalKey, { String globalKey, {
String? profileId, String? profileId,
+3
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart'; import '../media/ids.dart';
import 'app_database.dart'; import 'app_database.dart';
@@ -138,6 +139,7 @@ extension DownloadDatabaseOperations on AppDatabase {
return (await _validDownloadOwnerRows(globalKey)).length; return (await _validDownloadOwnerRows(globalKey)).length;
} }
@visibleForTesting
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async { Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId); final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
return rows.isNotEmpty; return rows.isNotEmpty;
@@ -573,6 +575,7 @@ extension DownloadDatabaseOperations on AppDatabase {
return (await query.map((row) => row.read(count) ?? 0).getSingle()); return (await query.map((row) => row.read(count) ?? 0).getSingle());
} }
@visibleForTesting
Future<Set<String>> getReferencedDownloadSafRoots() async { Future<Set<String>> getReferencedDownloadSafRoots() async {
final rows = final rows =
await (selectOnly(downloadedMedia) await (selectOnly(downloadedMedia)
+5 -7
View File
@@ -1043,7 +1043,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider; return provider;
}, },
update: (_, multiServerProvider, previous) { update: (_, multiServerProvider, previous) {
final provider = previous ?? OfflineModeProvider(_serverManager, multiServerProvider: multiServerProvider); final provider = previous!;
provider.updateMultiServerProvider(multiServerProvider); provider.updateMultiServerProvider(multiServerProvider);
provider.initialize(); // Idempotent - safe to call again provider.initialize(); // Idempotent - safe to call again
return provider; return provider;
@@ -1081,7 +1081,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>( ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>(
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase), create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
update: (context, activeProfile, previous) { update: (context, activeProfile, previous) {
final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase); final provider = previous!;
provider.setActiveProfileId(activeProfile.activeId); provider.setActiveProfileId(activeProfile.activeId);
return provider; return provider;
}, },
@@ -1134,7 +1134,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return _offlineWatchSyncService; return _offlineWatchSyncService;
}, },
update: (_, activeProfile, previous) { update: (_, activeProfile, previous) {
final provider = previous ?? _offlineWatchSyncService; final provider = previous!;
provider.setActiveProfileId( provider.setActiveProfileId(
activeProfile.activeId, activeProfile.activeId,
availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null, availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null,
@@ -1147,14 +1147,12 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
syncService: context.read<OfflineWatchSyncService>(), syncService: context.read<OfflineWatchSyncService>(),
downloadProvider: context.read<DownloadProvider>(), downloadProvider: context.read<DownloadProvider>(),
), ),
update: (_, syncService, downloadProvider, previous) { update: (_, syncService, downloadProvider, previous) => previous!,
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
},
), ),
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>( ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
create: (context) => UserProfileProvider(storageService: context.read<StorageService>()), create: (context) => UserProfileProvider(storageService: context.read<StorageService>()),
update: (context, activeProfile, connections, previous) { update: (context, activeProfile, connections, previous) {
final provider = previous ?? UserProfileProvider(storageService: context.read<StorageService>()); final provider = previous!;
provider.attach( provider.attach(
connections: connections, connections: connections,
activeProfile: activeProfile, activeProfile: activeProfile,
-68
View File
@@ -1,13 +1,8 @@
import '../models/livetv_capture_buffer.dart'; import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart'; import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart'; import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart'; import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart'; import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart'; import '../models/media_subscription.dart';
class LiveTvActivityResult<T> { class LiveTvActivityResult<T> {
@@ -202,67 +197,13 @@ abstract class LiveTvSupport {
/// recording APIs. /// recording APIs.
abstract class LiveTvDvrSupport { abstract class LiveTvDvrSupport {
Future<List<LiveTvDvr>> fetchDvrs(); Future<List<LiveTvDvr>> fetchDvrs();
Future<LiveTvServerStatus> fetchLiveTvServerStatus();
Future<LiveTvDvr?> fetchDvr(String dvrId);
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
});
Future<void> deleteDvr(String dvrId);
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs);
Future<void> attachDeviceToDvr(String dvrId, String deviceId);
Future<void> detachDeviceFromDvr(String dvrId, String deviceId);
Future<void> addLineupToDvr(String dvrId, String lineupUri);
Future<void> removeLineupFromDvr(String dvrId, String lineupUri);
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId); Future<LiveTvActivityResult<void>> reloadGuide(String dvrId);
Future<void> cancelGuideReload(String dvrId);
Future<List<MediaGrabber>> fetchGrabbers({String? protocol});
Future<List<MediaGrabberDevice>> fetchGrabberDevices();
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices();
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId);
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId});
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title});
Future<void> deleteGrabberDevice(String deviceId);
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId);
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
});
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId);
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request);
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs);
String buildGrabberDeviceThumbUrl(String deviceId, int version);
Future<List<LiveTvCountry>> fetchEpgCountries();
Future<List<LiveTvLanguage>> fetchEpgLanguages();
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId);
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region});
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri);
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris);
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri});
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
});
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid); Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid);
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}); Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true});
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
});
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request); Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request);
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs); Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs);
Future<void> deleteRecordingRule(String subscriptionId); Future<void> deleteRecordingRule(String subscriptionId);
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId});
Future<void> processRecordingRules(); Future<void> processRecordingRules();
Future<List<MediaGrabOperation>> fetchScheduledRecordings(); Future<List<MediaGrabOperation>> fetchScheduledRecordings();
Future<void> cancelGrab(String operationId); Future<void> cancelGrab(String operationId);
@@ -271,13 +212,4 @@ abstract class LiveTvDvrSupport {
required List<String> ratingKeys, required List<String> ratingKeys,
bool includeStorage = true, bool includeStorage = true,
}); });
Future<List<MediaProviderInfo>> fetchMediaProviders();
Future<void> registerMediaProvider(String url);
Future<void> refreshMediaProviders();
Future<void> unregisterMediaProvider(String providerId);
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed();
Future<LiveTvSession?> fetchLiveTvSession(String sessionId);
Uri buildNotificationWebSocketUri({List<String>? filters});
Uri buildNotificationEventSourceUri({List<String>? filters});
} }
-2
View File
@@ -26,8 +26,6 @@ enum MediaKind {
bool get isVideo => this == movie || this == episode || this == clip; bool get isVideo => this == movie || this == episode || this == clip;
bool get isShowRelated => this == show || this == season || this == episode;
bool get isMusic => this == artist || this == album || this == track; bool get isMusic => this == artist || this == album || this == track;
bool get isPlayable => isVideo || this == track; bool get isPlayable => isVideo || this == track;
-3
View File
@@ -266,9 +266,6 @@ abstract class MediaServerClient {
/// Free-text search across the user's libraries. /// Free-text search across the user's libraries.
Future<List<MediaItem>> searchItems(String query, {int limit = 100}); Future<List<MediaItem>> searchItems(String query, {int limit = 100});
/// Recently-added items across all libraries.
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50});
/// Items the user has started but not finished. Plex calls this "On Deck" /// Items the user has started but not finished. Plex calls this "On Deck"
/// internally; the neutral name matches the Continue Watching UI surface. /// internally; the neutral name matches the Continue Watching UI surface.
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}); Future<List<MediaItem>> fetchContinueWatching({int? count = 20});
-1
View File
@@ -275,7 +275,6 @@ class MediaMarker {
Duration get startTime => Duration(milliseconds: startTimeOffset); Duration get startTime => Duration(milliseconds: startTimeOffset);
Duration get endTime => Duration(milliseconds: endTimeOffset); Duration get endTime => Duration(milliseconds: endTimeOffset);
bool get isIntro => type == 'intro';
bool get isCredits => type == 'credits'; bool get isCredits => type == 'credits';
bool containsPosition(Duration position) { bool containsPosition(Duration position) {
-2
View File
@@ -34,8 +34,6 @@ sealed class DownloadProgress with _$DownloadProgress {
double get progressPercent => progress / 100.0; double get progressPercent => progress / 100.0;
String get speedFormatted => ByteFormatter.formatSpeed(speed); String get speedFormatted => ByteFormatter.formatSpeed(speed);
String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes);
String get totalFormatted => ByteFormatter.formatBytes(totalBytes);
bool get hasArtworkPaths => thumbPath != null; bool get hasArtworkPaths => thumbPath != null;
} }
-14
View File
@@ -37,18 +37,4 @@ class PlexHome {
Map<String, dynamic> toJson() => _$PlexHomeToJson(this); Map<String, dynamic> toJson() => _$PlexHomeToJson(this);
PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull; PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull;
List<PlexHomeUser> get managedUsers => users.where((user) => !user.admin).toList();
List<PlexHomeUser> get restrictedUsers => users.where((user) => user.restricted).toList();
PlexHomeUser? getUserByUUID(String uuid) {
try {
return users.firstWhere((user) => user.uuid == uuid);
} catch (e) {
return null;
}
}
bool get hasMultipleUsers => users.length > 1;
} }
@@ -26,6 +26,4 @@ class PlexVideoPlaybackData {
}); });
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty; bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
bool get hasMediaInfo => mediaInfo != null;
} }
+3 -96
View File
@@ -5,45 +5,13 @@ import 'seerr_media.dart';
part 'seerr_details.g.dart'; part 'seerr_details.g.dart';
/// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog /// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog
/// surfaces need (credits, external ids, availability, air status). /// surfaces need (credits, availability).
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class SeerrMovieDetails { class SeerrMovieDetails {
final int id;
final String? title;
final String? overview;
final String? posterPath;
final String? backdropPath;
final String? releaseDate;
/// Minutes.
final int? runtime;
/// `Released` / `In Production` / `Post Production` / `Planned` /
/// `Canceled` / `Rumored`.
final String? status;
final double? voteAverage;
final int? voteCount;
final List<SeerrGenre>? genres;
final SeerrCredits? credits; final SeerrCredits? credits;
final SeerrExternalIds? externalIds;
final SeerrMediaInfo? mediaInfo; final SeerrMediaInfo? mediaInfo;
const SeerrMovieDetails({ const SeerrMovieDetails({this.credits, this.mediaInfo});
required this.id,
this.title,
this.overview,
this.posterPath,
this.backdropPath,
this.releaseDate,
this.runtime,
this.status,
this.voteAverage,
this.voteCount,
this.genres,
this.credits,
this.externalIds,
this.mediaInfo,
});
factory SeerrMovieDetails.fromJson(Map<String, dynamic> json) => _$SeerrMovieDetailsFromJson(json); factory SeerrMovieDetails.fromJson(Map<String, dynamic> json) => _$SeerrMovieDetailsFromJson(json);
} }
@@ -51,66 +19,15 @@ class SeerrMovieDetails {
/// Full TV detail from `GET /tv/{tmdbId}`. /// Full TV detail from `GET /tv/{tmdbId}`.
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class SeerrTvDetails { class SeerrTvDetails {
final int id;
final String? name;
final String? overview;
final String? posterPath;
final String? backdropPath;
final String? firstAirDate;
final List<int>? episodeRunTime;
/// `Returning Series` / `Ended` / `Canceled` / `In Production` /
/// `Planned` / `Pilot`.
final String? status;
final double? voteAverage;
final int? voteCount;
final List<SeerrGenre>? genres;
final List<SeerrNetwork>? networks;
final int? numberOfEpisodes;
final int? numberOfSeasons;
final List<SeerrSeason>? seasons; final List<SeerrSeason>? seasons;
final SeerrCredits? credits; final SeerrCredits? credits;
final SeerrExternalIds? externalIds;
final SeerrMediaInfo? mediaInfo; final SeerrMediaInfo? mediaInfo;
const SeerrTvDetails({ const SeerrTvDetails({this.seasons, this.credits, this.mediaInfo});
required this.id,
this.name,
this.overview,
this.posterPath,
this.backdropPath,
this.firstAirDate,
this.episodeRunTime,
this.status,
this.voteAverage,
this.voteCount,
this.genres,
this.networks,
this.numberOfEpisodes,
this.numberOfSeasons,
this.seasons,
this.credits,
this.externalIds,
this.mediaInfo,
});
factory SeerrTvDetails.fromJson(Map<String, dynamic> json) => _$SeerrTvDetailsFromJson(json); factory SeerrTvDetails.fromJson(Map<String, dynamic> json) => _$SeerrTvDetailsFromJson(json);
} }
@JsonSerializable(createToJson: false)
class SeerrGenre {
final String? name;
const SeerrGenre({this.name});
factory SeerrGenre.fromJson(Map<String, dynamic> json) => _$SeerrGenreFromJson(json);
}
@JsonSerializable(createToJson: false)
class SeerrNetwork {
final String? name;
const SeerrNetwork({this.name});
factory SeerrNetwork.fromJson(Map<String, dynamic> json) => _$SeerrNetworkFromJson(json);
}
/// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials. /// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials.
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class SeerrSeason { class SeerrSeason {
@@ -141,13 +58,3 @@ class SeerrCastMember {
factory SeerrCastMember.fromJson(Map<String, dynamic> json) => _$SeerrCastMemberFromJson(json); factory SeerrCastMember.fromJson(Map<String, dynamic> json) => _$SeerrCastMemberFromJson(json);
} }
@JsonSerializable(createToJson: false)
class SeerrExternalIds {
final String? imdbId;
final int? tvdbId;
const SeerrExternalIds({this.imdbId, this.tvdbId});
factory SeerrExternalIds.fromJson(Map<String, dynamic> json) => _$SeerrExternalIdsFromJson(json);
}
-55
View File
@@ -8,27 +8,9 @@ part of 'seerr_details.dart';
SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> json) => SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> json) =>
SeerrMovieDetails( SeerrMovieDetails(
id: (json['id'] as num).toInt(),
title: json['title'] as String?,
overview: json['overview'] as String?,
posterPath: json['posterPath'] as String?,
backdropPath: json['backdropPath'] as String?,
releaseDate: json['releaseDate'] as String?,
runtime: (json['runtime'] as num?)?.toInt(),
status: json['status'] as String?,
voteAverage: (json['voteAverage'] as num?)?.toDouble(),
voteCount: (json['voteCount'] as num?)?.toInt(),
genres: (json['genres'] as List<dynamic>?)
?.map((e) => SeerrGenre.fromJson(e as Map<String, dynamic>))
.toList(),
credits: json['credits'] == null credits: json['credits'] == null
? null ? null
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>), : SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
externalIds: json['externalIds'] == null
? null
: SeerrExternalIds.fromJson(
json['externalIds'] as Map<String, dynamic>,
),
mediaInfo: json['mediaInfo'] == null mediaInfo: json['mediaInfo'] == null
? null ? null
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>), : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
@@ -36,48 +18,17 @@ SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> json) =>
SeerrTvDetails _$SeerrTvDetailsFromJson(Map<String, dynamic> json) => SeerrTvDetails _$SeerrTvDetailsFromJson(Map<String, dynamic> json) =>
SeerrTvDetails( SeerrTvDetails(
id: (json['id'] as num).toInt(),
name: json['name'] as String?,
overview: json['overview'] as String?,
posterPath: json['posterPath'] as String?,
backdropPath: json['backdropPath'] as String?,
firstAirDate: json['firstAirDate'] as String?,
episodeRunTime: (json['episodeRunTime'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
status: json['status'] as String?,
voteAverage: (json['voteAverage'] as num?)?.toDouble(),
voteCount: (json['voteCount'] as num?)?.toInt(),
genres: (json['genres'] as List<dynamic>?)
?.map((e) => SeerrGenre.fromJson(e as Map<String, dynamic>))
.toList(),
networks: (json['networks'] as List<dynamic>?)
?.map((e) => SeerrNetwork.fromJson(e as Map<String, dynamic>))
.toList(),
numberOfEpisodes: (json['numberOfEpisodes'] as num?)?.toInt(),
numberOfSeasons: (json['numberOfSeasons'] as num?)?.toInt(),
seasons: (json['seasons'] as List<dynamic>?) seasons: (json['seasons'] as List<dynamic>?)
?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>)) ?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
credits: json['credits'] == null credits: json['credits'] == null
? null ? null
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>), : SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
externalIds: json['externalIds'] == null
? null
: SeerrExternalIds.fromJson(
json['externalIds'] as Map<String, dynamic>,
),
mediaInfo: json['mediaInfo'] == null mediaInfo: json['mediaInfo'] == null
? null ? null
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>), : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
); );
SeerrGenre _$SeerrGenreFromJson(Map<String, dynamic> json) =>
SeerrGenre(name: json['name'] as String?);
SeerrNetwork _$SeerrNetworkFromJson(Map<String, dynamic> json) =>
SeerrNetwork(name: json['name'] as String?);
SeerrSeason _$SeerrSeasonFromJson(Map<String, dynamic> json) => SeerrSeason( SeerrSeason _$SeerrSeasonFromJson(Map<String, dynamic> json) => SeerrSeason(
seasonNumber: (json['seasonNumber'] as num).toInt(), seasonNumber: (json['seasonNumber'] as num).toInt(),
name: json['name'] as String?, name: json['name'] as String?,
@@ -97,9 +48,3 @@ SeerrCastMember _$SeerrCastMemberFromJson(Map<String, dynamic> json) =>
character: json['character'] as String?, character: json['character'] as String?,
profilePath: json['profilePath'] as String?, profilePath: json['profilePath'] as String?,
); );
SeerrExternalIds _$SeerrExternalIdsFromJson(Map<String, dynamic> json) =>
SeerrExternalIds(
imdbId: json['imdbId'] as String?,
tvdbId: (json['tvdbId'] as num?)?.toInt(),
);
-40
View File
@@ -117,44 +117,4 @@ class UserSwitchResponse {
attributionPartner: optString('attributionPartner'), attributionPartner: optString('attributionPartner'),
); );
} }
Map<String, dynamic> toJson() {
return {
'id': id,
'uuid': uuid,
'username': username,
'title': title,
'email': email,
'friendlyName': friendlyName,
'locale': locale,
'confirmed': confirmed,
'joinedAt': joinedAt,
'emailOnlyAuth': emailOnlyAuth,
'hasPassword': hasPassword,
'protected': protected,
'thumb': thumb,
'authToken': authToken,
'mailingListActive': mailingListActive,
'scrobbleTypes': scrobbleTypes,
'country': country,
'restricted': restricted,
'anonymous': anonymous,
'home': home,
'guest': guest,
'homeSize': homeSize,
'homeAdmin': homeAdmin,
'maxHomeSize': maxHomeSize,
'profile': profile.toJson()['profile'],
'twoFactorEnabled': twoFactorEnabled,
'backupCodesCreated': backupCodesCreated,
'attributionPartner': attributionPartner,
};
}
String get displayName => friendlyName ?? title;
bool get isAdminUser => homeAdmin;
bool get isRestrictedUser => restricted;
bool get isGuestUser => guest;
bool get requiresPassword => hasPassword;
} }
-6
View File
@@ -21,12 +21,6 @@ class NavigationTab {
return NavigationDestination(icon: AppIcon(icon, fill: 1), selectedIcon: AppIcon(icon, fill: 1), label: getLabel()); return NavigationDestination(icon: AppIcon(icon, fill: 1), selectedIcon: AppIcon(icon, fill: 1), label: getLabel());
} }
/// Get the index for a tab ID in the visible tabs list
static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false, bool hasExplore = false}) {
final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv, hasExplore: hasExplore);
return tabs.indexWhere((tab) => tab.id == id);
}
/// Get tabs filtered by offline mode and feature availability /// Get tabs filtered by offline mode and feature availability
static List<NavigationTab> getVisibleTabs({ static List<NavigationTab> getVisibleTabs({
required bool isOffline, required bool isOffline,
-2
View File
@@ -113,8 +113,6 @@ class ActiveProfileBinder {
final Set<String> _plexHomePreVerified = {}; final Set<String> _plexHomePreVerified = {};
final Set<String> _userInitiatedActivations = {}; final Set<String> _userInitiatedActivations = {};
bool get isSwitching => _isSwitching;
@visibleForTesting @visibleForTesting
String? get debugLastBoundProfileId => _lastBoundProfileId; String? get debugLastBoundProfileId => _lastBoundProfileId;
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../media/ids.dart'; import '../media/ids.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_playlist.dart'; import '../media/media_playlist.dart';
@@ -133,40 +131,6 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
return []; return [];
} }
/// Build standard app bar actions (play, shuffle, delete)
/// Subclasses can override to customize actions
List<Widget> buildAppBarActions({
VoidCallback? onDelete,
String? deleteTooltip,
Color? deleteColor,
bool showDelete = true,
}) {
return [
// Play button
if (items.isNotEmpty)
IconButton(
icon: const AppIcon(Symbols.play_arrow_rounded, fill: 1),
tooltip: t.common.play,
onPressed: playItems,
),
// Shuffle button
if (items.isNotEmpty)
IconButton(
icon: const AppIcon(Symbols.shuffle_rounded, fill: 1),
tooltip: t.common.shuffle,
onPressed: shufflePlayItems,
),
// Delete button
if (showDelete && onDelete != null)
IconButton(
icon: const AppIcon(Symbols.delete_rounded, fill: 1),
tooltip: deleteTooltip ?? t.common.delete,
onPressed: onDelete,
color: deleteColor ?? Colors.red,
),
];
}
} }
/// Mixin that provides standard loadItems implementation for media lists /// Mixin that provides standard loadItems implementation for media lists
@@ -48,10 +48,7 @@ class PlaylistDetailScreen extends StatefulWidget {
} }
class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetailScreen> class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetailScreen>
with with GridFocusNodeMixin<PlaylistDetailScreen>, FocusableDetailScreenMixin<PlaylistDetailScreen> {
StandardItemLoader<PlaylistDetailScreen>,
GridFocusNodeMixin<PlaylistDetailScreen>,
FocusableDetailScreenMixin<PlaylistDetailScreen> {
static const int _pageSize = playlistItemsPageSize; static const int _pageSize = playlistItemsPageSize;
@override @override
@@ -231,11 +228,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
super.dispose(); super.dispose();
} }
@override
Future<List<MediaItem>> fetchItems() async {
return fetchAllPlaylistItems(mediaClient, widget.playlist.id);
}
@override @override
Future<void> loadItems() async { Future<void> loadItems() async {
if (mounted) { if (mounted) {
@@ -323,11 +315,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
} }
} }
@override
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items for playlist: ${widget.playlist.title}';
}
/// Navigate from app bar down to content - overridden to handle both grid and list /// Navigate from app bar down to content - overridden to handle both grid and list
@override @override
void navigateToGrid() { void navigateToGrid() {
-21
View File
@@ -166,27 +166,6 @@ abstract class ApiCache {
await (_db.delete(_db.apiCache)..where((t) => t.pinned.equals(false))).go(); await (_db.delete(_db.apiCache)..where((t) => t.pinned.equals(false))).go();
} }
/// Pin every row whose `cacheKey` matches the SQL `LIKE` [pattern]. Used by
/// backend subclasses that pin by item-shape rather than a single endpoint
/// (e.g. Jellyfin's per-user item rows where the user segment is a
/// wildcard).
Future<void> pinByKeyPattern(String pattern) async {
await (_db.update(
_db.apiCache,
)..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(true)));
}
Future<void> unpinByKeyPattern(String pattern) async {
await (_db.update(
_db.apiCache,
)..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(false)));
}
Future<bool> hasPinnedMatching(String pattern) async {
final rows = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.like(pattern) & t.pinned.equals(true))).get();
return rows.isNotEmpty;
}
/// Pull pinned rows for [serverId] and extract the first capture group of /// Pull pinned rows for [serverId] and extract the first capture group of
/// [keyPattern] from each `cacheKey`. Returns the unique set of captured /// [keyPattern] from each `cacheKey`. Returns the unique set of captured
/// ids — backend subclasses use this to enumerate their pinned items /// ids — backend subclasses use this to enumerate their pinned items
@@ -388,10 +388,6 @@ class RemoteAuthService {
_cachedSecret = null; _cachedSecret = null;
_cachedSecretKey = null; _cachedSecretKey = null;
} }
// Static direction constants for external use
static int get directionHost => _directionHost;
static int get directionClient => _directionClient;
} }
/// Helper for building byte arrays. /// Helper for building byte arrays.
@@ -44,13 +44,6 @@ class DownloadArtworkService {
return isUsableArtworkFile(file); return isUsableArtworkFile(file);
} }
Future<bool> hasMissingArtwork(ServerId serverId, Iterable<DownloadArtworkSpec> specs) async {
for (final spec in specs) {
if (!await existsUsable(serverId, spec.localKey)) return true;
}
return false;
}
Future<bool> ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { Future<bool> ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async {
final serverId = metadata.serverId; final serverId = metadata.serverId;
if (serverId == null) return false; if (serverId == null) return false;
@@ -36,7 +36,6 @@ class AdjacentEpisodes {
bool get hasNext => next != null; bool get hasNext => next != null;
bool get hasPrevious => previous != null; bool get hasPrevious => previous != null;
bool get isEndConfirmed => nextStatus == QueueNavigationStatus.boundary; bool get isEndConfirmed => nextStatus == QueueNavigationStatus.boundary;
bool get nextLoadFailed => nextStatus == QueueNavigationStatus.failed;
} }
enum _EpisodeQueueAvailability { active, unavailable, failed } enum _EpisodeQueueAvailability { active, unavailable, failed }
+9 -7
View File
@@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../connection/connection.dart'; import '../connection/connection.dart';
import '../connection/connection_auth_service.dart';
import '../exceptions/media_server_exceptions.dart'; import '../exceptions/media_server_exceptions.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/media_server_http_client.dart'; import '../utils/media_server_http_client.dart';
@@ -45,9 +44,9 @@ class _JellyfinAuthenticationResponse {
/// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges /// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges
/// credentials for a long-lived access token and returns a built /// credentials for a long-lived access token and returns a built
/// [JellyfinConnection] ready to insert into [ConnectionRegistry]. /// [JellyfinConnection] ready to insert into [ConnectionRegistry].
/// 3. (later) [validate] / [refresh] / [signOut] for the [ConnectionAuthService] /// 3. (later) [validate] / [refresh] / [signOut] to keep the stored
/// contract. /// connection current.
class JellyfinConnectionAuthService implements ConnectionAuthService { class JellyfinConnectionAuthService {
JellyfinConnectionAuthService({ JellyfinConnectionAuthService({
required this.clientName, required this.clientName,
required this.clientVersion, required this.clientVersion,
@@ -320,7 +319,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
} }
} }
@override /// Best-effort check that an existing token still works. Returns false on
/// 401/403; throws on transport failures the caller should retry.
Future<bool> validate(Connection connection) async { Future<bool> validate(Connection connection) async {
if (connection is! JellyfinConnection) return false; if (connection is! JellyfinConnection) return false;
final client = _authenticatedClient(connection); final client = _authenticatedClient(connection);
@@ -335,7 +335,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
} }
} }
@override /// Re-check the stored token and return the connection with its status
/// updated accordingly.
Future<Connection> refresh(Connection connection) async { Future<Connection> refresh(Connection connection) async {
if (connection is! JellyfinConnection) return connection; if (connection is! JellyfinConnection) return connection;
final ok = await validate(connection); final ok = await validate(connection);
@@ -345,7 +346,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
return connection.copyWith(status: ConnectionStatus.online, lastAuthenticatedAt: DateTime.now()); return connection.copyWith(status: ConnectionStatus.online, lastAuthenticatedAt: DateTime.now());
} }
@override /// Revoke the token server-side and forget local credentials. The caller
/// is responsible for removing the row from [ConnectionRegistry].
Future<void> signOut(Connection connection) async { Future<void> signOut(Connection connection) async {
if (connection is! JellyfinConnection) return; if (connection is! JellyfinConnection) return;
final client = _authenticatedClient(connection); final client = _authenticatedClient(connection);
@@ -1189,27 +1189,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
} }
@override
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
// Matches userLibraryApi.getLatestMedia in the Jellyfin SDK.
final response = await _http.get(
'/Users/${_segment(connection.userId)}/Items/Latest',
queryParameters: {
'Limit': limit.toString(),
'Fields': _browseFields,
'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
// Latest returns a bare array, not an Items wrapper.
if (data is List) {
return _mapItems(data.whereType<Map<String, dynamic>>());
}
return _mapItems(_itemsArray(data));
}
@override @override
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async { Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
final results = await Future.wait([ final results = await Future.wait([
@@ -43,13 +43,6 @@ mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin {
return data is Map<String, dynamic> ? data : const <String, dynamic>{}; return data is Map<String, dynamic> ? data : const <String, dynamic>{};
} }
Future<List<Map<String, dynamic>>> getItemImageInfos(String itemId) async {
final response = await _http.get('/Items/${_segment(itemId)}/Images');
throwIfHttpError(response);
final data = response.data;
return data is List ? data.whereType<Map<String, dynamic>>().toList() : const <Map<String, dynamic>>[];
}
Future<bool> downloadRemoteImage(String itemId, {required String imageType, required String imageUrl}) async { Future<bool> downloadRemoteImage(String itemId, {required String imageType, required String imageUrl}) async {
final response = await _http.post( final response = await _http.post(
'/Items/${_segment(itemId)}/RemoteImages/Download', '/Items/${_segment(itemId)}/RemoteImages/Download',
-4
View File
@@ -110,10 +110,6 @@ class MacOSWindowService {
} }
} }
static void removeWindowDelegate(MacOSWindowDelegate delegate) {
_delegates.remove(delegate);
}
static Future<void> setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible}); static Future<void> setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible});
static Future<void> syncWindowChrome() => _invoke('syncWindowChrome'); static Future<void> syncWindowChrome() => _invoke('syncWindowChrome');
+4 -55
View File
@@ -97,10 +97,10 @@ class MultiServerManager {
/// Map of serverId to active optimization futures /// Map of serverId to active optimization futures
final Map<String, Future<void>> _activeOptimizations = {}; final Map<String, Future<void>> _activeOptimizations = {};
/// Per-server clientIdentifier. Plex servers added via [addPlexAccount] /// Per-server clientIdentifier. Plex servers added via
/// register their owning account's clientIdentifier here so reconnects + /// [refreshTokensForProfile] register their owning account's
/// endpoint optimization use the right identity (each account has its own /// clientIdentifier here so reconnects + endpoint optimization use the
/// device row on plex.tv). /// right identity (each account has its own device row on plex.tv).
final Map<String, String> _clientIdByServer = {}; final Map<String, String> _clientIdByServer = {};
final Map<String, PlexProfileScopeId> _plexScopeByServer = {}; final Map<String, PlexProfileScopeId> _plexScopeByServer = {};
@@ -499,57 +499,6 @@ class MultiServerManager {
} }
} }
/// Connect every server attached to a Plex account in parallel. Each
/// account has its own `clientIdentifier` (registered as a separate
/// device on plex.tv), and we keep that mapping per-server in
/// [_clientIdByServer] so subsequent reconnects + endpoint optimization
/// race connections from the right identity.
Future<int> addPlexAccount(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
Function(ServerId serverId, bool success)? onServerStatus,
}) async {
if (connection.servers.isEmpty) return 0;
appLogger.i(
'Connecting Plex account ${connection.accountLabel} '
'(${connection.servers.length} server${connection.servers.length == 1 ? '' : 's'})',
);
int connected = 0;
final futures = connection.servers.map((server) async {
final serverId = server.clientIdentifier;
final profileScopeId = buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: profileId);
_clientIdByServer[serverId] = connection.clientIdentifier;
_plexServers[serverId] = server;
_plexScopeByServer[serverId] = profileScopeId;
try {
final client = await _createClientForServer(
server: server,
clientIdentifier: connection.clientIdentifier,
profileScopeId: profileScopeId,
).namedTimeout(timeout, operation: 'connect to ${server.name}');
final oldClient = _clients[serverId];
if (oldClient != null) _closeClient(oldClient);
_clients[serverId] = client;
_serverStatus[serverId] = true;
onServerStatus?.call(ServerId(serverId), true);
connected++;
} catch (e, stackTrace) {
appLogger.e('Failed to connect ${server.name}', error: e, stackTrace: stackTrace);
_serverStatus[serverId] = false;
onServerStatus?.call(ServerId(serverId), false);
}
});
await Future.wait(futures);
_statusController.add(Map.from(_serverStatus));
if (connected > 0 && _connectivitySubscription == null) {
_startNetworkMonitoring();
}
return connected;
}
/// Apply a freshly-fetched [PlexAccountConnection] to the manager, /// Apply a freshly-fetched [PlexAccountConnection] to the manager,
/// rotating per-server access tokens in place when possible. /// rotating per-server access tokens in place when possible.
/// ///
@@ -37,9 +37,6 @@ class MusicPlayContext {
/// profile switch tears the session down. [notifyListeners] fires only on /// profile switch tears the session down. [notifyListeners] fires only on
/// discrete changes (track, status, queue shape, modes) — progress bars /// discrete changes (track, status, queue shape, modes) — progress bars
/// subscribe to [positionStream] instead. /// subscribe to [positionStream] instead.
///
/// [StubMusicPlaybackService] is registered until the playback engine lands;
/// UI gates transport affordances on [isAvailable].
abstract class MusicPlaybackService extends ChangeNotifier { abstract class MusicPlaybackService extends ChangeNotifier {
/// False on the stub — playback affordances should render disabled or /// False on the stub — playback affordances should render disabled or
/// fall back to a "not supported yet" notice. /// fall back to a "not supported yet" notice.
@@ -155,9 +152,8 @@ abstract class MusicPlaybackService extends ChangeNotifier {
Future<Lyrics?> fetchLyrics(MediaItem track); Future<Lyrics?> fetchLyrics(MediaItem track);
} }
/// No-op placeholder bound while the playback engine is not wired yet (or /// No-op base for test doubles, which override only the members under test.
/// on platforms where it failed to initialize). Keeps every UI consumer /// Production always binds `MusicPlaybackServiceImpl`.
/// null-safe without per-call-site feature checks.
class StubMusicPlaybackService extends MusicPlaybackService { class StubMusicPlaybackService extends MusicPlaybackService {
final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100); final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100);
int _playIntentGeneration = 0; int _playIntentGeneration = 0;
@@ -24,9 +24,6 @@ class MusicSource {
/// `DirectPlay` / `Transcode` for progress reports. /// `DirectPlay` / `Transcode` for progress reports.
final String? playMethod; final String? playMethod;
final int selectedMediaIndex;
final String? selectedMediaSourceId;
/// True when [url] points at a downloaded/local copy. /// True when [url] points at a downloaded/local copy.
final bool isOffline; final bool isOffline;
@@ -41,8 +38,6 @@ class MusicSource {
this.headers, this.headers,
this.playSessionId, this.playSessionId,
this.playMethod, this.playMethod,
this.selectedMediaIndex = 0,
this.selectedMediaSourceId,
this.isOffline = false, this.isOffline = false,
this.mediaInfo, this.mediaInfo,
this.reportingClient, this.reportingClient,
@@ -93,8 +88,6 @@ class ServerMusicSourceResolver implements MusicSourceResolver {
headers: context.streamHeaders, headers: context.streamHeaders,
playSessionId: result.playSessionId, playSessionId: result.playSessionId,
playMethod: result.playMethod ?? (result.isTranscoding ? 'Transcode' : 'DirectPlay'), playMethod: result.playMethod ?? (result.isTranscoding ? 'Transcode' : 'DirectPlay'),
selectedMediaIndex: result.selectedMediaIndex,
selectedMediaSourceId: result.selectedMediaSourceId,
isOffline: result.isOffline, isOffline: result.isOffline,
mediaInfo: result.mediaInfo, mediaInfo: result.mediaInfo,
reportingClient: context.reportingClient, reportingClient: context.reportingClient,
+25 -58
View File
@@ -28,9 +28,8 @@ export 'media_list_playback_launcher.dart'
/// 4. Handling errors with appropriate feedback /// 4. Handling errors with appropriate feedback
/// ///
/// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for /// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for
/// the backend-neutral entry point. Plex-only flows such as /// the backend-neutral entry point. Flows outside that abstraction, such as
/// [launchFromPlaylistItem] live directly on this class because they have no /// [launchFromFolder], live directly on this class.
/// Jellyfin equivalent.
class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
final BuildContext context; final BuildContext context;
final PlexClient client; final PlexClient client;
@@ -153,17 +152,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
); );
} }
// If the queue is empty, try fetching it again with getPlayQueue playQueue = await _refetchIfEmpty(playQueue, libraryId: sourceLibraryId, libraryTitle: sourceLibraryTitle);
if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) {
final fetchedQueue = await client.getPlayQueue(
playQueue.playQueueID,
librarySectionID: sourceLibraryId,
librarySectionTitle: sourceLibraryTitle,
);
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
playQueue = fetchedQueue;
}
}
// Close loading dialog before navigating to the player // Close loading dialog before navigating to the player
await dismissLoading(); await dismissLoading();
@@ -181,40 +170,6 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
); );
} }
/// Launch playback from a playlist starting at a specific item.
Future<PlayQueueResult> launchFromPlaylistItem({
required MediaPlaylist playlist,
required MediaItem selectedItem,
bool showLoadingIndicator = true,
}) async {
return executeWithLoading(
context: context,
showLoading: showLoadingIndicator,
actionLabel: t.common.play,
execute: (dismissLoading) async {
// Plex's createPlayQueue takes the metadata `key` (`/library/metadata/{id}`),
// not the bare ratingKey. Construct it from the MediaItem id.
final selectedKey = '/library/metadata/${selectedItem.id}';
final playQueue = await client.createPlayQueue(
playlistID: int.parse(playlist.id),
type: 'video',
key: selectedKey,
);
// Close loading dialog before navigating to the player
await dismissLoading();
return _launchFromQueue(
playQueue: playQueue,
ratingKey: playlist.id,
serverId: serverIdOrNull(serverId),
serverName: serverName,
selectedItem: _resolveSelectedMediaItem(playQueue),
);
},
);
}
/// Launch shuffled playback for a show or season. /// Launch shuffled playback for a show or season.
@override @override
Future<PlayQueueResult> launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async { Future<PlayQueueResult> launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async {
@@ -287,16 +242,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
librarySectionTitle: libraryTitle, librarySectionTitle: libraryTitle,
); );
if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { playQueue = await _refetchIfEmpty(playQueue, libraryId: libraryId, libraryTitle: libraryTitle);
final fetchedQueue = await client.getPlayQueue(
playQueue.playQueueID,
librarySectionID: libraryId,
librarySectionTitle: libraryTitle,
);
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
playQueue = fetchedQueue;
}
}
await dismissLoading(); await dismissLoading();
@@ -312,6 +258,27 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
); );
} }
/// Creation sometimes returns a queue without its items; re-read it by ID
/// and keep the refetched copy only when it actually carries items.
Future<PlayQueueResponse?> _refetchIfEmpty(
PlayQueueResponse? playQueue, {
String? libraryId,
String? libraryTitle,
}) async {
if (playQueue == null || (playQueue.items != null && playQueue.items!.isNotEmpty)) {
return playQueue;
}
final fetchedQueue = await client.getPlayQueue(
playQueue.playQueueID,
librarySectionID: libraryId,
librarySectionTitle: libraryTitle,
);
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
return fetchedQueue;
}
return playQueue;
}
/// Core method to launch playback from a play queue. /// Core method to launch playback from a play queue.
Future<PlayQueueResult> _launchFromQueue({ Future<PlayQueueResult> _launchFromQueue({
required PlayQueueResponse? playQueue, required PlayQueueResponse? playQueue,
-1
View File
@@ -27,5 +27,4 @@ class PlaybackContext {
bool get usesLocalMedia => sourceKind == PlaybackSourceKind.localFile; bool get usesLocalMedia => sourceKind == PlaybackSourceKind.localFile;
bool get shouldQueueOnReportFailure => reportingMode == PlaybackReportingMode.onlineWithOfflineFallback; bool get shouldQueueOnReportFailure => reportingMode == PlaybackReportingMode.onlineWithOfflineFallback;
bool get shouldQueueOnly => reportingMode == PlaybackReportingMode.offlineQueue;
} }
+2
View File
@@ -1,6 +1,7 @@
import '../media/ids.dart'; import '../media/ids.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../database/app_database.dart'; import '../database/app_database.dart';
import '../database/plex_metadata_recovery.dart'; import '../database/plex_metadata_recovery.dart';
@@ -73,6 +74,7 @@ class PlexApiCache extends ApiCache {
// Rating keys can be alphanumeric, not just numeric. // Rating keys can be alphanumeric, not just numeric.
static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$'); static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$');
@visibleForTesting
Future<Set<String>> getPinnedKeys(ServerId serverId) => extractPinnedIds(serverId, _metadataKeyPattern); Future<Set<String>> getPinnedKeys(ServerId serverId) => extractPinnedIds(serverId, _metadataKeyPattern);
/// Copy one pinned item between cache namespaces. /// Copy one pinned item between cache namespaces.
-23
View File
@@ -34,13 +34,8 @@ import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart'; import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart'; import '../models/livetv_dvr.dart';
import '../models/livetv_hub_result.dart'; import '../models/livetv_hub_result.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart'; import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart'; import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart'; import '../models/media_subscription.dart';
import '../models/plex/plex_activity.dart'; import '../models/plex/plex_activity.dart';
import '../models/plex/plex_config.dart'; import '../models/plex/plex_config.dart';
@@ -1420,18 +1415,6 @@ class PlexClient
return results; return results;
} }
/// Get recently added media (filtered to video content only)
Future<List<PlexMetadataDto>> _getRecentlyAdded({int limit = 50}) async {
final response = await _getWithFailover(
'/library/recentlyAdded',
queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1},
);
final allItems = _extractMetadataList(response);
// Filter out music content (artists, albums, tracks)
return allItems.where((item) => !ContentTypes.musicTypes.contains(item.type?.toLowerCase())).toList();
}
/// Get continue watching items via the hubs system. /// Get continue watching items via the hubs system.
/// Prefer the provider's dedicated Continue Watching feature key when /// Prefer the provider's dedicated Continue Watching feature key when
/// advertised; fall back to Plex Web's legacy hubs query. Both respect the /// advertised; fall back to Plex Web's legacy hubs query. Both respect the
@@ -3463,12 +3446,6 @@ class PlexClient
return results.map((m) => PlexMappers.mediaItem(m)).toList(); return results.map((m) => PlexMappers.mediaItem(m)).toList();
} }
@override
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
final items = await _getRecentlyAdded(limit: limit);
return items.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override @override
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async { Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
final items = await _getContinueWatching(count: count); final items = await _getContinueWatching(count: count);
+1 -350
View File
@@ -20,6 +20,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
// ignore: unused_element_parameter // ignore: unused_element_parameter
Map<String, String>? headers, Map<String, String>? headers,
// ignore: unused_element_parameter
Duration? timeout, Duration? timeout,
// ignore: unused_element_parameter // ignore: unused_element_parameter
AbortController? abort, AbortController? abort,
@@ -197,73 +198,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return dvrs.isNotEmpty; return dvrs.isNotEmpty;
} }
@override
Future<LiveTvServerStatus> fetchLiveTvServerStatus() async {
final response = await _getWithFailover('/');
final container = _getMediaContainer(response);
return LiveTvServerStatus.fromJson(container ?? const <String, dynamic>{});
}
@override
Future<LiveTvDvr?> fetchDvr(String dvrId) async {
final response = await _getWithFailover('/livetv/dvrs/$dvrId');
final container = _getMediaContainer(response);
final rootMappings = container?['ChannelMapping'];
return _extractFirst(response, const ['Dvr'], (json) {
final map = Map<String, dynamic>.from(json)..putIfAbsent('ChannelMapping', () => rootMappings);
return LiveTvDvr.fromJson(map);
});
}
@override
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
}) async {
final response = await _http.post(
'/livetv/dvrs',
queryParameters: {
'device': devices,
'lineup': lineups,
...?(language == null ? null : {'language': language}),
...?(country == null ? null : {'country': country}),
...?(postalCode == null ? null : {'postalCode': postalCode}),
},
timeout: MediaServerTimeouts.receive,
);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractFirst(response, const ['Dvr'], LiveTvDvr.fromJson),
activityUuid: _activityUuid(response),
);
}
@override
Future<void> deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId'));
@override
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs));
@override
Future<void> attachDeviceToDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId'));
@override
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId'));
@override
Future<void> addLineupToDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
@override
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
@override @override
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) async { Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) async {
final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive); final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive);
@@ -271,200 +205,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response)); return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response));
} }
@override
Future<void> cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide'));
@override
Future<List<MediaGrabber>> fetchGrabbers({String? protocol}) async {
final response = await _getWithFailover(
'/media/grabbers',
queryParameters: {
...?(protocol == null ? null : {'protocol': protocol}),
},
);
return _extractContainerList(response, const ['MediaGrabber'], MediaGrabber.fromJson);
}
@override
Future<List<MediaGrabberDevice>> fetchGrabberDevices() async {
final response = await _getWithFailover('/media/grabbers/devices');
return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() async {
final response = await _http.post('/media/grabbers/devices/discover', timeout: MediaServerTimeouts.receive);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson),
activityUuid: _activityUuid(response),
);
}
@override
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId');
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) async {
final path = grabberId == null ? '/media/grabbers/devices' : '/media/grabbers/$grabberId/devices';
final response = await _http.post(path, queryParameters: {'uri': uri});
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _expectOk(
() => _http.put(
'/media/grabbers/devices/$deviceId',
queryParameters: {
...?(enabled == null ? null : {'enabled': enabled ? 1 : 0}),
...?(title == null ? null : {'title': title}),
},
),
);
@override
Future<void> deleteGrabberDevice(String deviceId) =>
_expectOk(() => _http.delete('/media/grabbers/devices/$deviceId'));
@override
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels');
return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson);
}
@override
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
}) async {
final response = await _http.post(
'/media/grabbers/devices/$deviceId/scan',
queryParameters: {
...?(source == null ? null : {'source': source}),
for (final entry in prefs.entries) 'prefs[${entry.key}]': entry.value,
...?(network == null ? null : {'network': network}),
...?(country == null ? null : {'country': country}),
},
timeout: MediaServerTimeouts.receive,
);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson),
activityUuid: _activityUuid(response),
);
}
@override
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) async {
final response = await _http.delete('/media/grabbers/devices/$deviceId/scan');
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(
String deviceId,
MediaGrabberChannelMapRequest request,
) async {
final response = await _http.put(
'/media/grabbers/devices/$deviceId/channelmap',
queryParameters: {
if (request.channelsEnabled.isNotEmpty) 'channelsEnabled': request.channelsEnabled.join(','),
for (final entry in request.channelMapping.entries) 'channelMapping[${entry.key}]': entry.value,
for (final entry in request.channelMappingByKey.entries) 'channelMappingByKey[${entry.key}]': entry.value,
},
);
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/media/grabbers/devices/$deviceId/prefs', queryParameters: prefs));
@override
String buildGrabberDeviceThumbUrl(String deviceId, int version) =>
'${config.baseUrl}/media/grabbers/devices/$deviceId/thumb/$version'.withPlexToken(config.token);
@override
Future<List<LiveTvCountry>> fetchEpgCountries() async {
final response = await _getWithFailover('/livetv/epg/countries');
return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson);
}
@override
Future<List<LiveTvLanguage>> fetchEpgLanguages() async {
final response = await _getWithFailover('/livetv/epg/languages');
return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson);
}
@override
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId) async {
final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions');
return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson);
}
@override
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) async {
final path = region == null
? '/livetv/epg/countries/$country/$epgId/lineups'
: '/livetv/epg/countries/$country/$epgId/regions/$region/lineups';
final response = await _getWithFailover(
path,
queryParameters: {
...?(postalCode == null ? null : {'postalCode': postalCode}),
},
);
final container = _getMediaContainer(response);
return LiveTvLineupResult(
lineupGroupUuid: container?['uuid'] as String?,
lineups: _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson),
);
}
@override
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri) async {
final response = await _getWithFailover('/livetv/epg/channels', queryParameters: {'lineup': lineupUri});
return _extractContainerList(response, const [
'Channel',
], (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName));
}
@override
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris) async {
final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris});
return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson);
}
@override
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) async {
final response = await _getWithFailover(
'/livetv/epg/channelmap',
queryParameters: {'device': deviceUri, 'lineup': lineupUri},
);
return _extractContainerList(response, const ['ChannelMapping'], ChannelMapping.fromJson);
}
@override
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
}) async {
final response = await _getWithFailover(
'/livetv/epg/lineup',
queryParameters: {'device': deviceUri, 'lineupGroup': lineupGroupUri},
timeout: MediaServerTimeouts.receive,
);
return LiveTvActivityResult(value: _getMediaContainer(response), activityUuid: _activityUuid(response));
}
/// Get EPG channels using provider lineup endpoints (matches official Plex web client) /// Get EPG channels using provider lineup endpoints (matches official Plex web client)
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async { Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
List<LiveTvChannel> parseChannels(MediaServerResponse response) { List<LiveTvChannel> parseChannels(MediaServerResponse response) {
@@ -744,19 +484,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
} }
@override
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
}) async {
final response = await _getWithFailover(
'/media/subscriptions/$subscriptionId',
queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0},
);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override @override
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) async { Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) async {
final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request))); final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request)));
@@ -778,18 +505,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
Future<void> deleteRecordingRule(String subscriptionId) => Future<void> deleteRecordingRule(String subscriptionId) =>
_expectOk(() => _http.delete('/media/subscriptions/$subscriptionId')); _expectOk(() => _http.delete('/media/subscriptions/$subscriptionId'));
@override
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) async {
final response = await _http.put(
'/media/subscriptions/$subscriptionId/move',
queryParameters: {
...?(afterSubscriptionId == null ? null : {'after': afterSubscriptionId}),
},
);
_throwIfFailed(response);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override @override
Future<void> processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process')); Future<void> processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process'));
@@ -824,23 +539,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
} }
@override
Future<List<MediaProviderInfo>> fetchMediaProviders() async {
final response = await _getWithFailover('/media/providers');
return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson);
}
@override
Future<void> registerMediaProvider(String url) =>
_expectOk(() => _http.post('/media/providers', queryParameters: {'url': url}));
@override
Future<void> refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh'));
@override
Future<void> unregisterMediaProvider(String providerId) =>
_expectOk(() => _http.delete('/media/providers/$providerId'));
/// Tune to a live TV channel. /// Tune to a live TV channel.
/// ///
/// POSTs to the tune endpoint and extracts metadata, session info, and /// POSTs to the tune endpoint and extracts metadata, session info, and
@@ -1105,53 +803,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return '${config.baseUrl}$streamPath'.withPlexToken(config.token); return '${config.baseUrl}$streamPath'.withPlexToken(config.token);
} }
@override
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed() async {
final response = await _getWithFailover('/livetv/sessions');
return _extractContainerList(response, const [
'LiveTVSession',
'LiveTvSession',
'Session',
'Metadata',
], LiveTvSession.fromJson);
}
@override
Future<LiveTvSession?> fetchLiveTvSession(String sessionId) async {
final response = await _getWithFailover('/livetv/sessions/$sessionId');
return _extractFirst(response, const [
'LiveTVSession',
'LiveTvSession',
'Session',
'Metadata',
], LiveTvSession.fromJson);
}
@override
Uri buildNotificationWebSocketUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
scheme: base.scheme == 'https' ? 'wss' : 'ws',
path: '/:/websocket/notifications',
queryParameters: {
if (config.token != null) 'X-Plex-Token': config.token!,
if (filters != null) 'filters': filters.join(','),
},
);
}
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
path: '/:/eventsource/notifications',
queryParameters: {
if (config.token != null) 'X-Plex-Token': config.token!,
if (filters != null) 'filters': filters.join(','),
},
);
}
/// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}`
@override @override
Future<String> buildFavoriteChannelSource({String? lineup}) async { Future<String> buildFavoriteChannelSource({String? lineup}) async {
-4
View File
@@ -145,10 +145,6 @@ class SeerrClient {
return SeerrRequest.fromJson(data as Map<String, dynamic>); return SeerrRequest.fromJson(data as Map<String, dynamic>);
} }
Future<void> deleteRequest(int requestId) async {
await _request('DELETE', '/request/$requestId');
}
// ---------- Sonarr / Radarr options (request sheet advanced pickers) ---------- // ---------- Sonarr / Radarr options (request sheet advanced pickers) ----------
Future<List<SeerrServiceInstance>> getRadarrServices() => _serviceList('/service/radarr'); Future<List<SeerrServiceInstance>> getRadarrServices() => _serviceList('/service/radarr');
-2
View File
@@ -23,9 +23,7 @@ abstract final class SeerrMediaServerType {
/// app checks are named; the full mask is stored on the session untouched. /// app checks are named; the full mask is stored on the session untouched.
abstract final class SeerrPermission { abstract final class SeerrPermission {
static const int admin = 2; static const int admin = 2;
static const int manageRequests = 16;
static const int request = 32; static const int request = 32;
static const int autoApprove = 128;
static const int request4k = 1024; static const int request4k = 1024;
static const int request4kMovie = 2048; static const int request4kMovie = 2048;
static const int request4kTv = 4096; static const int request4kTv = 4096;
-2
View File
@@ -50,8 +50,6 @@ class SyncRuleExecutor {
SyncRuleExecutor({required this._database}); SyncRuleExecutor({required this._database});
bool get isExecuting => _isExecuting;
/// Execute every enabled sync rule. /// Execute every enabled sync rule.
/// ///
/// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only /// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only
-6
View File
@@ -59,12 +59,6 @@ enum TraktMediaKind {
movie, movie,
episode; episode;
static TraktMediaKind? tryFromMediaKindId(String type) => switch (type) {
'movie' => movie,
'episode' => episode,
_ => null,
};
static TraktMediaKind fromName(String name) => static TraktMediaKind fromName(String name) =>
values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name')); values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name'));
} }
-4
View File
@@ -51,10 +51,6 @@ class MemoryLogOutput extends LogOutput {
_currentSize = 0; _currentSize = 0;
} }
static int getCurrentSize() => _currentSize;
static double getCurrentSizeMB() => _currentSize / (1024 * 1024);
@override @override
void output(OutputEvent event) { void output(OutputEvent event) {
// Only print to console — storage is done in MemoryAwareLogPrinter.log() // Only print to console — storage is done in MemoryAwareLogPrinter.log()
-1
View File
@@ -17,7 +17,6 @@ class ContentTypes {
static const Set<String> musicTypes = {artist, album, track}; static const Set<String> musicTypes = {artist, album, track};
static const Set<String> videoTypes = {movie, show, season, episode}; static const Set<String> videoTypes = {movie, show, season, episode};
static const Set<String> playableTypes = {movie, episode, clip, track};
} }
class ContentTypeHelper { class ContentTypeHelper {
@@ -37,14 +37,12 @@ class ContinuationPaginationCoordinator<T> {
bool _disposed = false; bool _disposed = false;
bool _isLoading = false; bool _isLoading = false;
Object? _error; Object? _error;
StackTrace? _errorStackTrace;
int? get nextStartIndex => _nextStartIndex; int? get nextStartIndex => _nextStartIndex;
int? get totalCount => _totalCount; int? get totalCount => _totalCount;
bool get hasMore => _nextStartIndex != null; bool get hasMore => _nextStartIndex != null;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
Object? get error => _error; Object? get error => _error;
StackTrace? get errorStackTrace => _errorStackTrace;
/// Invalidates all prior work, runs [request], and reports whether its result /// Invalidates all prior work, runs [request], and reports whether its result
/// still belongs to the current generation. /// still belongs to the current generation.
@@ -65,7 +63,6 @@ class ContinuationPaginationCoordinator<T> {
_totalCount = totalCount; _totalCount = totalCount;
_nextStartIndex = startIndex < totalCount ? startIndex : null; _nextStartIndex = startIndex < totalCount ? startIndex : null;
_error = null; _error = null;
_errorStackTrace = null;
onStateChanged?.call(); onStateChanged?.call();
} }
@@ -99,7 +96,6 @@ class ContinuationPaginationCoordinator<T> {
_inFlightGeneration = null; _inFlightGeneration = null;
_isLoading = false; _isLoading = false;
_error = null; _error = null;
_errorStackTrace = null;
} }
int _beginGeneration() { int _beginGeneration() {
@@ -110,7 +106,6 @@ class ContinuationPaginationCoordinator<T> {
_inFlightGeneration = null; _inFlightGeneration = null;
_isLoading = false; _isLoading = false;
_error = null; _error = null;
_errorStackTrace = null;
if (!_disposed) onStateChanged?.call(); if (!_disposed) onStateChanged?.call();
return _generation; return _generation;
} }
@@ -120,7 +115,6 @@ class ContinuationPaginationCoordinator<T> {
Future<ContinuationLoadStatus> _loadRemaining(int generation) async { Future<ContinuationLoadStatus> _loadRemaining(int generation) async {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
_errorStackTrace = null;
onStateChanged?.call(); onStateChanged?.call();
try { try {
@@ -148,7 +142,6 @@ class ContinuationPaginationCoordinator<T> {
} catch (exception, stackTrace) { } catch (exception, stackTrace) {
if (!_isCurrent(generation)) return ContinuationLoadStatus.stale; if (!_isCurrent(generation)) return ContinuationLoadStatus.stale;
_error = exception; _error = exception;
_errorStackTrace = stackTrace;
onError?.call(exception, stackTrace); onError?.call(exception, stackTrace);
return ContinuationLoadStatus.failed; return ContinuationLoadStatus.failed;
} finally { } finally {
-4
View File
@@ -23,10 +23,6 @@ mixin HierarchicalEventMixin {
/// Check if this event affects a specific item by id. /// Check if this event affects a specific item by id.
bool affectsItem(String itemId) => this.itemId == itemId || parentChain.contains(itemId); bool affectsItem(String itemId) => this.itemId == itemId || parentChain.contains(itemId);
/// Check if this event affects a specific globalKey.
bool affectsGlobalKey(String globalKey) =>
this.globalKey == globalKey || parentChain.any((pk) => buildGlobalKey(serverId, pk) == globalKey);
/// Check if this event affects any item in a collection. /// Check if this event affects any item in a collection.
bool affectsAnyOf(Iterable<String> itemIds) { bool affectsAnyOf(Iterable<String> itemIds) {
if (itemIds.contains(itemId)) return true; if (itemIds.contains(itemId)) return true;
-1
View File
@@ -31,7 +31,6 @@ class ScreenBreakpoints {
/// Animation and notification durations. /// Animation and notification durations.
class AppDurations { class AppDurations {
static const Duration animFast = Duration(milliseconds: 200);
static const Duration animMedium = Duration(milliseconds: 300); static const Duration animMedium = Duration(milliseconds: 300);
static const Duration animSlow = Duration(milliseconds: 500); static const Duration animSlow = Duration(milliseconds: 500);
static const Duration snackBarDefault = Duration(seconds: 3); static const Duration snackBarDefault = Duration(seconds: 3);
-27
View File
@@ -334,31 +334,4 @@ class MediaImageHelper {
return true; return true;
} }
/// Optimized URL for clear-logo overlays ([ImageType.logo]).
static String logoUrl({
required MediaServerClient? client,
required String? thumbPath,
required BuildContext context,
required double containerWidth,
required double containerHeight,
}) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.logo);
static String _typedUrl(
MediaServerClient? client,
String? thumbPath,
BuildContext context,
double containerWidth,
double containerHeight,
ImageType type,
) {
return getOptimizedImageUrl(
client: client,
thumbPath: thumbPath,
maxWidth: containerWidth,
maxHeight: containerHeight,
devicePixelRatio: effectiveDevicePixelRatio(context),
imageType: type,
);
}
} }
-2
View File
@@ -112,8 +112,6 @@ class TvDetectionService {
bool get isTV => _isTV; bool get isTV => _isTV;
List<String> get tvDetectionReasons => _effectiveDetectionReasons;
List<String> get _effectiveDetectionReasons { List<String> get _effectiveDetectionReasons {
final reasons = <String>[..._detectionReasons]; final reasons = <String>[..._detectionReasons];
if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv'); if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv');
@@ -155,11 +155,6 @@ class WatchTogetherProvider with ChangeNotifier {
bool get hasCurrentPlayback => bool get hasCurrentPlayback =>
currentMediaRatingKey != null && currentMediaServerId != null && currentMediaTitle != null; currentMediaRatingKey != null && currentMediaServerId != null && currentMediaTitle != null;
/// Set the display name for this user
void setDisplayName(String name) {
_displayName = name;
}
String? _buildPlaybackKey(String? ratingKey, ServerId? serverId) { String? _buildPlaybackKey(String? ratingKey, ServerId? serverId) {
if (ratingKey == null || serverId == null) return null; if (ratingKey == null || serverId == null) return null;
return '$serverId:$ratingKey'; return '$serverId:$ratingKey';
@@ -120,7 +120,6 @@ class GuestPlaybackReconciler {
Timer? _statusRefreshTimer; Timer? _statusRefreshTimer;
PlaybackState? get latestState => _latestState; PlaybackState? get latestState => _latestState;
bool get isCorrecting => _correcting;
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Public inputs // Public inputs
@@ -117,7 +117,6 @@ class HostPlaybackCoordinator {
bool _disposed = false; bool _disposed = false;
PlaybackPhase get phase => _phase; PlaybackPhase get phase => _phase;
Set<String> get incompatiblePeers => Set.unmodifiable(_incompatiblePeers);
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Public inputs // Public inputs
-3
View File
@@ -230,9 +230,6 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
}); });
} }
/// Check if this hub currently has focus
bool get hasFocusedItem => _hubFocusNode.hasFocus;
/// Get the number of items in this hub /// Get the number of items in this hub
int get itemCount => _totalItemCount; int get itemCount => _totalItemCount;
@@ -2,40 +2,11 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../mpv/mpv.dart';
import '../../../theme/mono_tokens.dart'; import '../../../theme/mono_tokens.dart';
import '../../../utils/track_label_builder.dart'; import '../../../utils/track_label_builder.dart';
import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/focusable_list_tile.dart';
class TrackSelectionHelper { class TrackSelectionHelper {
/// Get the appropriate empty message based on track type
static String getEmptyMessage<T>() {
if (T == SubtitleTrack) {
return t.videoControls.noSubtitlesAvailable;
} else if (T == AudioTrack) {
return t.videoControls.noAudioTracksAvailable;
}
return t.videoControls.noTracksAvailable;
}
static Widget buildEmptyState<T>() {
return Center(child: Text(getEmptyMessage<T>()));
}
/// Check if "Off" is selected for a track
static bool isOffSelected<T>(T? selectedTrack, bool Function(T track)? isOffTrack) {
return selectedTrack == null || (isOffTrack?.call(selectedTrack) ?? false);
}
static String getTrackId<T>(T track) {
if (track is AudioTrack) {
return track.id;
} else if (track is SubtitleTrack) {
return track.id;
}
return '';
}
static Widget buildOffTile<T>({ static Widget buildOffTile<T>({
required BuildContext context, required BuildContext context,
required bool isSelected, required bool isSelected,
@@ -34,7 +34,6 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable<b
/// Whether controls may still be visibly rendered during their fade-out. /// Whether controls may still be visibly rendered during their fade-out.
bool get controlsPresented => _controlsPresented; bool get controlsPresented => _controlsPresented;
bool get contentStripVisible => _contentStripVisible; bool get contentStripVisible => _contentStripVisible;
bool get hasVisibleHold => _holds.isNotEmpty;
bool isHeld(PlayerChromeHold hold) => _holds.contains(hold); bool isHeld(PlayerChromeHold hold) => _holds.contains(hold);
PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget; PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget;
@@ -1,8 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/livetv_dvr.dart'; import 'package:plezy/models/livetv_dvr.dart';
import 'package:plezy/models/livetv_lineup.dart';
import 'package:plezy/models/media_grabber_device.dart';
import 'package:plezy/models/media_provider_info.dart';
import 'package:plezy/models/media_subscription.dart'; import 'package:plezy/models/media_subscription.dart';
void main() { void main() {
@@ -28,48 +25,6 @@ void main() {
{'uuid': 'device-1'}, {'uuid': 'device-1'},
]); ]);
final grabber = MediaGrabberDevice.fromJson({
'key': 'device-1',
'uuid': 'device-1',
'ChannelMapping': [
{'channelKey': 7},
{'channelKey': 'channel-1'},
],
'Setting': [
{'id': 7},
{'id': 'setting-1'},
],
});
expect(grabber.channelMappings.map((entry) => entry.channelKey), ['channel-1']);
expect(grabber.settings.map((entry) => entry.id), ['setting-1']);
final lineup = LiveTvLineup.fromJson({
'uuid': 'lineup-1',
'Channel': [
{'callSign': 7},
{'key': 'channel-1', 'callSign': 'ONE'},
],
});
expect(lineup.channels.map((entry) => entry.callSign), ['ONE']);
final provider = MediaProviderInfo.fromJson({
'identifier': 'provider-1',
'Feature': [
{'type': 7},
{
'type': 'livetv',
'Directory': [
'invalid',
{'key': 'guide'},
],
},
],
});
expect(provider.features.map((entry) => entry.type), ['livetv']);
expect(provider.features.single.directories, [
{'key': 'guide'},
]);
final template = SubscriptionTemplate.fromJson({ final template = SubscriptionTemplate.fromJson({
'MediaSubscription': [ 'MediaSubscription': [
{'title': 7}, {'title': 7},
+2 -33
View File
@@ -51,8 +51,8 @@ void main() {
); );
} }
http.Response jsonResponse(Map<String, dynamic> body, {Map<String, String>? headers}) { http.Response jsonResponse(Map<String, dynamic> body) {
return http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json', ...?headers}); return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'});
} }
test('favorite source follows requested lineup provider', () async { test('favorite source follows requested lineup provider', () async {
@@ -155,37 +155,6 @@ void main() {
expect(dvrs.single.channelMappings.single.enabled, isTrue); expect(dvrs.single.channelMappings.single.enabled, isTrue);
}); });
test('createDvr sends repeated device and lineup query params and exposes activity id', () async {
late http.Request captured;
final client = makeClient((request) async {
captured = request;
return jsonResponse(
{
'MediaContainer': {
'Dvr': [
{'key': '42', 'uuid': 'dvr-42'},
],
},
},
headers: {'x-plex-activity': 'activity-1'},
);
});
addTearDown(client.close);
final result = await client.liveTvDvr!.createDvr(
devices: const ['dev-a', 'dev-b'],
lineups: const ['lineup-a', 'lineup-b'],
language: 'eng',
);
expect(captured.url.path, '/livetv/dvrs');
expect(captured.url.queryParametersAll['device'], ['dev-a', 'dev-b']);
expect(captured.url.queryParametersAll['lineup'], ['lineup-a', 'lineup-b']);
expect(captured.url.queryParameters['language'], 'eng');
expect(result.activityUuid, 'activity-1');
expect(result.value?.key, '42');
});
test('subscription template parses settings and URL-encoded enum labels', () async { test('subscription template parses settings and URL-encoded enum labels', () async {
final client = makeClient((request) async { final client = makeClient((request) async {
expect(request.url.path, '/media/subscriptions/template'); expect(request.url.path, '/media/subscriptions/template');