Jellyfin and Emby users had no way to record live TV from Plezy at all: no record button in the EPG guide's program menu, and nothing in the recordings UI, because every recording surface was gated behind a Plex-only DVR adapter. The recording surfaces are now backed by a MediaBrowser DVR adapter that synthesizes the neutral (Plex-shaped) payload models from the timer APIs: - Template comes from `GET /LiveTv/Timers/Defaults?programId=`, offering a "Record Episode" entry and, for series airings, "Record Series". The defaults DTO travels JSON-encoded in the template's opaque `parameters` and setting ids are DTO field names, so the existing template-driven record-options sheet renders and round-trips them unchanged. - Create POSTs the mutated defaults whole to `/LiveTv/Timers` or `/LiveTv/SeriesTimers`; a duplicate one-off create answers 400, which the adapter rethrows as `RecordingConflictException` so the sheet can show "Already scheduled" without a backend check. - Scheduled recordings read `/LiveTv/Timers` minus `Cancelled`/`Completed` tombstones, rules read `/LiveTv/SeriesTimers` with their child timers nested. Rule keys carry a `timer:`/`series:` prefix so cancel, edit and delete dispatch to the right timer space inside the adapter. Guide programs now carry their recording state: the program id doubles as the record seed, and `TimerId`/`SeriesTimerId` become the rule keys that drive the guide's scheduled dot and the Manage action. The series key is stamped only when an airing actually records, so an episode a series rule skips does not show a false indicator. Three neutrality fixes on the shared UI: the record-options sheet only demands a target library when the template declares one (MediaBrowser records into its own configured folder), and "Re-evaluate rules" is hidden unless a connected DVR supports it, since only Plex has that endpoint. `fetchDvrs` deliberately stays empty so the synthesized per-server Live TV identity that channel fetches, favorites and playback key off is preserved. Verified end-to-end against a disposable jellyfin/jellyfin:10.11.11 container with an M3U tuner and XMLTV guide, driven through the real client: template, create, duplicate conflict, cancel, series create with child grabs, edit round-trip and delete. close #1645
195 lines
6.4 KiB
Dart
195 lines
6.4 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 {
|
|
/// Rule-type vocabulary carried in [type]. Plex puts its metadata-type ints
|
|
/// on the wire and the recordings UI keys series/episode presentation and
|
|
/// sort order off them, so the MediaBrowser adapter emits the same codes.
|
|
static const int typeSeries = 2;
|
|
static const int typeEpisode = 4;
|
|
|
|
@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},
|
|
);
|
|
}
|
|
}
|