Record into a Jellyfin library by default; configurable recording location
When no custom directory is set, recordings go to an "SRF Recordings" folder in the chosen library, or the first TV Shows library, so they show up without adding a library by hand. The settings page gets a library picker, a check that the directory is writable, and help for sandboxed systemd services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -165,10 +165,18 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
public bool GenerateTitleCards { get; set; }
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
/// 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
|
||||
|
||||
@@ -77,10 +77,31 @@
|
||||
</div>
|
||||
<br />
|
||||
<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">
|
||||
<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" />
|
||||
<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 class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="DisplayTimeZone">Display Time Zone</label>
|
||||
@@ -205,6 +226,7 @@
|
||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||
SRFPlayRecordingOutput.loadLibraries(config.RecordingLibraryId || '');
|
||||
document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || '';
|
||||
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
||||
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
||||
@@ -236,7 +258,8 @@
|
||||
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
||||
config.ProxyPassword = document.querySelector('#ProxyPassword').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.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
||||
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
||||
@@ -246,6 +269,7 @@
|
||||
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
|
||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
SRFPlayRecordingOutput.check();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,6 +277,58 @@
|
||||
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 = {
|
||||
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.')) {
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Common.Api;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -238,4 +239,16 @@ public class RecordingController : ControllerBase
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +75,10 @@ public interface IRecordingService
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
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.Utilities;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -25,6 +26,9 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
@@ -41,6 +45,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ConcurrentDictionary<string, ActiveRecording> _activeProcesses = new();
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
||||
@@ -59,6 +64,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
||||
/// <param name="appHost">The application host.</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(
|
||||
ILogger<RecordingService> logger,
|
||||
ISRFApiClientFactory apiClientFactory,
|
||||
@@ -66,7 +72,8 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
IStreamUrlResolver streamUrlResolver,
|
||||
IMediaCompositionFetcher mediaCompositionFetcher,
|
||||
IServerApplicationHost appHost,
|
||||
IMediaEncoder mediaEncoder)
|
||||
IMediaEncoder mediaEncoder,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_apiClientFactory = apiClientFactory;
|
||||
@@ -75,6 +82,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
_mediaCompositionFetcher = mediaCompositionFetcher;
|
||||
_appHost = appHost;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
private string GetDataFilePath()
|
||||
@@ -86,17 +94,84 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
|
||||
private string GetRecordingOutputPath()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var path = config?.RecordingOutputPath;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
|
||||
}
|
||||
|
||||
var path = ResolveOutputDirectory().Path;
|
||||
Directory.CreateDirectory(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()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
|
||||
Reference in New Issue
Block a user