< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Recordings.RecordingsManager
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
Line coverage
13%
Covered lines: 54
Uncovered lines: 361
Coverable lines: 415
Total lines: 838
Line coverage: 13%
Branch coverage
11%
Covered branches: 19
Total branches: 170
Branch coverage: 11.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 AM Line coverage: 16.5% (28/169) Branch coverage: 6% (6/100) Total lines: 8384/19/2026 - 12:14:27 AM Line coverage: 13% (54/415) Branch coverage: 11.1% (19/170) Total lines: 838 1/23/2026 - 12:11:06 AM Line coverage: 16.5% (28/169) Branch coverage: 6% (6/100) Total lines: 8384/19/2026 - 12:14:27 AM Line coverage: 13% (54/415) Branch coverage: 11.1% (19/170) Total lines: 838

Coverage delta

Coverage delta 6 -6

Metrics

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.Database.Implementations.Enums;
 14using Jellyfin.LiveTv.Configuration;
 15using Jellyfin.LiveTv.IO;
 16using Jellyfin.LiveTv.Timers;
 17using MediaBrowser.Common.Configuration;
 18using MediaBrowser.Controller.Configuration;
 19using MediaBrowser.Controller.Dto;
 20using MediaBrowser.Controller.Entities;
 21using MediaBrowser.Controller.Entities.TV;
 22using MediaBrowser.Controller.Library;
 23using MediaBrowser.Controller.LiveTv;
 24using MediaBrowser.Controller.MediaEncoding;
 25using MediaBrowser.Controller.Providers;
 26using MediaBrowser.Model.Configuration;
 27using MediaBrowser.Model.Dto;
 28using MediaBrowser.Model.Entities;
 29using MediaBrowser.Model.IO;
 30using MediaBrowser.Model.LiveTv;
 31using MediaBrowser.Model.MediaInfo;
 32using MediaBrowser.Model.Providers;
 33using Microsoft.Extensions.Logging;
 34
 35namespace Jellyfin.LiveTv.Recordings;
 36
 37/// <inheritdoc cref="IRecordingsManager" />
 38public sealed class RecordingsManager : IRecordingsManager, IDisposable
 39{
 40    private readonly ILogger<RecordingsManager> _logger;
 41    private readonly IServerConfigurationManager _config;
 42    private readonly IHttpClientFactory _httpClientFactory;
 43    private readonly IFileSystem _fileSystem;
 44    private readonly ILibraryManager _libraryManager;
 45    private readonly ILibraryMonitor _libraryMonitor;
 46    private readonly IProviderManager _providerManager;
 47    private readonly IMediaEncoder _mediaEncoder;
 48    private readonly IMediaSourceManager _mediaSourceManager;
 49    private readonly IStreamHelper _streamHelper;
 50    private readonly TimerManager _timerManager;
 51    private readonly SeriesTimerManager _seriesTimerManager;
 52    private readonly RecordingsMetadataManager _recordingsMetadataManager;
 53
 2154    private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new(StringComparer.OrdinalIgn
 2155    private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new();
 56    private bool _disposed;
 57
 58    /// <summary>
 59    /// Initializes a new instance of the <see cref="RecordingsManager"/> class.
 60    /// </summary>
 61    /// <param name="logger">The <see cref="ILogger"/>.</param>
 62    /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
 63    /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
 64    /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 65    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 66    /// <param name="libraryMonitor">The <see cref="ILibraryMonitor"/>.</param>
 67    /// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
 68    /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
 69    /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
 70    /// <param name="streamHelper">The <see cref="IStreamHelper"/>.</param>
 71    /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
 72    /// <param name="seriesTimerManager">The <see cref="SeriesTimerManager"/>.</param>
 73    /// <param name="recordingsMetadataManager">The <see cref="RecordingsMetadataManager"/>.</param>
 74    public RecordingsManager(
 75        ILogger<RecordingsManager> logger,
 76        IServerConfigurationManager config,
 77        IHttpClientFactory httpClientFactory,
 78        IFileSystem fileSystem,
 79        ILibraryManager libraryManager,
 80        ILibraryMonitor libraryMonitor,
 81        IProviderManager providerManager,
 82        IMediaEncoder mediaEncoder,
 83        IMediaSourceManager mediaSourceManager,
 84        IStreamHelper streamHelper,
 85        TimerManager timerManager,
 86        SeriesTimerManager seriesTimerManager,
 87        RecordingsMetadataManager recordingsMetadataManager)
 88    {
 2189        _logger = logger;
 2190        _config = config;
 2191        _httpClientFactory = httpClientFactory;
 2192        _fileSystem = fileSystem;
 2193        _libraryManager = libraryManager;
 2194        _libraryMonitor = libraryMonitor;
 2195        _providerManager = providerManager;
 2196        _mediaEncoder = mediaEncoder;
 2197        _mediaSourceManager = mediaSourceManager;
 2198        _streamHelper = streamHelper;
 2199        _timerManager = timerManager;
 21100        _seriesTimerManager = seriesTimerManager;
 21101        _recordingsMetadataManager = recordingsMetadataManager;
 102
 21103        _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
 21104    }
 105
 106    private string DefaultRecordingPath
 107    {
 108        get
 109        {
 23110            var path = _config.GetLiveTvConfiguration().RecordingPath;
 111
 23112            return string.IsNullOrWhiteSpace(path)
 23113                ? Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv", "recordings")
 23114                : path;
 115        }
 116    }
 117
 118    /// <inheritdoc />
 119    public string? GetActiveRecordingPath(string id)
 0120        => _activeRecordings.GetValueOrDefault(id)?.Path;
 121
 122    /// <inheritdoc />
 123    public ActiveRecordingInfo? GetActiveRecordingInfo(string path)
 124    {
 9125        if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty)
 126        {
 9127            return null;
 128        }
 129
 0130        foreach (var (_, recordingInfo) in _activeRecordings)
 131        {
 0132            if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal)
 0133                && !recordingInfo.CancellationTokenSource.IsCancellationRequested)
 134            {
 0135                return recordingInfo.Timer.Status == RecordingStatus.InProgress ? recordingInfo : null;
 136            }
 137        }
 138
 0139        return null;
 0140    }
 141
 142    /// <inheritdoc />
 143    public IEnumerable<VirtualFolderInfo> GetRecordingFolders()
 144    {
 23145        if (Directory.Exists(DefaultRecordingPath))
 146        {
 0147            yield return new VirtualFolderInfo
 0148            {
 0149                Locations = [DefaultRecordingPath],
 0150                Name = "Recordings"
 0151            };
 152        }
 153
 23154        var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath;
 23155        if (!string.IsNullOrWhiteSpace(customPath)
 23156            && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
 23157            && Directory.Exists(customPath))
 158        {
 0159            yield return new VirtualFolderInfo
 0160            {
 0161                Locations = [customPath],
 0162                Name = "Recorded Movies",
 0163                CollectionType = CollectionTypeOptions.movies
 0164            };
 165        }
 166
 23167        customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath;
 23168        if (!string.IsNullOrWhiteSpace(customPath)
 23169            && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
 23170            && Directory.Exists(customPath))
 171        {
 0172            yield return new VirtualFolderInfo
 0173            {
 0174                Locations = [customPath],
 0175                Name = "Recorded Shows",
 0176                CollectionType = CollectionTypeOptions.tvshows
 0177            };
 178        }
 23179    }
 180
 181    /// <inheritdoc />
 182    public async Task CreateRecordingFolders()
 183    {
 184        try
 185        {
 23186            var recordingFolders = GetRecordingFolders().ToArray();
 23187            var virtualFolders = _libraryManager.GetVirtualFolders();
 188
 23189            var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList();
 190
 23191            var pathsAdded = new List<string>();
 192
 46193            foreach (var recordingFolder in recordingFolders)
 194            {
 0195                var pathsToCreate = recordingFolder.Locations
 0196                    .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i)))
 0197                    .ToList();
 198
 0199                if (pathsToCreate.Count == 0)
 200                {
 201                    continue;
 202                }
 203
 0204                var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray();
 0205                var libraryOptions = new LibraryOptions
 0206                {
 0207                    PathInfos = mediaPathInfos
 0208                };
 209
 210                try
 211                {
 0212                    await _libraryManager
 0213                        .AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true)
 0214                        .ConfigureAwait(false);
 0215                }
 0216                catch (Exception ex)
 217                {
 0218                    _logger.LogError(ex, "Error creating virtual folder");
 0219                }
 220
 0221                pathsAdded.AddRange(pathsToCreate);
 0222            }
 223
 23224            var config = _config.GetLiveTvConfiguration();
 225
 23226            var pathsToRemove = config.MediaLocationsCreated
 23227                .Except(recordingFolders.SelectMany(i => i.Locations))
 23228                .ToList();
 229
 23230            if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
 231            {
 0232                pathsAdded.InsertRange(0, config.MediaLocationsCreated);
 0233                config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct().ToArray();
 0234                _config.SaveConfiguration("livetv", config);
 235            }
 236
 46237            foreach (var path in pathsToRemove)
 238            {
 0239                await RemovePathFromLibraryAsync(path).ConfigureAwait(false);
 240            }
 23241        }
 0242        catch (Exception ex)
 243        {
 0244            _logger.LogError(ex, "Error creating recording folders");
 0245        }
 23246    }
 247
 248    private async Task RemovePathFromLibraryAsync(string path)
 249    {
 0250        _logger.LogDebug("Removing path from library: {0}", path);
 251
 0252        var requiresRefresh = false;
 0253        var virtualFolders = _libraryManager.GetVirtualFolders();
 254
 0255        foreach (var virtualFolder in virtualFolders)
 256        {
 0257            if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase))
 258            {
 259                continue;
 260            }
 261
 0262            if (virtualFolder.Locations.Length == 1)
 263            {
 264                try
 265                {
 0266                    await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false);
 0267                }
 0268                catch (Exception ex)
 269                {
 0270                    _logger.LogError(ex, "Error removing virtual folder");
 0271                }
 272            }
 273            else
 274            {
 275                try
 276                {
 0277                    _libraryManager.RemoveMediaPath(virtualFolder.Name, path);
 0278                    requiresRefresh = true;
 0279                }
 0280                catch (Exception ex)
 281                {
 0282                    _logger.LogError(ex, "Error removing media path");
 0283                }
 284            }
 285        }
 286
 0287        if (requiresRefresh)
 288        {
 0289            await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(fa
 290        }
 0291    }
 292
 293    /// <inheritdoc />
 294    public void CancelRecording(string timerId, TimerInfo? timer)
 295    {
 0296        if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
 297        {
 0298            activeRecordingInfo.Timer = timer;
 0299            activeRecordingInfo.CancellationTokenSource.Cancel();
 300        }
 0301    }
 302
 303    /// <inheritdoc />
 304    public async Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate)
 305    {
 0306        ArgumentNullException.ThrowIfNull(recordingInfo);
 0307        ArgumentNullException.ThrowIfNull(channel);
 308
 0309        var timer = recordingInfo.Timer;
 0310        var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
 0311        var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath);
 312
 0313        string? liveStreamId = null;
 314        RecordingStatus recordingStatus;
 315        try
 316        {
 0317            var allMediaSources = await _mediaSourceManager
 0318                .GetPlaybackMediaSources(channel, null, true, false, CancellationToken.None).ConfigureAwait(false);
 319
 0320            var mediaStreamInfo = allMediaSources[0];
 0321            IDirectStreamProvider? directStreamProvider = null;
 0322            if (mediaStreamInfo.RequiresOpening)
 323            {
 0324                var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal(
 0325                    new LiveStreamRequest
 0326                    {
 0327                        ItemId = channel.Id,
 0328                        OpenToken = mediaStreamInfo.OpenToken
 0329                    },
 0330                    CancellationToken.None).ConfigureAwait(false);
 331
 0332                mediaStreamInfo = liveStreamResponse.Item1.MediaSource;
 0333                liveStreamId = mediaStreamInfo.LiveStreamId;
 0334                directStreamProvider = liveStreamResponse.Item2;
 335            }
 336
 0337            using var recorder = GetRecorder(mediaStreamInfo);
 338
 0339            recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath);
 0340            recordingPath = EnsureFileUnique(recordingPath, timer.Id);
 341
 0342            _libraryMonitor.ReportFileSystemChangeBeginning(recordingPath);
 343
 0344            var duration = recordingEndDate - DateTime.UtcNow;
 345
 0346            _logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes);
 0347            _logger.LogInformation("Writing file to: {Path}", recordingPath);
 348
 349            async void OnStarted()
 350            {
 351                recordingInfo.Path = recordingPath;
 352                _activeRecordings.TryAdd(timer.Id, recordingInfo);
 353
 354                timer.Status = RecordingStatus.InProgress;
 355                _timerManager.AddOrUpdate(timer, false);
 356
 357                await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(
 358                await CreateRecordingFolders().ConfigureAwait(false);
 359
 360                TriggerRefresh(recordingPath);
 361                await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
 362            }
 363
 0364            await recorder.Record(
 0365                directStreamProvider,
 0366                mediaStreamInfo,
 0367                recordingPath,
 0368                duration,
 0369                OnStarted,
 0370                recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
 371
 0372            recordingStatus = RecordingStatus.Completed;
 0373            _logger.LogInformation("Recording completed: {RecordPath}", recordingPath);
 0374        }
 0375        catch (OperationCanceledException)
 376        {
 0377            _logger.LogInformation("Recording stopped: {RecordPath}", recordingPath);
 0378            recordingStatus = RecordingStatus.Completed;
 0379        }
 0380        catch (Exception ex)
 381        {
 0382            _logger.LogError(ex, "Error recording to {RecordPath}", recordingPath);
 0383            recordingStatus = RecordingStatus.Error;
 0384        }
 385
 0386        if (!string.IsNullOrWhiteSpace(liveStreamId))
 387        {
 388            try
 389            {
 0390                await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
 0391            }
 0392            catch (Exception ex)
 393            {
 0394                _logger.LogError(ex, "Error closing live stream");
 0395            }
 396        }
 397
 0398        DeleteFileIfEmpty(recordingPath);
 0399        TriggerRefresh(recordingPath);
 0400        _libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false);
 0401        _activeRecordings.TryRemove(timer.Id, out _);
 402
 0403        if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
 404        {
 405            const int RetryIntervalSeconds = 60;
 0406            _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
 407
 0408            timer.Status = RecordingStatus.New;
 0409            timer.PrePaddingSeconds = 0;
 0410            timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
 0411            timer.RetryCount++;
 0412            _timerManager.AddOrUpdate(timer);
 413        }
 0414        else if (File.Exists(recordingPath))
 415        {
 0416            timer.RecordingPath = recordingPath;
 0417            timer.Status = RecordingStatus.Completed;
 0418            _timerManager.AddOrUpdate(timer, false);
 0419            await PostProcessRecording(recordingPath).ConfigureAwait(false);
 420        }
 421        else
 422        {
 0423            _timerManager.Delete(timer);
 424        }
 0425    }
 426
 427    /// <inheritdoc />
 428    public void Dispose()
 429    {
 21430        if (_disposed)
 431        {
 0432            return;
 433        }
 434
 21435        _recordingDeleteSemaphore.Dispose();
 436
 42437        foreach (var pair in _activeRecordings.ToList())
 438        {
 0439            pair.Value.CancellationTokenSource.Cancel();
 440        }
 441
 21442        _disposed = true;
 21443    }
 444
 445    private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
 446    {
 1447        if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
 448        {
 1449            await CreateRecordingFolders().ConfigureAwait(false);
 450        }
 1451    }
 452
 453    private async Task<RemoteSearchResult?> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
 454    {
 0455        if (!timer.IsSeries || timer.SeriesProviderIds.Count == 0)
 456        {
 0457            return null;
 458        }
 459
 0460        var query = new RemoteSearchQuery<SeriesInfo>
 0461        {
 0462            SearchInfo = new SeriesInfo
 0463            {
 0464                ProviderIds = timer.SeriesProviderIds,
 0465                Name = timer.Name,
 0466                MetadataCountryCode = _config.Configuration.MetadataCountryCode,
 0467                MetadataLanguage = _config.Configuration.PreferredMetadataLanguage
 0468            }
 0469        };
 470
 0471        var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, cancellationToken).Config
 472
 0473        return results.FirstOrDefault();
 0474    }
 475
 476    private string GetRecordingPath(TimerInfo timer, RemoteSearchResult? metadata, out string? seriesPath)
 477    {
 0478        var recordingPath = DefaultRecordingPath;
 0479        var config = _config.GetLiveTvConfiguration();
 0480        seriesPath = null;
 481
 0482        if (timer.IsProgramSeries)
 483        {
 0484            var customRecordingPath = config.SeriesRecordingPath;
 0485            var allowSubfolder = true;
 0486            if (!string.IsNullOrWhiteSpace(customRecordingPath))
 487            {
 0488                allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
 0489                recordingPath = customRecordingPath;
 490            }
 491
 0492            if (allowSubfolder && config.EnableRecordingSubfolders)
 493            {
 0494                recordingPath = Path.Combine(recordingPath, "Series");
 495            }
 496
 497            // trim trailing period from the folder name
 0498            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
 499
 0500            if (metadata is not null && metadata.ProductionYear.HasValue)
 501            {
 0502                folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
 503            }
 504
 505            // Can't use the year here in the folder name because it is the year of the episode, not the series.
 0506            recordingPath = Path.Combine(recordingPath, folderName);
 507
 0508            seriesPath = recordingPath;
 509
 0510            if (timer.SeasonNumber.HasValue)
 511            {
 0512                folderName = string.Format(
 0513                    CultureInfo.InvariantCulture,
 0514                    "Season {0}",
 0515                    timer.SeasonNumber.Value);
 0516                recordingPath = Path.Combine(recordingPath, folderName);
 517            }
 518        }
 0519        else if (timer.IsMovie)
 520        {
 0521            var customRecordingPath = config.MovieRecordingPath;
 0522            var allowSubfolder = true;
 0523            if (!string.IsNullOrWhiteSpace(customRecordingPath))
 524            {
 0525                allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
 0526                recordingPath = customRecordingPath;
 527            }
 528
 0529            if (allowSubfolder && config.EnableRecordingSubfolders)
 530            {
 0531                recordingPath = Path.Combine(recordingPath, "Movies");
 532            }
 533
 0534            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
 0535            if (timer.ProductionYear.HasValue)
 536            {
 0537                folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
 538            }
 539
 540            // trim trailing period from the folder name
 0541            folderName = folderName.TrimEnd('.').Trim();
 542
 0543            recordingPath = Path.Combine(recordingPath, folderName);
 544        }
 0545        else if (timer.IsKids)
 546        {
 0547            if (config.EnableRecordingSubfolders)
 548            {
 0549                recordingPath = Path.Combine(recordingPath, "Kids");
 550            }
 551
 0552            var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
 0553            if (timer.ProductionYear.HasValue)
 554            {
 0555                folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
 556            }
 557
 558            // trim trailing period from the folder name
 0559            folderName = folderName.TrimEnd('.').Trim();
 560
 0561            recordingPath = Path.Combine(recordingPath, folderName);
 562        }
 0563        else if (timer.IsSports)
 564        {
 0565            if (config.EnableRecordingSubfolders)
 566            {
 0567                recordingPath = Path.Combine(recordingPath, "Sports");
 568            }
 569
 0570            recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
 571        }
 572        else
 573        {
 0574            if (config.EnableRecordingSubfolders)
 575            {
 0576                recordingPath = Path.Combine(recordingPath, "Other");
 577            }
 578
 0579            recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
 580        }
 581
 0582        var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
 583
 0584        return Path.Combine(recordingPath, recordingFileName);
 585    }
 586
 587    private void DeleteFileIfEmpty(string path)
 588    {
 0589        var file = _fileSystem.GetFileInfo(path);
 590
 0591        if (file.Exists && file.Length == 0)
 592        {
 593            try
 594            {
 0595                _fileSystem.DeleteFile(path);
 0596            }
 0597            catch (Exception ex)
 598            {
 0599                _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path);
 0600            }
 601        }
 0602    }
 603
 604    private void TriggerRefresh(string path)
 605    {
 0606        _logger.LogInformation("Triggering refresh on {Path}", path);
 607
 0608        var item = GetAffectedBaseItem(Path.GetDirectoryName(path));
 0609        if (item is null)
 610        {
 0611            return;
 612        }
 613
 0614        _logger.LogInformation("Refreshing recording parent {Path}", item.Path);
 0615        _providerManager.QueueRefresh(
 0616            item.Id,
 0617            new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 0618            {
 0619                RefreshPaths =
 0620                [
 0621                    path,
 0622                    Path.GetDirectoryName(path),
 0623                    Path.GetDirectoryName(Path.GetDirectoryName(path))
 0624                ]
 0625            },
 0626            RefreshPriority.High);
 0627    }
 628
 629    private BaseItem? GetAffectedBaseItem(string? path)
 630    {
 0631        BaseItem? item = null;
 0632        var parentPath = Path.GetDirectoryName(path);
 0633        while (item is null && !string.IsNullOrEmpty(path))
 634        {
 0635            item = _libraryManager.FindByPath(path, null);
 0636            path = Path.GetDirectoryName(path);
 637        }
 638
 0639        if (item is not null
 0640            && item.GetType() == typeof(Folder)
 0641            && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase))
 642        {
 0643            var parentItem = item.GetParent();
 0644            if (parentItem is not null && parentItem is not AggregateFolder)
 645            {
 0646                item = parentItem;
 647            }
 648        }
 649
 0650        return item;
 651    }
 652
 653    private async Task EnforceKeepUpTo(TimerInfo timer, string? seriesPath)
 654    {
 0655        if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)
 0656            || string.IsNullOrWhiteSpace(seriesPath))
 657        {
 0658            return;
 659        }
 660
 0661        var seriesTimerId = timer.SeriesTimerId;
 0662        var seriesTimer = _seriesTimerManager.GetAll()
 0663            .FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase));
 664
 0665        if (seriesTimer is null || seriesTimer.KeepUpTo <= 0)
 666        {
 0667            return;
 668        }
 669
 0670        if (_disposed)
 671        {
 0672            return;
 673        }
 674
 0675        using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false))
 676        {
 0677            if (_disposed)
 678            {
 0679                return;
 680            }
 681
 0682            var timersToDelete = _timerManager.GetAll()
 0683                .Where(timerInfo => timerInfo.Status == RecordingStatus.Completed
 0684                    && !string.IsNullOrWhiteSpace(timerInfo.RecordingPath)
 0685                    && string.Equals(timerInfo.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)
 0686                    && File.Exists(timerInfo.RecordingPath))
 0687                .OrderByDescending(i => i.EndDate)
 0688                .Skip(seriesTimer.KeepUpTo - 1)
 0689                .ToList();
 690
 0691            DeleteLibraryItemsForTimers(timersToDelete);
 692
 0693            if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries)
 694            {
 0695                return;
 696            }
 697
 0698            var episodesToDelete = librarySeries.GetItemList(
 0699                    new InternalItemsQuery
 0700                    {
 0701                        OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)],
 0702                        IsVirtualItem = false,
 0703                        IsFolder = false,
 0704                        Recursive = true,
 0705                        DtoOptions = new DtoOptions(true)
 0706                    })
 0707                .Where(i => i.IsFileProtocol && File.Exists(i.Path))
 0708                .Skip(seriesTimer.KeepUpTo - 1);
 709
 0710            foreach (var item in episodesToDelete)
 711            {
 712                try
 713                {
 0714                    _libraryManager.DeleteItem(
 0715                        item,
 0716                        new DeleteOptions
 0717                        {
 0718                            DeleteFileLocation = true
 0719                        },
 0720                        true);
 0721                }
 0722                catch (Exception ex)
 723                {
 0724                    _logger.LogError(ex, "Error deleting item");
 0725                }
 726            }
 0727        }
 0728    }
 729
 730    private void DeleteLibraryItemsForTimers(List<TimerInfo> timers)
 731    {
 0732        foreach (var timer in timers)
 733        {
 0734            if (_disposed)
 735            {
 0736                return;
 737            }
 738
 739            try
 740            {
 0741                DeleteLibraryItemForTimer(timer);
 0742            }
 0743            catch (Exception ex)
 744            {
 0745                _logger.LogError(ex, "Error deleting recording");
 0746            }
 747        }
 0748    }
 749
 750    private void DeleteLibraryItemForTimer(TimerInfo timer)
 751    {
 0752        var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false);
 0753        if (libraryItem is not null)
 754        {
 0755            _libraryManager.DeleteItem(
 0756                libraryItem,
 0757                new DeleteOptions
 0758                {
 0759                    DeleteFileLocation = true
 0760                },
 0761                true);
 762        }
 0763        else if (File.Exists(timer.RecordingPath))
 764        {
 0765            _fileSystem.DeleteFile(timer.RecordingPath);
 766        }
 767
 0768        _timerManager.Delete(timer);
 0769    }
 770
 771    private string EnsureFileUnique(string path, string timerId)
 772    {
 0773        var parent = Path.GetDirectoryName(path)!;
 0774        var name = Path.GetFileNameWithoutExtension(path);
 0775        var extension = Path.GetExtension(path);
 776
 0777        var index = 1;
 0778        while (File.Exists(path) || _activeRecordings.Any(i
 0779                   => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase)
 0780                      && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)))
 781        {
 0782            name += " - " + index.ToString(CultureInfo.InvariantCulture);
 783
 0784            path = Path.ChangeExtension(Path.Combine(parent, name), extension);
 0785            index++;
 786        }
 787
 0788        return path;
 789    }
 790
 791    private IRecorder GetRecorder(MediaSourceInfo mediaSource)
 792    {
 0793        if (mediaSource.RequiresLooping
 0794            || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase)
 0795            || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
 796        {
 0797            return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config);
 798        }
 799
 0800        return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
 801    }
 802
 803    private async Task PostProcessRecording(string path)
 804    {
 0805        var options = _config.GetLiveTvConfiguration();
 0806        if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
 807        {
 0808            return;
 809        }
 810
 811        try
 812        {
 0813            using var process = new Process();
 0814            process.StartInfo = new ProcessStartInfo
 0815            {
 0816                Arguments = options.RecordingPostProcessorArguments
 0817                    .Replace("{path}", path, StringComparison.OrdinalIgnoreCase),
 0818                CreateNoWindow = true,
 0819                ErrorDialog = false,
 0820                FileName = options.RecordingPostProcessor,
 0821                WindowStyle = ProcessWindowStyle.Hidden,
 0822                UseShellExecute = false
 0823            };
 0824            process.EnableRaisingEvents = true;
 825
 0826            _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.Start
 827
 0828            process.Start();
 0829            await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
 830
 0831            _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitC
 0832        }
 0833        catch (Exception ex)
 834        {
 0835            _logger.LogError(ex, "Error running recording post processor");
 0836        }
 0837    }
 838}

