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;