< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Recordings.RecordingsMetadataManager
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
Line coverage
1%
Covered lines: 4
Uncovered lines: 232
Coverable lines: 236
Total lines: 496
Line coverage: 1.6%
Branch coverage
0%
Covered branches: 0
Total branches: 127
Branch coverage: 0%
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: 100% (4/4) Total lines: 4964/19/2026 - 12:14:27 AM Line coverage: 1.6% (4/236) Branch coverage: 0% (0/127) Total lines: 496 4/19/2026 - 12:14:27 AM Line coverage: 1.6% (4/236) Branch coverage: 0% (0/127) Total lines: 496

Coverage delta

Coverage delta 99 -99

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SaveRecordingMetadata()0%506220%
SaveSeriesNfoAsync()0%272160%
SaveVideoNfoAsync()0%3422580%
SaveRecordingImages()0%272160%
SaveRecordingImage()0%240150%

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Text;
 7using System.Threading.Tasks;
 8using System.Xml;
 9using Jellyfin.Data.Enums;
 10using Jellyfin.Extensions;
 11using Jellyfin.LiveTv.Configuration;
 12using MediaBrowser.Common.Configuration;
 13using MediaBrowser.Common.Extensions;
 14using MediaBrowser.Controller.Dto;
 15using MediaBrowser.Controller.Entities;
 16using MediaBrowser.Controller.Library;
 17using MediaBrowser.Controller.LiveTv;
 18using MediaBrowser.Model.Entities;
 19using Microsoft.Extensions.Logging;
 20
 21namespace Jellyfin.LiveTv.Recordings;
 22
 23/// <summary>
 24/// A service responsible for saving recording metadata.
 25/// </summary>
 26public class RecordingsMetadataManager
 27{
 28    private const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
 29
 30    private readonly ILogger<RecordingsMetadataManager> _logger;
 31    private readonly IConfigurationManager _config;
 32    private readonly ILibraryManager _libraryManager;
 33
 34    /// <summary>
 35    /// Initializes a new instance of the <see cref="RecordingsMetadataManager"/> class.
 36    /// </summary>
 37    /// <param name="logger">The <see cref="ILogger"/>.</param>
 38    /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
 39    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 40    public RecordingsMetadataManager(
 41        ILogger<RecordingsMetadataManager> logger,
 42        IConfigurationManager config,
 43        ILibraryManager libraryManager)
 44    {
 2145        _logger = logger;
 2146        _config = config;
 2147        _libraryManager = libraryManager;
 2148    }
 49
 50    /// <summary>
 51    /// Saves the metadata for a provided recording.
 52    /// </summary>
 53    /// <param name="timer">The recording timer.</param>
 54    /// <param name="recordingPath">The recording path.</param>
 55    /// <param name="seriesPath">The series path.</param>
 56    /// <returns>A task representing the metadata saving.</returns>
 57    public async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string? seriesPath)
 58    {
 59        try
 60        {
 061            var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalIt
 062            {
 063                IncludeItemTypes = [BaseItemKind.LiveTvProgram],
 064                Limit = 1,
 065                ExternalId = timer.ProgramId,
 066                DtoOptions = new DtoOptions(true)
 067            }).FirstOrDefault() as LiveTvProgram;
 68
 69            // dummy this up
 070            program ??= new LiveTvProgram
 071            {
 072                Name = timer.Name,
 073                Overview = timer.Overview,
 074                Genres = timer.Genres,
 075                CommunityRating = timer.CommunityRating,
 076                OfficialRating = timer.OfficialRating,
 077                ProductionYear = timer.ProductionYear,
 078                PremiereDate = timer.OriginalAirDate,
 079                IndexNumber = timer.EpisodeNumber,
 080                ParentIndexNumber = timer.SeasonNumber
 081            };
 82
 083            if (timer.IsSports)
 84            {
 085                program.AddGenre("Sports");
 86            }
 87
 088            if (timer.IsKids)
 89            {
 090                program.AddGenre("Kids");
 091                program.AddGenre("Children");
 92            }
 93
 094            if (timer.IsNews)
 95            {
 096                program.AddGenre("News");
 97            }
 98
 099            var config = _config.GetLiveTvConfiguration();
 100
 0101            if (config.SaveRecordingNFO)
 102            {
 0103                if (timer.IsProgramSeries)
 104                {
 0105                    ArgumentNullException.ThrowIfNull(seriesPath);
 106
 0107                    await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
 0108                    await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
 109                }
 0110                else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
 111                {
 0112                    await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
 113                }
 114                else
 115                {
 0116                    await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
 117                }
 118            }
 119
 0120            if (config.SaveRecordingImages)
 121            {
 0122                await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
 123            }
 0124        }
 0125        catch (Exception ex)
 126        {
 0127            _logger.LogError(ex, "Error saving nfo");
 0128        }
 0129    }
 130
 131    private static async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath)
 132    {
 0133        var nfoPath = Path.Combine(seriesPath, "tvshow.nfo");
 134
 0135        if (File.Exists(nfoPath))
 136        {
 0137            return;
 138        }
 139
 0140        var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
 0141        await using (stream.ConfigureAwait(false))
 142        {
 0143            var settings = new XmlWriterSettings
 0144            {
 0145                Indent = true,
 0146                Encoding = Encoding.UTF8,
 0147                Async = true
 0148            };
 149
 0150            var writer = XmlWriter.Create(stream, settings);
 0151            await using (writer.ConfigureAwait(false))
 152            {
 0153                await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
 0154                await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false);
 0155                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id))
 156                {
 0157                    await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false);
 158                }
 159
 0160                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id))
 161                {
 0162                    await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false);
 163                }
 164
 0165                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id))
 166                {
 0167                    await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false);
 168                }
 169
 0170                if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id))
 171                {
 0172                    await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false);
 173                }
 174
 0175                if (!string.IsNullOrWhiteSpace(timer.Name))
 176                {
 0177                    await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false);
 178                }
 179
 0180                if (!string.IsNullOrWhiteSpace(timer.OfficialRating))
 181                {
 0182                    await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false)
 183                }
 184
 0185                foreach (var genre in timer.Genres)
 186                {
 0187                    await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
 188                }
 189
 0190                await writer.WriteEndElementAsync().ConfigureAwait(false);
 0191                await writer.WriteEndDocumentAsync().ConfigureAwait(false);
 192            }
 0193        }
 0194    }
 195
 196    private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData)
 197    {
 0198        var nfoPath = Path.ChangeExtension(recordingPath, ".nfo");
 199
 0200        if (File.Exists(nfoPath))
 201        {
 0202            return;
 203        }
 204
 0205        var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
 0206        await using (stream.ConfigureAwait(false))
 207        {
 0208            var settings = new XmlWriterSettings
 0209            {
 0210                Indent = true,
 0211                Encoding = Encoding.UTF8,
 0212                Async = true
 0213            };
 214
 0215            var options = _config.GetNfoConfiguration();
 216
 0217            var isSeriesEpisode = timer.IsProgramSeries;
 218
 0219            var writer = XmlWriter.Create(stream, settings);
 0220            await using (writer.ConfigureAwait(false))
 221            {
 0222                await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
 223
 0224                if (isSeriesEpisode)
 225                {
 0226                    await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false);
 227
 0228                    if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle))
 229                    {
 0230                        await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(fal
 231                    }
 232
 0233                    var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null);
 234
 0235                    if (premiereDate.HasValue)
 236                    {
 0237                        var formatString = options.ReleaseDateFormat;
 238
 0239                        await writer.WriteElementStringAsync(
 0240                            null,
 0241                            "aired",
 0242                            null,
 0243                            premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).Confi
 244                    }
 245
 0246                    if (item.IndexNumber.HasValue)
 247                    {
 0248                        await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(Cult
 249                    }
 250
 0251                    if (item.ParentIndexNumber.HasValue)
 252                    {
 0253                        await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString
 254                    }
 255                }
 256                else
 257                {
 0258                    await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false);
 259
 0260                    if (!string.IsNullOrWhiteSpace(item.Name))
 261                    {
 0262                        await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false);
 263                    }
 264
 0265                    if (!string.IsNullOrWhiteSpace(item.OriginalTitle))
 266                    {
 0267                        await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureA
 268                    }
 269
 0270                    if (item.PremiereDate.HasValue)
 271                    {
 0272                        var formatString = options.ReleaseDateFormat;
 273
 0274                        await writer.WriteElementStringAsync(
 0275                            null,
 0276                            "premiered",
 0277                            null,
 0278                            item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).
 0279                        await writer.WriteElementStringAsync(
 0280                            null,
 0281                            "releasedate",
 0282                            null,
 0283                            item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).
 0284                    }
 285                }
 286
 0287                await writer.WriteElementStringAsync(
 0288                    null,
 0289                    "dateadded",
 0290                    null,
 0291                    DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
 292
 0293                if (item.ProductionYear.HasValue)
 294                {
 0295                    await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureI
 296                }
 297
 0298                if (!string.IsNullOrEmpty(item.OfficialRating))
 299                {
 0300                    await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false);
 301                }
 302
 0303                var overview = (item.Overview ?? string.Empty)
 0304                    .StripHtml()
 0305                    .Replace("&quot;", "'", StringComparison.Ordinal);
 306
 0307                await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false);
 308
 0309                if (item.CommunityRating.HasValue)
 310                {
 0311                    await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(Cultu
 312                }
 313
 0314                foreach (var genre in item.Genres)
 315                {
 0316                    await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
 317                }
 318
 0319                var people = item.Id.IsEmpty() ? new List<PersonInfo>() : _libraryManager.GetPeople(item);
 320
 0321                var directors = people
 0322                    .Where(i => i.IsType(PersonKind.Director))
 0323                    .Select(i => i.Name)
 0324                    .ToList();
 325
 0326                foreach (var person in directors)
 327                {
 0328                    await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false);
 329                }
 330
 0331                var writers = people
 0332                    .Where(i => i.IsType(PersonKind.Writer))
 0333                    .Select(i => i.Name)
 0334                    .Distinct(StringComparer.OrdinalIgnoreCase)
 0335                    .ToList();
 336
 0337                foreach (var person in writers)
 338                {
 0339                    await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false);
 340                }
 341
 0342                foreach (var person in writers)
 343                {
 0344                    await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false);
 345                }
 346
 0347                if (item.TryGetProviderId(MetadataProvider.TmdbCollection, out var tmdbCollection))
 348                {
 0349                    await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(
 350                }
 351
 0352                if (item.TryGetProviderId(MetadataProvider.Imdb, out var imdb))
 353                {
 0354                    if (!isSeriesEpisode)
 355                    {
 0356                        await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false);
 357                    }
 358
 0359                    await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false);
 360
 361                    // No need to lock if we have identified the content already
 0362                    lockData = false;
 363                }
 364
 0365                if (item.TryGetProviderId(MetadataProvider.Tvdb, out var tvdb))
 366                {
 0367                    await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false);
 368
 369                    // No need to lock if we have identified the content already
 0370                    lockData = false;
 371                }
 372
 0373                if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdb))
 374                {
 0375                    await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false);
 376
 377                    // No need to lock if we have identified the content already
 0378                    lockData = false;
 379                }
 380
 0381                if (lockData)
 382                {
 0383                    await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false);
 384                }
 385
 0386                if (item.CriticRating.HasValue)
 387                {
 0388                    await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(Cu
 389                }
 390
 0391                if (!string.IsNullOrWhiteSpace(item.Tagline))
 392                {
 0393                    await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false);
 394                }
 395
 0396                foreach (var studio in item.Studios)
 397                {
 0398                    await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false);
 399                }
 400
 0401                await writer.WriteEndElementAsync().ConfigureAwait(false);
 0402                await writer.WriteEndDocumentAsync().ConfigureAwait(false);
 0403            }
 0404        }
 0405    }
 406
 407    private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program)
 408    {
 0409        var image = program.IsSeries ?
 0410            (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) :
 0411            (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0));
 412
 0413        if (image is not null)
 414        {
 415            try
 416            {
 0417                await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
 0418            }
 0419            catch (Exception ex)
 420            {
 0421                _logger.LogError(ex, "Error saving recording image");
 0422            }
 423        }
 424
 0425        if (!program.IsSeries)
 426        {
 0427            image = program.GetImageInfo(ImageType.Backdrop, 0);
 0428            if (image is not null)
 429            {
 430                try
 431                {
 0432                    await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
 0433                }
 0434                catch (Exception ex)
 435                {
 0436                    _logger.LogError(ex, "Error saving recording image");
 0437                }
 438            }
 439
 0440            image = program.GetImageInfo(ImageType.Thumb, 0);
 0441            if (image is not null)
 442            {
 443                try
 444                {
 0445                    await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
 0446                }
 0447                catch (Exception ex)
 448                {
 0449                    _logger.LogError(ex, "Error saving recording image");
 0450                }
 451            }
 452
 0453            image = program.GetImageInfo(ImageType.Logo, 0);
 0454            if (image is not null)
 455            {
 456                try
 457                {
 0458                    await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
 0459                }
 0460                catch (Exception ex)
 461                {
 0462                    _logger.LogError(ex, "Error saving recording image");
 0463                }
 464            }
 465        }
 0466    }
 467
 468    private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image)
 469    {
 0470        if (!image.IsLocalFile)
 471        {
 0472            image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false);
 473        }
 474
 0475        var imageSaveFilenameWithoutExtension = image.Type switch
 0476        {
 0477            ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster
 0478            ImageType.Logo => "logo",
 0479            ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscap
 0480            ImageType.Backdrop => "fanart",
 0481            _ => null
 0482        };
 483
 0484        if (imageSaveFilenameWithoutExtension is null)
 485        {
 0486            return;
 487        }
 488
 0489        var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath)!, imageSaveFilenameWithoutExtension);
 490
 491        // preserve original image extension
 0492        imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path));
 493
 0494        File.Copy(image.Path, imageSavePath, true);
 0495    }
 496}