Browse Source

add external chapter file scanning (#2317)

* add external chapter file scanning

Support Matroska chapter xml files next to media file with extension .xml or .chapters

* only update chapters in db

---------

Co-authored-by: Jeff Slutter <MrMustard@gmail.com>
Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
dependabot/nuget/ErsatzTV.Infrastructure/Elastic.Clients.Elasticsearch-9.1.4
midnite8177 2 days ago committed by GitHub
parent
commit
f626954eb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 1
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  3. 41
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  4. 2
      ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs
  5. 3
      ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs
  6. 3
      ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs
  7. 8
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalChaptersProvider.cs
  8. 3
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  9. 3
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  10. 263
      ErsatzTV.Scanner/Core/Metadata/LocalChaptersProvider.cs
  11. 34
      ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
  12. 34
      ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs
  13. 34
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  14. 18
      ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs
  15. 19
      ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs
  16. 19
      ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs
  17. 18
      ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs
  18. 3
      ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs
  19. 3
      ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs
  20. 3
      ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs
  21. 1
      ErsatzTV.Scanner/Program.cs

3
CHANGELOG.md

@ -43,6 +43,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

1
ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs

@ -48,4 +48,5 @@ public interface IMetadataRepository @@ -48,4 +48,5 @@ public interface IMetadataRepository
Task<bool> RemoveDirector(Director director);
Task<bool> RemoveWriter(Writer writer);
Task<bool> UpdateSubtitles(Domain.Metadata metadata, List<Subtitle> subtitles);
Task<bool> UpdateChapters(MediaVersion version, List<MediaChapter> chapters);
}

41
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -537,6 +537,47 @@ public class MetadataRepository : IMetadataRepository @@ -537,6 +537,47 @@ public class MetadataRepository : IMetadataRepository
return await UpdateSubtitles(dbContext, metadata, subtitles);
}
public async Task<bool> UpdateChapters(MediaVersion version, List<MediaChapter> 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<bool> RemoveGenre(Genre genre)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

2
ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs

@ -648,6 +648,7 @@ public class MovieFolderScannerTests @@ -648,6 +648,7 @@ public class MovieFolderScannerTests
_movieRepository,
_localStatisticsProvider,
Substitute.For<ILocalSubtitlesProvider>(),
Substitute.For<ILocalChaptersProvider>(),
_localMetadataProvider,
Substitute.For<IMetadataRepository>(),
_imageCache,
@ -666,6 +667,7 @@ public class MovieFolderScannerTests @@ -666,6 +667,7 @@ public class MovieFolderScannerTests
_movieRepository,
_localStatisticsProvider,
Substitute.For<ILocalSubtitlesProvider>(),
Substitute.For<ILocalChaptersProvider>(),
_localMetadataProvider,
Substitute.For<IMetadataRepository>(),
_imageCache,

3
ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs

@ -5,6 +5,7 @@ using ErsatzTV.Core.Extensions; @@ -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 : @@ -28,10 +29,12 @@ public class EmbyMovieLibraryScanner :
IEmbyMovieRepository embyMovieRepository,
IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository,
ILogger<EmbyMovieLibraryScanner> logger)
: base(
localFileSystem,
localChaptersProvider,
metadataRepository,
mediator,
logger)

3
ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -5,6 +5,7 @@ using ErsatzTV.Core.Extensions; @@ -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< @@ -27,11 +28,13 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
IEmbyTelevisionRepository televisionRepository,
IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository,
IMediator mediator,
ILogger<EmbyTelevisionLibraryScanner> logger)
: base(
localFileSystem,
localChaptersProvider,
metadataRepository,
mediator,
logger)

8
ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalChaptersProvider.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface ILocalChaptersProvider : IDisposable
{
Task<bool> UpdateChapters(MediaItem mediaItem, Option<string> localPath);
}

3
ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; @@ -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 : @@ -28,10 +29,12 @@ public class JellyfinMovieLibraryScanner :
IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository,
ILogger<JellyfinMovieLibraryScanner> logger)
: base(
localFileSystem,
localChaptersProvider,
metadataRepository,
mediator,
logger)

