test jellyfin plugin, would be nice

This commit is contained in:
2026-08-22 22:00:28 +02:00
parent 7d6405c776
commit 4baee3fafe
23 changed files with 1713 additions and 1 deletions
@@ -0,0 +1,30 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.PersonalRecordings.Configuration;
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the plugin is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the root folder for per-user recordings (e.g. /recordings).
/// Files are moved to {TargetRootPath}/{Username}/.
/// </summary>
public string TargetRootPath { get; set; } = "/recordings";
/// <summary>
/// Gets or sets the poll interval in seconds for completed recordings.
/// </summary>
public int PollIntervalSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether to only log moves without changing files.
/// </summary>
public bool DryRun { get; set; }
}
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Personal Recordings</title>
</head>
<body>
<div id="PersonalRecordingsConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="PersonalRecordingsConfigForm">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
<span>Enable personal recording moves</span>
</label>
<div class="fieldDescription">When enabled, completed recordings are moved into per-user folders.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TargetRootPath">Target root path</label>
<input id="TargetRootPath" name="TargetRootPath" type="text" is="emby-input" />
<div class="fieldDescription">Recordings are moved to {TargetRootPath}/{Username}/ (default: /recordings)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PollIntervalSeconds">Poll interval (seconds)</label>
<input id="PollIntervalSeconds" name="PollIntervalSeconds" type="number" is="emby-input" min="15" />
<div class="fieldDescription">How often to check for completed recordings (minimum 15).</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="DryRun" name="DryRun" type="checkbox" is="emby-checkbox" />
<span>Dry run</span>
</label>
<div class="fieldDescription">Log intended moves without changing files.</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var PersonalRecordingsConfig = {
pluginUniqueId: '7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a'
};
document.querySelector('#PersonalRecordingsConfigPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PersonalRecordingsConfig.pluginUniqueId).then(function (config) {
document.querySelector('#Enabled').checked = config.Enabled;
document.querySelector('#TargetRootPath').value = config.TargetRootPath || '/recordings';
document.querySelector('#PollIntervalSeconds').value = config.PollIntervalSeconds || 60;
document.querySelector('#DryRun').checked = !!config.DryRun;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#PersonalRecordingsConfigForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PersonalRecordingsConfig.pluginUniqueId).then(function (config) {
config.Enabled = document.querySelector('#Enabled').checked;
config.TargetRootPath = document.querySelector('#TargetRootPath').value;
config.PollIntervalSeconds = parseInt(document.querySelector('#PollIntervalSeconds').value, 10) || 60;
config.DryRun = document.querySelector('#DryRun').checked;
ApiClient.updatePluginConfiguration(PersonalRecordingsConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.PersonalRecordings</RootNamespace>
<AssemblyName>Jellyfin.Plugin.PersonalRecordings</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Version>1.0.0.0</Version>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.3">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.3">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.PersonalRecordings.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.PersonalRecordings;
/// <summary>
/// Moves completed Live TV recordings into per-user folders based on who scheduled them.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Application paths.</param>
/// <param name="xmlSerializer">XML serializer.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "Personal Recordings";
/// <inheritdoc />
public override string Description =>
"Tracks which user scheduled a Live TV recording and moves completed files into /recordings/<Username>/.";
/// <inheritdoc />
public override Guid Id => Guid.Parse("7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a");
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.configPage.html",
GetType().Namespace)
}
];
}
}
@@ -0,0 +1,20 @@
using Jellyfin.Plugin.PersonalRecordings.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.PersonalRecordings;
/// <summary>
/// Registers plugin services.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<OwnershipStore>();
serviceCollection.AddHostedService<TimerOwnershipHost>();
serviceCollection.AddHostedService<RecordingMoverHost>();
}
}
@@ -0,0 +1,277 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.PersonalRecordings.Services;
/// <summary>
/// Persists timer/series ownership mappings.
/// </summary>
public class OwnershipStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ILogger<OwnershipStore> _logger;
private readonly string _storePath;
private readonly object _lock = new();
private OwnershipData _data = new();
/// <summary>
/// Initializes a new instance of the <see cref="OwnershipStore"/> class.
/// </summary>
/// <param name="applicationPaths">Application paths.</param>
/// <param name="logger">Logger.</param>
public OwnershipStore(IApplicationPaths applicationPaths, ILogger<OwnershipStore> logger)
{
_logger = logger;
var dir = Path.Combine(applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.PersonalRecordings");
Directory.CreateDirectory(dir);
_storePath = Path.Combine(dir, "ownership.json");
Load();
}
/// <summary>
/// Saves ownership for a timer.
/// </summary>
public void SetTimerOwner(string timerId, Guid userId, string username, string? seriesTimerId, string? name, DateTime? startDate)
{
if (string.IsNullOrWhiteSpace(timerId))
{
return;
}
lock (_lock)
{
_data.Timers[timerId] = new OwnershipRecord
{
UserId = userId,
Username = username,
SeriesTimerId = seriesTimerId,
Name = name,
StartDate = startDate,
Moved = false
};
Save();
}
_logger.LogInformation("Recorded timer {TimerId} owner {Username}", timerId, username);
}
/// <summary>
/// Saves ownership for a series timer.
/// </summary>
public void SetSeriesOwner(string seriesTimerId, Guid userId, string username, string? name)
{
if (string.IsNullOrWhiteSpace(seriesTimerId))
{
return;
}
lock (_lock)
{
_data.SeriesTimers[seriesTimerId] = new OwnershipRecord
{
UserId = userId,
Username = username,
Name = name,
Moved = false
};
Save();
}
_logger.LogInformation("Recorded series timer {SeriesTimerId} owner {Username}", seriesTimerId, username);
}
/// <summary>
/// Tries to get ownership for a timer, including inheritance from a series timer.
/// </summary>
public bool TryGetTimerOwner(string timerId, string? seriesTimerId, out OwnershipRecord record)
{
lock (_lock)
{
if (_data.Timers.TryGetValue(timerId, out record!))
{
return true;
}
if (!string.IsNullOrWhiteSpace(seriesTimerId)
&& _data.SeriesTimers.TryGetValue(seriesTimerId, out record!))
{
return true;
}
record = null!;
return false;
}
}
/// <summary>
/// Tries to get series timer ownership.
/// </summary>
public bool TryGetSeriesOwner(string seriesTimerId, out OwnershipRecord record)
{
lock (_lock)
{
return _data.SeriesTimers.TryGetValue(seriesTimerId, out record!);
}
}
/// <summary>
/// Marks a timer as moved.
/// </summary>
public void MarkMoved(string timerId, string destinationPath)
{
lock (_lock)
{
if (_data.Timers.TryGetValue(timerId, out var record))
{
record.Moved = true;
record.DestinationPath = destinationPath;
Save();
}
}
}
/// <summary>
/// Removes timer ownership.
/// </summary>
public void RemoveTimer(string timerId)
{
lock (_lock)
{
if (_data.Timers.Remove(timerId))
{
Save();
}
}
}
/// <summary>
/// Removes series timer ownership.
/// </summary>
public void RemoveSeriesTimer(string seriesTimerId)
{
lock (_lock)
{
if (_data.SeriesTimers.Remove(seriesTimerId))
{
Save();
}
}
}
/// <summary>
/// Returns a snapshot of timer ownership records that are not yet moved.
/// </summary>
public IReadOnlyList<(string TimerId, OwnershipRecord Record)> GetPendingTimers()
{
lock (_lock)
{
return _data.Timers
.Where(kv => !kv.Value.Moved)
.Select(kv => (kv.Key, kv.Value))
.ToList();
}
}
private void Load()
{
try
{
if (!File.Exists(_storePath))
{
return;
}
var json = File.ReadAllText(_storePath);
var data = JsonSerializer.Deserialize<OwnershipData>(json, JsonOptions);
if (data is not null)
{
_data = data;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load ownership store from {Path}", _storePath);
}
}
private void Save()
{
try
{
var json = JsonSerializer.Serialize(_data, JsonOptions);
File.WriteAllText(_storePath, json);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save ownership store to {Path}", _storePath);
}
}
}
/// <summary>
/// Ownership persistence root.
/// </summary>
public class OwnershipData
{
/// <summary>
/// Gets timer ownership keyed by timer id.
/// </summary>
public Dictionary<string, OwnershipRecord> Timers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets series timer ownership keyed by series timer id.
/// </summary>
public Dictionary<string, OwnershipRecord> SeriesTimers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Ownership for a timer or series timer.
/// </summary>
public class OwnershipRecord
{
/// <summary>
/// Gets or sets the Jellyfin user id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets the Jellyfin username (folder name).
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the parent series timer id when applicable.
/// </summary>
public string? SeriesTimerId { get; set; }
/// <summary>
/// Gets or sets the program/timer name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the scheduled start time (UTC).
/// </summary>
public DateTime? StartDate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the recording was already moved.
/// </summary>
public bool Moved { get; set; }
/// <summary>
/// Gets or sets the destination path after a successful move.
/// </summary>
public string? DestinationPath { get; set; }
}
@@ -0,0 +1,368 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.PersonalRecordings.Services;
/// <summary>
/// Moves completed recordings into per-user folders under the configured target root.
/// </summary>
public class RecordingMoverHost : IHostedService, IDisposable
{
private readonly ILogger<RecordingMoverHost> _logger;
private readonly ILiveTvManager _liveTvManager;
private readonly ILibraryMonitor _libraryMonitor;
private readonly OwnershipStore _ownershipStore;
private CancellationTokenSource? _cts;
private Task? _loop;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingMoverHost"/> class.
/// </summary>
public RecordingMoverHost(
ILogger<RecordingMoverHost> logger,
ILiveTvManager liveTvManager,
ILibraryMonitor libraryMonitor,
OwnershipStore ownershipStore)
{
_logger = logger;
_liveTvManager = liveTvManager;
_libraryMonitor = libraryMonitor;
_ownershipStore = ownershipStore;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_loop = Task.Run(() => RunAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_cts is null)
{
return;
}
await _cts.CancelAsync().ConfigureAwait(false);
if (_loop is not null)
{
try
{
await _loop.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_cts?.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
private async Task RunAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await ProcessAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while processing personal recordings");
}
var delay = GetPollInterval();
try
{
await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
}
}
private static int GetPollInterval()
{
var config = Plugin.Instance?.Configuration;
var seconds = config?.PollIntervalSeconds ?? 60;
return Math.Clamp(seconds, 15, 3600);
}
private async Task ProcessAsync(CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.Enabled)
{
return;
}
var targetRoot = config.TargetRootPath?.Trim();
if (string.IsNullOrWhiteSpace(targetRoot))
{
_logger.LogWarning("Personal Recordings: TargetRootPath is empty");
return;
}
var pending = _ownershipStore.GetPendingTimers();
if (pending.Count == 0)
{
return;
}
QueryResult<TimerInfoDto> timers;
try
{
timers = await _liveTvManager.GetTimers(new TimerQuery(), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to query Live TV timers");
return;
}
var completedById = (timers.Items ?? Array.Empty<TimerInfoDto>())
.Where(t => t.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(t.Id))
.ToDictionary(t => t.Id!, StringComparer.OrdinalIgnoreCase);
IReadOnlyList<BaseItemDto> recordings = Array.Empty<BaseItemDto>();
try
{
var dtoOptions = new DtoOptions(true)
{
Fields = new List<ItemFields> { ItemFields.Path }
};
var result = await _liveTvManager.GetRecordingsAsync(
new RecordingQuery
{
Status = RecordingStatus.Completed,
EnableTotalRecordCount = false
},
dtoOptions).ConfigureAwait(false);
recordings = result.Items ?? Array.Empty<BaseItemDto>();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to query completed recordings");
}
foreach (var (timerId, ownership) in pending)
{
cancellationToken.ThrowIfCancellationRequested();
if (!completedById.TryGetValue(timerId, out var timer))
{
continue;
}
var sourcePath = FindRecordingPath(timer, ownership, recordings);
if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath))
{
_logger.LogDebug(
"Completed timer {TimerId} ({Name}) has no local file yet",
timerId,
timer.Name);
continue;
}
var safeUser = SanitizeFolderName(ownership.Username);
if (string.IsNullOrWhiteSpace(safeUser))
{
_logger.LogWarning("Invalid username for timer {TimerId}", timerId);
continue;
}
var userRoot = Path.GetFullPath(Path.Combine(targetRoot, safeUser));
var fullSource = Path.GetFullPath(sourcePath);
if (fullSource.StartsWith(userRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| string.Equals(Path.GetDirectoryName(fullSource), userRoot, StringComparison.OrdinalIgnoreCase))
{
_ownershipStore.MarkMoved(timerId, fullSource);
continue;
}
Directory.CreateDirectory(userRoot);
var destination = Path.Combine(userRoot, Path.GetFileName(fullSource));
destination = EnsureUniquePath(destination);
if (config.DryRun)
{
_logger.LogInformation(
"DryRun: would move {Source} -> {Destination}",
fullSource,
destination);
continue;
}
try
{
MoveFile(fullSource, destination);
TryMoveSidecar(fullSource, destination);
_ownershipStore.MarkMoved(timerId, destination);
_libraryMonitor.ReportFileSystemChanged(fullSource);
_libraryMonitor.ReportFileSystemChanged(destination);
_logger.LogInformation(
"Moved recording for {Username}: {Source} -> {Destination}",
ownership.Username,
fullSource,
destination);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to move {Source} to {Destination}", fullSource, destination);
}
}
}
private static string? FindRecordingPath(
TimerInfoDto timer,
OwnershipRecord ownership,
IReadOnlyList<BaseItemDto> recordings)
{
var name = timer.Name ?? ownership.Name;
var start = timer.StartDate != default ? timer.StartDate : ownership.StartDate;
BaseItemDto? match = null;
if (!string.IsNullOrWhiteSpace(name))
{
match = recordings.FirstOrDefault(r =>
!string.IsNullOrWhiteSpace(r.Path)
&& string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)
&& MatchesStartWindow(r, start));
}
match ??= recordings.FirstOrDefault(r =>
!string.IsNullOrWhiteSpace(r.Path)
&& !string.IsNullOrWhiteSpace(name)
&& (r.Name?.Contains(name, StringComparison.OrdinalIgnoreCase) ?? false));
return match?.Path;
}
private static bool MatchesStartWindow(BaseItemDto item, DateTime? start)
{
if (!start.HasValue)
{
return true;
}
if (item.PremiereDate.HasValue
&& Math.Abs((item.PremiereDate.Value - start.Value).TotalMinutes) < 30)
{
return true;
}
if (item.DateCreated is DateTime created)
{
return Math.Abs((created - start.Value).TotalHours) < 12;
}
return true;
}
private static void MoveFile(string source, string destination)
{
try
{
File.Move(source, destination);
}
catch (IOException)
{
File.Copy(source, destination, overwrite: false);
File.Delete(source);
}
}
private static void TryMoveSidecar(string sourceMediaPath, string destinationMediaPath)
{
var sourceDir = Path.GetDirectoryName(sourceMediaPath);
var destDir = Path.GetDirectoryName(destinationMediaPath);
if (sourceDir is null || destDir is null)
{
return;
}
var baseName = Path.GetFileNameWithoutExtension(sourceMediaPath);
foreach (var sidecar in Directory.EnumerateFiles(sourceDir, baseName + ".*"))
{
if (string.Equals(sidecar, sourceMediaPath, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var dest = Path.Combine(destDir, Path.GetFileName(sidecar));
dest = EnsureUniquePath(dest);
try
{
MoveFile(sidecar, dest);
}
catch
{
// Best-effort for nfo/jpg/etc.
}
}
}
private static string EnsureUniquePath(string path)
{
if (!File.Exists(path))
{
return path;
}
var dir = Path.GetDirectoryName(path) ?? ".";
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 1; i < 1000; i++)
{
var candidate = Path.Combine(dir, $"{name} ({i}){ext}");
if (!File.Exists(candidate))
{
return candidate;
}
}
return Path.Combine(dir, $"{name}-{Guid.NewGuid():N}{ext}");
}
private static string SanitizeFolderName(string username)
{
var invalid = Path.GetInvalidFileNameChars();
var cleaned = new string(username.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
return cleaned.Trim().TrimEnd('.');
}
}
@@ -0,0 +1,216 @@
using System;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.PersonalRecordings.Services;
/// <summary>
/// Captures which Jellyfin user created Live TV timers / series timers.
/// </summary>
public class TimerOwnershipHost : IHostedService
{
private const string UserIdClaimType = "Jellyfin-UserId";
private readonly ILogger<TimerOwnershipHost> _logger;
private readonly ILiveTvManager _liveTvManager;
private readonly IUserManager _userManager;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly OwnershipStore _ownershipStore;
/// <summary>
/// Initializes a new instance of the <see cref="TimerOwnershipHost"/> class.
/// </summary>
public TimerOwnershipHost(
ILogger<TimerOwnershipHost> logger,
ILiveTvManager liveTvManager,
IUserManager userManager,
IHttpContextAccessor httpContextAccessor,
OwnershipStore ownershipStore)
{
_logger = logger;
_liveTvManager = liveTvManager;
_userManager = userManager;
_httpContextAccessor = httpContextAccessor;
_ownershipStore = ownershipStore;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_liveTvManager.TimerCreated += OnTimerCreated;
_liveTvManager.SeriesTimerCreated += OnSeriesTimerCreated;
_liveTvManager.TimerCancelled += OnTimerCancelled;
_liveTvManager.SeriesTimerCancelled += OnSeriesTimerCancelled;
_logger.LogInformation("Personal Recordings: listening for Live TV timer events");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_liveTvManager.TimerCreated -= OnTimerCreated;
_liveTvManager.SeriesTimerCreated -= OnSeriesTimerCreated;
_liveTvManager.TimerCancelled -= OnTimerCancelled;
_liveTvManager.SeriesTimerCancelled -= OnSeriesTimerCancelled;
return Task.CompletedTask;
}
private async void OnTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
{
try
{
var timerId = e.Argument.Id;
if (string.IsNullOrWhiteSpace(timerId))
{
return;
}
TimerInfoDto? timer = null;
try
{
timer = await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not load timer {TimerId} after create", timerId);
}
var seriesTimerId = timer?.SeriesTimerId;
if (TryResolveUser(out var userId, out var username))
{
_ownershipStore.SetTimerOwner(
timerId,
userId,
username,
seriesTimerId,
timer?.Name,
timer?.StartDate);
return;
}
if (!string.IsNullOrWhiteSpace(seriesTimerId)
&& _ownershipStore.TryGetSeriesOwner(seriesTimerId, out var seriesOwner))
{
_ownershipStore.SetTimerOwner(
timerId,
seriesOwner.UserId,
seriesOwner.Username,
seriesTimerId,
timer?.Name ?? seriesOwner.Name,
timer?.StartDate);
return;
}
_logger.LogWarning(
"Timer {TimerId} created without resolvable user context; recording will not be moved",
timerId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling TimerCreated");
}
}
private async void OnSeriesTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
{
try
{
var seriesTimerId = e.Argument.Id;
if (string.IsNullOrWhiteSpace(seriesTimerId))
{
return;
}
SeriesTimerInfoDto? seriesTimer = null;
try
{
seriesTimer = await _liveTvManager.GetSeriesTimer(seriesTimerId, CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not load series timer {SeriesTimerId} after create", seriesTimerId);
}
if (!TryResolveUser(out var userId, out var username))
{
_logger.LogWarning(
"Series timer {SeriesTimerId} created without resolvable user context",
seriesTimerId);
return;
}
_ownershipStore.SetSeriesOwner(seriesTimerId, userId, username, seriesTimer?.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling SeriesTimerCreated");
}
}
private void OnTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e)
{
try
{
if (!string.IsNullOrWhiteSpace(e.Argument.Id))
{
_ownershipStore.RemoveTimer(e.Argument.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling TimerCancelled");
}
}
private void OnSeriesTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e)
{
try
{
if (!string.IsNullOrWhiteSpace(e.Argument.Id))
{
_ownershipStore.RemoveSeriesTimer(e.Argument.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling SeriesTimerCancelled");
}
}
private bool TryResolveUser(out Guid userId, out string username)
{
userId = default;
username = string.Empty;
var principal = _httpContextAccessor.HttpContext?.User;
if (principal is null)
{
return false;
}
var claim = principal.FindFirst(UserIdClaimType)?.Value
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(claim) || !Guid.TryParse(claim, out userId) || userId == Guid.Empty)
{
return false;
}
var user = _userManager.GetUserById(userId);
if (user is null || string.IsNullOrWhiteSpace(user.Username))
{
return false;
}
username = user.Username;
return true;
}
}