diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8a1893..4b14581f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `channel_number` - `channel_name` - `time_of_day_seconds` - the start time for the current item, represented in seconds since midnight +- Add support for external chapter files next to video files + - Currently supports Matroska Chapter XML format + - Chapter files have .xml or .chapters extension ### Fix - Fix database operations that were slowing down playout builds diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs index fadfb849..926d37cd 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs @@ -48,4 +48,5 @@ public interface IMetadataRepository Task RemoveDirector(Director director); Task RemoveWriter(Writer writer); Task UpdateSubtitles(Domain.Metadata metadata, List subtitles); + Task UpdateChapters(MediaVersion version, List chapters); } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs index c3a3b967..af3b91b8 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs @@ -537,6 +537,47 @@ public class MetadataRepository : IMetadataRepository return await UpdateSubtitles(dbContext, metadata, subtitles); } + public async Task UpdateChapters(MediaVersion version, List chapters) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + var maybeExisting = await dbContext.MediaVersions + .Include(mv => mv.Chapters) + .SelectOneAsync(mv => mv.Id, mv => mv.Id == version.Id); + + foreach (MediaVersion existing in maybeExisting) + { + var chaptersToAdd = chapters + .Filter(s => existing.Chapters.All(es => es.ChapterId != s.ChapterId)) + .ToList(); + var chaptersToRemove = existing.Chapters + .Filter(es => chapters.All(s => s.ChapterId != es.ChapterId)) + .ToList(); + var chaptersToUpdate = chapters.Except(chaptersToAdd).ToList(); + + // add + existing.Chapters.AddRange(chaptersToAdd); + + // remove + existing.Chapters.RemoveAll(chaptersToRemove.Contains); + + // update + foreach (MediaChapter incomingChapter in chaptersToUpdate) + { + MediaChapter existingChapter = existing.Chapters + .First(s => s.ChapterId == incomingChapter.ChapterId); + + existingChapter.StartTime = incomingChapter.StartTime; + existingChapter.EndTime = incomingChapter.EndTime; + existingChapter.Title = incomingChapter.Title; + } + + return await dbContext.SaveChangesAsync() > 0; + } + + return false; + } + public async Task RemoveGenre(Genre genre) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs b/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs index 99890a3b..2c1c65e0 100644 --- a/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs @@ -648,6 +648,7 @@ public class MovieFolderScannerTests _movieRepository, _localStatisticsProvider, Substitute.For(), + Substitute.For(), _localMetadataProvider, Substitute.For(), _imageCache, @@ -666,6 +667,7 @@ public class MovieFolderScannerTests _movieRepository, _localStatisticsProvider, Substitute.For(), + Substitute.For(), _localMetadataProvider, Substitute.For(), _imageCache, diff --git a/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs index d71003b6..2c0c9eda 100644 --- a/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs @@ -5,6 +5,7 @@ using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata; using Microsoft.Extensions.Logging; @@ -28,10 +29,12 @@ public class EmbyMovieLibraryScanner : IEmbyMovieRepository embyMovieRepository, IEmbyPathReplacementService pathReplacementService, ILocalFileSystem localFileSystem, + ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, ILogger logger) : base( localFileSystem, + localChaptersProvider, metadataRepository, mediator, logger) diff --git a/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs index 42315574..bc6d30cf 100644 --- a/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs @@ -5,6 +5,7 @@ using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata; using Microsoft.Extensions.Logging; @@ -27,11 +28,13 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< IEmbyTelevisionRepository televisionRepository, IEmbyPathReplacementService pathReplacementService, ILocalFileSystem localFileSystem, + ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, IMediator mediator, ILogger logger) : base( localFileSystem, + localChaptersProvider, metadataRepository, mediator, logger) diff --git a/ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalChaptersProvider.cs b/ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalChaptersProvider.cs new file mode 100644 index 00000000..9cd4ece3 --- /dev/null +++ b/ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalChaptersProvider.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Scanner.Core.Interfaces.Metadata; + +public interface ILocalChaptersProvider : IDisposable +{ + Task UpdateChapters(MediaItem mediaItem, Option localPath); +} \ No newline at end of file diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs index ceb679bd..366b9848 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs @@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata; @@ -28,10 +29,12 @@ public class JellyfinMovieLibraryScanner : IJellyfinPathReplacementService pathReplacementService, IMediaSourceRepository mediaSourceRepository, ILocalFileSystem localFileSystem, + ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, ILogger logger) : base( localFileSystem, + localChaptersProvider, metadataRepository, mediator, logger) diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs index ff212a5a..f87b3610 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs @@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata; @@ -28,11 +29,13 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan IJellyfinTelevisionRepository televisionRepository, IJellyfinPathReplacementService pathReplacementService, ILocalFileSystem localFileSystem, + ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, IMediator mediator, ILogger logger) : base( localFileSystem, + localChaptersProvider, metadataRepository, mediator, logger) diff --git a/ErsatzTV.Scanner/Core/Metadata/LocalChaptersProvider.cs b/ErsatzTV.Scanner/Core/Metadata/LocalChaptersProvider.cs new file mode 100644 index 00000000..7aa234c0 --- /dev/null +++ b/ErsatzTV.Scanner/Core/Metadata/LocalChaptersProvider.cs @@ -0,0 +1,263 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; +using Microsoft.Extensions.Logging; +using System.Text.RegularExpressions; +using System.Xml; + +namespace ErsatzTV.Scanner.Core.Metadata; + +public partial class LocalChaptersProvider : ILocalChaptersProvider +{ + private readonly ILocalFileSystem _localFileSystem; + private readonly ILogger _logger; + private readonly IMetadataRepository _metadataRepository; + + private bool _disposedValue; + + public LocalChaptersProvider( + IMetadataRepository metadataRepository, + ILocalFileSystem localFileSystem, + ILogger logger) + { + _metadataRepository = metadataRepository; + _localFileSystem = localFileSystem; + _logger = logger; + } + + public async Task UpdateChapters(MediaItem mediaItem, Option localPath) + { + try + { + MediaVersion version = mediaItem.GetHeadVersion(); + string mediaItemPath = await localPath.IfNoneAsync(() => version.MediaFiles.Head().Path); + + List chapters = LocateExternalChapters(mediaItemPath); + + if (chapters.Count > 0) + { + _logger.LogDebug("Located {Count} external chapters for {Path}", chapters.Count, mediaItemPath); + return await _metadataRepository.UpdateChapters(version, chapters); + } + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update chapters for media item {MediaItemId}", mediaItem.Id); + return false; + } + } + + public List LocateExternalChapters(string mediaItemPath) + { + var result = new List(); + + string? folder = Path.GetDirectoryName(mediaItemPath); + string withoutExtension = Path.GetFileNameWithoutExtension(mediaItemPath); + + foreach (string file in _localFileSystem.ListFiles(folder, $"{withoutExtension}*")) + { + string lowerFile = file.ToLowerInvariant(); + string fileName = Path.GetFileName(file); + + if (!fileName.StartsWith(withoutExtension, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string extension = Path.GetExtension(lowerFile); + if (extension is not (".xml" or ".chapters")) + { + continue; + } + + try + { + List chapters = ParseChapterFile(file); + if (chapters.Count > 0) + { + _logger.LogDebug("Located external chapter file at {Path}", file); + result.AddRange(chapters); + break; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse chapter file at {Path}", file); + } + } + + return result; + } + + private List ParseChapterFile(string filePath) + { + var chapters = new List(); + + try + { + var doc = new XmlDocument(); + doc.Load(filePath); + + // Check if this is a Matroska XML chapter file + XmlNode? chaptersNode = doc.SelectSingleNode("//Chapters") ?? doc.SelectSingleNode("//chapters"); + if (chaptersNode != null) + { + return ParseMatroskaXmlChapters(chaptersNode); + } + + _logger.LogWarning("Unsupported chapter file format: {Path}", filePath); + } + catch (XmlException ex) + { + _logger.LogWarning(ex, "Invalid XML in chapter file: {Path}", filePath); + } + + return chapters; + } + + private static List ParseMatroskaXmlChapters(XmlNode chaptersNode) + { + var chapters = new List(); + + XmlNodeList? chapterAtoms = chaptersNode.SelectNodes(".//ChapterAtom") ?? + chaptersNode.SelectNodes(".//chapteratom"); + + if (chapterAtoms == null) + { + return chapters; + } + + long chapterId = 0; + foreach (XmlNode chapterAtom in chapterAtoms) + { + var chapter = ParseChapterAtom(chapterAtom, chapterId++); + if (chapter != null) + { + chapters.Add(chapter); + } + } + + chapters.Sort((a, b) => a.StartTime.CompareTo(b.StartTime)); + + for (int i = 0; i < chapters.Count; i++) + { + chapters[i].ChapterId = i; + } + + return chapters; + } + + private static MediaChapter? ParseChapterAtom(XmlNode chapterAtom, long chapterId) + { + XmlNode? startNode = chapterAtom.SelectSingleNode(".//ChapterTimeStart") ?? + chapterAtom.SelectSingleNode(".//chaptertimestart"); + + if (startNode?.InnerText == null) + { + return null; + } + + if (!TryParseMatroskaTime(startNode.InnerText, out TimeSpan startTime)) + { + return null; + } + + TimeSpan endTime = TimeSpan.Zero; + XmlNode? endNode = chapterAtom.SelectSingleNode(".//ChapterTimeEnd") ?? + chapterAtom.SelectSingleNode(".//chaptertimeend"); + + if (endNode?.InnerText != null) + { + _ = TryParseMatroskaTime(endNode.InnerText, out endTime); + } + + string title = string.Empty; + XmlNode? titleNode = chapterAtom.SelectSingleNode(".//ChapterString") ?? + chapterAtom.SelectSingleNode(".//ChapString") ?? + chapterAtom.SelectSingleNode(".//chapterstring") ?? + chapterAtom.SelectSingleNode(".//chapstring"); + + if (titleNode?.InnerText != null) + { + title = titleNode.InnerText.Trim(); + } + + return new MediaChapter + { + ChapterId = chapterId, + StartTime = startTime, + EndTime = endTime, + Title = title + }; + } + + private static bool TryParseMatroskaTime(string timeString, out TimeSpan timeSpan) + { + timeSpan = TimeSpan.Zero; + + if (string.IsNullOrWhiteSpace(timeString)) + { + return false; + } + + // Handle nanoseconds format (raw timestamp) + if (long.TryParse(timeString, out long nanoseconds)) + { + timeSpan = TimeSpan.FromTicks(nanoseconds / 100); + return true; + } + + // Handle time format HH:MM:SS.mmm or HH:MM:SS,mmm + var timeFormats = new Regex[] + { + GetParseFullTimeCodeRegex(), + GetParseTimeCodeNoMilliRegex() + }; + + foreach (Regex pattern in timeFormats) + { + var match = pattern.Match(timeString); + if (match.Success) + { + if (int.TryParse(match.Groups[1].Value, out int hours) && + int.TryParse(match.Groups[2].Value, out int minutes) && + int.TryParse(match.Groups[3].Value, out int seconds)) + { + int milliseconds = 0; + if (match.Groups.Count > 4 && !int.TryParse(match.Groups[4].Value, out milliseconds)) + { + milliseconds = 0; + } + + timeSpan = new TimeSpan(0, hours, minutes, seconds, milliseconds); + return true; + } + } + } + + return false; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + _disposedValue = true; + } + } + + [GeneratedRegex(@"^(\d{1,2}):(\d{2}):(\d{2})[\.,](\d{3})$")] + private static partial Regex GetParseFullTimeCodeRegex(); + [GeneratedRegex(@"^(\d{1,2}):(\d{2}):(\d{2})$")] + private static partial Regex GetParseTimeCodeNoMilliRegex(); +} \ No newline at end of file diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs index bbfb1e0f..eb155993 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs @@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.Metadata; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using Microsoft.Extensions.Logging; namespace ErsatzTV.Scanner.Core.Metadata; @@ -19,17 +20,20 @@ public abstract class MediaServerMovieLibraryScanner>> UpdateChapters( + MediaItemScanResult existing) + { + try + { + if (string.IsNullOrEmpty(existing.LocalPath)) + { + // No local path available for external chapter file lookup + return existing; + } + + if (await _localChaptersProvider.UpdateChapters(existing.Item, Some(existing.LocalPath))) + { + existing.IsUpdated = true; + } + + return existing; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } } diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs index f845f495..87ef6327 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs @@ -6,6 +6,7 @@ using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.Metadata; using Microsoft.Extensions.Logging; @@ -19,17 +20,20 @@ public abstract class MediaServerOtherVideoLibraryScanner>> UpdateChapters( + MediaItemScanResult existing) + { + try + { + if (string.IsNullOrEmpty(existing.LocalPath)) + { + // No local path available for external chapter file lookup + return existing; + } + + if (await _localChaptersProvider.UpdateChapters(existing.Item, Some(existing.LocalPath))) + { + existing.IsUpdated = true; + } + + return existing; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } } diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs index 71469371..bd2e8ba1 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs @@ -5,6 +5,7 @@ using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.Metadata; using Microsoft.Extensions.Logging; @@ -21,17 +22,20 @@ public abstract class MediaServerTelevisionLibraryScanner>> UpdateChapters( + MediaItemScanResult existing) + { + try + { + if (string.IsNullOrEmpty(existing.LocalPath)) + { + // No local path available for external chapter file lookup + return existing; + } + + if (await _localChaptersProvider.UpdateChapters(existing.Item, Some(existing.LocalPath))) + { + existing.IsUpdated = true; + } + + return existing; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } } diff --git a/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs index f58b647c..2795e860 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs @@ -24,6 +24,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner private readonly ILocalFileSystem _localFileSystem; private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider; + private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediator _mediator; @@ -34,6 +35,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner IMovieRepository movieRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, + ILocalChaptersProvider localChaptersProvider, ILocalMetadataProvider localMetadataProvider, IMetadataRepository metadataRepository, IImageCache imageCache, @@ -58,6 +60,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner _localFileSystem = localFileSystem; _movieRepository = movieRepository; _localSubtitlesProvider = localSubtitlesProvider; + _localChaptersProvider = localChaptersProvider; _localMetadataProvider = localMetadataProvider; _libraryRepository = libraryRepository; _mediaItemRepository = mediaItemRepository; @@ -180,6 +183,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken)) .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken)) .BindT(UpdateSubtitles) + .BindT(UpdateChapters) .BindT(FlagNormal); foreach (BaseError error in maybeMovie.LeftToSeq()) @@ -334,6 +338,20 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner } } + private async Task>> UpdateChapters(MediaItemScanResult result) + { + try + { + await _localChaptersProvider.UpdateChapters(result.Item, None); + return result; + } + catch (Exception ex) + { + _client.Notify(ex); + return BaseError.New(ex.ToString()); + } + } + private Option LocateNfoFile(Movie movie) { string path = movie.MediaVersions.Head().MediaFiles.Head().Path; diff --git a/ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs index a837cf55..90a5f002 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs @@ -24,6 +24,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan private readonly ILocalFileSystem _localFileSystem; private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider; + private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediator _mediator; @@ -34,6 +35,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan ILocalStatisticsProvider localStatisticsProvider, ILocalMetadataProvider localMetadataProvider, ILocalSubtitlesProvider localSubtitlesProvider, + ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, IImageCache imageCache, IArtistRepository artistRepository, @@ -58,6 +60,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan _localFileSystem = localFileSystem; _localMetadataProvider = localMetadataProvider; _localSubtitlesProvider = localSubtitlesProvider; + _localChaptersProvider = localChaptersProvider; _artistRepository = artistRepository; _musicVideoRepository = musicVideoRepository; _libraryRepository = libraryRepository; @@ -380,6 +383,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan .BindT(UpdateMetadata) .BindT(result => UpdateThumbnail(result, cancellationToken)) .BindT(UpdateSubtitles) + .BindT(UpdateChapters) .BindT(FlagNormal); foreach (BaseError error in maybeMusicVideo.LeftToSeq()) @@ -543,6 +547,21 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan } } + private async Task>> UpdateChapters( + MediaItemScanResult result) + { + try + { + await _localChaptersProvider.UpdateChapters(result.Item, None); + return result; + } + catch (Exception ex) + { + _client.Notify(ex); + return BaseError.New(ex.ToString()); + } + } + private Option LocateThumbnail(MusicVideo musicVideo) { string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path; diff --git a/ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs b/ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs index 7c40a5b8..729de7a3 100644 --- a/ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs @@ -23,6 +23,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan private readonly ILocalFileSystem _localFileSystem; private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider; + private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediator _mediator; @@ -33,6 +34,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan ILocalStatisticsProvider localStatisticsProvider, ILocalMetadataProvider localMetadataProvider, ILocalSubtitlesProvider localSubtitlesProvider, + ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, IImageCache imageCache, IMediator mediator, @@ -56,6 +58,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan _localFileSystem = localFileSystem; _localMetadataProvider = localMetadataProvider; _localSubtitlesProvider = localSubtitlesProvider; + _localChaptersProvider = localChaptersProvider; _mediator = mediator; _otherVideoRepository = otherVideoRepository; _libraryRepository = libraryRepository; @@ -189,6 +192,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan .BindT(UpdateMetadata) .BindT(video => UpdateThumbnail(video, cancellationToken)) .BindT(UpdateSubtitles) + .BindT(UpdateChapters) .BindT(FlagNormal); foreach (BaseError error in maybeVideo.LeftToSeq()) @@ -339,6 +343,21 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan } } + private async Task>> UpdateChapters( + MediaItemScanResult result) + { + try + { + await _localChaptersProvider.UpdateChapters(result.Item, None); + return result; + } + catch (Exception ex) + { + _client.Notify(ex); + return BaseError.New(ex.ToString()); + } + } + private async Task>> UpdateThumbnail( MediaItemScanResult result, CancellationToken cancellationToken) diff --git a/ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs index 2cf614c6..7aaee76e 100644 --- a/ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs @@ -24,6 +24,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan private readonly ILocalFileSystem _localFileSystem; private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider; + private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediator _mediator; @@ -36,6 +37,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan ILocalStatisticsProvider localStatisticsProvider, ILocalMetadataProvider localMetadataProvider, ILocalSubtitlesProvider localSubtitlesProvider, + ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, IImageCache imageCache, ILibraryRepository libraryRepository, @@ -60,6 +62,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan _televisionRepository = televisionRepository; _localMetadataProvider = localMetadataProvider; _localSubtitlesProvider = localSubtitlesProvider; + _localChaptersProvider = localChaptersProvider; _metadataRepository = metadataRepository; _libraryRepository = libraryRepository; _mediaItemRepository = mediaItemRepository; @@ -349,6 +352,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan .BindT(UpdateMetadata) .BindT(e => UpdateThumbnail(e, cancellationToken)) .BindT(UpdateSubtitles) + .BindT(UpdateChapters) .BindT(e => FlagNormal(new MediaItemScanResult(e))) .MapT(r => r.Item); @@ -587,6 +591,20 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan } } + private async Task> UpdateChapters(Episode episode) + { + try + { + await _localChaptersProvider.UpdateChapters(episode, None); + return episode; + } + catch (Exception ex) + { + _client.Notify(ex); + return BaseError.New(ex.ToString()); + } + } + private Option LocateNfoFileForShow(string showFolder) => Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _localFileSystem.FileExists(s)); diff --git a/ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs index d99da085..83426ae2 100644 --- a/ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs @@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Scanner.Core.Metadata; using Microsoft.Extensions.Logging; @@ -32,9 +33,11 @@ public class PlexMovieLibraryScanner : IPlexMovieRepository plexMovieRepository, IPlexPathReplacementService plexPathReplacementService, ILocalFileSystem localFileSystem, + ILocalChaptersProvider localChaptersProvider, ILogger logger) : base( localFileSystem, + localChaptersProvider, metadataRepository, mediator, logger) diff --git a/ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs b/ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs index d289fe5f..3ce4a6c6 100644 --- a/ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs @@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Scanner.Core.Metadata; @@ -32,9 +33,11 @@ public class PlexOtherVideoLibraryScanner : IPlexOtherVideoRepository plexOtherVideoRepository, IPlexPathReplacementService plexPathReplacementService, ILocalFileSystem localFileSystem, + ILocalChaptersProvider localChaptersProvider, ILogger logger) : base( localFileSystem, + localChaptersProvider, metadataRepository, mediator, logger) diff --git a/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs index d56936e8..c8ac73eb 100644 --- a/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs @@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Scanner.Core.Metadata; @@ -32,9 +33,11 @@ public class PlexTelevisionLibraryScanner : IPlexPathReplacementService plexPathReplacementService, IPlexTelevisionRepository plexTelevisionRepository, ILocalFileSystem localFileSystem, + ILocalChaptersProvider localChaptersProvider, ILogger logger) : base( localFileSystem, + localChaptersProvider, metadataRepository, mediator, logger) diff --git a/ErsatzTV.Scanner/Program.cs b/ErsatzTV.Scanner/Program.cs index 4d5c9c4d..422bc0cb 100644 --- a/ErsatzTV.Scanner/Program.cs +++ b/ErsatzTV.Scanner/Program.cs @@ -187,6 +187,7 @@ public class Program services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();