Files
plezy/lib/models/media_subscription.dart
edde746 a11da3fec8 feat(livetv): record live TV on Jellyfin and Emby via the timer APIs
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
2026-08-24 00:08:11 +02:00

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},
);
}
}