< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Recordings.RecordingsManager
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
Line coverage
16%
Covered lines: 28
Uncovered lines: 141
Coverable lines: 169
Total lines: 837
Line coverage: 16.5%
Branch coverage
6%
Covered branches: 6
Total branches: 100
Branch coverage: 6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_DefaultRecordingPath()50%22100%
GetActiveRecordingPath(...)0%620%
GetActiveRecordingInfo(...)25%72.751225%
CancelRecording(...)0%620%
Dispose()50%4.37471.42%
GetRecordingPath(...)0%1332360%
DeleteFileIfEmpty(...)0%2040%
TriggerRefresh(...)0%620%
GetAffectedBaseItem(...)0%210140%
DeleteLibraryItemsForTimers(...)0%2040%
DeleteLibraryItemForTimer(...)0%2040%
EnsureFileUnique(...)0%2040%
GetRecorder(...)0%110100%

File(s)

/srv/git/jellyfin/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Generic;
 4using System.Diagnostics;
 5using System.Globalization;
 6using System.IO;
 7using System.Linq;
 8using System.Net.Http;
 9using System.Threading;
 10using System.Threading.Tasks;
 11using AsyncKeyedLock;
 12using Jellyfin.Data.Enums;
 13using Jellyfin.LiveTv.Configuration;
 14using Jellyfin.LiveTv.IO;
 15using Jellyfin.LiveTv.Timers;
 16using MediaBrowser.Common.Configuration;
 17using MediaBrowser.Controller.Configuration;
 18using MediaBrowser.Controller.Dto;
 19using MediaBrowser.Controller.Entities;
 20using MediaBrowser.Controller.Entities.TV;
 21using MediaBrowser.Controller.Library;
 22using MediaBrowser.Controller.LiveTv;
 23using MediaBrowser.Controller.MediaEncoding;
 24using MediaBrowser.Controller.Providers;
 25using MediaBrowser.Model.Configuration;
 26using MediaBrowser.Model.Dto;
 27using MediaBrowser.Model.Entities;
 28using MediaBrowser.Model.IO;
 29using MediaBrowser.Model.LiveTv;
 30using MediaBrowser.Model.MediaInfo;
 31using MediaBrowser.Model.Providers;
 32using Microsoft.Extensions.Logging;
 33
 34namespace Jellyfin.LiveTv.Recordings;
 35
 36/// <inheritdoc cref="IRecordingsManager" />
 37public sealed class RecordingsManager : IRecordingsManager, IDisposable
 38{
 39    private readonly ILogger<RecordingsManager> _logger;
 40    private readonly IServerConfigurationManager _config;
 41    private readonly IHttpClientFactory _httpClientFactory;
 42    private readonly IFileSystem _fileSystem;
 43    private readonly ILibraryManager _libraryManager;
 44    private readonly ILibraryMonitor _libraryMonitor;
 45    private readonly IProviderManager _providerManager;
 46    private readonly IMediaEncoder _mediaEncoder;
 47    private readonly IMediaSourceManager _mediaSourceManager;
 48    private readonly IStreamHelper _streamHelper;
 49    private readonly TimerManager _timerManager;
 50    private readonly SeriesTimerManager _seriesTimerManager;
 51    private readonly RecordingsMetadataManager _recordingsMetadataManager;
 52
 2253    private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new(StringComparer.OrdinalIgn
 2254    private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new();
 55    private bool _disposed;
 56
 57    /// <summary>
 58    /// Initializes a new instance of the <see cref="RecordingsManager"/> class.
 59    /// </summary>
 60    /// <param name="logger">The <see cref="ILogger"/>.</param>
 61    /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
 62    /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
 63    /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 64    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 65    /// <param name="libraryMonitor">The <see cref="ILibraryMonitor"/>.</param>
 66    /// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
 67    /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
 68    /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
 69    /// <param name="streamHelper">The <see cref="IStreamHelper"/>.</param>
 70    /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
 71    /// <param name="seriesTimerManager">The <see cref="SeriesTimerManager"/>.</param>
 72    /// <param name="recordingsMetadataManager">The <see cref="RecordingsMetadataManager"/>.</param>
 73    public RecordingsManager(
 74        ILogger<RecordingsManager> logger,
 75        IServerConfigurationManager config,
 76        IHttpClientFactory httpClientFactory,
 77        IFileSystem fileSystem,
 78        ILibraryManager libraryManager,
 79        ILibraryMonitor libraryMonitor,
 80        IProviderManager providerManager,
 81        IMediaEncoder mediaEncoder,
 82        IMediaSourceManager mediaSourceManager,
 83        IStreamHelper streamHelper,
 84        TimerManager timerManager,
 85        SeriesTimerManager seriesTimerManager,
 86        RecordingsMetadataManager recordingsMetadataManager)
 87    {
 2288        _logger = logger;
 2289        _config = config;
 2290        _httpClientFactory = httpClientFactory;
 2291        _fileSystem = fileSystem;
 2292        _libraryManager = libraryManager;
 2293        _libraryMonitor = libraryMonitor;
 2294        _providerManager = providerManager;
 2295        _mediaEncoder = mediaEncoder;
 2296        _mediaSourceManager = mediaSourceManager;
 2297        _streamHelper = streamHelper;
 2298        _timerManager = timerManager;
 2299        _seriesTimerManager = seriesTimerManager;
 22100        _recordingsMetadataManager = recordingsMetadataManager;
 101
 22102        _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
 22103    }
 104
 105    private string DefaultRecordingPath
 106    {
 107        get
 108        {
 24109            var path = _config.GetLiveTvConfiguration().RecordingPath;
 110
 24111            return string.IsNullOrWhiteSpace(path)
 24112                ? Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv", "recordings")
 24113                : path;
 114        }
 115    }
 116
 117    /// <inheritdoc />
 118    public string? GetActiveRecordingPath(string id)
 0119        => _activeRecordings.GetValueOrDefault(id)?.Path;
 120
 121    /// <inheritdoc />
 122    public ActiveRecordingInfo? GetActiveRecordingInfo(string path)
 123    {
 14124        if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty)
 125        {
 14126            return null;
 127        }
 128
 0129        foreach (var (_, recordingInfo) in _activeRecordings)
 130        {
 0131            if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal)
 0132                && !recordingInfo.CancellationTokenSource.IsCancellationRequested)
 133            {
 0134                return recordingInfo.Timer.Status == RecordingStatus.InProgress ? recordingInfo : null;
 135            }
 136        }
 137
 0138        return null;
 0139    }
 140
 141    /// <inheritdoc />
 142    public IEnumerable<VirtualFolderInfo> GetRecordingFolders()
 143    {
 144        if (Directory.Exists(DefaultRecordingPath))
 145        {
 146            yield return new VirtualFolderInfo
 147            {
 148                Locations = [DefaultRecordingPath],
 149                Name = "Recordings"
 150            };
 151        }
 152
 153        var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath;
 154        if (!string.IsNullOrWhiteSpace(customPath)
 155            && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
 156            && Directory.Exists(customPath))
 157        {
 158            yield return new VirtualFolderInfo
 159            {
 160                Locations = [customPath],
 161                Name = "Recorded Movies",
 162                CollectionType = CollectionTypeOptions.movies
 163            };
 164        }
 165
 166        customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath;
 167        if (!string.IsNullOrWhiteSpace(customPath)
 168            && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
 169            && Directory.Exists(customPath))
 170        {
 171            yield return new VirtualFolderInfo
 172            {
 173                Locations = [customPath],
 174                Name = "Recorded Shows",
 175                CollectionType = CollectionTypeOptions.tvshows
 176            };
 177        }
 178    }
 179
 180    /// <inheritdoc />
 181    public async Task CreateRecordingFolders()
 182    {
 183        try
 184        {
 185            var recordingFolders = GetRecordingFolders().ToArray();
 186            var virtualFolders = _libraryManager.GetVirtualFolders();
 187
 188            var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList();
 189
 190            var pathsAdded = new List<string>();
 191
 192            foreach (var recordingFolder in recordingFolders)
 193            {
 194                var pathsToCreate = recordingFolder.Locations
 195                    .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i)))
 196                    .ToList();
 197
 198                if (pathsToCreate.Count == 0)
 199                {
 200                    continue;
 201                }
 202
 203                var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray();
 204                var libraryOptions = new LibraryOptions
 205                {
 206                    PathInfos = mediaPathInfos
 207                };
 208
 209                try
 210                {
 211                    await _libraryManager
 212                        .AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true)
 213                        .ConfigureAwait(false);
 214                }
 215                catch (Exception ex)
 216                {
 217                    _logger.LogError(ex, "Error creating virtual folder");
 218                }
 219
 220                pathsAdded.AddRange(pathsToCreate);
 221            }
 222
 223            var config = _config.GetLiveTvConfiguration();
 224
 225            var pathsToRemove = config.MediaLocationsCreated
 226                .Except(recordingFolders.SelectMany(i => i.Locations))
 227                .ToList();
 228
 229            if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
 230            {
 231                pathsAdded.InsertRange(0, config.MediaLocationsCreated);
 232                config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCas
 233                _config.SaveConfiguration("livetv", config);
 234            }
 235
 236            foreach (var path in pathsToRemove)
 237            {
 238                await RemovePathFromLibraryAsync(path).ConfigureAwait(false);
 239            }
 240        }
 241        catch (Exception ex)
 242        {
 243            _logger.LogError(ex, "Error creating recording folders");
 244        }
 245    }
 246
 247    private async Task RemovePathFromLibraryAsync(string path)
 248    {
 249        _logger.LogDebug("Removing path from library: {0}", path);
 250
 251        var requiresRefresh = false;
 252        var virtualFolders = _libraryManager.GetVirtualFolders();
 253
 254        foreach (var virtualFolder in virtualFolders)
 255        {
 256            if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase))
 257            {
 258                continue;
 259            }
 260
 261            if (virtualFolder.Locations.Length == 1)
 262            {
 263                try
 264                {
 265                    await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false);
 266                }
 267                catch (Exception ex)
 268                {
 269                    _logger.LogError(ex, "Error removing virtual folder");
 270                }
 271            }
 272            else
 273            {
 274                try
 275                {
 276                    _libraryManager.RemoveMediaPath(virtualFolder.Name, path);
 277                    requiresRefresh = true;
 278                }
 279                catch (Exception ex)
 280                {
 281                    _logger.LogError(ex, "Error removing media path");
 282                }
 283            }
 284        }
 285
 286        if (requiresRefresh)
 287        {
 288            await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(fa
 289        }
 290    }
 291
 292    /// <inheritdoc />
 293    public void CancelRecording(string timerId, TimerInfo? timer)
 294    {
 0295        if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
 296        {
 0297            activeRecordingInfo.Timer = timer;
 0298            activeRecordingInfo.CancellationTokenSource.Cancel();
 299        }
 0300    }
 301
 302    /// <inheritdoc />
 303    public async Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate)
 304    {
 305        ArgumentNullException.ThrowIfNull(recordingInfo);
 306        ArgumentNullException.ThrowIfNull(channel);
 307
 308        var timer = recordingInfo.Timer;
 309        var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
 310        var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath);
 311
 312        string? liveStreamId = null;
 313        RecordingStatus recordingStatus;
 314        try
 315        {
 316            var allMediaSources = await _mediaSourceManager
 317                .GetPlaybackMediaSources(channel, null, true, false, CancellationToken.None).ConfigureAwait(false);
 318
 319            var mediaStreamInfo = allMediaSources[0];
 320            IDirectStreamProvider? directStreamProvider = null;
 321            if (mediaStreamInfo.RequiresOpening)
 322            {
 323                var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal(
 324                    new LiveStreamRequest
 325                    {
 326                        ItemId = channel.Id,
 327                        OpenToken = mediaStreamInfo.OpenToken
 328                    },
 329                    CancellationToken.None).ConfigureAwait(false);
 330
 331                mediaStreamInfo = liveStreamResponse.Item1.MediaSource;
 332                liveStreamId = mediaStreamInfo.LiveStreamId;
 333                directStreamProvider = liveStreamResponse.Item2;
 334            }
 335
 336            using var recorder = GetRecorder(mediaStreamInfo);
 337
 338            recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath);
 339            recordingPath = EnsureFileUnique(recordingPath, timer.Id);
 340
 341            _libraryMonitor.ReportFileSystemChangeBeginning(recordingPath);
 342
 343            var duration = recordingEndDate - DateTime.UtcNow;
 344
 345            _logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes);
 346            _logger.LogInformation("Writing file to: {Path}", recordingPath);
 347
 348            async void OnStarted()
 349            {
 350                recordingInfo.Path = recordingPath;
 351                _activeRecordings.TryAdd(timer.Id, recordingInfo);
 352
 353                timer.Status = RecordingStatus.InProgress;
 354                _timerManager.AddOrUpdate(timer, false);
 355
 356                await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(
 357                await CreateRecordingFolders().ConfigureAwait(false);
 358
 359                TriggerRefresh(recordingPath);
 360                await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
 361            }
 362
 363            await recorder.Record(
 364                directStreamProvider,
 365                mediaStreamInfo,
 366                recordingPath,
 367                duration,
 368                OnStarted,
 369                recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
 370
 371            recordingStatus = RecordingStatus.Completed;
 372            _logger.LogInformation("Recording completed: {RecordPath}", recordingPath);
 373        }
 374        catch (OperationCanceledException)
 375        {
 376            _logger.LogInformation("Recording stopped: {RecordPath}", recordingPath);
 377            recordingStatus = RecordingStatus.Completed;
 378        }
 379        catch (Exception ex)
 380        {
 381            _logger.LogError(ex, "Error recording to {RecordPath}", recordingPath);
 382            recordingStatus = RecordingStatus.Error;
 383        }
 384
 385        if (!string.IsNullOrWhiteSpace(liveStreamId))
 386        {
 387            try
 388            {
 389                await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
 390            }
 391            catch (Exception ex)
 392            {
 393                _logger.LogError(ex, "Error closing live stream");
 394            }
 395        }
 396
 397        DeleteFileIfEmpty(recordingPath);
 398        TriggerRefresh(recordingPath);
 399        _libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false);
 400        _activeRecordings.TryRemove(timer.Id, out _);
 401
 402        if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
 403        {
 404            const int RetryIntervalSeconds = 60;
 405            _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
 406
 407            timer.Status = RecordingStatus.New;
 408            timer.PrePaddingSeconds = 0;
 409            timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
 410            timer.RetryCount++;
 411            _timerManager.AddOrUpdate(timer);
 412        }
 413        else if (File.Exists(recordingPath))
 414        {
 415            timer.RecordingPath = recordingPath;
 416            timer.Status = RecordingStatus.Completed;
 417            _timerManager.AddOrUpdate(timer, false);
 418            await PostProcessRecording(recordingPath).ConfigureAwait(false);
 419        }
 420        else
 421        {
 422            _timerManager.Delete(timer);
 423        }
 424    }
 425
 426    /// <inheritdoc />
 427    public void Dispose()
 428    {
 22429        if (_disposed)
 430        {
 0431            return;
 432        }
 433
 22434        _recordingDeleteSemaphore.Dispose();
 435
 44436        foreach (var pair in _activeRecordings.ToList())
 437        {
 0438            pair.Value.CancellationTokenSource.Cancel();
 439        }
 440
 22441        _disposed = true;
 22442    }
 443
 444    private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
 445    {
 446        if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
 447        {
 448            await CreateRecordingFolders().ConfigureAwait(false);
 449        }
 450    }
 451
 452    private async Task<RemoteSearchResult?> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
 453    {
 454        if (!timer.IsSeries || timer.SeriesProviderIds.Count == 0)
 455        {
 456            return null;
 457        }
 458
 459        var query = new RemoteSearchQuery<SeriesInfo>
 460        {
 461            SearchInfo = new SeriesInfo
 462            {
 463                ProviderIds = timer.SeriesProviderIds,
 464                Name = timer.Name,
 465                MetadataCountryCode = _config.Configuration.MetadataCountryCode,
 466                MetadataLanguage = _config.Configuration.PreferredMetadataLanguage
 467            }
 468        };
 469
 470        var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, cancellationToken).Config
 471
 472        return results.FirstOrDefault();
 473    }
 474
 475    private string GetRecordingPath(TimerInfo timer, RemoteSearchResult? metadata, out string? seriesPath)
 476    {
 0477        var recordingPath = DefaultRecordingPath;
 0478        var config = _config.GetLiveTvConfiguration();
 0479        seriesPath = null;
 480
 0481        if (timer.IsProgramSeries)
 482        {
 0483            var customRecordingPath = config.SeriesRecordingPath;
 0484            var allowSubfolder = true;
 0485            if (!string.IsNullOrWhiteSpace(customRecordingPath))
 486            {
 0487                allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
 0488                recordingPath = customRecordingPath;
 489            }
 490
 0491            if (allowSubfolder && config.EnableRecordingSubfolders)
 492            {
 0493                recordingPath = Path.Combine(recordingPath, "Series");
 494            }
 495
 496            // trim trailing period from the folder name
 0497            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
 498
 0499            if (metadata is not null && metadata.ProductionYear.HasValue)
 500            {
 0501                folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
 502            }
 503
 504            // Can't use the year here in the folder name because it is the year of the episode, not the series.
 0505            recordingPath = Path.Combine(recordingPath, folderName);
 506
 0507            seriesPath = recordingPath;
 508
 0509            if (timer.SeasonNumber.HasValue)
 510            {
 0511                folderName = string.Format(
 0512                    CultureInfo.InvariantCulture,
 0513                    "Season {0}",
 0514                    timer.SeasonNumber.Value);
 0515                recordingPath = Path.Combine(recordingPath, folderName);
 516            }
 517        }
 0518        else if (timer.IsMovie)
 519        {
 0520            var customRecordingPath = config.MovieRecordingPath;
 0521            var allowSubfolder = true;
 0522            if (!string.IsNullOrWhiteSpace(customRecordingPath))
 523            {
 0524                allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
 0525                recordingPath = customRecordingPath;
 526            }
 527
 0528            if (allowSubfolder && config.EnableRecordingSubfolders)
 529            {
 0530                recordingPath = Path.Combine(recordingPath, "Movies");
 531            }
 532
 0533            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
 0534            if (timer.ProductionYear.HasValue)
 535            {
 0536                folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
 537            }
 538
 539            // trim trailing period from the folder name
 0540            folderName = folderName.TrimEnd('.').Trim();
 541
 0542            recordingPath = Path.Combine(recordingPath, folderName);
 543        }
 0544        else if (timer.IsKids)
 545        {
 0546            if (config.EnableRecordingSubfolders)
 547            {
 0548                recordingPath = Path.Combine(recordingPath, "Kids");
 549            }
 550
 0551            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
 0552            if (timer.ProductionYear.HasValue)
 553            {
 0554                folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
 555            }
 556
 557            // trim trailing period from the folder name
 0558            folderName = folderName.TrimEnd('.').Trim();
 559
 0560            recordingPath = Path.Combine(recordingPath, folderName);
 561        }
 0562        else if (timer.IsSports)
 563        {
 0564            if (config.EnableRecordingSubfolders)
 565            {
 0566                recordingPath = Path.Combine(recordingPath, "Sports");
 567            }
 568
 0569            recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
 570        }
 571        else
 572        {
 0573            if (config.EnableRecordingSubfolders)
 574            {
 0575                recordingPath = Path.Combine(recordingPath, "Other");
 576            }
 577
 0578            recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
 579        }
 580
 0581        var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
 582
 0583        return Path.Combine(recordingPath, recordingFileName);
 584    }
 585
 586    private void DeleteFileIfEmpty(string path)
 587    {
 0588        var file = _fileSystem.GetFileInfo(path);
 589
 0590        if (file.Exists && file.Length == 0)
 591        {
 592            try
 593            {
 0594                _fileSystem.DeleteFile(path);
 0595            }
 0596            catch (Exception ex)
 597            {
 0598                _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path);
 0599            }
 600        }
 0601    }
 602
 603    private void TriggerRefresh(string path)
 604    {
 0605        _logger.LogInformation("Triggering refresh on {Path}", path);
 606
 0607        var item = GetAffectedBaseItem(Path.GetDirectoryName(path));
 0608        if (item is null)
 609        {
 0610            return;
 611        }
 612
 0613        _logger.LogInformation("Refreshing recording parent {Path}", item.Path);
 0614        _providerManager.QueueRefresh(
 0615            item.Id,
 0616            new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 0617            {
 0618                RefreshPaths =
 0619                [
 0620                    path,
 0621                    Path.GetDirectoryName(path),
 0622                    Path.GetDirectoryName(Path.GetDirectoryName(path))
 0623                ]
 0624            },
 0625            RefreshPriority.High);
 0626    }
 627
 628    private BaseItem? GetAffectedBaseItem(string? path)
 629    {
 0630        BaseItem? item = null;
 0631        var parentPath = Path.GetDirectoryName(path);
 0632        while (item is null && !string.IsNullOrEmpty(path))
 633        {
 0634            item = _libraryManager.FindByPath(path, null);
 0635            path = Path.GetDirectoryName(path);
 636        }
 637
 0638        if (item is not null
 0639            && item.GetType() == typeof(Folder)
 0640            && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase))
 641        {
 0642            var parentItem = item.GetParent();
 0643            if (parentItem is not null && parentItem is not AggregateFolder)
 644            {
 0645                item = parentItem;
 646            }
 647        }
 648
 0649        return item;
 650    }
 651
 652    private async Task EnforceKeepUpTo(TimerInfo timer, string? seriesPath)
 653    {
 654        if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)
 655            || string.IsNullOrWhiteSpace(seriesPath))
 656        {
 657            return;
 658        }
 659
 660        var seriesTimerId = timer.SeriesTimerId;
 661        var seriesTimer = _seriesTimerManager.GetAll()
 662            .FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase));
 663
 664        if (seriesTimer is null || seriesTimer.KeepUpTo <= 0)
 665        {
 666            return;
 667        }
 668
 669        if (_disposed)
 670        {
 671            return;
 672        }
 673
 674        using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false))
 675        {
 676            if (_disposed)
 677            {
 678                return;
 679            }
 680
 681            var timersToDelete = _timerManager.GetAll()
 682                .Where(timerInfo => timerInfo.Status == RecordingStatus.Completed
 683                    && !string.IsNullOrWhiteSpace(timerInfo.RecordingPath)
 684                    && string.Equals(timerInfo.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)
 685                    && File.Exists(timerInfo.RecordingPath))
 686                .OrderByDescending(i => i.EndDate)
 687                .Skip(seriesTimer.KeepUpTo - 1)
 688                .ToList();
 689
 690            DeleteLibraryItemsForTimers(timersToDelete);
 691
 692            if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries)
 693            {
 694                return;
 695            }
 696
 697            var episodesToDelete = librarySeries.GetItemList(
 698                    new InternalItemsQuery
 699                    {
 700                        OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)],
 701                        IsVirtualItem = false,
 702                        IsFolder = false,
 703                        Recursive = true,
 704                        DtoOptions = new DtoOptions(true)
 705                    })
 706                .Where(i => i.IsFileProtocol && File.Exists(i.Path))
 707                .Skip(seriesTimer.KeepUpTo - 1);
 708
 709            foreach (var item in episodesToDelete)
 710            {
 711                try
 712                {
 713                    _libraryManager.DeleteItem(
 714                        item,
 715                        new DeleteOptions
 716                        {
 717                            DeleteFileLocation = true
 718                        },
 719                        true);
 720                }
 721                catch (Exception ex)
 722                {
 723                    _logger.LogError(ex, "Error deleting item");
 724                }
 725            }
 726        }
 727    }
 728
 729    private void DeleteLibraryItemsForTimers(List<TimerInfo> timers)
 730    {
 0731        foreach (var timer in timers)
 732        {
 0733            if (_disposed)
 734            {
 0735                return;
 736            }
 737
 738            try
 739            {
 0740                DeleteLibraryItemForTimer(timer);
 0741            }
 0742            catch (Exception ex)
 743            {
 0744                _logger.LogError(ex, "Error deleting recording");
 0745            }
 746        }
 0747    }
 748
 749    private void DeleteLibraryItemForTimer(TimerInfo timer)
 750    {
 0751        var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false);
 0752        if (libraryItem is not null)
 753        {
 0754            _libraryManager.DeleteItem(
 0755                libraryItem,
 0756                new DeleteOptions
 0757                {
 0758                    DeleteFileLocation = true
 0759                },
 0760                true);
 761        }
 0762        else if (File.Exists(timer.RecordingPath))
 763        {
 0764            _fileSystem.DeleteFile(timer.RecordingPath);
 765        }
 766
 0767        _timerManager.Delete(timer);
 0768    }
 769
 770    private string EnsureFileUnique(string path, string timerId)
 771    {
 0772        var parent = Path.GetDirectoryName(path)!;
 0773        var name = Path.GetFileNameWithoutExtension(path);
 0774        var extension = Path.GetExtension(path);
 775
 0776        var index = 1;
 0777        while (File.Exists(path) || _activeRecordings.Any(i
 0778                   => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase)
 0779                      && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)))
 780        {
 0781            name += " - " + index.ToString(CultureInfo.InvariantCulture);
 782
 0783            path = Path.ChangeExtension(Path.Combine(parent, name), extension);
 0784            index++;
 785        }
 786
 0787        return path;
 788    }
 789
 790    private IRecorder GetRecorder(MediaSourceInfo mediaSource)
 791    {
 0792        if (mediaSource.RequiresLooping
 0793            || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase)
 0794            || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
 795        {
 0796            return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config);
 797        }
 798
 0799        return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
 800    }
 801
 802    private async Task PostProcessRecording(string path)
 803    {
 804        var options = _config.GetLiveTvConfiguration();
 805        if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
 806        {
 807            return;
 808        }
 809
 810        try
 811        {
 812            using var process = new Process();
 813            process.StartInfo = new ProcessStartInfo
 814            {
 815                Arguments = options.RecordingPostProcessorArguments
 816                    .Replace("{path}", path, StringComparison.OrdinalIgnoreCase),
 817                CreateNoWindow = true,
 818                ErrorDialog = false,
 819                FileName = options.RecordingPostProcessor,
 820                WindowStyle = ProcessWindowStyle.Hidden,
 821                UseShellExecute = false
 822            };
 823            process.EnableRaisingEvents = true;
 824
 825            _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.Start
 826
 827            process.Start();
 828            await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
 829
 830            _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitC
 831        }
 832        catch (Exception ex)
 833        {
 834            _logger.LogError(ex, "Error running recording post processor");
 835        }
 836    }
 837}