3
ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; @@ -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 @@ -28,11 +29,13 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
IJellyfinTelevisionRepository televisionRepository,
IJellyfinPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository,
IMediator mediator,
ILogger<JellyfinTelevisionLibraryScanner> logger)
: base(
localFileSystem,
localChaptersProvider,
metadataRepository,
mediator,
logger)

263
ErsatzTV.Scanner/Core/Metadata/LocalChaptersProvider.cs

@ -0,0 +1,263 @@ @@ -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<LocalChaptersProvider> _logger;
private readonly IMetadataRepository _metadataRepository;
private bool _disposedValue;
public LocalChaptersProvider(
IMetadataRepository metadataRepository,
ILocalFileSystem localFileSystem,
ILogger<LocalChaptersProvider> logger)
{
_metadataRepository = metadataRepository;
_localFileSystem = localFileSystem;
_logger = logger;
}
public async Task<bool> UpdateChapters(MediaItem mediaItem, Option<string> localPath)
{
try
{
MediaVersion version = mediaItem.GetHeadVersion();
string mediaItemPath = await localPath.IfNoneAsync(() => version.MediaFiles.Head().Path);
List<MediaChapter> 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<MediaChapter> LocateExternalChapters(string mediaItemPath)
{
var result = new List<MediaChapter>();
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<MediaChapter> 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<MediaChapter> ParseChapterFile(string filePath)
{
var chapters = new List<MediaChapter>();
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<MediaChapter> ParseMatroskaXmlChapters(XmlNode chaptersNode)
{
var chapters = new List<MediaChapter>();
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();
}

34
ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Metadata; @@ -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<TConnectionParameters, TLib @@ -19,17 +20,20 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
where TEtag : MediaServerItemEtag
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly ILogger _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
protected MediaServerMovieLibraryScanner(
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository,
IMediator mediator,
ILogger logger)
{
_localFileSystem = localFileSystem;
_localChaptersProvider = localChaptersProvider;
_metadataRepository = metadataRepository;
_mediator = mediator;
_logger = logger;
@ -118,7 +122,8 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -118,7 +122,8 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
library,
existing,
incoming,
deepScan));
deepScan))
.BindT(UpdateChapters);
}
else
{
@ -143,7 +148,8 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -143,7 +148,8 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
incoming,
deepScan,
None))
.BindT(UpdateSubtitles);
.BindT(UpdateSubtitles)
.BindT(UpdateChapters);
}
if (maybeMovie.IsLeft)
@ -469,4 +475,28 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -469,4 +475,28 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateChapters(
MediaItemScanResult<TMovie> 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());
}
}
}

34
ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs

@ -6,6 +6,7 @@ using ErsatzTV.Core.Errors; @@ -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<TConnectionParameters, @@ -19,17 +20,20 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
where TEtag : MediaServerItemEtag
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly ILogger _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
protected MediaServerOtherVideoLibraryScanner(
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository,
IMediator mediator,
ILogger logger)
{
_localFileSystem = localFileSystem;
_localChaptersProvider = localChaptersProvider;
_metadataRepository = metadataRepository;
_mediator = mediator;
_logger = logger;
@ -125,7 +129,8 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters, @@ -125,7 +129,8 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
library,
existing,
incoming,
deepScan));
deepScan))
.BindT(UpdateChapters);
}
else
{
@ -150,7 +155,8 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters, @@ -150,7 +155,8 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
incoming,
deepScan,
None))
.BindT(UpdateSubtitles);
.BindT(UpdateSubtitles)
.BindT(UpdateChapters);
}
if (maybeOtherVideo.IsLeft)
@ -478,4 +484,28 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters, @@ -478,4 +484,28 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> UpdateChapters(
MediaItemScanResult<TOtherVideo> 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());
}
}
}

