Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abf5d36a7f | ||
|
|
0fd1e61979 | ||
|
|
aa47de9912 | ||
|
|
9467cb28c7 | ||
|
|
ac16cf7394 | ||
|
|
71a65aab6e | ||
|
|
c6a1fd9f63 | ||
|
|
fb32d5c5e2 |
@@ -81,6 +81,13 @@ public class RecordingEntry
|
|||||||
[JsonPropertyName("recordingEndedAt")]
|
[JsonPropertyName("recordingEndedAt")]
|
||||||
public DateTime? RecordingEndedAt { get; set; }
|
public DateTime? RecordingEndedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets when the stream disappeared from the API after recording had started.
|
||||||
|
/// Used to detect that a broadcast ended before its scheduled ValidTo.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("streamLostAt")]
|
||||||
|
public DateTime? StreamLostAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the file size in bytes.
|
/// Gets or sets the file size in bytes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -98,4 +105,10 @@ public class RecordingEntry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("createdAt")]
|
[JsonPropertyName("createdAt")]
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the output file may still grow (recording, or waiting to resume).
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsInProgress => State is RecordingState.Recording or RecordingState.WaitingForStream;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where recordings will be written and whether the server can write there.
|
||||||
|
/// </summary>
|
||||||
|
public class RecordingOutputInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolved output directory.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("path")]
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets how the directory was chosen: "custom", "library", "tvLibrary" or "fallback".
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("source")]
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the library the directory belongs to, if any.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("libraryName")]
|
||||||
|
public string? LibraryName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the directory could be created and written to.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("writable")]
|
||||||
|
public bool Writable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the error from the write test, if it failed.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ namespace Jellyfin.Plugin.SRFPlay.Channels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||||
{
|
{
|
||||||
|
private const string InProgressPrefix = "● REC · ";
|
||||||
|
|
||||||
private readonly ILogger<SrgChannelBase> _logger;
|
private readonly ILogger<SrgChannelBase> _logger;
|
||||||
private readonly IContentRefreshService _contentRefreshService;
|
private readonly IContentRefreshService _contentRefreshService;
|
||||||
private readonly IStreamUrlResolver _streamResolver;
|
private readonly IStreamUrlResolver _streamResolver;
|
||||||
@@ -320,15 +322,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
{
|
{
|
||||||
var items = new List<ChannelItemInfo>();
|
var items = new List<ChannelItemInfo>();
|
||||||
var unit = Unit.ToLowerString();
|
var unit = Unit.ToLowerString();
|
||||||
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
var serverBaseUrl = _mediaSourceFactory.GetServerBaseUrl();
|
||||||
|
|
||||||
foreach (var recording in recordings)
|
foreach (var recording in _recordingService.GetPlayableRecordings())
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show recordings belonging to this unit. Recordings created before the
|
// Only show recordings belonging to this unit. Recordings created before the
|
||||||
// BusinessUnit tag existed have an empty value and are shown everywhere so they
|
// BusinessUnit tag existed have an empty value and are shown everywhere so they
|
||||||
// are not lost.
|
// are not lost.
|
||||||
@@ -338,43 +335,72 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
|
var outputPath = recording.OutputPath!;
|
||||||
var itemId = $"recording_{recording.Id}";
|
var itemId = $"recording_{recording.Id}";
|
||||||
|
var container = System.IO.Path.GetExtension(outputPath).TrimStart('.').ToLowerInvariant();
|
||||||
|
var name = recording.IsInProgress ? $"{InProgressPrefix}{recording.Title}" : recording.Title;
|
||||||
|
|
||||||
var mediaSource = new MediaSourceInfo
|
MediaSourceInfo mediaSource;
|
||||||
|
if (recording.IsInProgress)
|
||||||
{
|
{
|
||||||
Id = itemId,
|
// Same approach as Jellyfin's own DVR: the file is still growing, so treat it as a live
|
||||||
Name = recording.Title,
|
// stream and have ffmpeg read it through an endpoint that follows the file.
|
||||||
Path = recording.OutputPath,
|
mediaSource = new MediaSourceInfo
|
||||||
Protocol = MediaProtocol.File,
|
{
|
||||||
Container = "mkv",
|
Id = itemId,
|
||||||
SupportsDirectPlay = true,
|
Name = name,
|
||||||
SupportsDirectStream = true,
|
Path = outputPath,
|
||||||
SupportsTranscoding = true,
|
Protocol = MediaProtocol.File,
|
||||||
IsRemote = false,
|
EncoderPath = $"{serverBaseUrl}/Plugins/SRFPlay/Recording/{recording.Id}/stream",
|
||||||
Size = fileInfo.Length,
|
EncoderProtocol = MediaProtocol.Http,
|
||||||
Type = MediaSourceType.Default
|
Container = container,
|
||||||
};
|
SupportsDirectPlay = false,
|
||||||
|
SupportsDirectStream = true,
|
||||||
|
SupportsTranscoding = true,
|
||||||
|
IsInfiniteStream = true,
|
||||||
|
IgnoreDts = true,
|
||||||
|
IgnoreIndex = true,
|
||||||
|
IsRemote = false,
|
||||||
|
Type = MediaSourceType.Default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mediaSource = new MediaSourceInfo
|
||||||
|
{
|
||||||
|
Id = itemId,
|
||||||
|
Name = name,
|
||||||
|
Path = outputPath,
|
||||||
|
Protocol = MediaProtocol.File,
|
||||||
|
Container = container,
|
||||||
|
SupportsDirectPlay = true,
|
||||||
|
SupportsDirectStream = true,
|
||||||
|
SupportsTranscoding = true,
|
||||||
|
IsRemote = false,
|
||||||
|
Size = new System.IO.FileInfo(outputPath).Length,
|
||||||
|
Type = MediaSourceType.Default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var item = new ChannelItemInfo
|
var item = new ChannelItemInfo
|
||||||
{
|
{
|
||||||
Id = itemId,
|
Id = itemId,
|
||||||
Name = recording.Title,
|
Name = name,
|
||||||
Overview = recording.Description,
|
Overview = recording.Description,
|
||||||
Type = ChannelItemType.Media,
|
Type = ChannelItemType.Media,
|
||||||
ContentType = ChannelMediaContentType.Movie,
|
ContentType = ChannelMediaContentType.Movie,
|
||||||
MediaType = ChannelMediaType.Video,
|
MediaType = ChannelMediaType.Video,
|
||||||
DateCreated = recording.RecordingStartedAt,
|
DateCreated = recording.RecordingStartedAt,
|
||||||
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
||||||
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
|
? CreateProxiedImageUrl(recording.ImageUrl, serverBaseUrl)
|
||||||
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
|
: CreatePlaceholderImageUrl(recording.Title, serverBaseUrl),
|
||||||
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
||||||
};
|
};
|
||||||
|
|
||||||
items.Add(item);
|
items.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
|
_logger.LogInformation("Returning {Count} recordings as channel items", items.Count);
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,8 +518,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
||||||
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
|
// Include in-progress count so items switch from live to finished sources when a recording ends
|
||||||
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
var recordings = _recordingService.GetPlayableRecordings();
|
||||||
|
var recordingKey = $"{recordings.Count}_{recordings.Count(r => r.IsInProgress)}";
|
||||||
|
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingKey}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -165,10 +165,18 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
public bool GenerateTitleCards { get; set; }
|
public bool GenerateTitleCards { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the output directory for sport livestream recordings.
|
/// Gets or sets a custom output directory for livestream recordings. Takes precedence over
|
||||||
|
/// <see cref="RecordingLibraryId"/>. Empty means record into a library.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ItemId of the library to record into. Recordings go into an
|
||||||
|
/// "SRF Recordings" folder in the library's first location. Empty picks the first
|
||||||
|
/// TV Shows library, so recordings show up without adding a library by hand.
|
||||||
|
/// </summary>
|
||||||
|
public string RecordingLibraryId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the time zone used for times the plugin writes as text: the start time in
|
/// Gets or sets the time zone used for times the plugin writes as text: the start time in
|
||||||
/// upcoming livestream names and the timestamp in recording file names. An IANA id such as
|
/// upcoming livestream names and the timestamp in recording file names. An IANA id such as
|
||||||
|
|||||||
@@ -77,10 +77,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<h2>Recording Settings</h2>
|
<h2>Recording Settings</h2>
|
||||||
|
<div class="selectContainer">
|
||||||
|
<label class="selectLabel" for="RecordingLibraryId">Record Into Library</label>
|
||||||
|
<select is="emby-select" id="RecordingLibraryId" name="RecordingLibraryId" class="emby-select-withcolor emby-select">
|
||||||
|
<option value="">Automatic (first TV Shows library)</option>
|
||||||
|
</select>
|
||||||
|
<div class="fieldDescription">Recordings are saved to an "SRF Recordings" folder inside this library, so they appear there after the next library scan (requires ffmpeg).</div>
|
||||||
|
</div>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
|
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Custom Recording Directory (Optional)</label>
|
||||||
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
||||||
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
<div class="fieldDescription">Overrides the library above. Add this directory as a library yourself if you want recordings to appear in Jellyfin.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordingOutput.check()">
|
||||||
|
<span>Check Recording Directory</span>
|
||||||
|
</button>
|
||||||
|
<div id="RecordingOutputStatus" class="fieldDescription" style="margin-top: 0.5em;"></div>
|
||||||
|
<details class="fieldDescription" style="margin-top: 0.5em;">
|
||||||
|
<summary>Recording directory not writable?</summary>
|
||||||
|
<p>The Jellyfin user needs write access to the directory and execute (traverse) access to every parent directory. Check with <code>namei -l /path/to/recordings</code>.</p>
|
||||||
|
<p>On Linux the packaged systemd service may be sandboxed, so it can read your media folders but not write to them. Allow the directory with <code>sudo systemctl edit jellyfin.service</code> and add:</p>
|
||||||
|
<pre>[Service]
|
||||||
|
ReadWritePaths=/path/to/recordings</pre>
|
||||||
|
<p>then run <code>sudo systemctl daemon-reload && sudo systemctl restart jellyfin</code>. In Docker, make sure the directory is mounted read-write.</p>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="DisplayTimeZone">Display Time Zone</label>
|
<label class="inputLabel inputLabelUnfocused" for="DisplayTimeZone">Display Time Zone</label>
|
||||||
@@ -205,6 +226,7 @@
|
|||||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||||
|
SRFPlayRecordingOutput.loadLibraries(config.RecordingLibraryId || '');
|
||||||
document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || '';
|
document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || '';
|
||||||
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
||||||
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
||||||
@@ -236,7 +258,8 @@
|
|||||||
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
||||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||||
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value.trim();
|
||||||
|
config.RecordingLibraryId = document.querySelector('#RecordingLibraryId').value;
|
||||||
config.DisplayTimeZone = document.querySelector('#DisplayTimeZone').value.trim();
|
config.DisplayTimeZone = document.querySelector('#DisplayTimeZone').value.trim();
|
||||||
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
||||||
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
||||||
@@ -246,6 +269,7 @@
|
|||||||
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
|
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
|
||||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
|
SRFPlayRecordingOutput.check();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,6 +277,58 @@
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var SRFPlayRecordingOutput = {
|
||||||
|
loadLibraries: function(selectedId) {
|
||||||
|
var select = document.querySelector('#RecordingLibraryId');
|
||||||
|
ApiClient.getVirtualFolders().then(function(folders) {
|
||||||
|
while (select.options.length > 1) {
|
||||||
|
select.remove(1);
|
||||||
|
}
|
||||||
|
(folders || []).forEach(function(folder) {
|
||||||
|
if (!folder.Locations || !folder.Locations.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var option = document.createElement('option');
|
||||||
|
option.value = folder.ItemId;
|
||||||
|
option.textContent = folder.Name + ' (' + folder.Locations[0] + ')';
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
select.value = selectedId;
|
||||||
|
if (select.value !== selectedId) {
|
||||||
|
// Chosen library was removed; fall back to automatic
|
||||||
|
select.value = '';
|
||||||
|
}
|
||||||
|
SRFPlayRecordingOutput.check();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
check: function() {
|
||||||
|
var status = document.querySelector('#RecordingOutputStatus');
|
||||||
|
status.textContent = 'Checking…';
|
||||||
|
ApiClient.getJSON(ApiClient.getUrl('Plugins/SRFPlay/Recording/OutputInfo')).then(function(info) {
|
||||||
|
var sources = {
|
||||||
|
custom: 'custom directory',
|
||||||
|
library: 'library "' + info.libraryName + '"',
|
||||||
|
tvLibrary: 'first TV Shows library "' + info.libraryName + '"',
|
||||||
|
fallback: 'no TV Shows library found, using the Jellyfin user\'s home. Add this directory as a library to see recordings'
|
||||||
|
};
|
||||||
|
status.textContent = '';
|
||||||
|
var line = document.createElement('div');
|
||||||
|
line.textContent = (info.writable ? '\u2714 ' : '\u2718 ') + info.path + ' (' + (sources[info.source] || info.source) + ')';
|
||||||
|
line.style.color = info.writable ? '' : '#e57373';
|
||||||
|
status.appendChild(line);
|
||||||
|
if (!info.writable) {
|
||||||
|
var err = document.createElement('div');
|
||||||
|
err.textContent = 'Not writable: ' + (info.error || 'unknown error') + '. Saved settings apply; see the help below.';
|
||||||
|
err.style.color = '#e57373';
|
||||||
|
status.appendChild(err);
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
status.textContent = 'Could not check the recording directory.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var SRFPlayMaintenance = {
|
var SRFPlayMaintenance = {
|
||||||
cleanupResumePoints: function(clearAll) {
|
cleanupResumePoints: function(clearAll) {
|
||||||
if (clearAll && !confirm('Clear the playback position of every SRF Play item for all users? Nothing is deleted and items stay unwatched.')) {
|
if (clearAll && !confirm('Clear the playback position of every SRF Play item for all users? Nothing is deleted and items stay unwatched.')) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -19,6 +22,9 @@ namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public class RecordingController : ControllerBase
|
public class RecordingController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>How long a followed stream waits at the end of an in-progress file before giving up.</summary>
|
||||||
|
private static readonly TimeSpan FollowIdleTimeout = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
private readonly ILogger<RecordingController> _logger;
|
private readonly ILogger<RecordingController> _logger;
|
||||||
private readonly IRecordingService _recordingService;
|
private readonly IRecordingService _recordingService;
|
||||||
|
|
||||||
@@ -56,6 +62,67 @@ public class RecordingController : ControllerBase
|
|||||||
return File(resourceStream, "text/html");
|
return File(resourceStream, "text/html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a recording's .ts file. While the recording is still in progress this keeps
|
||||||
|
/// following the file as it grows, so it can be watched like a livestream buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The recording ID.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The MPEG-TS stream.</returns>
|
||||||
|
[HttpGet("{id}/stream")]
|
||||||
|
[AllowAnonymous] // Fetched server-side by Jellyfin/ffmpeg without a token, like the stream proxy
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetRecordingStream(string id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// The path always comes from our own recording list (which only contains existing files),
|
||||||
|
// never from the request
|
||||||
|
var entry = _recordingService.GetPlayableRecordings().FirstOrDefault(r => r.Id == id);
|
||||||
|
if (entry?.OutputPath is not { } path)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
Response.ContentType = "video/mp2t";
|
||||||
|
|
||||||
|
var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
await using (input.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
var lastDataAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (read > 0)
|
||||||
|
{
|
||||||
|
await Response.Body.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||||
|
lastDataAt = DateTime.UtcNow;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At the end of the file: keep following while it's still being written. The idle
|
||||||
|
// limit covers an ffmpeg restart but ends the stream if recording has stalled.
|
||||||
|
var stillRecording = _recordingService.GetRecording(id)?.IsInProgress == true;
|
||||||
|
if (!stillRecording || DateTime.UtcNow - lastDataAt > FollowIdleTimeout)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Client went away
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmptyResult();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets upcoming sport livestreams available for recording.
|
/// Gets upcoming sport livestreams available for recording.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -172,4 +239,16 @@ public class RecordingController : ControllerBase
|
|||||||
{
|
{
|
||||||
return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound();
|
return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows where recordings will be written and whether the server can write there.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The resolved output directory and write-test result.</returns>
|
||||||
|
[HttpGet("OutputInfo")]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<RecordingOutputInfo> GetOutputInfo()
|
||||||
|
{
|
||||||
|
return Ok(_recordingService.GetOutputInfo());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ public interface IRecordingService
|
|||||||
/// <returns>List of matching recording entries.</returns>
|
/// <returns>List of matching recording entries.</returns>
|
||||||
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
|
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a single recording by ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recordingId">The recording ID.</param>
|
||||||
|
/// <returns>The recording entry, or null if not found.</returns>
|
||||||
|
RecordingEntry? GetRecording(string recordingId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets recordings that have an output file on disk, finished or still in progress.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Playable recordings, newest first.</returns>
|
||||||
|
IReadOnlyList<RecordingEntry> GetPlayableRecordings();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a completed recording (entry and optionally the file).
|
/// Deletes a completed recording (entry and optionally the file).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -62,4 +75,10 @@ public interface IRecordingService
|
|||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>A task representing the async operation.</returns>
|
/// <returns>A task representing the async operation.</returns>
|
||||||
Task ProcessRecordingsAsync(CancellationToken cancellationToken = default);
|
Task ProcessRecordingsAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves where recordings will be written and checks the server can write there.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The output directory and the result of a write test.</returns>
|
||||||
|
RecordingOutputInfo GetOutputInfo();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
|||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -25,6 +26,18 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class RecordingService : IRecordingService, IDisposable
|
public class RecordingService : IRecordingService, IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>Folder created inside a library for recordings; shows up there as one series.</summary>
|
||||||
|
private const string RecordingsFolderName = "SRF Recordings";
|
||||||
|
|
||||||
|
/// <summary>How long the stream may be missing from the API mid-recording before we treat the broadcast as over.</summary>
|
||||||
|
private static readonly TimeSpan StreamLostGracePeriod = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>When a recording has no ValidTo, how long after ValidFrom to keep waiting for the stream.</summary>
|
||||||
|
private static readonly TimeSpan NoValidToGiveUpAfter = TimeSpan.FromHours(12);
|
||||||
|
|
||||||
|
/// <summary>Longest the scheduler will block waiting for an imminent DVR window start.</summary>
|
||||||
|
private static readonly TimeSpan MaxInlineStartWait = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
private readonly ILogger<RecordingService> _logger;
|
private readonly ILogger<RecordingService> _logger;
|
||||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||||
private readonly IStreamProxyService _proxyService;
|
private readonly IStreamProxyService _proxyService;
|
||||||
@@ -32,7 +45,8 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
||||||
private readonly IServerApplicationHost _appHost;
|
private readonly IServerApplicationHost _appHost;
|
||||||
private readonly IMediaEncoder _mediaEncoder;
|
private readonly IMediaEncoder _mediaEncoder;
|
||||||
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ConcurrentDictionary<string, ActiveRecording> _activeProcesses = new();
|
||||||
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||||
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
||||||
private readonly SemaphoreSlim _processLock = new(1, 1);
|
private readonly SemaphoreSlim _processLock = new(1, 1);
|
||||||
@@ -50,6 +64,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
||||||
/// <param name="appHost">The application host.</param>
|
/// <param name="appHost">The application host.</param>
|
||||||
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
|
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
|
||||||
|
/// <param name="libraryManager">The library manager, used to record into a library.</param>
|
||||||
public RecordingService(
|
public RecordingService(
|
||||||
ILogger<RecordingService> logger,
|
ILogger<RecordingService> logger,
|
||||||
ISRFApiClientFactory apiClientFactory,
|
ISRFApiClientFactory apiClientFactory,
|
||||||
@@ -57,7 +72,8 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
IStreamUrlResolver streamUrlResolver,
|
IStreamUrlResolver streamUrlResolver,
|
||||||
IMediaCompositionFetcher mediaCompositionFetcher,
|
IMediaCompositionFetcher mediaCompositionFetcher,
|
||||||
IServerApplicationHost appHost,
|
IServerApplicationHost appHost,
|
||||||
IMediaEncoder mediaEncoder)
|
IMediaEncoder mediaEncoder,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_apiClientFactory = apiClientFactory;
|
_apiClientFactory = apiClientFactory;
|
||||||
@@ -66,6 +82,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
_mediaCompositionFetcher = mediaCompositionFetcher;
|
_mediaCompositionFetcher = mediaCompositionFetcher;
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
_mediaEncoder = mediaEncoder;
|
_mediaEncoder = mediaEncoder;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetDataFilePath()
|
private string GetDataFilePath()
|
||||||
@@ -77,17 +94,84 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
private string GetRecordingOutputPath()
|
private string GetRecordingOutputPath()
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var path = ResolveOutputDirectory().Path;
|
||||||
var path = config?.RecordingOutputPath;
|
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
|
||||||
{
|
|
||||||
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
|
|
||||||
}
|
|
||||||
|
|
||||||
Directory.CreateDirectory(path);
|
Directory.CreateDirectory(path);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the recording directory: custom path, then the chosen library, then the first
|
||||||
|
/// TV Shows library, then a folder in the service user's home.
|
||||||
|
/// </summary>
|
||||||
|
private RecordingOutputInfo ResolveOutputDirectory()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
if (!string.IsNullOrWhiteSpace(config?.RecordingOutputPath))
|
||||||
|
{
|
||||||
|
return new RecordingOutputInfo { Path = config.RecordingOutputPath.Trim(), Source = "custom" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var libraries = _libraryManager.GetVirtualFolders()
|
||||||
|
.Where(f => f.Locations != null && f.Locations.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var selectedId = config?.RecordingLibraryId;
|
||||||
|
var library = string.IsNullOrWhiteSpace(selectedId)
|
||||||
|
? null
|
||||||
|
: libraries.FirstOrDefault(f => string.Equals(f.ItemId, selectedId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var source = "library";
|
||||||
|
|
||||||
|
if (library == null)
|
||||||
|
{
|
||||||
|
// CollectionType is an enum whose member casing differs between Jellyfin versions
|
||||||
|
library = libraries.FirstOrDefault(f => string.Equals(f.CollectionType?.ToString(), "tvshows", StringComparison.OrdinalIgnoreCase));
|
||||||
|
source = "tvLibrary";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library != null)
|
||||||
|
{
|
||||||
|
return new RecordingOutputInfo
|
||||||
|
{
|
||||||
|
Path = Path.Combine(library.Locations[0], RecordingsFolderName),
|
||||||
|
Source = source,
|
||||||
|
LibraryName = library.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not read libraries to pick a recording directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecordingOutputInfo
|
||||||
|
{
|
||||||
|
Path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), RecordingsFolderName),
|
||||||
|
Source = "fallback"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public RecordingOutputInfo GetOutputInfo()
|
||||||
|
{
|
||||||
|
var info = ResolveOutputDirectory();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(info.Path);
|
||||||
|
var probe = Path.Combine(info.Path, $".srfplay-write-test-{Guid.NewGuid():N}");
|
||||||
|
File.WriteAllText(probe, string.Empty);
|
||||||
|
File.Delete(probe);
|
||||||
|
info.Writable = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
info.Error = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
private string GetServerBaseUrl()
|
private string GetServerBaseUrl()
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
@@ -266,15 +350,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
StopFfmpeg(recordingId);
|
StopFfmpeg(recordingId);
|
||||||
|
CompleteRecording(entry, DateTime.UtcNow);
|
||||||
entry.State = RecordingState.Completed;
|
|
||||||
entry.RecordingEndedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
|
||||||
{
|
|
||||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = SaveRecordingsAsync();
|
_ = SaveRecordingsAsync();
|
||||||
|
|
||||||
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
||||||
@@ -298,6 +374,22 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
|
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public RecordingEntry? GetRecording(string recordingId)
|
||||||
|
{
|
||||||
|
return GetRecordings(null).FirstOrDefault(r => r.Id == recordingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<RecordingEntry> GetPlayableRecordings()
|
||||||
|
{
|
||||||
|
// Gate on the file rather than the state: an in-progress or failed recording
|
||||||
|
// still has watchable footage.
|
||||||
|
return GetRecordings(null)
|
||||||
|
.Where(r => !string.IsNullOrEmpty(r.OutputPath) && File.Exists(r.OutputPath))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool DeleteRecording(string recordingId, bool deleteFile)
|
public bool DeleteRecording(string recordingId, bool deleteFile)
|
||||||
{
|
{
|
||||||
@@ -369,8 +461,26 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
{
|
{
|
||||||
case RecordingState.Scheduled:
|
case RecordingState.Scheduled:
|
||||||
case RecordingState.WaitingForStream:
|
case RecordingState.WaitingForStream:
|
||||||
// Check if it's time to start recording
|
// Give up once the broadcast window is over, otherwise we'd retry forever
|
||||||
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
var giveUpAt = validToUtc ?? validFromUtc?.Add(NoValidToGiveUpAfter);
|
||||||
|
if (giveUpAt.HasValue && giveUpAt.Value <= now)
|
||||||
|
{
|
||||||
|
if (entry.RecordingStartedAt.HasValue)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Recording '{Title}' reached end of broadcast window while waiting for stream, completing", entry.Title);
|
||||||
|
CompleteRecording(entry, now);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Stream for '{Title}' never became available (window ended {GiveUpAt}), marking failed", entry.Title, giveUpAt.Value);
|
||||||
|
entry.State = RecordingState.Failed;
|
||||||
|
entry.ErrorMessage = "Stream never became available";
|
||||||
|
entry.RecordingEndedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
else if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
||||||
@@ -389,13 +499,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
||||||
StopFfmpeg(entry.Id);
|
StopFfmpeg(entry.Id);
|
||||||
entry.State = RecordingState.Completed;
|
CompleteRecording(entry, now);
|
||||||
entry.RecordingEndedAt = now;
|
|
||||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
|
||||||
{
|
|
||||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
else if (!_activeProcesses.ContainsKey(entry.Id))
|
else if (!_activeProcesses.ContainsKey(entry.Id))
|
||||||
@@ -426,7 +530,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
if (chapter == null)
|
if (chapter == null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
||||||
entry.State = RecordingState.WaitingForStream;
|
MarkStreamUnavailable(entry);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,10 +541,29 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
if (string.IsNullOrEmpty(streamUrl))
|
if (string.IsNullOrEmpty(streamUrl))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
||||||
entry.State = RecordingState.WaitingForStream;
|
MarkStreamUnavailable(entry);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CDN rejects a DVR window whose start= is still in the future (400/404).
|
||||||
|
// Wait briefly if it's imminent, otherwise try again on the next scheduler run.
|
||||||
|
var windowStart = GetDvrWindowStart(streamUrl);
|
||||||
|
if (windowStart.HasValue && windowStart.Value > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
var wait = windowStart.Value - DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||||
|
if (wait > MaxInlineStartWait)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stream for '{Title}' starts at {Start}, waiting", entry.Title, windowStart.Value);
|
||||||
|
entry.State = RecordingState.WaitingForStream;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Stream for '{Title}' starts in {Seconds:F0}s, delaying ffmpeg start", entry.Title, wait.TotalSeconds);
|
||||||
|
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.StreamLostAt = null;
|
||||||
|
|
||||||
// Register the stream with the proxy so we can use the proxy URL
|
// Register the stream with the proxy so we can use the proxy URL
|
||||||
var itemId = $"rec_{entry.Id}";
|
var itemId = $"rec_{entry.Id}";
|
||||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
|
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
|
||||||
@@ -449,22 +572,26 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
// Build proxy URL for ffmpeg (use localhost for local access)
|
// Build proxy URL for ffmpeg (use localhost for local access)
|
||||||
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
||||||
|
|
||||||
// Build output file path
|
// Restarts append to the existing .ts so a recording stays one file
|
||||||
var safeTitle = SanitizeFileName(entry.Title);
|
var outputPath = entry.OutputPath;
|
||||||
var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
if (string.IsNullOrEmpty(outputPath) || !outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||||
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
|
{
|
||||||
entry.OutputPath = outputPath;
|
var safeTitle = SanitizeFileName(entry.Title);
|
||||||
|
var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
||||||
|
outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.ts");
|
||||||
|
entry.OutputPath = outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
// Start ffmpeg
|
// Start ffmpeg
|
||||||
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
||||||
|
|
||||||
entry.State = RecordingState.Recording;
|
entry.State = RecordingState.Recording;
|
||||||
entry.RecordingStartedAt = DateTime.UtcNow;
|
entry.RecordingStartedAt ??= DateTime.UtcNow;
|
||||||
|
|
||||||
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
||||||
entry.State = RecordingState.Failed;
|
entry.State = RecordingState.Failed;
|
||||||
@@ -473,6 +600,61 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the API no longer offering a stream. Before recording starts this just means "not live yet";
|
||||||
|
/// once recording has started it means the broadcast ended (possibly before ValidTo).
|
||||||
|
/// </summary>
|
||||||
|
private void MarkStreamUnavailable(RecordingEntry entry)
|
||||||
|
{
|
||||||
|
if (!entry.RecordingStartedAt.HasValue)
|
||||||
|
{
|
||||||
|
entry.State = RecordingState.WaitingForStream;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
entry.StreamLostAt ??= now;
|
||||||
|
|
||||||
|
if (now - entry.StreamLostAt.Value >= StreamLostGracePeriod)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Stream for '{Title}' has been gone since {LostAt}, broadcast has ended - completing recording",
|
||||||
|
entry.Title,
|
||||||
|
entry.StreamLostAt.Value);
|
||||||
|
StopFfmpeg(entry.Id);
|
||||||
|
CompleteRecording(entry, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = RecordingState.WaitingForStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CompleteRecording(RecordingEntry entry, DateTime now)
|
||||||
|
{
|
||||||
|
entry.State = RecordingState.Completed;
|
||||||
|
entry.RecordingEndedAt = now;
|
||||||
|
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||||
|
{
|
||||||
|
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the DVR window start (unix seconds in the start= query parameter) from a stream URL.
|
||||||
|
/// </summary>
|
||||||
|
private static DateTime? GetDvrWindowStart(string streamUrl)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(streamUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = System.Web.HttpUtility.ParseQueryString(uri.Query)["start"];
|
||||||
|
return long.TryParse(start, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
|
||||||
|
? DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
|
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
|
||||||
{
|
{
|
||||||
var process = new Process
|
var process = new Process
|
||||||
@@ -480,13 +662,16 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
StartInfo = new ProcessStartInfo
|
StartInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = _mediaEncoder.EncoderPath,
|
FileName = _mediaEncoder.EncoderPath,
|
||||||
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
|
// MPEG-TS on stdout: it has no header/index to finalise, so the file is playable while it
|
||||||
|
// grows, and we append it ourselves so restarts continue the same file. -copyts keeps the
|
||||||
|
// broadcast timestamps so an appended restart continues the timeline instead of resetting to 0.
|
||||||
|
Arguments = $"-i \"{inputUrl}\" -c copy -copyts -f mpegts pipe:1",
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
},
|
}
|
||||||
EnableRaisingEvents = true
|
|
||||||
};
|
};
|
||||||
|
|
||||||
process.ErrorDataReceived += (_, args) =>
|
process.ErrorDataReceived += (_, args) =>
|
||||||
@@ -497,23 +682,49 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
process.Exited += (_, _) =>
|
|
||||||
{
|
|
||||||
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
|
|
||||||
_activeProcesses.TryRemove(recordingId, out _);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Start();
|
process.Start();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
_activeProcesses[recordingId] = process;
|
var active = new ActiveRecording(process);
|
||||||
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
|
_activeProcesses[recordingId] = active;
|
||||||
|
active.Pump = PumpToFileAsync(recordingId, active, outputPath);
|
||||||
|
|
||||||
|
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId} -> {OutputPath}: {Args}", process.Id, recordingId, outputPath, process.StartInfo.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends ffmpeg's stdout to the recording file. The recording counts as active until
|
||||||
|
/// ffmpeg has exited and all of its output is on disk, so a restart never interleaves writes.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PumpToFileAsync(string recordingId, ActiveRecording active, string outputPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = new FileStream(outputPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
await using (output.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await active.Process.StandardOutput.BaseStream.CopyToAsync(output).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await active.Process.WaitForExitAsync().ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, active.Process.ExitCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error writing recording output for {RecordingId}", recordingId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Only remove our own entry; a newer process may already be registered
|
||||||
|
_activeProcesses.TryRemove(new KeyValuePair<string, ActiveRecording>(recordingId, active));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StopFfmpeg(string recordingId)
|
private void StopFfmpeg(string recordingId)
|
||||||
{
|
{
|
||||||
if (_activeProcesses.TryRemove(recordingId, out var process))
|
if (_activeProcesses.TryRemove(recordingId, out var active))
|
||||||
{
|
{
|
||||||
|
var process = active.Process;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!process.HasExited)
|
if (!process.HasExited)
|
||||||
@@ -529,6 +740,8 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let the remaining output reach the file before releasing the process
|
||||||
|
active.Pump?.Wait(TimeSpan.FromSeconds(10));
|
||||||
process.Dispose();
|
process.Dispose();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -578,4 +791,19 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A running ffmpeg process and the task copying its output to disk.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ActiveRecording
|
||||||
|
{
|
||||||
|
public ActiveRecording(Process process)
|
||||||
|
{
|
||||||
|
Process = process;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Process Process { get; }
|
||||||
|
|
||||||
|
public Task? Pump { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,10 +132,13 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
|
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
|
||||||
|
|
||||||
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
|
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
|
||||||
// Don't replace a valid token (>5s left) with a new deferred registration
|
// Don't replace a valid token (>5s left) with a new deferred registration.
|
||||||
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
|
// Livestreams with a URN refresh themselves from the API, so never swap them: an unrelated
|
||||||
|
// registration (e.g. from a library refresh) would point the stream at the wrong CDN path.
|
||||||
|
var selfRefreshing = streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn);
|
||||||
|
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5 && !selfRefreshing)
|
||||||
{
|
{
|
||||||
var freshStream = FindFreshestStream();
|
var freshStream = FindFreshestStream(streamInfo.Urn);
|
||||||
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
|
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
@@ -595,8 +598,9 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
|
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="urn">When set, only streams for this URN are considered.</param>
|
||||||
/// <returns>The freshest stream entry, or null if none found.</returns>
|
/// <returns>The freshest stream entry, or null if none found.</returns>
|
||||||
private KeyValuePair<string, StreamInfo>? FindFreshestStream()
|
private KeyValuePair<string, StreamInfo>? FindFreshestStream(string? urn)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
@@ -604,6 +608,11 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
// or have tokens that aren't expired yet
|
// or have tokens that aren't expired yet
|
||||||
var candidates = _streamMappings.Where(kvp =>
|
var candidates = _streamMappings.Where(kvp =>
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrEmpty(urn) && !string.Equals(kvp.Value.Urn, urn, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false; // Different content
|
||||||
|
}
|
||||||
|
|
||||||
if (kvp.Value.NeedsAuthentication)
|
if (kvp.Value.NeedsAuthentication)
|
||||||
{
|
{
|
||||||
return true; // Fresh deferred registration
|
return true; // Fresh deferred registration
|
||||||
|
|||||||
+72
-32
@@ -8,6 +8,78 @@
|
|||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.20260926.22712",
|
||||||
|
"changelog": "Nightly build 1.0.20260926.22712 (aa47de9)",
|
||||||
|
"targetAbi": "12.0.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260926.22712.zip",
|
||||||
|
"checksum": "ec8c65e37b5d3e119020ca494536f839",
|
||||||
|
"timestamp": "2026-09-26T23:51:31Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260926.22711",
|
||||||
|
"changelog": "Nightly build 1.0.20260926.22711 (aa47de9)",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260926.22711.zip",
|
||||||
|
"checksum": "1d70ff1908ebd4311479ce41b231115a",
|
||||||
|
"timestamp": "2026-09-26T23:51:31Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260926.22709",
|
||||||
|
"changelog": "Nightly build 1.0.20260926.22709 (aa47de9)",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260926.22709.zip",
|
||||||
|
"checksum": "f99805ec216c36ca19a3daaae2fb04df",
|
||||||
|
"timestamp": "2026-09-26T23:51:31Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260926.22312",
|
||||||
|
"changelog": "Nightly build 1.0.20260926.22312 (71a65aa)",
|
||||||
|
"targetAbi": "12.0.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260926.22312.zip",
|
||||||
|
"checksum": "6b734b14b852c8cd74eb53ac2d1e2f30",
|
||||||
|
"timestamp": "2026-09-26T15:44:29Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260926.22311",
|
||||||
|
"changelog": "Nightly build 1.0.20260926.22311 (71a65aa)",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260926.22311.zip",
|
||||||
|
"checksum": "3b917fbfd59e09fb817d09df6daefc1c",
|
||||||
|
"timestamp": "2026-09-26T15:44:29Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260926.22309",
|
||||||
|
"changelog": "Nightly build 1.0.20260926.22309 (71a65aa)",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260926.22309.zip",
|
||||||
|
"checksum": "e938109271b29cbeafee445f29204ecc",
|
||||||
|
"timestamp": "2026-09-26T15:44:29Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260925.21912",
|
||||||
|
"changelog": "Nightly build 1.0.20260925.21912 (95ce4ed)",
|
||||||
|
"targetAbi": "12.0.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21912.zip",
|
||||||
|
"checksum": "2256a19f4089e3958821124cc06952f3",
|
||||||
|
"timestamp": "2026-09-25T23:30:39Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260925.21911",
|
||||||
|
"changelog": "Nightly build 1.0.20260925.21911 (95ce4ed)",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21911.zip",
|
||||||
|
"checksum": "488443d6c721244594be8f1cada52a6f",
|
||||||
|
"timestamp": "2026-09-25T23:30:39Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260925.21909",
|
||||||
|
"changelog": "Nightly build 1.0.20260925.21909 (95ce4ed)",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21909.zip",
|
||||||
|
"checksum": "1b1bcaea6e46b3ecf71912fa6d07d985",
|
||||||
|
"timestamp": "2026-09-25T23:30:39Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.20260924.21612",
|
"version": "1.0.20260924.21612",
|
||||||
"changelog": "Nightly build 1.0.20260924.21612 (3c321b4)",
|
"changelog": "Nightly build 1.0.20260924.21612 (3c321b4)",
|
||||||
@@ -55,38 +127,6 @@
|
|||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21209.zip",
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21209.zip",
|
||||||
"checksum": "1f4b3fe079d32a08968d7dfb99dc813f",
|
"checksum": "1f4b3fe079d32a08968d7dfb99dc813f",
|
||||||
"timestamp": "2026-09-24T01:14:34Z"
|
"timestamp": "2026-09-24T01:14:34Z"
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.20260908.201",
|
|
||||||
"changelog": "Nightly build 1.0.20260908.201 (d0d5d06)",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260908.201.zip",
|
|
||||||
"checksum": "e1c9ed45efbbe7c889f2158fe7c1c537",
|
|
||||||
"timestamp": "2026-09-08T17:28:03Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.20260903.198",
|
|
||||||
"changelog": "Nightly build 1.0.20260903.198 (699b543)",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260903.198.zip",
|
|
||||||
"checksum": "fcd415e82c95a6fe1e34db1d187a684f",
|
|
||||||
"timestamp": "2026-09-03T19:09:41Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.20260627.189",
|
|
||||||
"changelog": "Nightly build 1.0.20260627.189 (7c81ef9)",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.189.zip",
|
|
||||||
"checksum": "553ac3f806bce24e5aae3b2c8d17395b",
|
|
||||||
"timestamp": "2026-06-27T13:46:17Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.20260627.185",
|
|
||||||
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.185.zip",
|
|
||||||
"checksum": "577af725ca202bec555bf51905b75913",
|
|
||||||
"timestamp": "2026-06-27T09:30:57Z"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,78 @@
|
|||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.5.0.12",
|
||||||
|
"changelog": "Release 1.5.0",
|
||||||
|
"targetAbi": "12.0.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.5.0/srfplay_1.5.0.12.zip",
|
||||||
|
"checksum": "41d6daa281f5ccbd8f1e9e93ba1f26fd",
|
||||||
|
"timestamp": "2026-09-27T00:10:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.5.0.11",
|
||||||
|
"changelog": "Release 1.5.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.5.0/srfplay_1.5.0.11.zip",
|
||||||
|
"checksum": "199b053e7149d317a71a513325f9284a",
|
||||||
|
"timestamp": "2026-09-27T00:10:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.5.0.9",
|
||||||
|
"changelog": "Release 1.5.0",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.5.0/srfplay_1.5.0.9.zip",
|
||||||
|
"checksum": "1edc084ae6e10d251ae3e669cbe3c942",
|
||||||
|
"timestamp": "2026-09-27T00:10:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.4.0.12",
|
||||||
|
"changelog": "Release 1.4.0",
|
||||||
|
"targetAbi": "12.0.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.4.0/srfplay_1.4.0.12.zip",
|
||||||
|
"checksum": "1f4c1b1203664a7471ea5cad41321b26",
|
||||||
|
"timestamp": "2026-09-26T16:03:07Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.4.0.11",
|
||||||
|
"changelog": "Release 1.4.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.4.0/srfplay_1.4.0.11.zip",
|
||||||
|
"checksum": "3655a8d659672e55d5341ba552b427d4",
|
||||||
|
"timestamp": "2026-09-26T16:03:07Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.4.0.9",
|
||||||
|
"changelog": "Release 1.4.0",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.4.0/srfplay_1.4.0.9.zip",
|
||||||
|
"checksum": "07296b3c8fb89e2ca02b462b8f390a0b",
|
||||||
|
"timestamp": "2026-09-26T16:03:07Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.3.0.12",
|
||||||
|
"changelog": "Release 1.3.0",
|
||||||
|
"targetAbi": "12.0.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.12.zip",
|
||||||
|
"checksum": "117970ad31cb34b6cccaf90262c51b4b",
|
||||||
|
"timestamp": "2026-09-25T23:32:44Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.3.0.11",
|
||||||
|
"changelog": "Release 1.3.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.11.zip",
|
||||||
|
"checksum": "2136a102d793b64561656d0c0b04dd6f",
|
||||||
|
"timestamp": "2026-09-25T23:32:44Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.3.0.9",
|
||||||
|
"changelog": "Release 1.3.0",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.9.zip",
|
||||||
|
"checksum": "47008ac9ba658868bb8fc3f56c8c7e02",
|
||||||
|
"timestamp": "2026-09-25T23:32:44Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.2.1.12",
|
"version": "1.2.1.12",
|
||||||
"changelog": "Release 1.2.1",
|
"changelog": "Release 1.2.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user