From aa47de99123bceb8b7e4653468abecc49f4ec01d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 26 Sep 2026 19:35:55 -0400 Subject: [PATCH] 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 --- .../Api/Models/RecordingOutputInfo.cs | 39 ++++++++ .../Configuration/PluginConfiguration.cs | 10 +- .../Configuration/configPage.html | 82 ++++++++++++++++- .../Controllers/RecordingController.cs | 13 +++ .../Services/Interfaces/IRecordingService.cs | 6 ++ .../Services/RecordingService.cs | 91 +++++++++++++++++-- 6 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 Jellyfin.Plugin.SRFPlay/Api/Models/RecordingOutputInfo.cs diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingOutputInfo.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingOutputInfo.cs new file mode 100644 index 0000000..87c9922 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingOutputInfo.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.SRFPlay.Api.Models; + +/// +/// Where recordings will be written and whether the server can write there. +/// +public class RecordingOutputInfo +{ + /// + /// Gets or sets the resolved output directory. + /// + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + /// + /// Gets or sets how the directory was chosen: "custom", "library", "tvLibrary" or "fallback". + /// + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the library the directory belongs to, if any. + /// + [JsonPropertyName("libraryName")] + public string? LibraryName { get; set; } + + /// + /// Gets or sets a value indicating whether the directory could be created and written to. + /// + [JsonPropertyName("writable")] + public bool Writable { get; set; } + + /// + /// Gets or sets the error from the write test, if it failed. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } +} diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs index 65b749c..8aab57d 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs @@ -165,10 +165,18 @@ public class PluginConfiguration : BasePluginConfiguration public bool GenerateTitleCards { get; set; } /// - /// Gets or sets the output directory for sport livestream recordings. + /// Gets or sets a custom output directory for livestream recordings. Takes precedence over + /// . Empty means record into a library. /// public string RecordingOutputPath { get; set; } = string.Empty; + /// + /// 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. + /// + public string RecordingLibraryId { get; set; } = string.Empty; + /// /// 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 diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html index 1e18ab3..8cf3bba 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html +++ b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html @@ -77,10 +77,31 @@

Recording Settings

+
+ + +
Recordings are saved to an "SRF Recordings" folder inside this library, so they appear there after the next library scan (requires ffmpeg).
+
- + -
Directory where sport livestream recordings will be saved (requires ffmpeg)
+
Overrides the library above. Add this directory as a library yourself if you want recordings to appear in Jellyfin.
+
+
+ +
+
+ Recording directory not writable? +

The Jellyfin user needs write access to the directory and execute (traverse) access to every parent directory. Check with namei -l /path/to/recordings.

+

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 sudo systemctl edit jellyfin.service and add:

+
[Service]
+ReadWritePaths=/path/to/recordings
+

then run sudo systemctl daemon-reload && sudo systemctl restart jellyfin. In Docker, make sure the directory is mounted read-write.

+
@@ -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.')) { diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs index eb72b69..a11e1d7 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/RecordingController.cs @@ -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(); } + + /// + /// Shows where recordings will be written and whether the server can write there. + /// + /// The resolved output directory and write-test result. + [HttpGet("OutputInfo")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetOutputInfo() + { + return Ok(_recordingService.GetOutputInfo()); + } } diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs index fb21d03..56a1709 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IRecordingService.cs @@ -75,4 +75,10 @@ public interface IRecordingService /// The cancellation token. /// A task representing the async operation. Task ProcessRecordingsAsync(CancellationToken cancellationToken = default); + + /// + /// Resolves where recordings will be written and checks the server can write there. + /// + /// The output directory and the result of a write test. + RecordingOutputInfo GetOutputInfo(); } diff --git a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs index 992a7e9..7187129 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs @@ -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; ///
public class RecordingService : IRecordingService, IDisposable { + /// Folder created inside a library for recordings; shows up there as one series. + private const string RecordingsFolderName = "SRF Recordings"; + /// How long the stream may be missing from the API mid-recording before we treat the broadcast as over. 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 _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 /// The media composition fetcher. /// The application host. /// The media encoder for ffmpeg path. + /// The library manager, used to record into a library. public RecordingService( ILogger 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; } + /// + /// 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. + /// + 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" + }; + } + + /// + 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;