optimize local library scanning by using etags (#196)

* use etags to optimize local movie scanner

* use etags to optimize local television scanner

* use etags to optimize local music video scanner

* code cleanup
This commit is contained in:
Jason Dove
2021-05-21 06:18:07 -05:00
committed by GitHub
parent c0b5ecd388
commit 0799fe25d1
20 changed files with 2774 additions and 68 deletions
@@ -90,7 +90,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
await _movieFolderScanner.ScanFolder( await _movieFolderScanner.ScanFolder(
libraryPath, libraryPath,
ffprobePath, ffprobePath,
lastScan,
progressMin, progressMin,
progressMax); progressMax);
break; break;
@@ -98,7 +97,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
await _televisionFolderScanner.ScanFolder( await _televisionFolderScanner.ScanFolder(
libraryPath, libraryPath,
ffprobePath, ffprobePath,
lastScan,
progressMin, progressMin,
progressMax); progressMax);
break; break;
@@ -106,7 +104,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
await _musicVideoFolderScanner.ScanFolder( await _musicVideoFolderScanner.ScanFolder(
libraryPath, libraryPath,
ffprobePath, ffprobePath,
lastScan,
progressMin, progressMin,
progressMax); progressMax);
break; break;
@@ -81,12 +81,11 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(Path.Combine(FakeRoot, Path.Combine("Movie (2020)", "Movie (2020).mkv"))) new FakeFileEntry(Path.Combine(FakeRoot, Path.Combine("Movie (2020)", "Movie (2020).mkv")))
); );
var libraryPath = new LibraryPath { Path = BadFakeRoot }; var libraryPath = new LibraryPath { Path = BadFakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -106,12 +105,12 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -147,12 +146,12 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(metadataPath) new FakeFileEntry(metadataPath)
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -189,12 +188,12 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(metadataPath) new FakeFileEntry(metadataPath)
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -235,12 +234,12 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now } new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now }
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -284,12 +283,12 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now } new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now }
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -333,12 +332,12 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now } new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now }
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -381,12 +380,12 @@ namespace ErsatzTV.Core.Tests.Metadata
Path.GetDirectoryName(moviePath) ?? string.Empty, Path.GetDirectoryName(moviePath) ?? string.Empty,
$"Movie (2020)-{extraFile}{videoExtension}")) $"Movie (2020)-{extraFile}{videoExtension}"))
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -425,12 +424,12 @@ namespace ErsatzTV.Core.Tests.Metadata
Path.GetDirectoryName(moviePath) ?? string.Empty, Path.GetDirectoryName(moviePath) ?? string.Empty,
Path.Combine(extraFolder, $"Movie (2020){videoExtension}"))) Path.Combine(extraFolder, $"Movie (2020){videoExtension}")))
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -463,12 +462,12 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -503,12 +502,12 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -532,12 +531,12 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
); );
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder( Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath, libraryPath,
FFprobePath, FFprobePath,
DateTimeOffset.MinValue,
0, 0,
1); 1);
@@ -558,6 +557,7 @@ namespace ErsatzTV.Core.Tests.Metadata
_imageCache.Object, _imageCache.Object,
new Mock<ISearchIndex>().Object, new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object, new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
new Mock<IMediator>().Object, new Mock<IMediator>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object new Mock<ILogger<MovieFolderScanner>>().Object
); );
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class LibraryFolder
{
public int Id { get; set; }
public string Path { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public string Etag { get; set; }
}
}
@@ -13,5 +13,6 @@ namespace ErsatzTV.Core.Domain
public Library Library { get; set; } public Library Library { get; set; }
public List<MediaItem> MediaItems { get; set; } public List<MediaItem> MediaItems { get; set; }
public List<LibraryFolder> LibraryFolders { get; set; }
} }
} }
@@ -1,5 +1,4 @@
using System; using System.Threading.Tasks;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
@@ -10,7 +9,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
Task<Either<BaseError, Unit>> ScanFolder( Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin, decimal progressMin,
decimal progressMax); decimal progressMax);
} }
@@ -1,5 +1,4 @@
using System; using System.Threading.Tasks;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
@@ -10,7 +9,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
Task<Either<BaseError, Unit>> ScanFolder( Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin, decimal progressMin,
decimal progressMax); decimal progressMax);
} }
@@ -1,5 +1,4 @@
using System; using System.Threading.Tasks;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
@@ -10,7 +9,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
Task<Either<BaseError, Unit>> ScanFolder( Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin, decimal progressMin,
decimal progressMax); decimal progressMax);
} }
@@ -18,5 +18,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<int> CountMediaItemsByPath(int libraryPathId); Task<int> CountMediaItemsByPath(int libraryPathId);
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId); Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId); Task DeleteLocalPath(int libraryPathId);
Task<Unit> SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
} }
} }
+35
View File
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Core.Interfaces.Metadata;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public static class FolderEtag
{
private static readonly MD5CryptoServiceProvider Crypto = new();
public static string Calculate(string folder, ILocalFileSystem localFileSystem)
{
IEnumerable<string> allFiles = localFileSystem.ListFiles(folder);
var sb = new StringBuilder();
foreach (string file in allFiles.OrderBy(identity))
{
sb.Append(file);
sb.Append(localFileSystem.GetLastWriteTime(file).Ticks);
}
var hash = new StringBuilder();
byte[] bytes = Crypto.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
foreach (byte t in bytes)
{
hash.Append(t.ToString("x2"));
}
return hash.ToString();
}
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ namespace ErsatzTV.Core.Metadata
string path = version.MediaFiles.Head().Path; string path = version.MediaFiles.Head().Path;
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path) || !version.Streams.Any()) if (version.DateUpdated != _localFileSystem.GetLastWriteTime(path) || !version.Streams.Any())
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
+20 -4
View File
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Metadata
{ {
public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
{ {
private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MovieFolderScanner> _logger; private readonly ILogger<MovieFolderScanner> _logger;
@@ -37,6 +38,7 @@ namespace ErsatzTV.Core.Metadata
IImageCache imageCache, IImageCache imageCache,
ISearchIndex searchIndex, ISearchIndex searchIndex,
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediator mediator, IMediator mediator,
ILogger<MovieFolderScanner> logger) ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger) : base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
@@ -46,6 +48,7 @@ namespace ErsatzTV.Core.Metadata
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex; _searchIndex = searchIndex;
_searchRepository = searchRepository; _searchRepository = searchRepository;
_libraryRepository = libraryRepository;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
} }
@@ -53,7 +56,6 @@ namespace ErsatzTV.Core.Metadata
public async Task<Either<BaseError, Unit>> ScanFolder( public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin, decimal progressMin,
decimal progressMax) decimal progressMax)
{ {
@@ -81,7 +83,9 @@ namespace ErsatzTV.Core.Metadata
string movieFolder = folderQueue.Dequeue(); string movieFolder = folderQueue.Dequeue();
foldersCompleted++; foldersCompleted++;
var allFiles = _localFileSystem.ListFiles(movieFolder) var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList();
var allFiles = filesForEtag
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter( .Filter(
f => !ExtraFiles.Any( f => !ExtraFiles.Any(
@@ -98,11 +102,21 @@ namespace ErsatzTV.Core.Metadata
continue; continue;
} }
if (allFiles.All(file => _localFileSystem.GetLastWriteTime(file) < lastScan)) string etag = FolderEtag.Calculate(movieFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == movieFolder)
.HeadOrNone();
// skip folder if etag matches
if (await knownFolder.Map(f => f.Etag).IfNoneAsync(string.Empty) == etag)
{ {
continue; continue;
} }
_logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
movieFolder);
foreach (string file in allFiles.OrderBy(identity)) foreach (string file in allFiles.OrderBy(identity))
{ {
// TODO: figure out how to rebuild playlists // TODO: figure out how to rebuild playlists
@@ -124,6 +138,8 @@ namespace ErsatzTV.Core.Metadata
{ {
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item }); await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
} }
await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag);
}, },
error => error =>
{ {
@@ -158,7 +174,7 @@ namespace ErsatzTV.Core.Metadata
{ {
bool shouldUpdate = Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match( bool shouldUpdate = Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback || m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile), m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true); true);
if (shouldUpdate) if (shouldUpdate)
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Metadata
public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner
{ {
private readonly IArtistRepository _artistRepository; private readonly IArtistRepository _artistRepository;
private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MusicVideoFolderScanner> _logger; private readonly ILogger<MusicVideoFolderScanner> _logger;
@@ -38,6 +39,7 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
IArtistRepository artistRepository, IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository, IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMediator mediator, IMediator mediator,
ILogger<MusicVideoFolderScanner> logger) : base( ILogger<MusicVideoFolderScanner> logger) : base(
localFileSystem, localFileSystem,
@@ -52,6 +54,7 @@ namespace ErsatzTV.Core.Metadata
_searchRepository = searchRepository; _searchRepository = searchRepository;
_artistRepository = artistRepository; _artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository; _musicVideoRepository = musicVideoRepository;
_libraryRepository = libraryRepository;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
} }
@@ -59,7 +62,6 @@ namespace ErsatzTV.Core.Metadata
public async Task<Either<BaseError, Unit>> ScanFolder( public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin, decimal progressMin,
decimal progressMax) decimal progressMax)
{ {
@@ -96,9 +98,7 @@ namespace ErsatzTV.Core.Metadata
libraryPath, libraryPath,
ffprobePath, ffprobePath,
result.Item, result.Item,
artistFolder, artistFolder);
// force scanning all folders if we're adding a new artist
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
if (result.IsAdded) if (result.IsAdded)
{ {
@@ -167,7 +167,7 @@ namespace ErsatzTV.Core.Metadata
{ {
bool shouldUpdate = Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match( bool shouldUpdate = Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback || m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile), m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true); true);
if (shouldUpdate) if (shouldUpdate)
@@ -222,12 +222,11 @@ namespace ErsatzTV.Core.Metadata
} }
} }
public async Task ScanMusicVideos( private async Task ScanMusicVideos(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
Artist artist, Artist artist,
string artistFolder, string artistFolder)
DateTimeOffset lastScan)
{ {
var folderQueue = new Queue<string>(); var folderQueue = new Queue<string>();
folderQueue.Enqueue(artistFolder); folderQueue.Enqueue(artistFolder);
@@ -247,7 +246,13 @@ namespace ErsatzTV.Core.Metadata
folderQueue.Enqueue(subdirectory); folderQueue.Enqueue(subdirectory);
} }
if (_localFileSystem.GetLastWriteTime(musicVideoFolder) < lastScan) string etag = FolderEtag.Calculate(musicVideoFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == musicVideoFolder)
.HeadOrNone();
// skip folder if etag matches
if (await knownFolder.Map(f => f.Etag).IfNoneAsync(string.Empty) == etag)
{ {
continue; continue;
} }
@@ -272,6 +277,8 @@ namespace ErsatzTV.Core.Metadata
{ {
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item }); await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
} }
await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag);
}, },
error => error =>
{ {
@@ -293,7 +300,7 @@ namespace ErsatzTV.Core.Metadata
{ {
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match( bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback || m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile), m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true); true);
if (shouldUpdate) if (shouldUpdate)
@@ -19,6 +19,7 @@ namespace ErsatzTV.Core.Metadata
{ {
public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner
{ {
private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<TelevisionFolderScanner> _logger; private readonly ILogger<TelevisionFolderScanner> _logger;
@@ -36,6 +37,7 @@ namespace ErsatzTV.Core.Metadata
IImageCache imageCache, IImageCache imageCache,
ISearchIndex searchIndex, ISearchIndex searchIndex,
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediator mediator, IMediator mediator,
ILogger<TelevisionFolderScanner> logger) : base( ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem, localFileSystem,
@@ -49,6 +51,7 @@ namespace ErsatzTV.Core.Metadata
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex; _searchIndex = searchIndex;
_searchRepository = searchRepository; _searchRepository = searchRepository;
_libraryRepository = libraryRepository;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
} }
@@ -56,7 +59,6 @@ namespace ErsatzTV.Core.Metadata
public async Task<Either<BaseError, Unit>> ScanFolder( public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin, decimal progressMin,
decimal progressMax) decimal progressMax)
{ {
@@ -91,9 +93,7 @@ namespace ErsatzTV.Core.Metadata
libraryPath, libraryPath,
ffprobePath, ffprobePath,
result.Item, result.Item,
showFolder, showFolder);
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
if (result.IsAdded) if (result.IsAdded)
{ {
@@ -146,12 +146,22 @@ namespace ErsatzTV.Core.Metadata
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
Show show, Show show,
string showFolder, string showFolder)
DateTimeOffset lastScan)
{ {
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder) foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
.OrderBy(identity)) .OrderBy(identity))
{ {
string etag = FolderEtag.Calculate(seasonFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == seasonFolder)
.HeadOrNone();
// skip folder if etag matches
if (await knownFolder.Map(f => f.Etag).IfNoneAsync(string.Empty) == etag)
{
continue;
}
Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder); Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder);
await maybeSeasonNumber.IfSomeAsync( await maybeSeasonNumber.IfSomeAsync(
async seasonNumber => async seasonNumber =>
@@ -161,7 +171,11 @@ namespace ErsatzTV.Core.Metadata
.BindT(season => UpdatePoster(season, seasonFolder)); .BindT(season => UpdatePoster(season, seasonFolder));
await maybeSeason.Match( await maybeSeason.Match(
season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder, lastScan), async season =>
{
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
},
error => error =>
{ {
_logger.LogWarning( _logger.LogWarning(
@@ -180,14 +194,8 @@ namespace ErsatzTV.Core.Metadata
LibraryPath libraryPath, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
Season season, Season season,
string seasonPath, string seasonPath)
DateTimeOffset lastScan)
{ {
if (_localFileSystem.GetLastWriteTime(seasonPath) < lastScan)
{
return Unit.Default;
}
foreach (string file in _localFileSystem.ListFiles(seasonPath) foreach (string file in _localFileSystem.ListFiles(seasonPath)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))).OrderBy(identity)) .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))).OrderBy(identity))
{ {
@@ -219,7 +227,7 @@ namespace ErsatzTV.Core.Metadata
{ {
bool shouldUpdate = Optional(show.ShowMetadata).Flatten().HeadOrNone().Match( bool shouldUpdate = Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback || m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile), m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true); true);
if (shouldUpdate) if (shouldUpdate)
@@ -261,7 +269,7 @@ namespace ErsatzTV.Core.Metadata
{ {
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match( bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback || m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile), m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true); true);
if (shouldUpdate) if (shouldUpdate)
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class LibraryFolderConfiguration : IEntityTypeConfiguration<LibraryFolder>
{
public void Configure(EntityTypeBuilder<LibraryFolder> builder) => builder.ToTable("LibraryFolder");
}
}
@@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.WithOne(i => i.LibraryPath) .WithOne(i => i.LibraryPath)
.HasForeignKey(i => i.LibraryPathId) .HasForeignKey(i => i.LibraryPathId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(p => p.LibraryFolders)
.WithOne(f => f.LibraryPath)
.HasForeignKey(f => f.LibraryPathId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
} }
@@ -35,6 +35,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
using TvContext context = _dbContextFactory.CreateDbContext(); using TvContext context = _dbContextFactory.CreateDbContext();
return context.Libraries return context.Libraries
.Include(l => l.Paths) .Include(l => l.Paths)
.ThenInclude(p => p.LibraryFolders)
.OrderBy(l => l.Id) .OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId) .SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional); .Map(Optional);
@@ -104,5 +105,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
context.LibraryPaths.Remove(libraryPath); context.LibraryPaths.Remove(libraryPath);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
public Task<Unit> SetEtag(
LibraryPath libraryPath,
Option<LibraryFolder> knownFolder,
string path,
string etag) =>
knownFolder.Match(
async folder =>
{
await _dbConnection.ExecuteAsync(
"UPDATE LibraryFolder SET Etag = @Etag WHERE Id = @Id",
new { folder.Id, Etag = etag });
},
async () =>
{
await using TvContext context = _dbContextFactory.CreateDbContext();
await context.LibraryFolders.AddAsync(
new LibraryFolder
{
Path = path,
Etag = etag,
LibraryPathId = libraryPath.Id
});
await context.SaveChangesAsync();
}).ToUnit();
} }
} }
@@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Library> Libraries { get; set; } public DbSet<Library> Libraries { get; set; }
public DbSet<LocalLibrary> LocalLibraries { get; set; } public DbSet<LocalLibrary> LocalLibraries { get; set; }
public DbSet<LibraryPath> LibraryPaths { get; set; } public DbSet<LibraryPath> LibraryPaths { get; set; }
public DbSet<LibraryFolder> LibraryFolders { get; set; }
public DbSet<PlexLibrary> PlexLibraries { get; set; } public DbSet<PlexLibrary> PlexLibraries { get; set; }
public DbSet<JellyfinLibrary> JellyfinLibraries { get; set; } public DbSet<JellyfinLibrary> JellyfinLibraries { get; set; }
public DbSet<PlexPathReplacement> PlexPathReplacements { get; set; } public DbSet<PlexPathReplacement> PlexPathReplacements { get; set; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_LibraryFolder : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"LibraryFolder",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Path = table.Column<string>("TEXT", nullable: true),
LibraryPathId = table.Column<int>("INTEGER", nullable: false),
Etag = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LibraryFolder", x => x.Id);
table.ForeignKey(
"FK_LibraryFolder_LibraryPath_LibraryPathId",
x => x.LibraryPathId,
"LibraryPath",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_LibraryFolder_LibraryPathId",
"LibraryFolder",
"LibraryPathId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"LibraryFolder");
}
}
@@ -517,6 +517,30 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Library"); b.ToTable("Library");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LibraryFolder",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<int>("LibraryPathId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryPathId");
b.ToTable("LibraryFolder");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.LibraryPath", "ErsatzTV.Core.Domain.LibraryPath",
b => b =>
@@ -1811,6 +1835,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaSource"); b.Navigation("MediaSource");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LibraryFolder",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath")
.WithMany("LibraryFolders")
.HasForeignKey("LibraryPathId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryPath");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.LibraryPath", "ErsatzTV.Core.Domain.LibraryPath",
b => b =>
@@ -2559,7 +2596,14 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => { b.Navigation("Paths"); }); modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => { b.Navigation("Paths"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => { b.Navigation("MediaItems"); }); modelBuilder.Entity(
"ErsatzTV.Core.Domain.LibraryPath",
b =>
{
b.Navigation("LibraryFolders");
b.Navigation("MediaItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => { b.Navigation("CollectionItems"); }); modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => { b.Navigation("CollectionItems"); });