fix(476): cascade FileNotFound from removed shows/seasons to descendants #481
@@ -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<in TLibrary, TShow, TSeason, T
|
||||
TLibrary library,
|
||||
List<string> 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<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> showIds,
|
||||
CancellationToken cancellationToken);
|
||||
Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken);
|
||||
Task<Option<int>> FlagUnavailable(TLibrary library, TEpisode episode, CancellationToken cancellationToken);
|
||||
Task<Option<int>> FlagRemoteOnly(TLibrary library, TEpisode episode, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -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<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> 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<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
|
||||
seasonIds,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<List<int>> FlagFileNotFoundByParent(
|
||||
string selectSql,
|
||||
List<int> parentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
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<List<int>> FlagFileNotFoundEpisodes(
|
||||
EmbyLibrary library,
|
||||
List<string> episodeItemIds,
|
||||
|
||||
@@ -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<JellyfinEpisode>(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<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> 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<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
|
||||
seasonIds,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<List<int>> FlagFileNotFoundByParent(
|
||||
string selectSql,
|
||||
List<int> parentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
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<List<int>> FlagFileNotFoundEpisodes(
|
||||
JellyfinLibrary library,
|
||||
List<string> episodeItemIds,
|
||||
|
||||
@@ -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<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> 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<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
|
||||
seasonIds,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<List<int>> FlagFileNotFoundByParent(
|
||||
string selectSql,
|
||||
List<int> parentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
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<List<int>> FlagFileNotFoundEpisodes(
|
||||
PlexLibrary library,
|
||||
List<string> episodeItemIds,
|
||||
|
||||
@@ -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<IJellyfinTelevisionRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "show-6366", State = MediaItemState.FileNotFound }
|
||||
});
|
||||
|
||||
televisionRepository
|
||||
.FlagFileNotFoundShows(library, Arg.Is<List<string>>(l => l.Contains("show-6366")),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 100 });
|
||||
televisionRepository
|
||||
.FlagFileNotFoundSeasonsForShows(Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 100 })),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 200, 201 });
|
||||
televisionRepository
|
||||
.FlagFileNotFoundEpisodesForSeasons(Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 200, 201 })),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 300, 301, 302 });
|
||||
scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var scanner = new TestTelevisionLibraryScanner(scannerProxy);
|
||||
|
||||
Either<BaseError, Unit> 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<List<string>>(l => l.Count == 1 && l.Contains("show-6366")),
|
||||
Arg.Any<CancellationToken>());
|
||||
await televisionRepository.Received(1).FlagFileNotFoundSeasonsForShows(
|
||||
Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 100 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
await televisionRepository.Received(1).FlagFileNotFoundEpisodesForSeasons(
|
||||
Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 200, 201 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
// every affected item (show + seasons + episodes) is reindexed so search reflects the new state
|
||||
await scannerProxy.Received(1).ReindexMediaItems(
|
||||
Arg.Is<int[]>(a => new[] { 100, 200, 201, 300, 301, 302 }.All(id => a.Contains(id))),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<JellyfinShow, int>> 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<IFileSystem>(),
|
||||
Substitute.For<ILocalChaptersProvider>(),
|
||||
Substitute.For<IMetadataRepository>(),
|
||||
Substitute.For<ILogger>())
|
||||
{
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Scan(
|
||||
IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||
JellyfinItemEtag> televisionRepository,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> showEntries) =>
|
||||
ScanLibrary(
|
||||
televisionRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
_ => string.Empty,
|
||||
showEntries,
|
||||
false,
|
||||
CancellationToken.None);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinShow, int>> 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<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
|
||||
JellyfinSeason season, bool isNewSeason) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinShow> result, JellyfinShow incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinSeason> result, JellyfinSeason incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinEpisode> result, JellyfinEpisode incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<Tuple<EpisodeMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinEpisode> result, JellyfinEpisode incoming) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinShow> result, ShowMetadata fullMetadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinSeason> result, SeasonMetadata fullMetadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinEpisode> result, EpisodeMetadata fullMetadata,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -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<TConnectionParameters,
|
||||
// trash shows that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> 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<int> seasonIds = await televisionRepository.FlagFileNotFoundSeasonsForShows(ids, cancellationToken);
|
||||
List<int> 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<TConnectionParameters,
|
||||
// trash seasons that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> 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<int> 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");
|
||||
}
|
||||
|
||||
@@ -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<JellyfinTelevisionRepository>.Instance);
|
||||
|
||||
// cascade from show 20 only
|
||||
List<int> 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<JellyfinTelevisionRepository>.Instance);
|
||||
|
||||
// cascade from show 20's seasons only
|
||||
List<int> 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<JellyfinTelevisionRepository>.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<MediaItemState> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user