mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* 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
21 changed files with 510 additions and 6 deletions
@ -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); |
||||||
|
} |
@ -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(); |
||||||
|
} |
Loading…
Reference in new issue