34
ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -5,6 +5,7 @@ using ErsatzTV.Core.Errors; @@ -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<TConnectionParameters, @@ -21,17 +22,20 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
where TEtag : MediaServerItemEtag
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly ILogger _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
protected MediaServerTelevisionLibraryScanner(
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository,
IMediator mediator,
ILogger logger)
{
_localFileSystem = localFileSystem;
_localChaptersProvider = localChaptersProvider;
_metadataRepository = metadataRepository;
_mediator = mediator;
_logger = logger;
@ -391,7 +395,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -391,7 +395,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
library,
existing,
incoming,
deepScan));
deepScan))
.BindT(UpdateChapters);
}
else
{
@ -416,7 +421,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -416,7 +421,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
incoming,
deepScan,
None))
.BindT(UpdateSubtitles);
.BindT(UpdateSubtitles)
.BindT(UpdateChapters);
}
if (maybeEpisode.IsLeft)
@ -781,4 +787,28 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -781,4 +787,28 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateChapters(
MediaItemScanResult<TEpisode> 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());
}
}
}

18
ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs

@ -24,6 +24,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -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<MovieFolderScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediator _mediator;
@ -34,6 +35,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -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 @@ -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 @@ -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 @@ -334,6 +338,20 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
}
}
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateChapters(MediaItemScanResult<Movie> result)
{
try
{
await _localChaptersProvider.UpdateChapters(result.Item, None);
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateNfoFile(Movie movie)
{
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;

19
ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs

@ -24,6 +24,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -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<MusicVideoFolderScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediator _mediator;
@ -34,6 +35,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -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 @@ -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 @@ -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 @@ -543,6 +547,21 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
}
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateChapters(
MediaItemScanResult<MusicVideo> result)
{
try
{
await _localChaptersProvider.UpdateChapters(result.Item, None);
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;

19
ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs

@ -23,6 +23,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -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<OtherVideoFolderScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediator _mediator;
@ -33,6 +34,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -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 @@ -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 @@ -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 @@ -339,6 +343,21 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
}
}
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateChapters(
MediaItemScanResult<OtherVideo> result)
{
try
{
await _localChaptersProvider.UpdateChapters(result.Item, None);
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateThumbnail(
MediaItemScanResult<OtherVideo> result,
CancellationToken cancellationToken)

18
ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs

@ -24,6 +24,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -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<TelevisionFolderScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediator _mediator;
@ -36,6 +37,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -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 @@ -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 @@ -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<Episode>(e)))
.MapT(r => r.Item);
@ -587,6 +591,20 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -587,6 +591,20 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
}
}
private async Task<Either<BaseError, Episode>> UpdateChapters(Episode episode)
{
try
{
await _localChaptersProvider.UpdateChapters(episode, None);
return episode;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateNfoFileForShow(string showFolder) =>
Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _localFileSystem.FileExists(s));

3
ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs

@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.Plex; @@ -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 : @@ -32,9 +33,11 @@ public class PlexMovieLibraryScanner :
IPlexMovieRepository plexMovieRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
ILogger<PlexMovieLibraryScanner> logger)
: base(
localFileSystem,
localChaptersProvider,
metadataRepository,
mediator,
logger)

3
ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs

@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; @@ -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 : @@ -32,9 +33,11 @@ public class PlexOtherVideoLibraryScanner :
IPlexOtherVideoRepository plexOtherVideoRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
ILogger<PlexOtherVideoLibraryScanner> logger)
: base(
localFileSystem,
localChaptersProvider,
metadataRepository,
mediator,
logger)

3
ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs

@ -4,6 +4,7 @@ using ErsatzTV.Core.Extensions; @@ -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 : @@ -32,9 +33,11 @@ public class PlexTelevisionLibraryScanner :
IPlexPathReplacementService plexPathReplacementService,
IPlexTelevisionRepository plexTelevisionRepository,
ILocalFileSystem localFileSystem,
ILocalChaptersProvider localChaptersProvider,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(
localFileSystem,
localChaptersProvider,
metadataRepository,
mediator,
logger)

1
ErsatzTV.Scanner/Program.cs

@ -187,6 +187,7 @@ public class Program @@ -187,6 +187,7 @@ public class Program
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
services.AddScoped<ILocalStatisticsProvider, LocalStatisticsProvider>();
services.AddScoped<ILocalSubtitlesProvider, LocalSubtitlesProvider>();
services.AddScoped<ILocalChaptersProvider, LocalChaptersProvider>();
services.AddScoped<IImageCache, ImageCache>();
services.AddScoped<ILocalFileSystem, LocalFileSystem>();
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();

Loading…
Cancel
Save