diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs index 8a8dc9695..f4dc575e8 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Metadata; namespace ErsatzTV.Core.Interfaces.Repositories; @@ -47,6 +47,16 @@ public interface IMediaServerTelevisionRepository episodeItemIds, CancellationToken cancellationToken); + + // Cascade helpers (#476): when a parent is swept to FileNotFound because it is gone from the media + // server, the per-parent loop never visits it, so its descendants are never reconciled. These flag + // the descendants by parent MediaItem.Id (Season.ShowId / Episode.SeasonId are on the base tables). + Task> FlagFileNotFoundSeasonsForShows( + List showIds, + CancellationToken cancellationToken); + Task> FlagFileNotFoundEpisodesForSeasons( + List seasonIds, + CancellationToken cancellationToken); Task> FlagUnavailable(TLibrary library, TEpisode episode, CancellationToken cancellationToken); Task> FlagRemoteOnly(TLibrary library, TEpisode episode, CancellationToken cancellationToken); } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs index ac8408a06..871d7ac73 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs @@ -1,4 +1,4 @@ -using Dapper; +using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby; @@ -387,6 +387,53 @@ public class EmbyTelevisionRepository( return ids; } + // #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the + // already-scoped show ids needs no provider join. + public async Task> FlagFileNotFoundSeasonsForShows( + List showIds, + CancellationToken cancellationToken) => + await FlagFileNotFoundByParent( + "SELECT Id FROM Season WHERE ShowId IN @ParentIds", + showIds, + cancellationToken); + + // #476: Episode.SeasonId is on the base Episode table. + public async Task> FlagFileNotFoundEpisodesForSeasons( + List seasonIds, + CancellationToken cancellationToken) => + await FlagFileNotFoundByParent( + "SELECT Id FROM Episode WHERE SeasonId IN @ParentIds", + seasonIds, + cancellationToken); + + private async Task> FlagFileNotFoundByParent( + string selectSql, + List parentIds, + CancellationToken cancellationToken) + { + if (parentIds.Count == 0) + { + return []; + } + + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + List ids = await dbContext.Connection.QueryAsync( + new CommandDefinition( + selectSql, + parameters: new { ParentIds = parentIds }, + cancellationToken: cancellationToken)) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + new CommandDefinition( + "UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1", + parameters: new { Ids = ids }, + cancellationToken: cancellationToken)); + + return ids; + } + public async Task> FlagFileNotFoundEpisodes( EmbyLibrary library, List episodeItemIds, diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs index 52d415689..c71be4b8d 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs @@ -1,4 +1,4 @@ -using Dapper; +using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; @@ -198,7 +198,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository await UpdateEpisode(dbContext, existing, item, cancellationToken); result = new MediaItemScanResult(existing) - { IsAdded = false, IsUpdated = true }; + { IsAdded = false, IsUpdated = true }; } else { @@ -421,6 +421,53 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository return ids; } + // #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the + // already-scoped show ids needs no provider join. + public async Task> FlagFileNotFoundSeasonsForShows( + List showIds, + CancellationToken cancellationToken) => + await FlagFileNotFoundByParent( + "SELECT Id FROM Season WHERE ShowId IN @ParentIds", + showIds, + cancellationToken); + + // #476: Episode.SeasonId is on the base Episode table. + public async Task> FlagFileNotFoundEpisodesForSeasons( + List seasonIds, + CancellationToken cancellationToken) => + await FlagFileNotFoundByParent( + "SELECT Id FROM Episode WHERE SeasonId IN @ParentIds", + seasonIds, + cancellationToken); + + private async Task> FlagFileNotFoundByParent( + string selectSql, + List parentIds, + CancellationToken cancellationToken) + { + if (parentIds.Count == 0) + { + return []; + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + List ids = await dbContext.Connection.QueryAsync( + new CommandDefinition( + selectSql, + parameters: new { ParentIds = parentIds }, + cancellationToken: cancellationToken)) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + new CommandDefinition( + "UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1", + parameters: new { Ids = ids }, + cancellationToken: cancellationToken)); + + return ids; + } + public async Task> FlagFileNotFoundEpisodes( JellyfinLibrary library, List episodeItemIds, diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs index 083e21f1b..32284e0ad 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -465,6 +465,53 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository return ids; } + // #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the + // already-scoped show ids needs no provider join. + public async Task> FlagFileNotFoundSeasonsForShows( + List showIds, + CancellationToken cancellationToken) => + await FlagFileNotFoundByParent( + "SELECT Id FROM Season WHERE ShowId IN @ParentIds", + showIds, + cancellationToken); + + // #476: Episode.SeasonId is on the base Episode table. + public async Task> FlagFileNotFoundEpisodesForSeasons( + List seasonIds, + CancellationToken cancellationToken) => + await FlagFileNotFoundByParent( + "SELECT Id FROM Episode WHERE SeasonId IN @ParentIds", + seasonIds, + cancellationToken); + + private async Task> FlagFileNotFoundByParent( + string selectSql, + List parentIds, + CancellationToken cancellationToken) + { + if (parentIds.Count == 0) + { + return []; + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + List ids = await dbContext.Connection.QueryAsync( + new CommandDefinition( + selectSql, + parameters: new { ParentIds = parentIds }, + cancellationToken: cancellationToken)) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + new CommandDefinition( + "UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1", + parameters: new { Ids = ids }, + cancellationToken: cancellationToken)); + + return ids; + } + public async Task> FlagFileNotFoundEpisodes( PlexLibrary library, List episodeItemIds, diff --git a/ErsatzTV.Scanner.Tests/Core/Metadata/MediaServerTelevisionLibraryScannerTests.cs b/ErsatzTV.Scanner.Tests/Core/Metadata/MediaServerTelevisionLibraryScannerTests.cs new file mode 100644 index 000000000..49c43862a --- /dev/null +++ b/ErsatzTV.Scanner.Tests/Core/Metadata/MediaServerTelevisionLibraryScannerTests.cs @@ -0,0 +1,173 @@ +using System.IO.Abstractions; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Scanner.Core.Interfaces; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; +using ErsatzTV.Scanner.Core.Metadata; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Scanner.Tests.Core.Metadata; + +// #476: a show/season gone from the media server is absent from the incoming list, so the per-parent +// loop never visits it and the descendant sweeps never run for it. These tests assert the scanner +// cascades the FileNotFound flag to descendants via the repository, using a substituted repository. +public class MediaServerTelevisionLibraryScannerTests +{ + [TestFixture] + public class CleanupFileNotFoundItems + { + [Test] + public async Task Removed_Show_Cascades_FileNotFound_To_Seasons_And_Episodes() + { + var televisionRepository = Substitute.For(); + var scannerProxy = Substitute.For(); + + var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" }; + + // one show exists in the DB but is NOT in the incoming (empty) list => it was removed upstream + televisionRepository.GetExistingShows(library, Arg.Any()) + .Returns(new List + { + new() { ItemId = "show-6366", State = MediaItemState.FileNotFound } + }); + + televisionRepository + .FlagFileNotFoundShows(library, Arg.Is>(l => l.Contains("show-6366")), + Arg.Any()) + .Returns(new List { 100 }); + televisionRepository + .FlagFileNotFoundSeasonsForShows(Arg.Is>(l => l.SequenceEqual(new[] { 100 })), + Arg.Any()) + .Returns(new List { 200, 201 }); + televisionRepository + .FlagFileNotFoundEpisodesForSeasons(Arg.Is>(l => l.SequenceEqual(new[] { 200, 201 })), + Arg.Any()) + .Returns(new List { 300, 301, 302 }); + scannerProxy.ReindexMediaItems(Arg.Any(), Arg.Any()).Returns(true); + + var scanner = new TestTelevisionLibraryScanner(scannerProxy); + + Either result = await scanner.Scan( + televisionRepository, + new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), + library, + EmptyShows()); + + result.IsRight.ShouldBeTrue(); + + await televisionRepository.Received(1).FlagFileNotFoundShows( + library, + Arg.Is>(l => l.Count == 1 && l.Contains("show-6366")), + Arg.Any()); + await televisionRepository.Received(1).FlagFileNotFoundSeasonsForShows( + Arg.Is>(l => l.SequenceEqual(new[] { 100 })), + Arg.Any()); + await televisionRepository.Received(1).FlagFileNotFoundEpisodesForSeasons( + Arg.Is>(l => l.SequenceEqual(new[] { 200, 201 })), + Arg.Any()); + + // every affected item (show + seasons + episodes) is reindexed so search reflects the new state + await scannerProxy.Received(1).ReindexMediaItems( + Arg.Is(a => new[] { 100, 200, 201, 300, 301, 302 }.All(id => a.Contains(id))), + Arg.Any()); + } + + private static async IAsyncEnumerable> EmptyShows() + { + await Task.CompletedTask; + yield break; + } + } + + // Minimal concrete subclass that exposes the abstract scanner's cleanup path. The incoming show list + // is supplied directly (empty = "all shows removed"), so none of the per-item metadata members below + // are ever invoked — they exist only to satisfy the abstract contract. + private sealed class TestTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< + JellyfinConnectionParameters, JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode, + JellyfinItemEtag> + { + public TestTelevisionLibraryScanner(IScannerProxy scannerProxy) + : base( + scannerProxy, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()) + { + } + + public Task> Scan( + IMediaServerTelevisionRepository televisionRepository, + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + IAsyncEnumerable> showEntries) => + ScanLibrary( + televisionRepository, + connectionParameters, + library, + _ => string.Empty, + showEntries, + false, + CancellationToken.None); + + protected override IAsyncEnumerable> GetShowLibraryItems( + JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) => + throw new NotSupportedException(); + + protected override string MediaServerItemId(JellyfinShow show) => show.ItemId; + protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId; + protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId; + protected override string MediaServerEtag(JellyfinShow show) => show.Etag; + protected override string MediaServerEtag(JellyfinSeason season) => season.Etag; + protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag; + + protected override IAsyncEnumerable> GetSeasonLibraryItems( + JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show) => + throw new NotSupportedException(); + + protected override IAsyncEnumerable> GetEpisodeLibraryItems( + JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show, + JellyfinSeason season, bool isNewSeason) => + throw new NotSupportedException(); + + protected override Task> GetFullMetadata( + JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, + MediaItemScanResult result, JellyfinShow incoming, bool deepScan) => + throw new NotSupportedException(); + + protected override Task> GetFullMetadata( + JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, + MediaItemScanResult result, JellyfinSeason incoming, bool deepScan) => + throw new NotSupportedException(); + + protected override Task> GetFullMetadata( + JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, + MediaItemScanResult result, JellyfinEpisode incoming, bool deepScan) => + throw new NotSupportedException(); + + protected override Task>> GetFullMetadataAndStatistics( + JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, + MediaItemScanResult result, JellyfinEpisode incoming) => + throw new NotSupportedException(); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, ShowMetadata fullMetadata) => + throw new NotSupportedException(); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, SeasonMetadata fullMetadata) => + throw new NotSupportedException(); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, EpisodeMetadata fullMetadata, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs index 2cb7b6a11..bc6a0619d 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs @@ -1,4 +1,4 @@ -using System.IO.Abstractions; +using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.MediaServer; @@ -175,7 +175,16 @@ public abstract class MediaServerTelevisionLibraryScanner s.MediaServerItemId).Except(incomingItemIds).ToList(); List ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds, cancellationToken); - if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken)) + + // #476: a show gone from the media server is absent from the incoming list, so the per-show + // loop never visits it and the season/episode sweeps below never run for it. Cascade the flag + // to its descendants so they don't linger (RemoteOnly on Jellyfin/Emby) and keep getting + // scheduled as guaranteed tune-in failures. + List seasonIds = await televisionRepository.FlagFileNotFoundSeasonsForShows(ids, cancellationToken); + List episodeIds = await televisionRepository.FlagFileNotFoundEpisodesForSeasons(seasonIds, cancellationToken); + + var reindexIds = ids.Concat(seasonIds).Concat(episodeIds).ToArray(); + if (!await _scannerProxy.ReindexMediaItems(reindexIds, cancellationToken)) { _logger.LogWarning("Failed to reindex media items from scanner process"); } @@ -369,7 +378,13 @@ public abstract class MediaServerTelevisionLibraryScanner s.MediaServerItemId).Except(incomingItemIds).ToList(); List ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds, cancellationToken); - if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken)) + + // #476: a season gone from the media server (while its show remains) is absent from the incoming + // list, so the per-season loop never visits it and the episode sweep in ScanEpisodes never runs + // for it. Cascade the flag to its episodes. + List episodeIds = await televisionRepository.FlagFileNotFoundEpisodesForSeasons(ids, cancellationToken); + + if (!await _scannerProxy.ReindexMediaItems(ids.Concat(episodeIds).ToArray(), cancellationToken)) { _logger.LogWarning("Failed to reindex media items from scanner process"); } diff --git a/ErsatzTV.Tests/Integration/TelevisionRepositoryCascadeTests.cs b/ErsatzTV.Tests/Integration/TelevisionRepositoryCascadeTests.cs new file mode 100644 index 000000000..a7cd8e80f --- /dev/null +++ b/ErsatzTV.Tests/Integration/TelevisionRepositoryCascadeTests.cs @@ -0,0 +1,156 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Integration; + +// #476: exercises the actual cascade SQL against the real schema. When a show/season is gone from the +// media server the per-parent scan loop never visits it, so its descendants must be swept to +// FileNotFound by parent MediaItem.Id. These prove the queries flip the right rows and only those. +[TestFixture] +public class TelevisionRepositoryCascadeTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task FlagFileNotFoundSeasonsForShows_Flags_Only_The_Targeted_Shows_Seasons() + { + await SeedTwoShows(); + var repository = new JellyfinTelevisionRepository(_db.Factory, NullLogger.Instance); + + // cascade from show 20 only + List flagged = await repository.FlagFileNotFoundSeasonsForShows([20], CancellationToken.None); + + flagged.ShouldBe([30, 31], ignoreOrder: true); + (await StateOf(30)).ShouldBe(MediaItemState.FileNotFound); + (await StateOf(31)).ShouldBe(MediaItemState.FileNotFound); + // sibling show 50's season is untouched + (await StateOf(60)).ShouldBe(MediaItemState.RemoteOnly); + } + + [Test] + public async Task FlagFileNotFoundEpisodesForSeasons_Flags_Only_The_Targeted_Seasons_Episodes() + { + await SeedTwoShows(); + var repository = new JellyfinTelevisionRepository(_db.Factory, NullLogger.Instance); + + // cascade from show 20's seasons only + List flagged = await repository.FlagFileNotFoundEpisodesForSeasons([30, 31], CancellationToken.None); + + flagged.ShouldBe([40, 41, 42], ignoreOrder: true); + (await StateOf(40)).ShouldBe(MediaItemState.FileNotFound); + (await StateOf(41)).ShouldBe(MediaItemState.FileNotFound); + (await StateOf(42)).ShouldBe(MediaItemState.FileNotFound); + // sibling show 50's episode is untouched + (await StateOf(70)).ShouldBe(MediaItemState.RemoteOnly); + } + + [Test] + public async Task Cascade_Helpers_No_Op_On_Empty_Input() + { + await SeedTwoShows(); + var repository = new JellyfinTelevisionRepository(_db.Factory, NullLogger.Instance); + + (await repository.FlagFileNotFoundSeasonsForShows([], CancellationToken.None)).ShouldBeEmpty(); + (await repository.FlagFileNotFoundEpisodesForSeasons([], CancellationToken.None)).ShouldBeEmpty(); + + // nothing changed + (await StateOf(30)).ShouldBe(MediaItemState.RemoteOnly); + (await StateOf(40)).ShouldBe(MediaItemState.RemoteOnly); + } + + private async Task StateOf(int id) + { + await using TvContext context = _db.CreateContext(); + MediaItem item = await context.MediaItems.AsNoTracking().SingleAsync(m => m.Id == id); + return item.State; + } + + // show 20 → seasons 30,31 → episodes 40,41 (season 30), 42 (season 31) + // sibling show 50 → season 60 → episode 70 (must never be touched by a cascade from show 20) + private async Task SeedTwoShows() + { + await using TvContext context = _db.CreateContext(); + + var library = new LocalLibrary { Id = 1, Name = "TV", MediaKind = LibraryMediaKind.Shows, Paths = [] }; + var path = new LibraryPath { Id = 1, Path = "/media", Library = library, LibraryFolders = [], MediaItems = [] }; + library.Paths.Add(path); + + Show show20 = MakeShow(20, path); + Season season30 = MakeSeason(30, path, show20); + Season season31 = MakeSeason(31, path, show20); + Episode ep40 = MakeEpisode(40, path, season30); + Episode ep41 = MakeEpisode(41, path, season30); + Episode ep42 = MakeEpisode(42, path, season31); + + Show show50 = MakeShow(50, path); + Season season60 = MakeSeason(60, path, show50); + Episode ep70 = MakeEpisode(70, path, season60); + + path.MediaItems.AddRange([show20, season30, season31, ep40, ep41, ep42, show50, season60, ep70]); + context.LocalLibraries.Add(library); + context.Shows.AddRange(show20, show50); + context.Seasons.AddRange(season30, season31, season60); + context.Episodes.AddRange(ep40, ep41, ep42, ep70); + await context.SaveChangesAsync(); + } + + private static Show MakeShow(int id, LibraryPath path) => new() + { + Id = id, + LibraryPath = path, + State = MediaItemState.FileNotFound, // the show itself is already swept; children lag behind + Collections = [], + CollectionItems = [], + TraktListItems = [], + Seasons = [], + ShowMetadata = [] + }; + + private static Season MakeSeason(int id, LibraryPath path, Show show) + { + var season = new Season + { + Id = id, + LibraryPath = path, + Show = show, + State = MediaItemState.RemoteOnly, + Collections = [], + CollectionItems = [], + TraktListItems = [], + Episodes = [], + SeasonMetadata = [] + }; + show.Seasons.Add(season); + return season; + } + + private static Episode MakeEpisode(int id, LibraryPath path, Season season) + { + var episode = new Episode + { + Id = id, + LibraryPath = path, + Season = season, + State = MediaItemState.RemoteOnly, + Collections = [], + CollectionItems = [], + TraktListItems = [], + EpisodeMetadata = [], + MediaVersions = [] + }; + season.Episodes.Add(episode); + return episode; + } +}