test jellyfin plugin, would be nice
This commit is contained in:
+277
@@ -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; }
|
||||
}
|
||||
+368
@@ -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('.');
|
||||
}
|
||||
}
|
||||
+216
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user