feat: live TV time-shift

This commit is contained in:
edde746
2026-04-01 12:20:07 +02:00
parent 45525a4f14
commit 3f1da5f289
44 changed files with 982 additions and 90 deletions
+176 -25
View File
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_hub_result.dart';
@@ -1293,7 +1294,11 @@ class PlexClient {
}
/// Send a live TV timeline heartbeat to keep the transcode session alive.
Future<void> updateLiveTimeline({
///
/// Returns an updated [CaptureBuffer] if the response contains a
/// `TranscodeSession` with seek-range data (used to expand the seekable
/// window over time).
Future<CaptureBuffer?> updateLiveTimeline({
required String ratingKey,
required String sessionPath,
required String sessionIdentifier,
@@ -1317,7 +1322,42 @@ class PlexClient {
);
if (response.statusCode != null && response.statusCode != 200) {
appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}');
return null;
}
// Parse updated capture buffer from TranscodeSession in the response
try {
final data = response.data;
if (data is! Map<String, dynamic>) return null;
final container = data['MediaContainer'] as Map<String, dynamic>? ?? data;
// Try CaptureBuffer wrapper first, then TranscodeSession directly
final captureBufferWrapper = container['CaptureBuffer'];
if (captureBufferWrapper != null) {
final cbMap = captureBufferWrapper is List
? captureBufferWrapper.firstOrNull as Map<String, dynamic>?
: captureBufferWrapper as Map<String, dynamic>?;
if (cbMap != null) {
final ts = cbMap['TranscodeSession'];
final tsMap = ts is List
? ts.firstOrNull as Map<String, dynamic>?
: ts as Map<String, dynamic>?;
if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap);
}
}
final transcodeSessions = container['TranscodeSession'];
if (transcodeSessions is List && transcodeSessions.isNotEmpty) {
return CaptureBuffer.fromTranscodeSession(
transcodeSessions.first as Map<String, dynamic>,
);
} else if (transcodeSessions is Map<String, dynamic>) {
return CaptureBuffer.fromTranscodeSession(transcodeSessions);
}
} catch (e) {
// Parsing failure is non-fatal — just no updated seek range
}
return null;
}
/// Remove item from Continue Watching (On Deck) without affecting watch status or progress
@@ -2424,21 +2464,46 @@ class PlexClient {
}
/// Generate 24-char random alphanumeric string (matching official client format)
static String _generateSessionIdentifier() {
static String generateSessionIdentifier() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
final rand = Random();
return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join();
}
/// Tune to a live TV channel and set up the transcode session.
/// Coerce String values to num for fields that json_serializable expects as num.
/// Plex tune responses use XML-to-JSON conversion where all values are strings.
static void _coerceNumericFields(Map<String, dynamic> json) {
const numericKeys = [
'duration', 'year', 'addedAt', 'updatedAt', 'lastViewedAt',
'parentIndex', 'index', 'viewOffset', 'viewCount', 'leafCount',
'viewedLeafCount', 'childCount', 'rating', 'audienceRating',
'userRating', 'ratingCount', 'skipCount', 'lastRatedAt',
];
for (final key in numericKeys) {
final val = json[key];
if (val is String) {
json[key] = num.tryParse(val);
}
}
}
/// Tune to a live TV channel.
///
/// Flow: tune → decision → return /start path (MKV-over-HTTP).
Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(
/// POSTs to the tune endpoint and extracts metadata, session info, and
/// capture buffer data from the response. Call [buildLiveStreamPath] after
/// to build the actual stream URL (with optional offset for time-shift).
Future<({
PlexMetadata metadata,
String sessionPath,
String sessionIdentifier,
CaptureBuffer? captureBuffer,
int? beginsAt,
})?> tuneChannel(
String dvrKey,
String channelIdentifier,
) async {
try {
final sessionIdentifier = _generateSessionIdentifier();
final sessionIdentifier = generateSessionIdentifier();
final response = await _dio.post(
'/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune',
@@ -2453,8 +2518,16 @@ class PlexClient {
final container = _getMediaContainer(response);
if (container == null) return null;
// Log the full container keys and types for debugging
appLogger.d('Tune response keys: ${container.keys.toList()}');
for (final key in container.keys) {
final val = container[key];
appLogger.d(' $key: ${val.runtimeType} = ${val is List ? '[${val.length} items]' : val is Map ? '{${val.keys.toList()}}' : val}');
}
final containerStatus = container['status'];
if (containerStatus != null && containerStatus != 0 && containerStatus != 200) {
final statusInt = containerStatus is num ? containerStatus.toInt() : containerStatus is String ? int.tryParse(containerStatus) : null;
if (statusInt != null && statusInt != 0 && statusInt != 200) {
final msg = container['message'] ?? 'Unknown error';
appLogger.w('Tune channel error: $msg (status: $containerStatus)');
throw Exception(msg);
@@ -2462,13 +2535,24 @@ class PlexClient {
// Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata
Map<String, dynamic>? metadataJson;
final subscriptions = container['MediaSubscription'] as List?;
if (subscriptions != null && subscriptions.isNotEmpty) {
final sub = subscriptions.first as Map<String, dynamic>;
final ops = sub['MediaGrabOperation'] as List?;
if (ops != null && ops.isNotEmpty) {
final op = ops.first as Map<String, dynamic>;
int? beginsAt;
final subscriptions = container['MediaSubscription'];
appLogger.d('MediaSubscription type: ${subscriptions.runtimeType}');
final subList = subscriptions is List ? subscriptions : subscriptions is Map ? [subscriptions] : null;
if (subList != null && subList.isNotEmpty) {
final sub = subList.first as Map<String, dynamic>;
final ops = sub['MediaGrabOperation'];
appLogger.d('MediaGrabOperation type: ${ops.runtimeType}');
final opList = ops is List ? ops : ops is Map ? [ops] : null;
if (opList != null && opList.isNotEmpty) {
final op = opList.first as Map<String, dynamic>;
appLogger.d('GrabOperation keys: ${op.keys.toList()}');
final rawBeginsAt = op['beginsAt'];
beginsAt = rawBeginsAt is num
? rawBeginsAt.toInt()
: rawBeginsAt is String ? int.tryParse(rawBeginsAt) : null;
final nested = op['Metadata'];
appLogger.d('Metadata type: ${nested.runtimeType}');
if (nested is Map<String, dynamic>) {
metadataJson = nested;
} else if (nested is List && nested.isNotEmpty) {
@@ -2476,14 +2560,36 @@ class PlexClient {
}
}
}
metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map<String, dynamic>?;
if (metadataJson == null) {
final fallback = container['Metadata'];
appLogger.d('Fallback Metadata type: ${fallback.runtimeType}');
if (fallback is List && fallback.isNotEmpty) {
metadataJson = fallback.first as Map<String, dynamic>;
} else if (fallback is Map<String, dynamic>) {
metadataJson = fallback;
}
}
if (metadataJson == null) {
appLogger.w('Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})');
return null;
}
final metadata = _createTaggedMetadata(metadataJson);
// Tune response may return XML-style string values where fromJson expects nums.
// Coerce known numeric fields so the generated deserializer doesn't choke.
_coerceNumericFields(metadataJson);
PlexMetadata metadata;
try {
metadata = _createTaggedMetadata(metadataJson);
} catch (e, st) {
appLogger.e('Tune metadata parse failed. Keys: ${metadataJson.keys.toList()}');
for (final entry in metadataJson.entries) {
appLogger.d(' ${entry.key}: ${entry.value.runtimeType} = ${entry.value}');
}
appLogger.e('Stack: $st');
rethrow;
}
final sessionPath = metadataJson['key'] as String?;
if (sessionPath == null) {
@@ -2491,8 +2597,57 @@ class PlexClient {
return null;
}
// All identity goes in query params; the only HTTP header is Accept-Language
// (matching the official Plex client behaviour).
// Extract capture buffer from TranscodeSession.
// May be at the container level OR inside the Metadata object.
CaptureBuffer? captureBuffer;
final tsSource = container['TranscodeSession'] ?? metadataJson['TranscodeSession'];
if (tsSource is List && tsSource.isNotEmpty) {
captureBuffer = CaptureBuffer.fromTranscodeSession(
tsSource.first as Map<String, dynamic>,
);
} else if (tsSource is Map<String, dynamic>) {
captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource);
}
// beginsAt may also be on the Media items (not just the GrabOperation)
if (beginsAt == null) {
final media = metadataJson['Media'];
if (media is List && media.isNotEmpty) {
final firstMedia = media.first;
if (firstMedia is Map<String, dynamic>) {
final raw = firstMedia['beginsAt'];
beginsAt = raw is num ? raw.toInt() : raw is String ? int.tryParse(raw) : null;
}
}
}
return (
metadata: metadata,
sessionPath: sessionPath,
sessionIdentifier: sessionIdentifier,
captureBuffer: captureBuffer,
beginsAt: beginsAt,
);
} catch (e, st) {
appLogger.e('Failed to tune channel', error: e, stackTrace: st);
return null;
}
}
/// Build a live TV stream URL (decision + start path).
///
/// [sessionPath] and [sessionIdentifier] come from [tuneChannel].
/// [transcodeSessionId] should be reused across seeks within the same
/// viewing session so the server reuses its capture buffer.
/// [offsetSeconds] positions the stream at that many seconds from the
/// capture buffer origin (for time-shift / watch-from-start).
Future<String?> buildLiveStreamPath({
required String sessionPath,
required String sessionIdentifier,
required String transcodeSessionId,
int? offsetSeconds,
}) async {
try {
final allParams = <String, String>{
'hasMDE': '1',
'path': sessionPath,
@@ -2510,7 +2665,7 @@ class PlexClient {
'directStreamAudio': '1',
'advancedSubtitles': 'text',
'mediaBufferSize': '157286',
'session': _generateSessionIdentifier(),
'session': transcodeSessionId,
'subtitles': 'auto',
'copyts': '0',
'Accept-Language': 'en',
@@ -2522,6 +2677,7 @@ class PlexClient {
'X-Plex-Client-Identifier': config.clientIdentifier,
'X-Plex-Platform': config.platform,
'X-Plex-Client-Profile-Name': 'Plex Desktop',
if (offsetSeconds != null) 'offset': offsetSeconds.toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
};
@@ -2552,14 +2708,9 @@ class PlexClient {
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return (
metadata: metadata,
streamPath: '/video/:/transcode/universal/start?$startQuery',
sessionIdentifier: sessionIdentifier,
sessionPath: sessionPath,
);
return '/video/:/transcode/universal/start?$startQuery';
} catch (e, st) {
appLogger.e('Failed to tune channel', error: e, stackTrace: st);
appLogger.e('Failed to build live stream path', error: e, stackTrace: st);
return null;
}
}