Recording indicators never appeared in the EPG and the program sheet kept offering "Record" for already-subscribed programmes, so rules were created repeatedly (nine duplicate subscriptions in the linked report). Two defects, both confirmed against the official web client bundle: - The sheet's scheduled-state check called /media/providers/<identifier>/media/subscriptions/mapping/<ratingKey>, which 404s: PMS mounts that route under the numeric MediaProvider id from /media/providers. The check failed instantly and unconditionally on every platform, so the button always read "Record". - The guide derived its dot solely from cross-matching grab metadata, and the grab refresh issued right after scheduling raced the server materializing the grab, wiping the optimistic key before one frame showed. The grid response already tags subscribed airings with subscriptionID / grandparentSubscriptionID - the signal the official client renders from. LiveTvProgram now carries those attributes; the guide checks them first (grab matching stays as the secondary signal) and keeps local schedule/cancel actions authoritative until the next grid load. The sheet resolves Record vs Manage from the tagged rule key without a network call, falling back to the fixed numeric-id mapping route for untagged airings. Grab parsing also accepts the nested airing under "Video", which PMS uses for every non-scheduled grab status in JSON. close #2009
189 lines
6.1 KiB
Dart
189 lines
6.1 KiB
Dart
import 'package:json_annotation/json_annotation.dart';
|
|
|
|
import '../utils/json_utils.dart';
|
|
import 'media_grab_operation.dart';
|
|
|
|
part 'media_subscription.g.dart';
|
|
|
|
List<MediaSubscription> _parseSubscriptions(Object? raw) => parseFlexibleJsonList(raw, MediaSubscription.fromJson);
|
|
|
|
List<SubscriptionSetting> _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson);
|
|
|
|
List<MediaGrabOperation> _parseGrabOperations(Object? raw) => parseFlexibleJsonList(raw, MediaGrabOperation.fromJson);
|
|
|
|
Map<String, dynamic>? _mapFromJson(Object? raw) => firstFlexibleMap(raw);
|
|
|
|
/// Rule identifier: `/media/subscriptions` returns it as `key`, while the
|
|
/// provider-scoped mapping endpoint identifies entries by `id` — both name the
|
|
/// same numeric rule id.
|
|
Object? _readSubscriptionKey(Map json, String key) => (json['key'] ?? json['id'])?.toString();
|
|
|
|
/// Template wrapper returned by `/media/subscriptions/template`.
|
|
@JsonSerializable(createToJson: false)
|
|
class SubscriptionTemplate {
|
|
@JsonKey(name: 'MediaSubscription', fromJson: _parseSubscriptions)
|
|
final List<MediaSubscription> subscriptions;
|
|
|
|
const SubscriptionTemplate({this.subscriptions = const []});
|
|
|
|
factory SubscriptionTemplate.fromJson(Map<String, dynamic> json) => _$SubscriptionTemplateFromJson(json);
|
|
}
|
|
|
|
/// A Plex recording/download rule (`MediaSubscription`).
|
|
@JsonSerializable(createToJson: false)
|
|
class MediaSubscription {
|
|
@JsonKey(defaultValue: '', readValue: _readSubscriptionKey)
|
|
final String key;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? type;
|
|
final String? provider;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? targetLibrarySectionID;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? targetSectionLocationID;
|
|
final String? title;
|
|
@JsonKey(fromJson: flexibleBoolNullable)
|
|
final bool? selected;
|
|
final String? parameters;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? createdAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? storageTotal;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? durationTotal;
|
|
final String? airingsType;
|
|
final String? librarySectionTitle;
|
|
final String? locationPath;
|
|
@JsonKey(name: 'Video', fromJson: _mapFromJson)
|
|
final Map<String, dynamic>? video;
|
|
@JsonKey(name: 'Directory', fromJson: _mapFromJson)
|
|
final Map<String, dynamic>? directory;
|
|
@JsonKey(name: 'Playlist', fromJson: _mapFromJson)
|
|
final Map<String, dynamic>? playlist;
|
|
@JsonKey(name: 'Setting', fromJson: _parseSettings)
|
|
final List<SubscriptionSetting> settings;
|
|
@JsonKey(name: 'MediaGrabOperation', fromJson: _parseGrabOperations)
|
|
final List<MediaGrabOperation> grabOperations;
|
|
|
|
const MediaSubscription({
|
|
required this.key,
|
|
this.type,
|
|
this.provider,
|
|
this.targetLibrarySectionID,
|
|
this.targetSectionLocationID,
|
|
this.title,
|
|
this.selected,
|
|
this.parameters,
|
|
this.createdAt,
|
|
this.storageTotal,
|
|
this.durationTotal,
|
|
this.airingsType,
|
|
this.librarySectionTitle,
|
|
this.locationPath,
|
|
this.video,
|
|
this.directory,
|
|
this.playlist,
|
|
this.settings = const [],
|
|
this.grabOperations = const [],
|
|
});
|
|
|
|
factory MediaSubscription.fromJson(Map<String, dynamic> json) => _$MediaSubscriptionFromJson(json);
|
|
}
|
|
|
|
@JsonSerializable(createToJson: false)
|
|
class SubscriptionSetting {
|
|
@JsonKey(defaultValue: '')
|
|
final String id;
|
|
final String? label;
|
|
final String? summary;
|
|
final String? type;
|
|
@JsonKey(name: 'default')
|
|
final Object? defaultValue;
|
|
final Object? value;
|
|
@JsonKey(fromJson: flexibleBool)
|
|
final bool hidden;
|
|
@JsonKey(fromJson: flexibleBool)
|
|
final bool advanced;
|
|
final String? group;
|
|
final String? enumValues;
|
|
|
|
const SubscriptionSetting({
|
|
required this.id,
|
|
this.label,
|
|
this.summary,
|
|
this.type,
|
|
this.defaultValue,
|
|
this.value,
|
|
this.hidden = false,
|
|
this.advanced = false,
|
|
this.group,
|
|
this.enumValues,
|
|
});
|
|
|
|
factory SubscriptionSetting.fromJson(Map<String, dynamic> json) => _$SubscriptionSettingFromJson(json);
|
|
|
|
List<SubscriptionSettingOption> get options {
|
|
final raw = enumValues;
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
return raw.split('|').map((entry) {
|
|
final separator = entry.indexOf(':');
|
|
if (separator < 0) {
|
|
return SubscriptionSettingOption(value: Uri.decodeComponent(entry), label: Uri.decodeComponent(entry));
|
|
}
|
|
return SubscriptionSettingOption(
|
|
value: Uri.decodeComponent(entry.substring(0, separator)),
|
|
label: Uri.decodeComponent(entry.substring(separator + 1)),
|
|
);
|
|
}).toList();
|
|
}
|
|
}
|
|
|
|
class SubscriptionSettingOption {
|
|
final String value;
|
|
final String label;
|
|
|
|
const SubscriptionSettingOption({required this.value, required this.label});
|
|
}
|
|
|
|
/// Query parameters for `POST /media/subscriptions`.
|
|
class MediaSubscriptionCreateRequest {
|
|
final int? targetLibrarySectionID;
|
|
final int? targetSectionLocationID;
|
|
final int? type;
|
|
final String? parameters;
|
|
final Map<String, Object?> prefs;
|
|
|
|
const MediaSubscriptionCreateRequest({
|
|
this.targetLibrarySectionID,
|
|
this.targetSectionLocationID,
|
|
this.type,
|
|
this.parameters,
|
|
this.prefs = const {},
|
|
});
|
|
|
|
factory MediaSubscriptionCreateRequest.fromTemplate(
|
|
MediaSubscription subscription, {
|
|
int? targetLibrarySectionID,
|
|
int? targetSectionLocationID,
|
|
Map<String, Object?> prefs = const {},
|
|
}) {
|
|
final templatePrefs = <String, Object?>{
|
|
for (final setting in subscription.settings)
|
|
if (setting.id.isNotEmpty) setting.id: setting.value ?? setting.defaultValue,
|
|
};
|
|
// The template's location id belongs to the template's section; when the
|
|
// caller redirects to another section it must not be sent along (the
|
|
// server then uses the new section's default location).
|
|
final overridingSection =
|
|
targetLibrarySectionID != null && targetLibrarySectionID != subscription.targetLibrarySectionID;
|
|
return MediaSubscriptionCreateRequest(
|
|
targetLibrarySectionID: targetLibrarySectionID ?? subscription.targetLibrarySectionID,
|
|
targetSectionLocationID:
|
|
targetSectionLocationID ?? (overridingSection ? null : subscription.targetSectionLocationID),
|
|
type: subscription.type,
|
|
parameters: subscription.parameters,
|
|
prefs: {...templatePrefs, ...prefs},
|
|
);
|
|
}
|
|
}
|