Methods/Properties

.ctor(Microsoft.Extensions.Logging.ILogger`1<Jellyfin.LiveTv.Recordings.RecordingsManager>,MediaBrowser.Controller.Configuration.IServerConfigurationManager,System.Net.Http.IHttpClientFactory,MediaBrowser.Model.IO.IFileSystem,MediaBrowser.Controller.Library.ILibraryManager,MediaBrowser.Controller.Library.ILibraryMonitor,MediaBrowser.Controller.Providers.IProviderManager,MediaBrowser.Controller.MediaEncoding.IMediaEncoder,MediaBrowser.Controller.Library.IMediaSourceManager,MediaBrowser.Model.IO.IStreamHelper,Jellyfin.LiveTv.Timers.TimerManager,Jellyfin.LiveTv.Timers.SeriesTimerManager,Jellyfin.LiveTv.Recordings.RecordingsMetadataManager)
get_DefaultRecordingPath()
GetActiveRecordingPath(System.String)
GetActiveRecordingInfo(System.String)
GetRecordingFolders()
CreateRecordingFolders()
RemovePathFromLibraryAsync()
CancelRecording(System.String,MediaBrowser.Controller.LiveTv.TimerInfo)
RecordStream()
Dispose()
OnNamedConfigurationUpdated()
FetchInternetMetadata()
GetRecordingPath(MediaBrowser.Controller.LiveTv.TimerInfo,MediaBrowser.Model.Providers.RemoteSearchResult,System.String&)
DeleteFileIfEmpty(System.String)
TriggerRefresh(System.String)
GetAffectedBaseItem(System.String)
EnforceKeepUpTo()
DeleteLibraryItemsForTimers(System.Collections.Generic.List`1<MediaBrowser.Controller.LiveTv.TimerInfo>)
DeleteLibraryItemForTimer(MediaBrowser.Controller.LiveTv.TimerInfo)
EnsureFileUnique(System.String,System.String)
GetRecorder(MediaBrowser.Model.Dto.MediaSourceInfo)
PostProcessRecording()