mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Backend Bugfixes and Enhanced Selections (#754)
* Updated some signatures to avoid a ToArray() within a loop. * Use UpdateSeries directly when adding new series, rather than a modified version for new series only. * Refactored some messages for scanner loop to reduce duplicate code and write messages more clear. Hooked in a RefreshMetadataProgress event (no UI changes). * Fixed a bug on docker where backup service was using different logic than non-docker, which isn't needed after config change last release. * Allow user to make more than 1 backup per day * Implemented a select all checkbox for library access modal
This commit is contained in:
parent
0aff08c9cd
commit
f6bfabde4c
@ -26,8 +26,6 @@ namespace API.Data
|
|||||||
"temp"
|
"temp"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory
|
/// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory
|
||||||
@ -66,8 +64,8 @@ namespace API.Data
|
|||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
||||||
|
|
||||||
Console.WriteLine($"Creating {ConfigDirectory}");
|
Console.WriteLine($"Creating {DirectoryService.ConfigDirectory}");
|
||||||
DirectoryService.ExistOrCreate(ConfigDirectory);
|
DirectoryService.ExistOrCreate(DirectoryService.ConfigDirectory);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -116,13 +114,13 @@ namespace API.Data
|
|||||||
|
|
||||||
foreach (var folderToMove in AppFolders)
|
foreach (var folderToMove in AppFolders)
|
||||||
{
|
{
|
||||||
if (new DirectoryInfo(Path.Join(ConfigDirectory, folderToMove)).Exists) continue;
|
if (new DirectoryInfo(Path.Join(DirectoryService.ConfigDirectory, folderToMove)).Exists) continue;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DirectoryService.CopyDirectoryToDirectory(
|
DirectoryService.CopyDirectoryToDirectory(
|
||||||
Path.Join(Directory.GetCurrentDirectory(), folderToMove),
|
Path.Join(Directory.GetCurrentDirectory(), folderToMove),
|
||||||
Path.Join(ConfigDirectory, folderToMove));
|
Path.Join(DirectoryService.ConfigDirectory, folderToMove));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@ -144,7 +142,7 @@ namespace API.Data
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
fileInfo.CopyTo(Path.Join(ConfigDirectory, fileInfo.Name));
|
fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
|
@ -266,7 +266,7 @@ namespace API.Services
|
|||||||
/// <param name="directoryPath"></param>
|
/// <param name="directoryPath"></param>
|
||||||
/// <param name="prepend">An optional string to prepend to the target file's name</param>
|
/// <param name="prepend">An optional string to prepend to the target file's name</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
|
public static bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "", ILogger logger = null)
|
||||||
{
|
{
|
||||||
ExistOrCreate(directoryPath);
|
ExistOrCreate(directoryPath);
|
||||||
string currentFile = null;
|
string currentFile = null;
|
||||||
@ -282,19 +282,24 @@ namespace API.Services
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file);
|
logger?.LogWarning("Tried to copy {File} but it doesn't exist", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
|
logger?.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
|
||||||
|
{
|
||||||
|
return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger);
|
||||||
|
}
|
||||||
|
|
||||||
public IEnumerable<string> ListDirectory(string rootPath)
|
public IEnumerable<string> ListDirectory(string rootPath)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
||||||
|
@ -218,14 +218,18 @@ namespace API.Services
|
|||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
var totalTime = 0L;
|
var totalTime = 0L;
|
||||||
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
|
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
|
||||||
|
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
||||||
|
MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F));
|
||||||
|
|
||||||
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
|
var i = 0;
|
||||||
|
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++, i++)
|
||||||
{
|
{
|
||||||
if (chunkInfo.TotalChunks == 0) continue;
|
if (chunkInfo.TotalChunks == 0) continue;
|
||||||
totalTime += stopwatch.ElapsedMilliseconds;
|
totalTime += stopwatch.ElapsedMilliseconds;
|
||||||
stopwatch.Restart();
|
stopwatch.Restart();
|
||||||
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
|
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
|
||||||
chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize);
|
chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize);
|
||||||
|
|
||||||
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
|
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
|
||||||
new UserParams()
|
new UserParams()
|
||||||
{
|
{
|
||||||
@ -233,6 +237,7 @@ namespace API.Services
|
|||||||
PageSize = chunkInfo.ChunkSize
|
PageSize = chunkInfo.ChunkSize
|
||||||
});
|
});
|
||||||
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
|
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
|
||||||
|
|
||||||
Parallel.ForEach(nonLibrarySeries, series =>
|
Parallel.ForEach(nonLibrarySeries, series =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -275,8 +280,14 @@ namespace API.Services
|
|||||||
"[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
"[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
||||||
chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name);
|
chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name);
|
||||||
}
|
}
|
||||||
|
var progress = Math.Max(0F, Math.Min(100F, i * 1F / chunkInfo.TotalChunks));
|
||||||
|
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
||||||
|
MessageFactory.RefreshMetadataProgressEvent(library.Id, progress));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
||||||
|
MessageFactory.RefreshMetadataProgressEvent(library.Id, 100F));
|
||||||
|
|
||||||
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
|
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,7 +9,6 @@ using API.Extensions;
|
|||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@ -20,8 +19,6 @@ namespace API.Services.Tasks
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ILogger<BackupService> _logger;
|
private readonly ILogger<BackupService> _logger;
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly string _tempDirectory = DirectoryService.TempDirectory;
|
|
||||||
private readonly string _logDirectory = DirectoryService.LogDirectory;
|
|
||||||
|
|
||||||
private readonly IList<string> _backupFiles;
|
private readonly IList<string> _backupFiles;
|
||||||
|
|
||||||
@ -35,30 +32,16 @@ namespace API.Services.Tasks
|
|||||||
var loggingSection = config.GetLoggingFileName();
|
var loggingSection = config.GetLoggingFileName();
|
||||||
var files = LogFiles(maxRollingFiles, loggingSection);
|
var files = LogFiles(maxRollingFiles, loggingSection);
|
||||||
|
|
||||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
|
||||||
|
_backupFiles = new List<string>()
|
||||||
{
|
{
|
||||||
_backupFiles = new List<string>()
|
"appsettings.json",
|
||||||
{
|
"Hangfire.db", // This is not used atm
|
||||||
"data/appsettings.json",
|
"Hangfire-log.db", // This is not used atm
|
||||||
"data/Hangfire.db",
|
"kavita.db",
|
||||||
"data/Hangfire-log.db",
|
"kavita.db-shm", // This wont always be there
|
||||||
"data/kavita.db",
|
"kavita.db-wal" // This wont always be there
|
||||||
"data/kavita.db-shm", // This wont always be there
|
};
|
||||||
"data/kavita.db-wal" // This wont always be there
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_backupFiles = new List<string>()
|
|
||||||
{
|
|
||||||
"appsettings.json",
|
|
||||||
"Hangfire.db",
|
|
||||||
"Hangfire-log.db",
|
|
||||||
"kavita.db",
|
|
||||||
"kavita.db-shm", // This wont always be there
|
|
||||||
"kavita.db-wal" // This wont always be there
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList())
|
foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList())
|
||||||
{
|
{
|
||||||
@ -72,7 +55,7 @@ namespace API.Services.Tasks
|
|||||||
var fi = new FileInfo(logFileName);
|
var fi = new FileInfo(logFileName);
|
||||||
|
|
||||||
var files = maxRollingFiles > 0
|
var files = maxRollingFiles > 0
|
||||||
? DirectoryService.GetFiles(_logDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
|
? DirectoryService.GetFiles(DirectoryService.LogDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
|
||||||
: new[] {"kavita.log"};
|
: new[] {"kavita.log"};
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@ -93,7 +76,7 @@ namespace API.Services.Tasks
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_");
|
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||||
var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
|
var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
|
||||||
|
|
||||||
if (File.Exists(zipPath))
|
if (File.Exists(zipPath))
|
||||||
@ -102,7 +85,7 @@ namespace API.Services.Tasks
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tempDirectory = Path.Join(_tempDirectory, dateString);
|
var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString);
|
||||||
DirectoryService.ExistOrCreate(tempDirectory);
|
DirectoryService.ExistOrCreate(tempDirectory);
|
||||||
DirectoryService.ClearDirectory(tempDirectory);
|
DirectoryService.ClearDirectory(tempDirectory);
|
||||||
|
|
||||||
|
@ -360,14 +360,13 @@ namespace API.Services.Tasks
|
|||||||
Series existingSeries;
|
Series existingSeries;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
existingSeries = allSeries.SingleOrDefault(s =>
|
existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key));
|
||||||
(s.NormalizedName.Equals(key.NormalizedName) || Parser.Parser.Normalize(s.OriginalName).Equals(key.NormalizedName))
|
|
||||||
&& (s.Format == key.Format || s.Format == MangaFormat.Unknown));
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
// NOTE: If I ever want to put Duplicates table, this is where it can go
|
||||||
_logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName);
|
_logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName);
|
||||||
var duplicateSeries = allSeries.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList();
|
var duplicateSeries = allSeries.Where(s => FindSeries(s, key));
|
||||||
foreach (var series in duplicateSeries)
|
foreach (var series in duplicateSeries)
|
||||||
{
|
{
|
||||||
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
|
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
|
||||||
@ -378,21 +377,20 @@ namespace API.Services.Tasks
|
|||||||
|
|
||||||
if (existingSeries != null) continue;
|
if (existingSeries != null) continue;
|
||||||
|
|
||||||
existingSeries = DbFactory.Series(infos[0].Series);
|
var s = DbFactory.Series(infos[0].Series);
|
||||||
existingSeries.Format = key.Format;
|
s.Format = key.Format;
|
||||||
newSeries.Add(existingSeries);
|
s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series.
|
||||||
|
newSeries.Add(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
var i = 0;
|
var i = 0;
|
||||||
foreach(var series in newSeries)
|
foreach(var series in newSeries)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||||
|
UpdateSeries(series, parsedSeries);
|
||||||
|
_unitOfWork.SeriesRepository.Attach(series);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
|
||||||
UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray());
|
|
||||||
series.Pages = series.Volumes.Sum(v => v.Pages);
|
|
||||||
series.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series.
|
|
||||||
_unitOfWork.SeriesRepository.Attach(series);
|
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
"[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
||||||
@ -403,7 +401,7 @@ namespace API.Services.Tasks
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey}",
|
_logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ",
|
||||||
series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}");
|
series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,13 +416,19 @@ namespace API.Services.Tasks
|
|||||||
newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name);
|
newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool FindSeries(Series series, ParsedSeries parsedInfoKey)
|
||||||
|
{
|
||||||
|
return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName))
|
||||||
|
&& (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateSeries(Series series, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries)
|
private void UpdateSeries(Series series, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||||
|
|
||||||
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray();
|
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series);
|
||||||
UpdateVolumes(series, parsedInfos);
|
UpdateVolumes(series, parsedInfos);
|
||||||
series.Pages = series.Volumes.Sum(v => v.Pages);
|
series.Pages = series.Volumes.Sum(v => v.Pages);
|
||||||
|
|
||||||
@ -491,7 +495,7 @@ namespace API.Services.Tasks
|
|||||||
/// <param name="missingSeries">Series not found on disk or can't be parsed</param>
|
/// <param name="missingSeries">Series not found on disk or can't be parsed</param>
|
||||||
/// <param name="removeCount"></param>
|
/// <param name="removeCount"></param>
|
||||||
/// <returns>the updated existingSeries</returns>
|
/// <returns>the updated existingSeries</returns>
|
||||||
public static IList<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount)
|
public static IEnumerable<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount)
|
||||||
{
|
{
|
||||||
var existingCount = existingSeries.Count;
|
var existingCount = existingSeries.Count;
|
||||||
var missingList = missingSeries.ToList();
|
var missingList = missingSeries.ToList();
|
||||||
@ -505,7 +509,7 @@ namespace API.Services.Tasks
|
|||||||
return existingSeries;
|
return existingSeries;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateVolumes(Series series, ParserInfo[] parsedInfos)
|
private void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos)
|
||||||
{
|
{
|
||||||
var startingVolumeCount = series.Volumes.Count;
|
var startingVolumeCount = series.Volumes.Count;
|
||||||
// Add new volumes and update chapters per volume
|
// Add new volumes and update chapters per volume
|
||||||
@ -559,7 +563,7 @@ namespace API.Services.Tasks
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="volume"></param>
|
/// <param name="volume"></param>
|
||||||
/// <param name="parsedInfos"></param>
|
/// <param name="parsedInfos"></param>
|
||||||
private void UpdateChapters(Volume volume, ParserInfo[] parsedInfos)
|
private void UpdateChapters(Volume volume, IList<ParserInfo> parsedInfos)
|
||||||
{
|
{
|
||||||
// Add new chapters
|
// Add new chapters
|
||||||
foreach (var info in parsedInfos)
|
foreach (var info in parsedInfos)
|
||||||
|
@ -60,6 +60,20 @@ namespace API.SignalR
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static SignalRMessage RefreshMetadataProgressEvent(int libraryId, float progress)
|
||||||
|
{
|
||||||
|
return new SignalRMessage()
|
||||||
|
{
|
||||||
|
Name = SignalREvents.RefreshMetadataProgress,
|
||||||
|
Body = new
|
||||||
|
{
|
||||||
|
LibraryId = libraryId,
|
||||||
|
Progress = progress,
|
||||||
|
EventTime = DateTime.Now
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId)
|
public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId)
|
||||||
|
@ -4,7 +4,14 @@
|
|||||||
{
|
{
|
||||||
public const string UpdateVersion = "UpdateVersion";
|
public const string UpdateVersion = "UpdateVersion";
|
||||||
public const string ScanSeries = "ScanSeries";
|
public const string ScanSeries = "ScanSeries";
|
||||||
|
/// <summary>
|
||||||
|
/// Event during Refresh Metadata for cover image change
|
||||||
|
/// </summary>
|
||||||
public const string RefreshMetadata = "RefreshMetadata";
|
public const string RefreshMetadata = "RefreshMetadata";
|
||||||
|
/// <summary>
|
||||||
|
/// Event sent out during Refresh Metadata for progress tracking
|
||||||
|
/// </summary>
|
||||||
|
public const string RefreshMetadataProgress = "RefreshMetadataProgress";
|
||||||
public const string ScanLibrary = "ScanLibrary";
|
public const string ScanLibrary = "ScanLibrary";
|
||||||
public const string SeriesAdded = "SeriesAdded";
|
public const string SeriesAdded = "SeriesAdded";
|
||||||
public const string SeriesRemoved = "SeriesRemoved";
|
public const string SeriesRemoved = "SeriesRemoved";
|
||||||
|
@ -16,6 +16,7 @@ export enum EVENTS {
|
|||||||
UpdateAvailable = 'UpdateAvailable',
|
UpdateAvailable = 'UpdateAvailable',
|
||||||
ScanSeries = 'ScanSeries',
|
ScanSeries = 'ScanSeries',
|
||||||
RefreshMetadata = 'RefreshMetadata',
|
RefreshMetadata = 'RefreshMetadata',
|
||||||
|
RefreshMetadataProgress = 'RefreshMetadataProgress',
|
||||||
SeriesAdded = 'SeriesAdded',
|
SeriesAdded = 'SeriesAdded',
|
||||||
SeriesRemoved = 'SeriesRemoved',
|
SeriesRemoved = 'SeriesRemoved',
|
||||||
ScanLibraryProgress = 'ScanLibraryProgress',
|
ScanLibraryProgress = 'ScanLibraryProgress',
|
||||||
@ -89,6 +90,13 @@ export class MessageHubService {
|
|||||||
this.scanLibrary.emit(resp.body);
|
this.scanLibrary.emit(resp.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => {
|
||||||
|
this.messagesSource.next({
|
||||||
|
event: EVENTS.RefreshMetadataProgress,
|
||||||
|
payload: resp.body
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
|
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
|
||||||
this.messagesSource.next({
|
this.messagesSource.next({
|
||||||
event: EVENTS.SeriesAddedToCollection,
|
event: EVENTS.SeriesAddedToCollection,
|
||||||
|
@ -6,17 +6,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="list-group">
|
<div class="list-group" *ngIf="!isLoading">
|
||||||
<li class="list-group-item" *ngFor="let library of selectedLibraries; let i = index">
|
<div class="form-check">
|
||||||
<div class="form-check">
|
<input id="selectall" type="checkbox" class="form-check-input"
|
||||||
<input id="library-{{i}}" type="checkbox" attr.aria-label="Library {{library.data.name}}" class="form-check-input"
|
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||||
[(ngModel)]="library.selected" name="library">
|
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||||
<label attr.for="library-{{i}}" class="form-check-label">{{library.data.name}}</label>
|
</div>
|
||||||
</div>
|
<ul>
|
||||||
</li>
|
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||||
<li class="list-group-item" *ngIf="selectedLibraries.length === 0">
|
<div class="form-check">
|
||||||
There are no libraries setup yet.
|
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
|
||||||
</li>
|
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||||
|
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||||
|
There are no libraries setup yet.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Component, Input, OnInit } from '@angular/core';
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { FormBuilder } from '@angular/forms';
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
|
||||||
import { Library } from 'src/app/_models/library';
|
import { Library } from 'src/app/_models/library';
|
||||||
import { Member } from 'src/app/_models/member';
|
import { Member } from 'src/app/_models/member';
|
||||||
import { LibraryService } from 'src/app/_services/library.service';
|
import { LibraryService } from 'src/app/_services/library.service';
|
||||||
@ -15,24 +16,21 @@ export class LibraryAccessModalComponent implements OnInit {
|
|||||||
@Input() member: Member | undefined;
|
@Input() member: Member | undefined;
|
||||||
allLibraries: Library[] = [];
|
allLibraries: Library[] = [];
|
||||||
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
|
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
|
||||||
|
selections!: SelectionModel<Library>;
|
||||||
|
selectAll: boolean = false;
|
||||||
|
isLoading: boolean = false;
|
||||||
|
|
||||||
|
get hasSomeSelected() {
|
||||||
|
console.log(this.selections != null && this.selections.hasSomeSelected());
|
||||||
|
return this.selections != null && this.selections.hasSomeSelected();
|
||||||
|
}
|
||||||
|
|
||||||
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
|
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.libraryService.getLibraries().subscribe(libs => {
|
this.libraryService.getLibraries().subscribe(libs => {
|
||||||
this.allLibraries = libs;
|
this.allLibraries = libs;
|
||||||
this.selectedLibraries = libs.map(item => {
|
this.setupSelections();
|
||||||
return {selected: false, data: item};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.member !== undefined) {
|
|
||||||
this.member.libraries.forEach(lib => {
|
|
||||||
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
|
|
||||||
if (foundLibrary.length > 0) {
|
|
||||||
foundLibrary[0].selected = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,25 +43,41 @@ export class LibraryAccessModalComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedLibraries = this.selectedLibraries.filter(item => item.selected).map(item => item.data);
|
const selectedLibraries = this.selections.selected();
|
||||||
this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => {
|
this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => {
|
||||||
this.modal.close(true);
|
this.modal.close(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
setupSelections() {
|
||||||
this.selectedLibraries = this.allLibraries.map(item => {
|
this.selections = new SelectionModel<Library>(false, this.allLibraries);
|
||||||
return {selected: false, data: item};
|
this.isLoading = false;
|
||||||
});
|
|
||||||
|
// If a member is passed in, then auto-select their libraries
|
||||||
|
|
||||||
if (this.member !== undefined) {
|
if (this.member !== undefined) {
|
||||||
this.member.libraries.forEach(lib => {
|
this.member.libraries.forEach(lib => {
|
||||||
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
|
this.selections.toggle(lib, true, (a, b) => a.name === b.name);
|
||||||
if (foundLibrary.length > 0) {
|
|
||||||
foundLibrary[0].selected = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.setupSelections();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll() {
|
||||||
|
this.selectAll = !this.selectAll;
|
||||||
|
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelection(item: Library) {
|
||||||
|
this.selections.toggle(item);
|
||||||
|
const numberOfSelected = this.selections.selected().length;
|
||||||
|
if (numberOfSelected == 0) {
|
||||||
|
this.selectAll = false;
|
||||||
|
} else if (numberOfSelected == this.selectedLibraries.length) {
|
||||||
|
this.selectAll = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// when a progress event comes in, show it on the UI next to library
|
// when a progress event comes in, show it on the UI next to library
|
||||||
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
|
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
|
||||||
if (event.event != EVENTS.ScanLibraryProgress) return;
|
if (event.event !== EVENTS.ScanLibraryProgress) return;
|
||||||
|
|
||||||
const scanEvent = event.payload as ScanLibraryProgressEvent;
|
const scanEvent = event.payload as ScanLibraryProgressEvent;
|
||||||
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
|
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
|
||||||
@ -55,6 +55,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
<h6>Applies to Series</h6>
|
<h6>Applies to Series</h6>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input id="selectall" type="checkbox" class="form-check-input"
|
<input id="selectall" type="checkbox" class="form-check-input"
|
||||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected">
|
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -35,6 +35,11 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||||||
imageUrls: Array<string> = [];
|
imageUrls: Array<string> = [];
|
||||||
selectedCover: string = '';
|
selectedCover: string = '';
|
||||||
|
|
||||||
|
get hasSomeSelected() {
|
||||||
|
return this.selections != null && this.selections.hasSomeSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
||||||
private collectionService: CollectionTagService, private toastr: ToastrService,
|
private collectionService: CollectionTagService, private toastr: ToastrService,
|
||||||
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
|
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
|
||||||
@ -133,11 +138,6 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get someSelected() {
|
|
||||||
const selected = this.selections.selected();
|
|
||||||
return (selected.length !== this.series.length && selected.length !== 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSelectedIndex(index: number) {
|
updateSelectedIndex(index: number) {
|
||||||
this.collectionTagForm.patchValue({
|
this.collectionTagForm.patchValue({
|
||||||
coverImageIndex: index
|
coverImageIndex: index
|
||||||
|
@ -5,6 +5,8 @@ import { debounceTime, filter, map, shareReplay, switchMap, take, takeUntil, tap
|
|||||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||||
import { TypeaheadSettings } from './typeahead-settings';
|
import { TypeaheadSettings } from './typeahead-settings';
|
||||||
|
|
||||||
|
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle.
|
* SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle.
|
||||||
* @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false.
|
* @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false.
|
||||||
@ -30,10 +32,16 @@ export class SelectionModel<T> {
|
|||||||
/**
|
/**
|
||||||
* Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true.
|
* Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true.
|
||||||
* @param data Item to toggle
|
* @param data Item to toggle
|
||||||
|
* @param selectedState Force the state
|
||||||
|
* @param compareFn An optional function to use for the lookup, else will use shallowEqual implementation
|
||||||
*/
|
*/
|
||||||
toggle(data: T, selectedState?: boolean) {
|
toggle(data: T, selectedState?: boolean, compareFn?: SelectionCompareFn<T>) {
|
||||||
//const dataItem = this._data.filter(d => d.value == data);
|
let lookupMethod = this.shallowEqual;
|
||||||
const dataItem = this._data.filter(d => this.shallowEqual(d.value, data));
|
if (compareFn != undefined || compareFn != null) {
|
||||||
|
lookupMethod = compareFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataItem = this._data.filter(d => lookupMethod(d.value, data));
|
||||||
if (dataItem.length > 0) {
|
if (dataItem.length > 0) {
|
||||||
if (selectedState != undefined) {
|
if (selectedState != undefined) {
|
||||||
dataItem[0].selected = selectedState;
|
dataItem[0].selected = selectedState;
|
||||||
@ -45,6 +53,7 @@ export class SelectionModel<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the passed item selected
|
* Is the passed item selected
|
||||||
* @param data item to check against
|
* @param data item to check against
|
||||||
@ -65,6 +74,15 @@ export class SelectionModel<T> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns If some of the items are selected, but not all
|
||||||
|
*/
|
||||||
|
hasSomeSelected(): boolean {
|
||||||
|
const selectedCount = this._data.filter(d => d.selected).length;
|
||||||
|
return (selectedCount !== this._data.length && selectedCount !== 0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @returns All Selected items
|
* @returns All Selected items
|
||||||
|
Loading…
x
Reference in New Issue
Block a user