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:
@@ -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);
|
||||
}
|
||||
@@ -950,6 +950,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
/// Get pending watch actions for a specific server
|
||||
@visibleForTesting
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where(
|
||||
@@ -979,6 +980,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
/// Get the latest action for a specific item
|
||||
@visibleForTesting
|
||||
Future<OfflineWatchProgressItem?> getLatestWatchAction(
|
||||
String globalKey, {
|
||||
String? profileId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../media/ids.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
@@ -138,6 +139,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return (await _validDownloadOwnerRows(globalKey)).length;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
|
||||
return rows.isNotEmpty;
|
||||
@@ -573,6 +575,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return (await query.map((row) => row.read(count) ?? 0).getSingle());
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<Set<String>> getReferencedDownloadSafRoots() async {
|
||||
final rows =
|
||||
await (selectOnly(downloadedMedia)
|
||||
|
||||
+5
-7
@@ -1043,7 +1043,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
return provider;
|
||||
},
|
||||
update: (_, multiServerProvider, previous) {
|
||||
final provider = previous ?? OfflineModeProvider(_serverManager, multiServerProvider: multiServerProvider);
|
||||
final provider = previous!;
|
||||
provider.updateMultiServerProvider(multiServerProvider);
|
||||
provider.initialize(); // Idempotent - safe to call again
|
||||
return provider;
|
||||
@@ -1081,7 +1081,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>(
|
||||
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
|
||||
update: (context, activeProfile, previous) {
|
||||
final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase);
|
||||
final provider = previous!;
|
||||
provider.setActiveProfileId(activeProfile.activeId);
|
||||
return provider;
|
||||
},
|
||||
@@ -1134,7 +1134,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
return _offlineWatchSyncService;
|
||||
},
|
||||
update: (_, activeProfile, previous) {
|
||||
final provider = previous ?? _offlineWatchSyncService;
|
||||
final provider = previous!;
|
||||
provider.setActiveProfileId(
|
||||
activeProfile.activeId,
|
||||
availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null,
|
||||
@@ -1147,14 +1147,12 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
syncService: context.read<OfflineWatchSyncService>(),
|
||||
downloadProvider: context.read<DownloadProvider>(),
|
||||
),
|
||||
update: (_, syncService, downloadProvider, previous) {
|
||||
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
|
||||
},
|
||||
update: (_, syncService, downloadProvider, previous) => previous!,
|
||||
),
|
||||
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
||||
create: (context) => UserProfileProvider(storageService: context.read<StorageService>()),
|
||||
update: (context, activeProfile, connections, previous) {
|
||||
final provider = previous ?? UserProfileProvider(storageService: context.read<StorageService>());
|
||||
final provider = previous!;
|
||||
provider.attach(
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import '../models/livetv_capture_buffer.dart';
|
||||
import '../models/livetv_channel.dart';
|
||||
import '../models/livetv_dvr.dart';
|
||||
import '../models/livetv_lineup.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_grabber_device.dart';
|
||||
import '../models/media_provider_info.dart';
|
||||
import '../models/media_subscription.dart';
|
||||
|
||||
class LiveTvActivityResult<T> {
|
||||
@@ -202,67 +197,13 @@ abstract class LiveTvSupport {
|
||||
/// recording APIs.
|
||||
abstract class LiveTvDvrSupport {
|
||||
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<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<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?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs);
|
||||
Future<void> deleteRecordingRule(String subscriptionId);
|
||||
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId});
|
||||
Future<void> processRecordingRules();
|
||||
Future<List<MediaGrabOperation>> fetchScheduledRecordings();
|
||||
Future<void> cancelGrab(String operationId);
|
||||
@@ -271,13 +212,4 @@ abstract class LiveTvDvrSupport {
|
||||
required List<String> ratingKeys,
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ enum MediaKind {
|
||||
|
||||
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 isPlayable => isVideo || this == track;
|
||||
|
||||
@@ -266,9 +266,6 @@ abstract class MediaServerClient {
|
||||
/// Free-text search across the user's libraries.
|
||||
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"
|
||||
/// internally; the neutral name matches the Continue Watching UI surface.
|
||||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20});
|
||||
|
||||
@@ -275,7 +275,6 @@ class MediaMarker {
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset);
|
||||
Duration get endTime => Duration(milliseconds: endTimeOffset);
|
||||
|
||||
bool get isIntro => type == 'intro';
|
||||
bool get isCredits => type == 'credits';
|
||||
|
||||
bool containsPosition(Duration position) {
|
||||
|
||||
@@ -34,8 +34,6 @@ sealed class DownloadProgress with _$DownloadProgress {
|
||||
double get progressPercent => progress / 100.0;
|
||||
|
||||
String get speedFormatted => ByteFormatter.formatSpeed(speed);
|
||||
String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes);
|
||||
String get totalFormatted => ByteFormatter.formatBytes(totalBytes);
|
||||
|
||||
bool get hasArtworkPaths => thumbPath != null;
|
||||
}
|
||||
|
||||
@@ -37,18 +37,4 @@ class PlexHome {
|
||||
Map<String, dynamic> toJson() => _$PlexHomeToJson(this);
|
||||
|
||||
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 hasMediaInfo => mediaInfo != null;
|
||||
}
|
||||
|
||||
@@ -5,45 +5,13 @@ import 'seerr_media.dart';
|
||||
part 'seerr_details.g.dart';
|
||||
|
||||
/// 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)
|
||||
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 SeerrExternalIds? externalIds;
|
||||
final SeerrMediaInfo? mediaInfo;
|
||||
|
||||
const SeerrMovieDetails({
|
||||
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,
|
||||
});
|
||||
const SeerrMovieDetails({this.credits, this.mediaInfo});
|
||||
|
||||
factory SeerrMovieDetails.fromJson(Map<String, dynamic> json) => _$SeerrMovieDetailsFromJson(json);
|
||||
}
|
||||
@@ -51,66 +19,15 @@ class SeerrMovieDetails {
|
||||
/// Full TV detail from `GET /tv/{tmdbId}`.
|
||||
@JsonSerializable(createToJson: false)
|
||||
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 SeerrCredits? credits;
|
||||
final SeerrExternalIds? externalIds;
|
||||
final SeerrMediaInfo? mediaInfo;
|
||||
|
||||
const SeerrTvDetails({
|
||||
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,
|
||||
});
|
||||
const SeerrTvDetails({this.seasons, this.credits, this.mediaInfo});
|
||||
|
||||
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.
|
||||
@JsonSerializable(createToJson: false)
|
||||
class SeerrSeason {
|
||||
@@ -141,13 +58,3 @@ class SeerrCastMember {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8,27 +8,9 @@ part of 'seerr_details.dart';
|
||||
|
||||
SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> json) =>
|
||||
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
|
||||
? null
|
||||
: 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
|
||||
? null
|
||||
: 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(
|
||||
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>?)
|
||||
?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
credits: json['credits'] == null
|
||||
? null
|
||||
: 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
|
||||
? null
|
||||
: 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(
|
||||
seasonNumber: (json['seasonNumber'] as num).toInt(),
|
||||
name: json['name'] as String?,
|
||||
@@ -97,9 +48,3 @@ SeerrCastMember _$SeerrCastMemberFromJson(Map<String, dynamic> json) =>
|
||||
character: json['character'] 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(),
|
||||
);
|
||||
|
||||
@@ -117,44 +117,4 @@ class UserSwitchResponse {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,6 @@ class NavigationTab {
|
||||
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
|
||||
static List<NavigationTab> getVisibleTabs({
|
||||
required bool isOffline,
|
||||
|
||||
@@ -113,8 +113,6 @@ class ActiveProfileBinder {
|
||||
final Set<String> _plexHomePreVerified = {};
|
||||
final Set<String> _userInitiatedActivations = {};
|
||||
|
||||
bool get isSwitching => _isSwitching;
|
||||
|
||||
@visibleForTesting
|
||||
String? get debugLastBoundProfileId => _lastBoundProfileId;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/material.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 '../media/media_item.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
@@ -133,40 +131,6 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
|
||||
|
||||
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
|
||||
|
||||
@@ -48,10 +48,7 @@ class PlaylistDetailScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetailScreen>
|
||||
with
|
||||
StandardItemLoader<PlaylistDetailScreen>,
|
||||
GridFocusNodeMixin<PlaylistDetailScreen>,
|
||||
FocusableDetailScreenMixin<PlaylistDetailScreen> {
|
||||
with GridFocusNodeMixin<PlaylistDetailScreen>, FocusableDetailScreenMixin<PlaylistDetailScreen> {
|
||||
static const int _pageSize = playlistItemsPageSize;
|
||||
|
||||
@override
|
||||
@@ -231,11 +228,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchItems() async {
|
||||
return fetchAllPlaylistItems(mediaClient, widget.playlist.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
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
|
||||
@override
|
||||
void navigateToGrid() {
|
||||
|
||||
@@ -166,27 +166,6 @@ abstract class ApiCache {
|
||||
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
|
||||
/// [keyPattern] from each `cacheKey`. Returns the unique set of captured
|
||||
/// ids — backend subclasses use this to enumerate their pinned items
|
||||
|
||||
@@ -388,10 +388,6 @@ class RemoteAuthService {
|
||||
_cachedSecret = null;
|
||||
_cachedSecretKey = null;
|
||||
}
|
||||
|
||||
// Static direction constants for external use
|
||||
static int get directionHost => _directionHost;
|
||||
static int get directionClient => _directionClient;
|
||||
}
|
||||
|
||||
/// Helper for building byte arrays.
|
||||
|
||||
@@ -44,13 +44,6 @@ class DownloadArtworkService {
|
||||
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 {
|
||||
final serverId = metadata.serverId;
|
||||
if (serverId == null) return false;
|
||||
|
||||
@@ -36,7 +36,6 @@ class AdjacentEpisodes {
|
||||
bool get hasNext => next != null;
|
||||
bool get hasPrevious => previous != null;
|
||||
bool get isEndConfirmed => nextStatus == QueueNavigationStatus.boundary;
|
||||
bool get nextLoadFailed => nextStatus == QueueNavigationStatus.failed;
|
||||
}
|
||||
|
||||
enum _EpisodeQueueAvailability { active, unavailable, failed }
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../connection/connection.dart';
|
||||
import '../connection/connection_auth_service.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
@@ -45,9 +44,9 @@ class _JellyfinAuthenticationResponse {
|
||||
/// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges
|
||||
/// credentials for a long-lived access token and returns a built
|
||||
/// [JellyfinConnection] ready to insert into [ConnectionRegistry].
|
||||
/// 3. (later) [validate] / [refresh] / [signOut] for the [ConnectionAuthService]
|
||||
/// contract.
|
||||
class JellyfinConnectionAuthService implements ConnectionAuthService {
|
||||
/// 3. (later) [validate] / [refresh] / [signOut] to keep the stored
|
||||
/// connection current.
|
||||
class JellyfinConnectionAuthService {
|
||||
JellyfinConnectionAuthService({
|
||||
required this.clientName,
|
||||
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 {
|
||||
if (connection is! JellyfinConnection) return false;
|
||||
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 {
|
||||
if (connection is! JellyfinConnection) return connection;
|
||||
final ok = await validate(connection);
|
||||
@@ -345,7 +346,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
|
||||
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 {
|
||||
if (connection is! JellyfinConnection) return;
|
||||
final client = _authenticatedClient(connection);
|
||||
|
||||
@@ -1189,27 +1189,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
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
|
||||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
|
||||
final results = await Future.wait([
|
||||
|
||||
@@ -43,13 +43,6 @@ mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin {
|
||||
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 {
|
||||
final response = await _http.post(
|
||||
'/Items/${_segment(itemId)}/RemoteImages/Download',
|
||||
|
||||
@@ -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> syncWindowChrome() => _invoke('syncWindowChrome');
|
||||
|
||||
@@ -97,10 +97,10 @@ class MultiServerManager {
|
||||
/// Map of serverId to active optimization futures
|
||||
final Map<String, Future<void>> _activeOptimizations = {};
|
||||
|
||||
/// Per-server clientIdentifier. Plex servers added via [addPlexAccount]
|
||||
/// register their owning account's clientIdentifier here so reconnects +
|
||||
/// endpoint optimization use the right identity (each account has its own
|
||||
/// device row on plex.tv).
|
||||
/// Per-server clientIdentifier. Plex servers added via
|
||||
/// [refreshTokensForProfile] register their owning account's
|
||||
/// clientIdentifier here so reconnects + endpoint optimization use the
|
||||
/// right identity (each account has its own device row on plex.tv).
|
||||
final Map<String, String> _clientIdByServer = {};
|
||||
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,
|
||||
/// 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
|
||||
/// discrete changes (track, status, queue shape, modes) — progress bars
|
||||
/// subscribe to [positionStream] instead.
|
||||
///
|
||||
/// [StubMusicPlaybackService] is registered until the playback engine lands;
|
||||
/// UI gates transport affordances on [isAvailable].
|
||||
abstract class MusicPlaybackService extends ChangeNotifier {
|
||||
/// False on the stub — playback affordances should render disabled or
|
||||
/// fall back to a "not supported yet" notice.
|
||||
@@ -155,9 +152,8 @@ abstract class MusicPlaybackService extends ChangeNotifier {
|
||||
Future<Lyrics?> fetchLyrics(MediaItem track);
|
||||
}
|
||||
|
||||
/// No-op placeholder bound while the playback engine is not wired yet (or
|
||||
/// on platforms where it failed to initialize). Keeps every UI consumer
|
||||
/// null-safe without per-call-site feature checks.
|
||||
/// No-op base for test doubles, which override only the members under test.
|
||||
/// Production always binds `MusicPlaybackServiceImpl`.
|
||||
class StubMusicPlaybackService extends MusicPlaybackService {
|
||||
final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100);
|
||||
int _playIntentGeneration = 0;
|
||||
|
||||
@@ -24,9 +24,6 @@ class MusicSource {
|
||||
/// `DirectPlay` / `Transcode` for progress reports.
|
||||
final String? playMethod;
|
||||
|
||||
final int selectedMediaIndex;
|
||||
final String? selectedMediaSourceId;
|
||||
|
||||
/// True when [url] points at a downloaded/local copy.
|
||||
final bool isOffline;
|
||||
|
||||
@@ -41,8 +38,6 @@ class MusicSource {
|
||||
this.headers,
|
||||
this.playSessionId,
|
||||
this.playMethod,
|
||||
this.selectedMediaIndex = 0,
|
||||
this.selectedMediaSourceId,
|
||||
this.isOffline = false,
|
||||
this.mediaInfo,
|
||||
this.reportingClient,
|
||||
@@ -93,8 +88,6 @@ class ServerMusicSourceResolver implements MusicSourceResolver {
|
||||
headers: context.streamHeaders,
|
||||
playSessionId: result.playSessionId,
|
||||
playMethod: result.playMethod ?? (result.isTranscoding ? 'Transcode' : 'DirectPlay'),
|
||||
selectedMediaIndex: result.selectedMediaIndex,
|
||||
selectedMediaSourceId: result.selectedMediaSourceId,
|
||||
isOffline: result.isOffline,
|
||||
mediaInfo: result.mediaInfo,
|
||||
reportingClient: context.reportingClient,
|
||||
|
||||
@@ -28,9 +28,8 @@ export 'media_list_playback_launcher.dart'
|
||||
/// 4. Handling errors with appropriate feedback
|
||||
///
|
||||
/// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for
|
||||
/// the backend-neutral entry point. Plex-only flows such as
|
||||
/// [launchFromPlaylistItem] live directly on this class because they have no
|
||||
/// Jellyfin equivalent.
|
||||
/// the backend-neutral entry point. Flows outside that abstraction, such as
|
||||
/// [launchFromFolder], live directly on this class.
|
||||
class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
final BuildContext context;
|
||||
final PlexClient client;
|
||||
@@ -153,17 +152,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
);
|
||||
}
|
||||
|
||||
// If the queue is empty, try fetching it again with getPlayQueue
|
||||
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;
|
||||
}
|
||||
}
|
||||
playQueue = await _refetchIfEmpty(playQueue, libraryId: sourceLibraryId, libraryTitle: sourceLibraryTitle);
|
||||
|
||||
// Close loading dialog before navigating to the player
|
||||
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.
|
||||
@override
|
||||
Future<PlayQueueResult> launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async {
|
||||
@@ -287,16 +242,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
librarySectionTitle: libraryTitle,
|
||||
);
|
||||
|
||||
if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) {
|
||||
final fetchedQueue = await client.getPlayQueue(
|
||||
playQueue.playQueueID,
|
||||
librarySectionID: libraryId,
|
||||
librarySectionTitle: libraryTitle,
|
||||
);
|
||||
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
|
||||
playQueue = fetchedQueue;
|
||||
}
|
||||
}
|
||||
playQueue = await _refetchIfEmpty(playQueue, libraryId: libraryId, libraryTitle: libraryTitle);
|
||||
|
||||
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.
|
||||
Future<PlayQueueResult> _launchFromQueue({
|
||||
required PlayQueueResponse? playQueue,
|
||||
|
||||
@@ -27,5 +27,4 @@ class PlaybackContext {
|
||||
|
||||
bool get usesLocalMedia => sourceKind == PlaybackSourceKind.localFile;
|
||||
bool get shouldQueueOnReportFailure => reportingMode == PlaybackReportingMode.onlineWithOfflineFallback;
|
||||
bool get shouldQueueOnly => reportingMode == PlaybackReportingMode.offlineQueue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../media/ids.dart';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/plex_metadata_recovery.dart';
|
||||
@@ -73,6 +74,7 @@ class PlexApiCache extends ApiCache {
|
||||
// Rating keys can be alphanumeric, not just numeric.
|
||||
static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$');
|
||||
|
||||
@visibleForTesting
|
||||
Future<Set<String>> getPinnedKeys(ServerId serverId) => extractPinnedIds(serverId, _metadataKeyPattern);
|
||||
|
||||
/// Copy one pinned item between cache namespaces.
|
||||
|
||||
@@ -34,13 +34,8 @@ import '../models/livetv_capture_buffer.dart';
|
||||
import '../models/livetv_channel.dart';
|
||||
import '../models/livetv_dvr.dart';
|
||||
import '../models/livetv_hub_result.dart';
|
||||
import '../models/livetv_lineup.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_grabber_device.dart';
|
||||
import '../models/media_provider_info.dart';
|
||||
import '../models/media_subscription.dart';
|
||||
import '../models/plex/plex_activity.dart';
|
||||
import '../models/plex/plex_config.dart';
|
||||
@@ -1420,18 +1415,6 @@ class PlexClient
|
||||
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.
|
||||
/// Prefer the provider's dedicated Continue Watching feature key when
|
||||
/// 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();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
|
||||
final items = await _getRecentlyAdded(limit: limit);
|
||||
return items.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
|
||||
final items = await _getContinueWatching(count: count);
|
||||
|
||||
@@ -20,6 +20,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
|
||||
Map<String, dynamic>? queryParameters,
|
||||
// ignore: unused_element_parameter
|
||||
Map<String, String>? headers,
|
||||
// ignore: unused_element_parameter
|
||||
Duration? timeout,
|
||||
// ignore: unused_element_parameter
|
||||
AbortController? abort,
|
||||
@@ -197,73 +198,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
|
||||
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
|
||||
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) async {
|
||||
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));
|
||||
}
|
||||
|
||||
@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)
|
||||
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
|
||||
List<LiveTvChannel> parseChannels(MediaServerResponse response) {
|
||||
@@ -744,19 +484,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
|
||||
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
|
||||
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) async {
|
||||
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) =>
|
||||
_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
|
||||
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);
|
||||
}
|
||||
|
||||
@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.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@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}`
|
||||
@override
|
||||
Future<String> buildFavoriteChannelSource({String? lineup}) async {
|
||||
|
||||
@@ -145,10 +145,6 @@ class SeerrClient {
|
||||
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) ----------
|
||||
|
||||
Future<List<SeerrServiceInstance>> getRadarrServices() => _serviceList('/service/radarr');
|
||||
|
||||
@@ -23,9 +23,7 @@ abstract final class SeerrMediaServerType {
|
||||
/// app checks are named; the full mask is stored on the session untouched.
|
||||
abstract final class SeerrPermission {
|
||||
static const int admin = 2;
|
||||
static const int manageRequests = 16;
|
||||
static const int request = 32;
|
||||
static const int autoApprove = 128;
|
||||
static const int request4k = 1024;
|
||||
static const int request4kMovie = 2048;
|
||||
static const int request4kTv = 4096;
|
||||
|
||||
@@ -50,8 +50,6 @@ class SyncRuleExecutor {
|
||||
|
||||
SyncRuleExecutor({required this._database});
|
||||
|
||||
bool get isExecuting => _isExecuting;
|
||||
|
||||
/// Execute every enabled sync rule.
|
||||
///
|
||||
/// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only
|
||||
|
||||
@@ -59,12 +59,6 @@ enum TraktMediaKind {
|
||||
movie,
|
||||
episode;
|
||||
|
||||
static TraktMediaKind? tryFromMediaKindId(String type) => switch (type) {
|
||||
'movie' => movie,
|
||||
'episode' => episode,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static TraktMediaKind fromName(String name) =>
|
||||
values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name'));
|
||||
}
|
||||
|
||||
@@ -51,10 +51,6 @@ class MemoryLogOutput extends LogOutput {
|
||||
_currentSize = 0;
|
||||
}
|
||||
|
||||
static int getCurrentSize() => _currentSize;
|
||||
|
||||
static double getCurrentSizeMB() => _currentSize / (1024 * 1024);
|
||||
|
||||
@override
|
||||
void output(OutputEvent event) {
|
||||
// Only print to console — storage is done in MemoryAwareLogPrinter.log()
|
||||
|
||||
@@ -17,7 +17,6 @@ class ContentTypes {
|
||||
|
||||
static const Set<String> musicTypes = {artist, album, track};
|
||||
static const Set<String> videoTypes = {movie, show, season, episode};
|
||||
static const Set<String> playableTypes = {movie, episode, clip, track};
|
||||
}
|
||||
|
||||
class ContentTypeHelper {
|
||||
|
||||
@@ -37,14 +37,12 @@ class ContinuationPaginationCoordinator<T> {
|
||||
bool _disposed = false;
|
||||
bool _isLoading = false;
|
||||
Object? _error;
|
||||
StackTrace? _errorStackTrace;
|
||||
|
||||
int? get nextStartIndex => _nextStartIndex;
|
||||
int? get totalCount => _totalCount;
|
||||
bool get hasMore => _nextStartIndex != null;
|
||||
bool get isLoading => _isLoading;
|
||||
Object? get error => _error;
|
||||
StackTrace? get errorStackTrace => _errorStackTrace;
|
||||
|
||||
/// Invalidates all prior work, runs [request], and reports whether its result
|
||||
/// still belongs to the current generation.
|
||||
@@ -65,7 +63,6 @@ class ContinuationPaginationCoordinator<T> {
|
||||
_totalCount = totalCount;
|
||||
_nextStartIndex = startIndex < totalCount ? startIndex : null;
|
||||
_error = null;
|
||||
_errorStackTrace = null;
|
||||
onStateChanged?.call();
|
||||
}
|
||||
|
||||
@@ -99,7 +96,6 @@ class ContinuationPaginationCoordinator<T> {
|
||||
_inFlightGeneration = null;
|
||||
_isLoading = false;
|
||||
_error = null;
|
||||
_errorStackTrace = null;
|
||||
}
|
||||
|
||||
int _beginGeneration() {
|
||||
@@ -110,7 +106,6 @@ class ContinuationPaginationCoordinator<T> {
|
||||
_inFlightGeneration = null;
|
||||
_isLoading = false;
|
||||
_error = null;
|
||||
_errorStackTrace = null;
|
||||
if (!_disposed) onStateChanged?.call();
|
||||
return _generation;
|
||||
}
|
||||
@@ -120,7 +115,6 @@ class ContinuationPaginationCoordinator<T> {
|
||||
Future<ContinuationLoadStatus> _loadRemaining(int generation) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_errorStackTrace = null;
|
||||
onStateChanged?.call();
|
||||
|
||||
try {
|
||||
@@ -148,7 +142,6 @@ class ContinuationPaginationCoordinator<T> {
|
||||
} catch (exception, stackTrace) {
|
||||
if (!_isCurrent(generation)) return ContinuationLoadStatus.stale;
|
||||
_error = exception;
|
||||
_errorStackTrace = stackTrace;
|
||||
onError?.call(exception, stackTrace);
|
||||
return ContinuationLoadStatus.failed;
|
||||
} finally {
|
||||
|
||||
@@ -23,10 +23,6 @@ mixin HierarchicalEventMixin {
|
||||
/// Check if this event affects a specific item by id.
|
||||
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.
|
||||
bool affectsAnyOf(Iterable<String> itemIds) {
|
||||
if (itemIds.contains(itemId)) return true;
|
||||
|
||||
@@ -31,7 +31,6 @@ class ScreenBreakpoints {
|
||||
|
||||
/// Animation and notification durations.
|
||||
class AppDurations {
|
||||
static const Duration animFast = Duration(milliseconds: 200);
|
||||
static const Duration animMedium = Duration(milliseconds: 300);
|
||||
static const Duration animSlow = Duration(milliseconds: 500);
|
||||
static const Duration snackBarDefault = Duration(seconds: 3);
|
||||
|
||||
@@ -334,31 +334,4 @@ class MediaImageHelper {
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +112,6 @@ class TvDetectionService {
|
||||
|
||||
bool get isTV => _isTV;
|
||||
|
||||
List<String> get tvDetectionReasons => _effectiveDetectionReasons;
|
||||
|
||||
List<String> get _effectiveDetectionReasons {
|
||||
final reasons = <String>[..._detectionReasons];
|
||||
if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv');
|
||||
|
||||
@@ -155,11 +155,6 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
bool get hasCurrentPlayback =>
|
||||
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) {
|
||||
if (ratingKey == null || serverId == null) return null;
|
||||
return '$serverId:$ratingKey';
|
||||
|
||||
@@ -120,7 +120,6 @@ class GuestPlaybackReconciler {
|
||||
Timer? _statusRefreshTimer;
|
||||
|
||||
PlaybackState? get latestState => _latestState;
|
||||
bool get isCorrecting => _correcting;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public inputs
|
||||
|
||||
@@ -117,7 +117,6 @@ class HostPlaybackCoordinator {
|
||||
bool _disposed = false;
|
||||
|
||||
PlaybackPhase get phase => _phase;
|
||||
Set<String> get incompatiblePeers => Set.unmodifiable(_incompatiblePeers);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public inputs
|
||||
|
||||
@@ -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
|
||||
int get itemCount => _totalItemCount;
|
||||
|
||||
|
||||
@@ -2,40 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../../utils/track_label_builder.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
|
||||
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>({
|
||||
required BuildContext context,
|
||||
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.
|
||||
bool get controlsPresented => _controlsPresented;
|
||||
bool get contentStripVisible => _contentStripVisible;
|
||||
bool get hasVisibleHold => _holds.isNotEmpty;
|
||||
bool isHeld(PlayerChromeHold hold) => _holds.contains(hold);
|
||||
PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget;
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.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';
|
||||
|
||||
void main() {
|
||||
@@ -28,48 +25,6 @@ void main() {
|
||||
{'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({
|
||||
'MediaSubscription': [
|
||||
{'title': 7},
|
||||
|
||||
@@ -51,8 +51,8 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
http.Response jsonResponse(Map<String, dynamic> body, {Map<String, String>? headers}) {
|
||||
return http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json', ...?headers});
|
||||
http.Response jsonResponse(Map<String, dynamic> body) {
|
||||
return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'});
|
||||
}
|
||||
|
||||
test('favorite source follows requested lineup provider', () async {
|
||||
@@ -155,37 +155,6 @@ void main() {
|
||||
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 {
|
||||
final client = makeClient((request) async {
|
||||
expect(request.url.path, '/media/subscriptions/template');
|
||||
|
||||
Reference in New Issue
Block a user