.NET 6 Coding Patterns + Unit Tests (#823)
* Refactored all files to have Interfaces within the same file. Started moving over to file-scoped namespaces. * Refactored common methods for getting underlying file's cover, pages, and extracting into 1 interface. * More refactoring around removing dependence on explicit filetype testing for getting information. * Code is buildable, tests are broken. Huge refactor (not completed) which makes most of DirectoryService testable with a mock filesystem (and thus the services that utilize it). * Finished porting DirectoryService to use mocked filesystem implementation. * Added a null check * Added a null check * Finished all unit tests for DirectoryService. * Some misc cleanup on the code * Fixed up some bugs from refactoring scan loop. * Implemented CleanupService testing and refactored more of DirectoryService to be non-static. Fixed a bug where cover file cleanup wasn't properly finding files due to a regex bug. * Fixed an issue in CleanupBackup() where we weren't properly selecting database files older than 30 days. Finished CleanupService Tests. * Refactored Flatten and RemoveNonImages to directory service to allow CacheService to be testable. * Finally have CacheService tested. Rewrote GetCachedPagePath() to be much more straightforward & performant. * Updated DefaultParserTests.cs to contain all existing tests and follow new test layout format. * All tests fixed up
This commit is contained in:
parent
bf1876ff44
commit
bbe8f800f6
115 changed files with 6734 additions and 5370 deletions
|
|
@ -1,2 +1,3 @@
|
|||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=covers/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=covers/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=wwwroot/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
|
|
@ -4,12 +4,11 @@ using System.Linq;
|
|||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ using System.Linq;
|
|||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.Entities;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data;
|
||||
using API.DTOs.Downloads;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
|
|
@ -47,21 +46,21 @@ namespace API.Controllers
|
|||
public async Task<ActionResult<long>> GetVolumeSize(int volumeId)
|
||||
{
|
||||
var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId);
|
||||
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
}
|
||||
|
||||
[HttpGet("chapter-size")]
|
||||
public async Task<ActionResult<long>> GetChapterSize(int chapterId)
|
||||
{
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
}
|
||||
|
||||
[HttpGet("series-size")]
|
||||
public async Task<ActionResult<long>> GetSeriesSize(int seriesId)
|
||||
{
|
||||
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
|
||||
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
}
|
||||
|
||||
[HttpGet("volume")]
|
||||
|
|
@ -141,13 +140,13 @@ namespace API.Controllers
|
|||
var totalFilePaths = new List<string>();
|
||||
|
||||
var tempFolder = $"download_{series.Id}_bookmarks";
|
||||
var fullExtractPath = Path.Join(DirectoryService.TempDirectory, tempFolder);
|
||||
if (new DirectoryInfo(fullExtractPath).Exists)
|
||||
var fullExtractPath = Path.Join(_directoryService.TempDirectory, tempFolder);
|
||||
if (_directoryService.FileSystem.DirectoryInfo.FromDirectoryName(fullExtractPath).Exists)
|
||||
{
|
||||
return BadRequest(
|
||||
"Server is currently processing this exact download. Please try again in a few minutes.");
|
||||
}
|
||||
DirectoryService.ExistOrCreate(fullExtractPath);
|
||||
_directoryService.ExistOrCreate(fullExtractPath);
|
||||
|
||||
var uniqueChapterIds = downloadBookmarkDto.Bookmarks.Select(b => b.ChapterId).Distinct().ToList();
|
||||
|
||||
|
|
@ -160,16 +159,16 @@ namespace API.Controllers
|
|||
switch (series.Format)
|
||||
{
|
||||
case MangaFormat.Image:
|
||||
DirectoryService.ExistOrCreate(chapterExtractPath);
|
||||
_directoryService.ExistOrCreate(chapterExtractPath);
|
||||
_directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath, $"{chapterId}_");
|
||||
break;
|
||||
case MangaFormat.Archive:
|
||||
case MangaFormat.Pdf:
|
||||
_cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList());
|
||||
var originalFiles = DirectoryService.GetFilesWithExtension(chapterExtractPath,
|
||||
var originalFiles = _directoryService.GetFilesWithExtension(chapterExtractPath,
|
||||
Parser.Parser.ImageFileExtensions);
|
||||
_directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_");
|
||||
DirectoryService.DeleteFiles(originalFiles);
|
||||
_directoryService.DeleteFiles(originalFiles);
|
||||
break;
|
||||
case MangaFormat.Epub:
|
||||
return BadRequest("Series is not in a valid format.");
|
||||
|
|
@ -177,7 +176,7 @@ namespace API.Controllers
|
|||
return BadRequest("Series is not in a valid format. Please rescan series and try again.");
|
||||
}
|
||||
|
||||
var files = DirectoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
|
||||
var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
|
||||
// Filter out images that aren't in bookmarks
|
||||
Array.Sort(files, _numericComparer);
|
||||
totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i)));
|
||||
|
|
@ -186,7 +185,7 @@ namespace API.Controllers
|
|||
|
||||
var (fileBytes, _) = await _archiveService.CreateZipForDownload(totalFilePaths,
|
||||
tempFolder);
|
||||
DirectoryService.ClearAndDeleteDirectory(fullExtractPath);
|
||||
_directoryService.ClearAndDeleteDirectory(fullExtractPath);
|
||||
return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
using System.IO;
|
||||
using API.Interfaces;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers
|
||||
|
|
@ -12,7 +12,7 @@ namespace API.Controllers
|
|||
|
||||
public FallbackController(ITaskScheduler taskScheduler)
|
||||
{
|
||||
// This is used to load TaskScheduler on startup without having to navigate to a Controller that uses.
|
||||
// This is used to load TaskScheduler on startup without having to navigate to a Controller that uses.
|
||||
_taskScheduler = taskScheduler;
|
||||
}
|
||||
|
||||
|
|
@ -21,4 +21,4 @@ namespace API.Controllers
|
|||
return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
|
@ -13,11 +13,13 @@ namespace API.Controllers
|
|||
public class ImageController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImageController(IUnitOfWork unitOfWork)
|
||||
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -28,12 +30,12 @@ namespace API.Controllers
|
|||
[HttpGet("chapter-cover")]
|
||||
public async Task<ActionResult> GetChapterCoverImage(int chapterId)
|
||||
{
|
||||
var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId));
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId));
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path);
|
||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -44,12 +46,12 @@ namespace API.Controllers
|
|||
[HttpGet("volume-cover")]
|
||||
public async Task<ActionResult> GetVolumeCoverImage(int volumeId)
|
||||
{
|
||||
var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId));
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId));
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path);
|
||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -60,12 +62,12 @@ namespace API.Controllers
|
|||
[HttpGet("series-cover")]
|
||||
public async Task<ActionResult> GetSeriesCoverImage(int seriesId)
|
||||
{
|
||||
var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId));
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId));
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path);
|
||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -76,12 +78,12 @@ namespace API.Controllers
|
|||
[HttpGet("collection-cover")]
|
||||
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId)
|
||||
{
|
||||
var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path);
|
||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ namespace API.Controllers
|
|||
|
||||
try
|
||||
{
|
||||
var (path, _) = await _cacheService.GetCachedPagePath(chapter, page);
|
||||
var path = _cacheService.GetCachedPagePath(chapter, page);
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ namespace API.Controllers
|
|||
LibraryId = dto.LibraryId,
|
||||
IsSpecial = dto.IsSpecial,
|
||||
Pages = dto.Pages,
|
||||
ChapterTitle = dto.ChapterTitle
|
||||
ChapterTitle = dto.ChapterTitle ?? string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ using API.Data;
|
|||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Interfaces;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ using System.Threading.Tasks;
|
|||
using API.DTOs.Stats;
|
||||
using API.DTOs.Update;
|
||||
using API.Extensions;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
@ -26,10 +27,11 @@ namespace API.Controllers
|
|||
private readonly ICacheService _cacheService;
|
||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||
private readonly IStatsService _statsService;
|
||||
private readonly ICleanupService _cleanupService;
|
||||
|
||||
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
|
||||
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
|
||||
IVersionUpdaterService versionUpdaterService, IStatsService statsService)
|
||||
IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService)
|
||||
{
|
||||
_applicationLifetime = applicationLifetime;
|
||||
_logger = logger;
|
||||
|
|
@ -39,6 +41,7 @@ namespace API.Controllers
|
|||
_cacheService = cacheService;
|
||||
_versionUpdaterService = versionUpdaterService;
|
||||
_statsService = statsService;
|
||||
_cleanupService = cleanupService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -62,7 +65,7 @@ namespace API.Controllers
|
|||
public ActionResult ClearCache()
|
||||
{
|
||||
_logger.LogInformation("{UserName} is clearing cache of server from admin dashboard", User.GetUsername());
|
||||
_cacheService.Cleanup();
|
||||
_cleanupService.CleanupCacheDirectory();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
|
@ -93,7 +96,7 @@ namespace API.Controllers
|
|||
[HttpGet("logs")]
|
||||
public async Task<ActionResult> GetLogs()
|
||||
{
|
||||
var files = _backupService.LogFiles(_config.GetMaxRollingFiles(), _config.GetLoggingFileName());
|
||||
var files = _backupService.GetLogFiles(_config.GetMaxRollingFiles(), _config.GetLoggingFileName());
|
||||
try
|
||||
{
|
||||
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files, "logs");
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Converters;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Extensions;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Uploads;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using API.Entities.Enums;
|
||||
using System;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
|
|
@ -12,7 +13,7 @@ namespace API.DTOs.Reader
|
|||
public MangaFormat SeriesFormat { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
public string ChapterTitle { get; set; } = "";
|
||||
public string ChapterTitle { get; set; } = string.Empty;
|
||||
public int Pages { get; set; }
|
||||
public string FileName { get; set; }
|
||||
public bool IsSpecial { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using API.Data.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
|
||||
namespace API.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// A Migration to migrate config related files to the config/ directory for installs prior to v0.4.9.
|
||||
/// </summary>
|
||||
public static class MigrateConfigFiles
|
||||
{
|
||||
private static readonly List<string> LooseLeafFiles = new List<string>()
|
||||
|
|
@ -31,7 +35,7 @@ namespace API.Data
|
|||
/// 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
|
||||
/// to config/
|
||||
/// </summary>
|
||||
public static void Migrate(bool isDocker)
|
||||
public static void Migrate(bool isDocker, IDirectoryService directoryService)
|
||||
{
|
||||
Console.WriteLine("Checking if migration to config/ is needed");
|
||||
|
||||
|
|
@ -46,8 +50,8 @@ namespace API.Data
|
|||
Console.WriteLine(
|
||||
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
||||
|
||||
CopyAppFolders();
|
||||
DeleteAppFolders();
|
||||
CopyAppFolders(directoryService);
|
||||
DeleteAppFolders(directoryService);
|
||||
|
||||
UpdateConfiguration();
|
||||
|
||||
|
|
@ -64,14 +68,14 @@ namespace API.Data
|
|||
Console.WriteLine(
|
||||
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
||||
|
||||
Console.WriteLine($"Creating {DirectoryService.ConfigDirectory}");
|
||||
DirectoryService.ExistOrCreate(DirectoryService.ConfigDirectory);
|
||||
Console.WriteLine($"Creating {directoryService.ConfigDirectory}");
|
||||
directoryService.ExistOrCreate(directoryService.ConfigDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
CopyLooseLeafFiles();
|
||||
CopyLooseLeafFiles(directoryService);
|
||||
|
||||
CopyAppFolders();
|
||||
CopyAppFolders(directoryService);
|
||||
|
||||
// Then we need to update the config file to point to the new DB file
|
||||
UpdateConfiguration();
|
||||
|
|
@ -84,43 +88,43 @@ namespace API.Data
|
|||
|
||||
// Finally delete everything in the source directory
|
||||
Console.WriteLine("Removing old files");
|
||||
DeleteLooseFiles();
|
||||
DeleteAppFolders();
|
||||
DeleteLooseFiles(directoryService);
|
||||
DeleteAppFolders(directoryService);
|
||||
Console.WriteLine("Removing old files...DONE");
|
||||
|
||||
Console.WriteLine("Migration complete. All config files are now in config/ directory");
|
||||
}
|
||||
|
||||
private static void DeleteAppFolders()
|
||||
private static void DeleteAppFolders(IDirectoryService directoryService)
|
||||
{
|
||||
foreach (var folderToDelete in AppFolders)
|
||||
{
|
||||
if (!new DirectoryInfo(Path.Join(Directory.GetCurrentDirectory(), folderToDelete)).Exists) continue;
|
||||
|
||||
DirectoryService.ClearAndDeleteDirectory(Path.Join(Directory.GetCurrentDirectory(), folderToDelete));
|
||||
directoryService.ClearAndDeleteDirectory(Path.Join(Directory.GetCurrentDirectory(), folderToDelete));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteLooseFiles()
|
||||
private static void DeleteLooseFiles(IDirectoryService directoryService)
|
||||
{
|
||||
var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(Directory.GetCurrentDirectory(), file)))
|
||||
.Where(f => f.Exists);
|
||||
DirectoryService.DeleteFiles(configFiles.Select(f => f.FullName));
|
||||
directoryService.DeleteFiles(configFiles.Select(f => f.FullName));
|
||||
}
|
||||
|
||||
private static void CopyAppFolders()
|
||||
private static void CopyAppFolders(IDirectoryService directoryService)
|
||||
{
|
||||
Console.WriteLine("Moving folders to config");
|
||||
|
||||
foreach (var folderToMove in AppFolders)
|
||||
{
|
||||
if (new DirectoryInfo(Path.Join(DirectoryService.ConfigDirectory, folderToMove)).Exists) continue;
|
||||
if (new DirectoryInfo(Path.Join(directoryService.ConfigDirectory, folderToMove)).Exists) continue;
|
||||
|
||||
try
|
||||
{
|
||||
DirectoryService.CopyDirectoryToDirectory(
|
||||
Path.Join(Directory.GetCurrentDirectory(), folderToMove),
|
||||
Path.Join(DirectoryService.ConfigDirectory, folderToMove));
|
||||
directoryService.CopyDirectoryToDirectory(
|
||||
Path.Join(directoryService.FileSystem.Directory.GetCurrentDirectory(), folderToMove),
|
||||
Path.Join(directoryService.ConfigDirectory, folderToMove));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
@ -132,9 +136,9 @@ namespace API.Data
|
|||
Console.WriteLine("Moving folders to config...DONE");
|
||||
}
|
||||
|
||||
private static void CopyLooseLeafFiles()
|
||||
private static void CopyLooseLeafFiles(IDirectoryService directoryService)
|
||||
{
|
||||
var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(Directory.GetCurrentDirectory(), file)))
|
||||
var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(directoryService.FileSystem.Directory.GetCurrentDirectory(), file)))
|
||||
.Where(f => f.Exists);
|
||||
// First step is to move all the files
|
||||
Console.WriteLine("Moving files to config/");
|
||||
|
|
@ -142,7 +146,7 @@ namespace API.Data
|
|||
{
|
||||
try
|
||||
{
|
||||
fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name));
|
||||
fileInfo.CopyTo(Path.Join(directoryService.ConfigDirectory, fileInfo.Name));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ namespace API.Data
|
|||
/// <summary>
|
||||
/// Run first. Will extract byte[]s from DB and write them to the cover directory.
|
||||
/// </summary>
|
||||
public static void ExtractToImages(DbContext context)
|
||||
public static void ExtractToImages(DbContext context, IDirectoryService directoryService, IImageService imageService)
|
||||
{
|
||||
Console.WriteLine("Migrating Cover Images to disk. Expect delay.");
|
||||
DirectoryService.ExistOrCreate(DirectoryService.CoverImageDirectory);
|
||||
directoryService.ExistOrCreate(directoryService.CoverImageDirectory);
|
||||
|
||||
Console.WriteLine("Extracting cover images for Series");
|
||||
var lockedSeries = SqlHelper.RawSqlQuery(context, "Select Id, CoverImage From Series Where CoverImage IS NOT NULL", x =>
|
||||
|
|
@ -45,14 +45,14 @@ namespace API.Data
|
|||
foreach (var series in lockedSeries)
|
||||
{
|
||||
if (series.CoverImage == null || !series.CoverImage.Any()) continue;
|
||||
if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory,
|
||||
if (File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetSeriesFormat(int.Parse(series.Id))}.png"))) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var stream = new MemoryStream(series.CoverImage);
|
||||
stream.Position = 0;
|
||||
ImageService.WriteCoverThumbnail(stream, ImageService.GetSeriesFormat(int.Parse(series.Id)));
|
||||
imageService.WriteCoverThumbnail(stream, ImageService.GetSeriesFormat(int.Parse(series.Id)));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
@ -71,14 +71,14 @@ namespace API.Data
|
|||
foreach (var chapter in chapters)
|
||||
{
|
||||
if (chapter.CoverImage == null || !chapter.CoverImage.Any()) continue;
|
||||
if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory,
|
||||
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}.png"))) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var stream = new MemoryStream(chapter.CoverImage);
|
||||
stream.Position = 0;
|
||||
ImageService.WriteCoverThumbnail(stream, $"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}");
|
||||
imageService.WriteCoverThumbnail(stream, $"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
@ -97,13 +97,13 @@ namespace API.Data
|
|||
foreach (var tag in tags)
|
||||
{
|
||||
if (tag.CoverImage == null || !tag.CoverImage.Any()) continue;
|
||||
if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory,
|
||||
if (directoryService.FileSystem.File.Exists(Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}.png"))) continue;
|
||||
try
|
||||
{
|
||||
var stream = new MemoryStream(tag.CoverImage);
|
||||
stream.Position = 0;
|
||||
ImageService.WriteCoverThumbnail(stream, $"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}");
|
||||
imageService.WriteCoverThumbnail(stream, $"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
@ -116,13 +116,13 @@ namespace API.Data
|
|||
/// Run after <see cref="ExtractToImages"/>. Will update the DB with names of files that were extracted.
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
public static async Task UpdateDatabaseWithImages(DataContext context)
|
||||
public static async Task UpdateDatabaseWithImages(DataContext context, IDirectoryService directoryService)
|
||||
{
|
||||
Console.WriteLine("Updating Series entities");
|
||||
var seriesCovers = await context.Series.Where(s => !string.IsNullOrEmpty(s.CoverImage)).ToListAsync();
|
||||
foreach (var series in seriesCovers)
|
||||
{
|
||||
if (!File.Exists(Path.Join(DirectoryService.CoverImageDirectory,
|
||||
if (!directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetSeriesFormat(series.Id)}.png"))) continue;
|
||||
series.CoverImage = $"{ImageService.GetSeriesFormat(series.Id)}.png";
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ namespace API.Data
|
|||
var chapters = await context.Chapter.ToListAsync();
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory,
|
||||
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}.png")))
|
||||
{
|
||||
chapter.CoverImage = $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}.png";
|
||||
|
|
@ -149,7 +149,7 @@ namespace API.Data
|
|||
{
|
||||
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting).FirstOrDefault();
|
||||
if (firstChapter == null) continue;
|
||||
if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory,
|
||||
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png")))
|
||||
{
|
||||
volume.CoverImage = $"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png";
|
||||
|
|
@ -163,7 +163,7 @@ namespace API.Data
|
|||
var tags = await context.CollectionTag.ToListAsync();
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory,
|
||||
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetCollectionTagFormat(tag.Id)}.png")))
|
||||
{
|
||||
tag.CoverImage = $"{ImageService.GetCollectionTagFormat(tag.Id)}.png";
|
||||
|
|
|
|||
|
|
@ -2,78 +2,84 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Interfaces.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IAppUserProgressRepository
|
||||
{
|
||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
void Update(AppUserProgress userProgress);
|
||||
Task<int> CleanupAbandonedChapters();
|
||||
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
|
||||
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
|
||||
}
|
||||
|
||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
|
||||
public AppUserProgressRepository(DataContext context)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public AppUserProgressRepository(DataContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
public void Update(AppUserProgress userProgress)
|
||||
{
|
||||
_context.Entry(userProgress).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(AppUserProgress userProgress)
|
||||
{
|
||||
_context.Entry(userProgress).State = EntityState.Modified;
|
||||
}
|
||||
/// <summary>
|
||||
/// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well.
|
||||
/// </summary>
|
||||
public async Task<int> CleanupAbandonedChapters()
|
||||
{
|
||||
var chapterIds = _context.Chapter.Select(c => c.Id);
|
||||
|
||||
/// <summary>
|
||||
/// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well.
|
||||
/// </summary>
|
||||
public async Task<int> CleanupAbandonedChapters()
|
||||
{
|
||||
var chapterIds = _context.Chapter.Select(c => c.Id);
|
||||
var rowsToRemove = await _context.AppUserProgresses
|
||||
.Where(progress => !chapterIds.Contains(progress.ChapterId))
|
||||
.ToListAsync();
|
||||
|
||||
var rowsToRemove = await _context.AppUserProgresses
|
||||
.Where(progress => !chapterIds.Contains(progress.ChapterId))
|
||||
.ToListAsync();
|
||||
var rowsToRemoveBookmarks = await _context.AppUserBookmark
|
||||
.Where(progress => !chapterIds.Contains(progress.ChapterId))
|
||||
.ToListAsync();
|
||||
|
||||
var rowsToRemoveBookmarks = await _context.AppUserBookmark
|
||||
.Where(progress => !chapterIds.Contains(progress.ChapterId))
|
||||
.ToListAsync();
|
||||
var rowsToRemoveReadingLists = await _context.ReadingListItem
|
||||
.Where(item => !chapterIds.Contains(item.ChapterId))
|
||||
.ToListAsync();
|
||||
|
||||
var rowsToRemoveReadingLists = await _context.ReadingListItem
|
||||
.Where(item => !chapterIds.Contains(item.ChapterId))
|
||||
.ToListAsync();
|
||||
_context.RemoveRange(rowsToRemove);
|
||||
_context.RemoveRange(rowsToRemoveBookmarks);
|
||||
_context.RemoveRange(rowsToRemoveReadingLists);
|
||||
return await _context.SaveChangesAsync() > 0 ? rowsToRemove.Count : 0;
|
||||
}
|
||||
|
||||
_context.RemoveRange(rowsToRemove);
|
||||
_context.RemoveRange(rowsToRemoveBookmarks);
|
||||
_context.RemoveRange(rowsToRemoveReadingLists);
|
||||
return await _context.SaveChangesAsync() > 0 ? rowsToRemove.Count : 0;
|
||||
}
|
||||
/// <summary>
|
||||
/// Checks if user has any progress against a library of passed type
|
||||
/// </summary>
|
||||
/// <param name="libraryType"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> UserHasProgress(LibraryType libraryType, int userId)
|
||||
{
|
||||
var seriesIds = await _context.AppUserProgresses
|
||||
.Where(aup => aup.PagesRead > 0 && aup.AppUserId == userId)
|
||||
.AsNoTracking()
|
||||
.Select(aup => aup.SeriesId)
|
||||
.ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if user has any progress against a library of passed type
|
||||
/// </summary>
|
||||
/// <param name="libraryType"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> UserHasProgress(LibraryType libraryType, int userId)
|
||||
{
|
||||
var seriesIds = await _context.AppUserProgresses
|
||||
.Where(aup => aup.PagesRead > 0 && aup.AppUserId == userId)
|
||||
.AsNoTracking()
|
||||
.Select(aup => aup.SeriesId)
|
||||
.ToListAsync();
|
||||
if (seriesIds.Count == 0) return false;
|
||||
|
||||
if (seriesIds.Count == 0) return false;
|
||||
return await _context.Series
|
||||
.Include(s => s.Library)
|
||||
.Where(s => seriesIds.Contains(s.Id) && s.Library.Type == libraryType)
|
||||
.AsNoTracking()
|
||||
.AnyAsync();
|
||||
}
|
||||
|
||||
return await _context.Series
|
||||
.Include(s => s.Library)
|
||||
.Where(s => seriesIds.Contains(s.Id) && s.Library.Type == libraryType)
|
||||
.AsNoTracking()
|
||||
.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
.Where(p => p.ChapterId == chapterId && p.AppUserId == userId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
.Where(p => p.ChapterId == chapterId && p.AppUserId == userId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,195 +1,207 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IChapterRepository
|
||||
{
|
||||
public class ChapterRepository : IChapterRepository
|
||||
void Update(Chapter chapter);
|
||||
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds);
|
||||
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
|
||||
Task<int> GetChapterTotalPagesAsync(int chapterId);
|
||||
Task<Chapter> GetChapterAsync(int chapterId);
|
||||
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
|
||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||
Task<string> GetChapterCoverImageAsync(int chapterId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
|
||||
}
|
||||
public class ChapterRepository : IChapterRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ChapterRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public ChapterRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
public void Update(Chapter chapter)
|
||||
{
|
||||
_context.Entry(chapter).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(Chapter chapter)
|
||||
{
|
||||
_context.Entry(chapter).State = EntityState.Modified;
|
||||
}
|
||||
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => chapterIds.Contains(c.Id))
|
||||
.Include(c => c.Volume)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => chapterIds.Contains(c.Id))
|
||||
.Include(c => c.Volume)
|
||||
.ToListAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// Populates a partial IChapterInfoDto
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId)
|
||||
{
|
||||
var chapterInfo = await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new
|
||||
{
|
||||
ChapterNumber = chapter.Range,
|
||||
VolumeNumber = volume.Number,
|
||||
VolumeId = volume.Id,
|
||||
chapter.IsSpecial,
|
||||
chapter.TitleName,
|
||||
volume.SeriesId,
|
||||
chapter.Pages,
|
||||
})
|
||||
.Join(_context.Series, data => data.SeriesId, series => series.Id, (data, series) => new
|
||||
{
|
||||
data.ChapterNumber,
|
||||
data.VolumeNumber,
|
||||
data.VolumeId,
|
||||
data.IsSpecial,
|
||||
data.SeriesId,
|
||||
data.Pages,
|
||||
data.TitleName,
|
||||
SeriesFormat = series.Format,
|
||||
SeriesName = series.Name,
|
||||
series.LibraryId
|
||||
})
|
||||
.Select(data => new ChapterInfoDto()
|
||||
{
|
||||
ChapterNumber = data.ChapterNumber,
|
||||
VolumeNumber = data.VolumeNumber + string.Empty,
|
||||
VolumeId = data.VolumeId,
|
||||
IsSpecial = data.IsSpecial,
|
||||
SeriesId =data.SeriesId,
|
||||
SeriesFormat = data.SeriesFormat,
|
||||
SeriesName = data.SeriesName,
|
||||
LibraryId = data.LibraryId,
|
||||
Pages = data.Pages,
|
||||
ChapterTitle = data.TitleName
|
||||
})
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Populates a partial IChapterInfoDto
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId)
|
||||
{
|
||||
var chapterInfo = await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new
|
||||
{
|
||||
ChapterNumber = chapter.Range,
|
||||
VolumeNumber = volume.Number,
|
||||
VolumeId = volume.Id,
|
||||
chapter.IsSpecial,
|
||||
chapter.TitleName,
|
||||
volume.SeriesId,
|
||||
chapter.Pages,
|
||||
})
|
||||
.Join(_context.Series, data => data.SeriesId, series => series.Id, (data, series) => new
|
||||
{
|
||||
data.ChapterNumber,
|
||||
data.VolumeNumber,
|
||||
data.VolumeId,
|
||||
data.IsSpecial,
|
||||
data.SeriesId,
|
||||
data.Pages,
|
||||
data.TitleName,
|
||||
SeriesFormat = series.Format,
|
||||
SeriesName = series.Name,
|
||||
series.LibraryId
|
||||
})
|
||||
.Select(data => new ChapterInfoDto()
|
||||
{
|
||||
ChapterNumber = data.ChapterNumber,
|
||||
VolumeNumber = data.VolumeNumber + string.Empty,
|
||||
VolumeId = data.VolumeId,
|
||||
IsSpecial = data.IsSpecial,
|
||||
SeriesId =data.SeriesId,
|
||||
SeriesFormat = data.SeriesFormat,
|
||||
SeriesName = data.SeriesName,
|
||||
LibraryId = data.LibraryId,
|
||||
Pages = data.Pages,
|
||||
ChapterTitle = data.TitleName
|
||||
})
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
return chapterInfo;
|
||||
}
|
||||
|
||||
return chapterInfo;
|
||||
}
|
||||
public Task<int> GetChapterTotalPagesAsync(int chapterId)
|
||||
{
|
||||
return _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Select(c => c.Pages)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<ChapterDto> GetChapterDtoAsync(int chapterId)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
public Task<int> GetChapterTotalPagesAsync(int chapterId)
|
||||
{
|
||||
return _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Select(c => c.Pages)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<ChapterDto> GetChapterDtoAsync(int chapterId)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
return chapter;
|
||||
}
|
||||
|
||||
return chapter;
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a given chapterId
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterId == c.ChapterId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a given chapterId
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterId == c.ChapterId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Chapter> GetChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Chapter> GetChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns Chapters for a volume id.
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.VolumeId == volumeId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Chapters for a volume id.
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.VolumeId == volumeId)
|
||||
.ToListAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns the cover image for a chapter id.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> GetChapterCoverImageAsync(int chapterId)
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cover image for a chapter id.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> GetChapterCoverImageAsync(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Select(c => c.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Select(c => c.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<IList<string>> GetAllCoverImagesAsync()
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Select(c => c.CoverImage)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<string>> GetAllCoverImagesAsync()
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Select(c => c.CoverImage)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns cover images for locked chapters
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync()
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.CoverImageLocked)
|
||||
.Select(c => c.CoverImage)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cover images for locked chapters
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync()
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.CoverImageLocked)
|
||||
.Select(c => c.CoverImage)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a set of <paramref name="chapterIds"/>
|
||||
/// </summary>
|
||||
/// <param name="chapterIds">List of chapter Ids</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterIds.Contains(c.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a set of <paramref name="chapterIds"/>
|
||||
/// </summary>
|
||||
/// <param name="chapterIds">List of chapter Ids</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterIds.Contains(c.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,123 +3,136 @@ using System.Linq;
|
|||
using System.Threading.Tasks;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface ICollectionTagRepository
|
||||
{
|
||||
public class CollectionTagRepository : ICollectionTagRepository
|
||||
void Add(CollectionTag tag);
|
||||
void Remove(CollectionTag tag);
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
|
||||
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery);
|
||||
Task<string> GetCoverImageAsync(int collectionTagId);
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync();
|
||||
Task<CollectionTag> GetTagAsync(int tagId);
|
||||
Task<CollectionTag> GetFullTagAsync(int tagId);
|
||||
void Update(CollectionTag tag);
|
||||
Task<int> RemoveTagsWithoutSeries();
|
||||
Task<IEnumerable<CollectionTag>> GetAllTagsAsync();
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
}
|
||||
public class CollectionTagRepository : ICollectionTagRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public CollectionTagRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public CollectionTagRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
public void Add(CollectionTag tag)
|
||||
{
|
||||
_context.CollectionTag.Add(tag);
|
||||
}
|
||||
|
||||
public void Add(CollectionTag tag)
|
||||
{
|
||||
_context.CollectionTag.Add(tag);
|
||||
}
|
||||
public void Remove(CollectionTag tag)
|
||||
{
|
||||
_context.CollectionTag.Remove(tag);
|
||||
}
|
||||
|
||||
public void Remove(CollectionTag tag)
|
||||
{
|
||||
_context.CollectionTag.Remove(tag);
|
||||
}
|
||||
public void Update(CollectionTag tag)
|
||||
{
|
||||
_context.Entry(tag).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(CollectionTag tag)
|
||||
{
|
||||
_context.Entry(tag).State = EntityState.Modified;
|
||||
}
|
||||
/// <summary>
|
||||
/// Removes any collection tags without any series
|
||||
/// </summary>
|
||||
public async Task<int> RemoveTagsWithoutSeries()
|
||||
{
|
||||
var tagsToDelete = await _context.CollectionTag
|
||||
.Include(c => c.SeriesMetadatas)
|
||||
.Where(c => c.SeriesMetadatas.Count == 0)
|
||||
.ToListAsync();
|
||||
_context.RemoveRange(tagsToDelete);
|
||||
|
||||
/// <summary>
|
||||
/// Removes any collection tags without any series
|
||||
/// </summary>
|
||||
public async Task<int> RemoveTagsWithoutSeries()
|
||||
{
|
||||
var tagsToDelete = await _context.CollectionTag
|
||||
.Include(c => c.SeriesMetadatas)
|
||||
.Where(c => c.SeriesMetadatas.Count == 0)
|
||||
.ToListAsync();
|
||||
_context.RemoveRange(tagsToDelete);
|
||||
return await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return await _context.SaveChangesAsync();
|
||||
}
|
||||
public async Task<IEnumerable<CollectionTag>> GetAllTagsAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTag>> GetAllTagsAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
public async Task<IList<string>> GetAllCoverImagesAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Select(t => t.CoverImage)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<string>> GetAllCoverImagesAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Select(t => t.CoverImage)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Select(c => c)
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Select(c => c)
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Promoted)
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync()
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Promoted)
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
public async Task<CollectionTag> GetTagAsync(int tagId)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Id == tagId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CollectionTag> GetTagAsync(int tagId)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Id == tagId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<CollectionTag> GetFullTagAsync(int tagId)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Id == tagId)
|
||||
.Include(c => c.SeriesMetadatas)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CollectionTag> GetFullTagAsync(int tagId)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Id == tagId)
|
||||
.Include(c => c.SeriesMetadatas)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
|
||||
.OrderBy(s => s.Title)
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
|
||||
.OrderBy(s => s.Title)
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<string> GetCoverImageAsync(int collectionTagId)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Id == collectionTagId)
|
||||
.Select(c => c.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<string> GetCoverImageAsync(int collectionTagId)
|
||||
{
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Id == collectionTagId)
|
||||
.Select(c => c.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,20 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IGenreRepository
|
||||
{
|
||||
void Attach(Genre genre);
|
||||
void Remove(Genre genre);
|
||||
Task<Genre> FindByNameAsync(string genreName);
|
||||
Task<IList<Genre>> GetAllGenres();
|
||||
Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false);
|
||||
}
|
||||
|
||||
public class GenreRepository : IGenreRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
|
|
@ -51,6 +59,6 @@ public class GenreRepository : IGenreRepository
|
|||
|
||||
public async Task<IList<Genre>> GetAllGenres()
|
||||
{
|
||||
return await _context.Genre.ToListAsync();;
|
||||
return await _context.Genre.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,194 +5,208 @@ using System.Threading.Tasks;
|
|||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
[Flags]
|
||||
public enum LibraryIncludes
|
||||
{
|
||||
|
||||
[Flags]
|
||||
public enum LibraryIncludes
|
||||
{
|
||||
None = 1,
|
||||
Series = 2,
|
||||
AppUser = 4,
|
||||
Folders = 8,
|
||||
// Ratings = 16
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public LibraryRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Add(Library library)
|
||||
{
|
||||
_context.Library.Add(library);
|
||||
}
|
||||
|
||||
public void Update(Library library)
|
||||
{
|
||||
_context.Entry(library).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Delete(Library library)
|
||||
{
|
||||
_context.Library.Remove(library);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosForUsernameAsync(string userName)
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(library => library.AppUsers.Any(x => x.UserName == userName))
|
||||
.OrderBy(l => l.Name)
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.AsSingleQuery()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Library>> GetLibrariesAsync()
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteLibrary(int libraryId)
|
||||
{
|
||||
var library = await GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.Series);
|
||||
_context.Library.Remove(library);
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LibraryType> GetLibraryTypeAsync(int libraryId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Where(l => l.Id == libraryId)
|
||||
.AsNoTracking()
|
||||
.Select(l => l.Type)
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(f => f.Folders)
|
||||
.OrderBy(l => l.Name)
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Library> GetLibraryForIdAsync(int libraryId, LibraryIncludes includes)
|
||||
{
|
||||
|
||||
var query = _context.Library
|
||||
.Where(x => x.Id == libraryId);
|
||||
|
||||
query = AddIncludesToQuery(query, includes);
|
||||
return await query.SingleAsync();
|
||||
}
|
||||
|
||||
private static IQueryable<Library> AddIncludesToQuery(IQueryable<Library> query, LibraryIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(LibraryIncludes.Folders))
|
||||
{
|
||||
query = query.Include(l => l.Folders);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(LibraryIncludes.Series))
|
||||
{
|
||||
query = query.Include(l => l.Series);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(LibraryIncludes.AppUser))
|
||||
{
|
||||
query = query.Include(l => l.AppUsers);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// This returns a Library with all it's Series -> Volumes -> Chapters. This is expensive. Should only be called when needed.
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Library> GetFullLibraryForIdAsync(int libraryId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Where(x => x.Id == libraryId)
|
||||
.Include(f => f.Folders)
|
||||
.Include(l => l.Series)
|
||||
.ThenInclude(s => s.Metadata)
|
||||
.Include(l => l.Series)
|
||||
.ThenInclude(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.AsSplitQuery()
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is a heavy call, pulls all entities for a Library, except this version only grabs for one series id
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Library> GetFullLibraryForIdAsync(int libraryId, int seriesId)
|
||||
{
|
||||
|
||||
return await _context.Library
|
||||
.Where(x => x.Id == libraryId)
|
||||
.Include(f => f.Folders)
|
||||
.Include(l => l.Series.Where(s => s.Id == seriesId))
|
||||
.ThenInclude(s => s.Metadata)
|
||||
.Include(l => l.Series.Where(s => s.Id == seriesId))
|
||||
.ThenInclude(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.AsSplitQuery()
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> LibraryExists(string libraryName)
|
||||
{
|
||||
return await _context.Library
|
||||
.AsNoTracking()
|
||||
.AnyAsync(x => x.Name == libraryName);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibrariesForUserAsync(AppUser user)
|
||||
{
|
||||
return await _context.Library
|
||||
.Where(library => library.AppUsers.Contains(user))
|
||||
.Include(l => l.Folders)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
None = 1,
|
||||
Series = 2,
|
||||
AppUser = 4,
|
||||
Folders = 8,
|
||||
// Ratings = 16
|
||||
}
|
||||
|
||||
public interface ILibraryRepository
|
||||
{
|
||||
void Add(Library library);
|
||||
void Update(Library library);
|
||||
void Delete(Library library);
|
||||
Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync();
|
||||
Task<bool> LibraryExists(string libraryName);
|
||||
Task<Library> GetLibraryForIdAsync(int libraryId, LibraryIncludes includes);
|
||||
Task<Library> GetFullLibraryForIdAsync(int libraryId);
|
||||
Task<Library> GetFullLibraryForIdAsync(int libraryId, int seriesId);
|
||||
Task<IEnumerable<LibraryDto>> GetLibraryDtosForUsernameAsync(string userName);
|
||||
Task<IEnumerable<Library>> GetLibrariesAsync();
|
||||
Task<bool> DeleteLibrary(int libraryId);
|
||||
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
|
||||
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public LibraryRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Add(Library library)
|
||||
{
|
||||
_context.Library.Add(library);
|
||||
}
|
||||
|
||||
public void Update(Library library)
|
||||
{
|
||||
_context.Entry(library).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Delete(Library library)
|
||||
{
|
||||
_context.Library.Remove(library);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosForUsernameAsync(string userName)
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(library => library.AppUsers.Any(x => x.UserName == userName))
|
||||
.OrderBy(l => l.Name)
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.AsSingleQuery()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Library>> GetLibrariesAsync()
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteLibrary(int libraryId)
|
||||
{
|
||||
var library = await GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.Series);
|
||||
_context.Library.Remove(library);
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LibraryType> GetLibraryTypeAsync(int libraryId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Where(l => l.Id == libraryId)
|
||||
.AsNoTracking()
|
||||
.Select(l => l.Type)
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(f => f.Folders)
|
||||
.OrderBy(l => l.Name)
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Library> GetLibraryForIdAsync(int libraryId, LibraryIncludes includes)
|
||||
{
|
||||
|
||||
var query = _context.Library
|
||||
.Where(x => x.Id == libraryId);
|
||||
|
||||
query = AddIncludesToQuery(query, includes);
|
||||
return await query.SingleAsync();
|
||||
}
|
||||
|
||||
private static IQueryable<Library> AddIncludesToQuery(IQueryable<Library> query, LibraryIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(LibraryIncludes.Folders))
|
||||
{
|
||||
query = query.Include(l => l.Folders);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(LibraryIncludes.Series))
|
||||
{
|
||||
query = query.Include(l => l.Series);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(LibraryIncludes.AppUser))
|
||||
{
|
||||
query = query.Include(l => l.AppUsers);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// This returns a Library with all it's Series -> Volumes -> Chapters. This is expensive. Should only be called when needed.
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Library> GetFullLibraryForIdAsync(int libraryId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Where(x => x.Id == libraryId)
|
||||
.Include(f => f.Folders)
|
||||
.Include(l => l.Series)
|
||||
.ThenInclude(s => s.Metadata)
|
||||
.Include(l => l.Series)
|
||||
.ThenInclude(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.AsSplitQuery()
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is a heavy call, pulls all entities for a Library, except this version only grabs for one series id
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Library> GetFullLibraryForIdAsync(int libraryId, int seriesId)
|
||||
{
|
||||
|
||||
return await _context.Library
|
||||
.Where(x => x.Id == libraryId)
|
||||
.Include(f => f.Folders)
|
||||
.Include(l => l.Series.Where(s => s.Id == seriesId))
|
||||
.ThenInclude(s => s.Metadata)
|
||||
.Include(l => l.Series.Where(s => s.Id == seriesId))
|
||||
.ThenInclude(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.AsSplitQuery()
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> LibraryExists(string libraryName)
|
||||
{
|
||||
return await _context.Library
|
||||
.AsNoTracking()
|
||||
.AnyAsync(x => x.Name == libraryName);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibrariesForUserAsync(AppUser user)
|
||||
{
|
||||
return await _context.Library
|
||||
.Where(library => library.AppUsers.Contains(user))
|
||||
.Include(l => l.Folders)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,59 +2,65 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IPersonRepository
|
||||
{
|
||||
public class PersonRepository : IPersonRepository
|
||||
void Attach(Person person);
|
||||
void Remove(Person person);
|
||||
Task<IList<Person>> GetAllPeople();
|
||||
Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false);
|
||||
}
|
||||
|
||||
public class PersonRepository : IPersonRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public PersonRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public PersonRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
public void Attach(Person person)
|
||||
{
|
||||
_context.Person.Attach(person);
|
||||
}
|
||||
|
||||
public void Attach(Person person)
|
||||
{
|
||||
_context.Person.Attach(person);
|
||||
}
|
||||
public void Remove(Person person)
|
||||
{
|
||||
_context.Person.Remove(person);
|
||||
}
|
||||
|
||||
public void Remove(Person person)
|
||||
{
|
||||
_context.Person.Remove(person);
|
||||
}
|
||||
public async Task<Person> FindByNameAsync(string name)
|
||||
{
|
||||
var normalizedName = Parser.Parser.Normalize(name);
|
||||
return await _context.Person
|
||||
.Where(p => normalizedName.Equals(p.NormalizedName))
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Person> FindByNameAsync(string name)
|
||||
{
|
||||
var normalizedName = Parser.Parser.Normalize(name);
|
||||
return await _context.Person
|
||||
.Where(p => normalizedName.Equals(p.NormalizedName))
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false)
|
||||
{
|
||||
var peopleWithNoConnections = await _context.Person
|
||||
.Include(p => p.SeriesMetadatas)
|
||||
.Include(p => p.ChapterMetadatas)
|
||||
.Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false)
|
||||
{
|
||||
var peopleWithNoConnections = await _context.Person
|
||||
.Include(p => p.SeriesMetadatas)
|
||||
.Include(p => p.ChapterMetadatas)
|
||||
.Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0)
|
||||
.ToListAsync();
|
||||
_context.Person.RemoveRange(peopleWithNoConnections);
|
||||
|
||||
_context.Person.RemoveRange(peopleWithNoConnections);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IList<Person>> GetAllPeople()
|
||||
{
|
||||
return await _context.Person
|
||||
.ToListAsync();
|
||||
}
|
||||
public async Task<IList<Person>> GetAllPeople()
|
||||
{
|
||||
return await _context.Person
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,175 +4,187 @@ using System.Threading.Tasks;
|
|||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Helpers;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IReadingListRepository
|
||||
{
|
||||
public class ReadingListRepository : IReadingListRepository
|
||||
Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams);
|
||||
Task<ReadingList> GetReadingListByIdAsync(int readingListId);
|
||||
Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId);
|
||||
Task<ReadingListDto> GetReadingListDtoByIdAsync(int readingListId, int userId);
|
||||
Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
|
||||
Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title);
|
||||
Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
|
||||
void Remove(ReadingListItem item);
|
||||
void BulkRemove(IEnumerable<ReadingListItem> items);
|
||||
void Update(ReadingList list);
|
||||
}
|
||||
|
||||
public class ReadingListRepository : IReadingListRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ReadingListRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public ReadingListRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
public void Update(ReadingList list)
|
||||
{
|
||||
_context.Entry(list).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(ReadingList list)
|
||||
{
|
||||
_context.Entry(list).State = EntityState.Modified;
|
||||
}
|
||||
public void Remove(ReadingListItem item)
|
||||
{
|
||||
_context.ReadingListItem.Remove(item);
|
||||
}
|
||||
|
||||
public void Remove(ReadingListItem item)
|
||||
{
|
||||
_context.ReadingListItem.Remove(item);
|
||||
}
|
||||
|
||||
public void BulkRemove(IEnumerable<ReadingListItem> items)
|
||||
{
|
||||
_context.ReadingListItem.RemoveRange(items);
|
||||
}
|
||||
public void BulkRemove(IEnumerable<ReadingListItem> items)
|
||||
{
|
||||
_context.ReadingListItem.RemoveRange(items);
|
||||
}
|
||||
|
||||
|
||||
public async Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams)
|
||||
{
|
||||
var query = _context.ReadingList
|
||||
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
|
||||
.OrderBy(l => l.LastModified)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
public async Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams)
|
||||
{
|
||||
var query = _context.ReadingList
|
||||
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
|
||||
.OrderBy(l => l.LastModified)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
|
||||
return await PagedList<ReadingListDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
return await PagedList<ReadingListDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<ReadingList> GetReadingListByIdAsync(int readingListId)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(r => r.Id == readingListId)
|
||||
.Include(r => r.Items.OrderBy(item => item.Order))
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<ReadingList> GetReadingListByIdAsync(int readingListId)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(r => r.Id == readingListId)
|
||||
.Include(r => r.Items.OrderBy(item => item.Order))
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId)
|
||||
{
|
||||
var userLibraries = _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(library => library.AppUsers.Any(user => user.Id == userId))
|
||||
.AsNoTracking()
|
||||
.Select(library => library.Id)
|
||||
.ToList();
|
||||
public async Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId)
|
||||
{
|
||||
var userLibraries = _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(library => library.AppUsers.Any(user => user.Id == userId))
|
||||
.AsNoTracking()
|
||||
.Select(library => library.Id)
|
||||
.ToList();
|
||||
|
||||
var items = await _context.ReadingListItem
|
||||
.Where(s => s.ReadingListId == readingListId)
|
||||
.Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new
|
||||
{
|
||||
TotalPages = chapter.Pages,
|
||||
ChapterNumber = chapter.Range,
|
||||
readingListItem = data
|
||||
})
|
||||
.Join(_context.Volume, s => s.readingListItem.VolumeId, volume => volume.Id, (data, volume) => new
|
||||
var items = await _context.ReadingListItem
|
||||
.Where(s => s.ReadingListId == readingListId)
|
||||
.Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new
|
||||
{
|
||||
TotalPages = chapter.Pages,
|
||||
ChapterNumber = chapter.Range,
|
||||
readingListItem = data
|
||||
})
|
||||
.Join(_context.Volume, s => s.readingListItem.VolumeId, volume => volume.Id, (data, volume) => new
|
||||
{
|
||||
data.readingListItem,
|
||||
data.TotalPages,
|
||||
data.ChapterNumber,
|
||||
VolumeId = volume.Id,
|
||||
VolumeNumber = volume.Name,
|
||||
})
|
||||
.Join(_context.Series, s => s.readingListItem.SeriesId, series => series.Id,
|
||||
(data, s) => new
|
||||
{
|
||||
SeriesName = s.Name,
|
||||
SeriesFormat = s.Format,
|
||||
s.LibraryId,
|
||||
data.readingListItem,
|
||||
data.TotalPages,
|
||||
data.ChapterNumber,
|
||||
VolumeId = volume.Id,
|
||||
VolumeNumber = volume.Name,
|
||||
data.VolumeNumber,
|
||||
data.VolumeId
|
||||
})
|
||||
.Join(_context.Series, s => s.readingListItem.SeriesId, series => series.Id,
|
||||
(data, s) => new
|
||||
{
|
||||
SeriesName = s.Name,
|
||||
SeriesFormat = s.Format,
|
||||
s.LibraryId,
|
||||
data.readingListItem,
|
||||
data.TotalPages,
|
||||
data.ChapterNumber,
|
||||
data.VolumeNumber,
|
||||
data.VolumeId
|
||||
})
|
||||
.Select(data => new ReadingListItemDto()
|
||||
{
|
||||
Id = data.readingListItem.Id,
|
||||
ChapterId = data.readingListItem.ChapterId,
|
||||
Order = data.readingListItem.Order,
|
||||
SeriesId = data.readingListItem.SeriesId,
|
||||
SeriesName = data.SeriesName,
|
||||
SeriesFormat = data.SeriesFormat,
|
||||
PagesTotal = data.TotalPages,
|
||||
ChapterNumber = data.ChapterNumber,
|
||||
VolumeNumber = data.VolumeNumber,
|
||||
LibraryId = data.LibraryId,
|
||||
VolumeId = data.VolumeId,
|
||||
ReadingListId = data.readingListItem.ReadingListId
|
||||
})
|
||||
.Where(o => userLibraries.Contains(o.LibraryId))
|
||||
.OrderBy(rli => rli.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
// Attach progress information
|
||||
var fetchedChapterIds = items.Select(i => i.ChapterId);
|
||||
var progresses = await _context.AppUserProgresses
|
||||
.Where(p => fetchedChapterIds.Contains(p.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var progress in progresses)
|
||||
.Select(data => new ReadingListItemDto()
|
||||
{
|
||||
var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId);
|
||||
if (progressItem == null) continue;
|
||||
Id = data.readingListItem.Id,
|
||||
ChapterId = data.readingListItem.ChapterId,
|
||||
Order = data.readingListItem.Order,
|
||||
SeriesId = data.readingListItem.SeriesId,
|
||||
SeriesName = data.SeriesName,
|
||||
SeriesFormat = data.SeriesFormat,
|
||||
PagesTotal = data.TotalPages,
|
||||
ChapterNumber = data.ChapterNumber,
|
||||
VolumeNumber = data.VolumeNumber,
|
||||
LibraryId = data.LibraryId,
|
||||
VolumeId = data.VolumeId,
|
||||
ReadingListId = data.readingListItem.ReadingListId
|
||||
})
|
||||
.Where(o => userLibraries.Contains(o.LibraryId))
|
||||
.OrderBy(rli => rli.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
progressItem.PagesRead = progress.PagesRead;
|
||||
}
|
||||
// Attach progress information
|
||||
var fetchedChapterIds = items.Select(i => i.ChapterId);
|
||||
var progresses = await _context.AppUserProgresses
|
||||
.Where(p => fetchedChapterIds.Contains(p.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<ReadingListDto> GetReadingListDtoByIdAsync(int readingListId, int userId)
|
||||
foreach (var progress in progresses)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted))
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId);
|
||||
if (progressItem == null) continue;
|
||||
|
||||
progressItem.PagesRead = progress.PagesRead;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items)
|
||||
{
|
||||
var chapterIds = items.Select(i => i.ChapterId).Distinct().ToList();
|
||||
var userProgress = await _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId && chapterIds.Contains(p.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var progress = userProgress.Where(p => p.ChapterId == item.ChapterId);
|
||||
item.PagesRead = progress.Sum(p => p.PagesRead);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(r => r.Title.Equals(title))
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId)
|
||||
{
|
||||
return await _context.ReadingListItem
|
||||
.Where(r => r.ReadingListId == readingListId)
|
||||
.OrderBy(r => r.Order)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<ReadingListDto> GetReadingListDtoByIdAsync(int readingListId, int userId)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted))
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items)
|
||||
{
|
||||
var chapterIds = items.Select(i => i.ChapterId).Distinct().ToList();
|
||||
var userProgress = await _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId && chapterIds.Contains(p.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var progress = userProgress.Where(p => p.ChapterId == item.ChapterId);
|
||||
item.PagesRead = progress.Sum(p => p.PagesRead);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(r => r.Title.Equals(title))
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId)
|
||||
{
|
||||
return await _context.ReadingListItem
|
||||
.Where(r => r.ReadingListId == readingListId)
|
||||
.OrderBy(r => r.Order)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
using API.Entities;
|
||||
using API.Entities.Metadata;
|
||||
using API.Interfaces.Repositories;
|
||||
using API.Entities.Metadata;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface ISeriesMetadataRepository
|
||||
{
|
||||
public class SeriesMetadataRepository : ISeriesMetadataRepository
|
||||
void Update(SeriesMetadata seriesMetadata);
|
||||
}
|
||||
|
||||
public class SeriesMetadataRepository : ISeriesMetadataRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
|
||||
public SeriesMetadataRepository(DataContext context)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public SeriesMetadataRepository(DataContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public void Update(SeriesMetadata seriesMetadata)
|
||||
{
|
||||
_context.SeriesMetadata.Update(seriesMetadata);
|
||||
}
|
||||
public void Update(SeriesMetadata seriesMetadata)
|
||||
{
|
||||
_context.SeriesMetadata.Update(seriesMetadata);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,45 +4,50 @@ using System.Threading.Tasks;
|
|||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface ISettingsRepository
|
||||
{
|
||||
public class SettingsRepository : ISettingsRepository
|
||||
void Update(ServerSetting settings);
|
||||
Task<ServerSettingDto> GetSettingsDtoAsync();
|
||||
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
|
||||
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
|
||||
}
|
||||
public class SettingsRepository : ISettingsRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public SettingsRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public SettingsRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
public void Update(ServerSetting settings)
|
||||
{
|
||||
_context.Entry(settings).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(ServerSetting settings)
|
||||
{
|
||||
_context.Entry(settings).State = EntityState.Modified;
|
||||
}
|
||||
public async Task<ServerSettingDto> GetSettingsDtoAsync()
|
||||
{
|
||||
var settings = await _context.ServerSetting
|
||||
.Select(x => x)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
return _mapper.Map<ServerSettingDto>(settings);
|
||||
}
|
||||
|
||||
public async Task<ServerSettingDto> GetSettingsDtoAsync()
|
||||
{
|
||||
var settings = await _context.ServerSetting
|
||||
.Select(x => x)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
return _mapper.Map<ServerSettingDto>(settings);
|
||||
}
|
||||
public Task<ServerSetting> GetSettingAsync(ServerSettingKey key)
|
||||
{
|
||||
return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key);
|
||||
}
|
||||
|
||||
public Task<ServerSetting> GetSettingAsync(ServerSettingKey key)
|
||||
{
|
||||
return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ServerSetting>> GetSettingsAsync()
|
||||
{
|
||||
return await _context.ServerSetting.ToListAsync();
|
||||
}
|
||||
public async Task<IEnumerable<ServerSetting>> GetSettingsAsync()
|
||||
{
|
||||
return await _context.ServerSetting.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,254 +6,276 @@ using API.Constants;
|
|||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
[Flags]
|
||||
public enum AppUserIncludes
|
||||
{
|
||||
[Flags]
|
||||
public enum AppUserIncludes
|
||||
None = 1,
|
||||
Progress = 2,
|
||||
Bookmarks = 4,
|
||||
ReadingLists = 8,
|
||||
Ratings = 16
|
||||
}
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
void Update(AppUser user);
|
||||
void Update(AppUserPreferences preferences);
|
||||
void Update(AppUserBookmark bookmark);
|
||||
public void Delete(AppUser user);
|
||||
Task<IEnumerable<MemberDto>> GetMembersAsync();
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<IEnumerable<AppUser>> GetNonAdminUsersAsync();
|
||||
Task<bool> IsUserAdmin(AppUser user);
|
||||
Task<AppUserRating> GetUserRating(int seriesId, int userId);
|
||||
Task<AppUserPreferences> GetPreferencesAsync(string username);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
|
||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
|
||||
Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId);
|
||||
Task<int> GetUserIdByApiKeyAsync(string apiKey);
|
||||
Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||
Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||
Task<int> GetUserIdByUsernameAsync(string username);
|
||||
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public UserRepository(DataContext context, UserManager<AppUser> userManager, IMapper mapper)
|
||||
{
|
||||
None = 1,
|
||||
Progress = 2,
|
||||
Bookmarks = 4,
|
||||
ReadingLists = 8,
|
||||
Ratings = 16
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
public void Update(AppUser user)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly IMapper _mapper;
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public UserRepository(DataContext context, UserManager<AppUser> userManager, IMapper mapper)
|
||||
public void Update(AppUserPreferences preferences)
|
||||
{
|
||||
_context.Entry(preferences).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(AppUserBookmark bookmark)
|
||||
{
|
||||
_context.Entry(bookmark).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Delete(AppUser user)
|
||||
{
|
||||
_context.AppUser.Remove(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None)
|
||||
{
|
||||
var query = _context.Users
|
||||
.Where(x => x.UserName == username);
|
||||
|
||||
query = AddIncludesToQuery(query, includeFlags);
|
||||
|
||||
return await query.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None)
|
||||
{
|
||||
var query = _context.Users
|
||||
.Where(x => x.Id == userId);
|
||||
|
||||
query = AddIncludesToQuery(query, includeFlags);
|
||||
|
||||
return await query.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static IQueryable<AppUser> AddIncludesToQuery(IQueryable<AppUser> query, AppUserIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Bookmarks))
|
||||
{
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_mapper = mapper;
|
||||
query = query.Include(u => u.Bookmarks);
|
||||
}
|
||||
|
||||
public void Update(AppUser user)
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Progress))
|
||||
{
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
query = query.Include(u => u.Progresses);
|
||||
}
|
||||
|
||||
public void Update(AppUserPreferences preferences)
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ReadingLists))
|
||||
{
|
||||
_context.Entry(preferences).State = EntityState.Modified;
|
||||
query = query.Include(u => u.ReadingLists);
|
||||
}
|
||||
|
||||
public void Update(AppUserBookmark bookmark)
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Ratings))
|
||||
{
|
||||
_context.Entry(bookmark).State = EntityState.Modified;
|
||||
query = query.Include(u => u.Ratings);
|
||||
}
|
||||
|
||||
public void Delete(AppUser user)
|
||||
{
|
||||
_context.AppUser.Remove(user);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None)
|
||||
{
|
||||
var query = _context.Users
|
||||
.Where(x => x.UserName == username);
|
||||
|
||||
query = AddIncludesToQuery(query, includeFlags);
|
||||
/// <summary>
|
||||
/// This fetches the Id for a user. Use whenever you just need an ID.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> GetUserIdByUsernameAsync(string username)
|
||||
{
|
||||
return await _context.Users
|
||||
.Where(x => x.UserName == username)
|
||||
.Select(u => u.Id)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
return await query.SingleOrDefaultAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// Gets an AppUser by username. Returns back Reading List and their Items.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username)
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(u => u.ReadingLists)
|
||||
.ThenInclude(l => l.Items)
|
||||
.SingleOrDefaultAsync(x => x.UserName == username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None)
|
||||
{
|
||||
var query = _context.Users
|
||||
.Where(x => x.Id == userId);
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
query = AddIncludesToQuery(query, includeFlags);
|
||||
public async Task<IEnumerable<AppUser>> GetNonAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.PlebRole);
|
||||
}
|
||||
|
||||
return await query.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<bool> IsUserAdmin(AppUser user)
|
||||
{
|
||||
return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
public async Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<AppUserRating> GetUserRating(int seriesId, int userId)
|
||||
{
|
||||
return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static IQueryable<AppUser> AddIncludesToQuery(IQueryable<AppUser> query, AppUserIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Bookmarks))
|
||||
public async Task<AppUserPreferences> GetPreferencesAsync(string username)
|
||||
{
|
||||
return await _context.AppUserPreferences
|
||||
.Include(p => p.AppUser)
|
||||
.SingleOrDefaultAsync(p => p.AppUser.UserName == username);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId && x.SeriesId == seriesId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId && x.VolumeId == volumeId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId && x.ChapterId == chapterId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the UserId by API Key. This does not include any extra information
|
||||
/// </summary>
|
||||
/// <param name="apiKey"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> GetUserIdByApiKeyAsync(string apiKey)
|
||||
{
|
||||
return await _context.AppUser
|
||||
.Where(u => u.ApiKey.Equals(apiKey))
|
||||
.Select(u => u.Id)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(x => x.Libraries)
|
||||
.Include(r => r.UserRoles)
|
||||
.ThenInclude(r => r.Role)
|
||||
.OrderBy(u => u.UserName)
|
||||
.Select(u => new MemberDto
|
||||
{
|
||||
query = query.Include(u => u.Bookmarks);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Progress))
|
||||
{
|
||||
query = query.Include(u => u.Progresses);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ReadingLists))
|
||||
{
|
||||
query = query.Include(u => u.ReadingLists);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Ratings))
|
||||
{
|
||||
query = query.Include(u => u.Ratings);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// This fetches the Id for a user. Use whenever you just need an ID.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> GetUserIdByUsernameAsync(string username)
|
||||
{
|
||||
return await _context.Users
|
||||
.Where(x => x.UserName == username)
|
||||
.Select(u => u.Id)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AppUser by username. Returns back Reading List and their Items.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username)
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(u => u.ReadingLists)
|
||||
.ThenInclude(l => l.Items)
|
||||
.SingleOrDefaultAsync(x => x.UserName == username);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetNonAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.PlebRole);
|
||||
}
|
||||
|
||||
public async Task<bool> IsUserAdmin(AppUser user)
|
||||
{
|
||||
return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
public async Task<AppUserRating> GetUserRating(int seriesId, int userId)
|
||||
{
|
||||
return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserPreferences> GetPreferencesAsync(string username)
|
||||
{
|
||||
return await _context.AppUserPreferences
|
||||
.Include(p => p.AppUser)
|
||||
.SingleOrDefaultAsync(p => p.AppUser.UserName == username);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId && x.SeriesId == seriesId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId && x.VolumeId == volumeId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId && x.ChapterId == chapterId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId)
|
||||
.OrderBy(x => x.Page)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the UserId by API Key. This does not include any extra information
|
||||
/// </summary>
|
||||
/// <param name="apiKey"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> GetUserIdByApiKeyAsync(string apiKey)
|
||||
{
|
||||
return await _context.AppUser
|
||||
.Where(u => u.ApiKey.Equals(apiKey))
|
||||
.Select(u => u.Id)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(x => x.Libraries)
|
||||
.Include(r => r.UserRoles)
|
||||
.ThenInclude(r => r.Role)
|
||||
.OrderBy(u => u.UserName)
|
||||
.Select(u => new MemberDto
|
||||
Id = u.Id,
|
||||
Username = u.UserName,
|
||||
Created = u.Created,
|
||||
LastActive = u.LastActive,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||
Libraries = u.Libraries.Select(l => new LibraryDto
|
||||
{
|
||||
Id = u.Id,
|
||||
Username = u.UserName,
|
||||
Created = u.Created,
|
||||
LastActive = u.LastActive,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||
Libraries = u.Libraries.Select(l => new LibraryDto
|
||||
{
|
||||
Name = l.Name,
|
||||
Type = l.Type,
|
||||
LastScanned = l.LastScanned,
|
||||
Folders = l.Folders.Select(x => x.Path).ToList()
|
||||
}).ToList()
|
||||
})
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
Name = l.Name,
|
||||
Type = l.Type,
|
||||
LastScanned = l.LastScanned,
|
||||
Folders = l.Folders.Select(x => x.Path).ToList()
|
||||
}).ToList()
|
||||
})
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,206 +4,220 @@ using System.Threading.Tasks;
|
|||
using API.Comparators;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IVolumeRepository
|
||||
{
|
||||
public class VolumeRepository : IVolumeRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public VolumeRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Add(Volume volume)
|
||||
{
|
||||
_context.Volume.Add(volume);
|
||||
}
|
||||
|
||||
public void Update(Volume volume)
|
||||
{
|
||||
_context.Entry(volume).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Remove(Volume volume)
|
||||
{
|
||||
_context.Volume.Remove(volume);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of non-tracked files for a given volume.
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => volumeId == c.VolumeId)
|
||||
.Include(c => c.Files)
|
||||
.SelectMany(c => c.Files)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cover image file for the given volume
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> GetVolumeCoverImageAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Where(v => v.Id == volumeId)
|
||||
.Select(v => v.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all chapter Ids belonging to a list of Volume Ids
|
||||
/// </summary>
|
||||
/// <param name="volumeIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => volumeIds.Contains(c.VolumeId))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all volumes that contain a seriesId in passed array.
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false)
|
||||
{
|
||||
var query = _context.Volume
|
||||
.Where(v => seriesIds.Contains(v.SeriesId));
|
||||
|
||||
if (includeChapters)
|
||||
{
|
||||
query = query.Include(v => v.Chapters);
|
||||
}
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an individual Volume including Chapters and Files and Reading Progress for a given volumeId
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId)
|
||||
{
|
||||
var volume = await _context.Volume
|
||||
.Where(vol => vol.Id == volumeId)
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
|
||||
.SingleAsync(vol => vol.Id == volumeId);
|
||||
|
||||
var volumeList = new List<VolumeDto>() {volume};
|
||||
await AddVolumeModifiers(userId, volumeList);
|
||||
|
||||
return volumeList[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full Volumes including Chapters and Files for a given series
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<Volume>> GetVolumes(int seriesId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Where(vol => vol.SeriesId == seriesId)
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.OrderBy(vol => vol.Number)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single volume with Chapter and Files
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Volume> GetVolumeAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns all volumes for a given series with progress information attached. Includes all Chapters as well.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId)
|
||||
{
|
||||
var volumes = await _context.Volume
|
||||
.Where(vol => vol.SeriesId == seriesId)
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.People) // TODO: Measure cost of this
|
||||
.OrderBy(volume => volume.Number)
|
||||
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
await AddVolumeModifiers(userId, volumes);
|
||||
SortSpecialChapters(volumes);
|
||||
|
||||
return volumes;
|
||||
}
|
||||
|
||||
public async Task<Volume> GetVolumeByIdAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId);
|
||||
}
|
||||
|
||||
|
||||
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
|
||||
{
|
||||
var sorter = new NaturalSortComparer();
|
||||
foreach (var v in volumes.Where(vDto => vDto.Number == 0))
|
||||
{
|
||||
v.Chapters = v.Chapters.OrderBy(x => x.Range, sorter).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes)
|
||||
{
|
||||
var volIds = volumes.Select(s => s.Id);
|
||||
var userProgress = await _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var v in volumes)
|
||||
{
|
||||
foreach (var c in v.Chapters)
|
||||
{
|
||||
c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead);
|
||||
}
|
||||
|
||||
v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
void Add(Volume volume);
|
||||
void Update(Volume volume);
|
||||
void Remove(Volume volume);
|
||||
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
|
||||
Task<string> GetVolumeCoverImageAsync(int volumeId);
|
||||
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
|
||||
Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId);
|
||||
Task<Volume> GetVolumeAsync(int volumeId);
|
||||
Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId);
|
||||
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
|
||||
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
|
||||
Task<Volume> GetVolumeByIdAsync(int volumeId);
|
||||
}
|
||||
public class VolumeRepository : IVolumeRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public VolumeRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Add(Volume volume)
|
||||
{
|
||||
_context.Volume.Add(volume);
|
||||
}
|
||||
|
||||
public void Update(Volume volume)
|
||||
{
|
||||
_context.Entry(volume).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Remove(Volume volume)
|
||||
{
|
||||
_context.Volume.Remove(volume);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of non-tracked files for a given volume.
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => volumeId == c.VolumeId)
|
||||
.Include(c => c.Files)
|
||||
.SelectMany(c => c.Files)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cover image file for the given volume
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> GetVolumeCoverImageAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Where(v => v.Id == volumeId)
|
||||
.Select(v => v.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all chapter Ids belonging to a list of Volume Ids
|
||||
/// </summary>
|
||||
/// <param name="volumeIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => volumeIds.Contains(c.VolumeId))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all volumes that contain a seriesId in passed array.
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <param name="includeChapters">Include chapter entities</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false)
|
||||
{
|
||||
var query = _context.Volume
|
||||
.Where(v => seriesIds.Contains(v.SeriesId));
|
||||
|
||||
if (includeChapters)
|
||||
{
|
||||
query = query.Include(v => v.Chapters);
|
||||
}
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an individual Volume including Chapters and Files and Reading Progress for a given volumeId
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId)
|
||||
{
|
||||
var volume = await _context.Volume
|
||||
.Where(vol => vol.Id == volumeId)
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
|
||||
.SingleAsync(vol => vol.Id == volumeId);
|
||||
|
||||
var volumeList = new List<VolumeDto>() {volume};
|
||||
await AddVolumeModifiers(userId, volumeList);
|
||||
|
||||
return volumeList[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full Volumes including Chapters and Files for a given series
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<Volume>> GetVolumes(int seriesId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Where(vol => vol.SeriesId == seriesId)
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.OrderBy(vol => vol.Number)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single volume with Chapter and Files
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Volume> GetVolumeAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.Files)
|
||||
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns all volumes for a given series with progress information attached. Includes all Chapters as well.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId)
|
||||
{
|
||||
var volumes = await _context.Volume
|
||||
.Where(vol => vol.SeriesId == seriesId)
|
||||
.Include(vol => vol.Chapters)
|
||||
.ThenInclude(c => c.People)
|
||||
.OrderBy(volume => volume.Number)
|
||||
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
await AddVolumeModifiers(userId, volumes);
|
||||
SortSpecialChapters(volumes);
|
||||
|
||||
return volumes;
|
||||
}
|
||||
|
||||
public async Task<Volume> GetVolumeByIdAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId);
|
||||
}
|
||||
|
||||
|
||||
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
|
||||
{
|
||||
var sorter = new NaturalSortComparer();
|
||||
foreach (var v in volumes.Where(vDto => vDto.Number == 0))
|
||||
{
|
||||
v.Chapters = v.Chapters.OrderBy(x => x.Range, sorter).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes)
|
||||
{
|
||||
var volIds = volumes.Select(s => s.Id);
|
||||
var userProgress = await _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var v in volumes)
|
||||
{
|
||||
foreach (var c in v.Chapters)
|
||||
{
|
||||
c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead);
|
||||
}
|
||||
|
||||
v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,13 +35,13 @@ namespace API.Data
|
|||
}
|
||||
}
|
||||
|
||||
public static async Task SeedSettings(DataContext context)
|
||||
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
IList<ServerSetting> defaultSettings = new List<ServerSetting>()
|
||||
{
|
||||
new () {Key = ServerSettingKey.CacheDirectory, Value = DirectoryService.CacheDirectory},
|
||||
new () {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
|
||||
new () {Key = ServerSettingKey.TaskScan, Value = "daily"},
|
||||
new () {Key = ServerSettingKey.LoggingLevel, Value = "Information"}, // Not used from DB, but DB is sync with appSettings.json
|
||||
new () {Key = ServerSettingKey.TaskBackup, Value = "weekly"},
|
||||
|
|
@ -71,7 +71,7 @@ namespace API.Data
|
|||
context.ServerSetting.First(s => s.Key == ServerSettingKey.LoggingLevel).Value =
|
||||
Configuration.LogLevel + string.Empty;
|
||||
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value =
|
||||
DirectoryService.CacheDirectory + string.Empty;
|
||||
directoryService.CacheDirectory + string.Empty;
|
||||
context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value =
|
||||
DirectoryService.BackupDirectory + string.Empty;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,85 +1,102 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace API.Data
|
||||
namespace API.Data;
|
||||
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
public class UnitOfWork : IUnitOfWork
|
||||
ISeriesRepository SeriesRepository { get; }
|
||||
IUserRepository UserRepository { get; }
|
||||
ILibraryRepository LibraryRepository { get; }
|
||||
IVolumeRepository VolumeRepository { get; }
|
||||
ISettingsRepository SettingsRepository { get; }
|
||||
IAppUserProgressRepository AppUserProgressRepository { get; }
|
||||
ICollectionTagRepository CollectionTagRepository { get; }
|
||||
IChapterRepository ChapterRepository { get; }
|
||||
IReadingListRepository ReadingListRepository { get; }
|
||||
ISeriesMetadataRepository SeriesMetadataRepository { get; }
|
||||
IPersonRepository PersonRepository { get; }
|
||||
IGenreRepository GenreRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
bool Rollback();
|
||||
Task<bool> RollbackAsync();
|
||||
}
|
||||
public class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
|
||||
public UnitOfWork(DataContext context, IMapper mapper, UserManager<AppUser> userManager)
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public UnitOfWork(DataContext context, IMapper mapper, UserManager<AppUser> userManager)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
}
|
||||
public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper);
|
||||
public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper);
|
||||
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper);
|
||||
|
||||
public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper);
|
||||
public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper);
|
||||
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper);
|
||||
public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper);
|
||||
|
||||
public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper);
|
||||
public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper);
|
||||
|
||||
public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper);
|
||||
public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context);
|
||||
public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper);
|
||||
public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper);
|
||||
public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper);
|
||||
public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context);
|
||||
public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper);
|
||||
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
||||
|
||||
public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context);
|
||||
public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper);
|
||||
public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper);
|
||||
public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper);
|
||||
public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context);
|
||||
public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper);
|
||||
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool Commit()
|
||||
{
|
||||
return _context.SaveChanges() > 0;
|
||||
}
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> CommitAsync()
|
||||
{
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool Commit()
|
||||
{
|
||||
return _context.SaveChanges() > 0;
|
||||
}
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> CommitAsync()
|
||||
{
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
/// <summary>
|
||||
/// Is the DB Context aware of Changes in loaded entities
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool HasChanges()
|
||||
{
|
||||
return _context.ChangeTracker.HasChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Is the DB Context aware of Changes in loaded entities
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool HasChanges()
|
||||
{
|
||||
return _context.ChangeTracker.HasChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rollback transaction
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> RollbackAsync()
|
||||
{
|
||||
await _context.DisposeAsync();
|
||||
return true;
|
||||
}
|
||||
/// <summary>
|
||||
/// Rollback transaction
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool Rollback()
|
||||
{
|
||||
_context.Dispose();
|
||||
return true;
|
||||
}
|
||||
/// <summary>
|
||||
/// Rollback transaction
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> RollbackAsync()
|
||||
{
|
||||
await _context.DisposeAsync();
|
||||
return true;
|
||||
}
|
||||
/// <summary>
|
||||
/// Rollback transaction
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool Rollback()
|
||||
{
|
||||
_context.Dispose();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Entities.Metadata;
|
||||
using API.Parser;
|
||||
|
||||
namespace API.Entities
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ namespace API.Entities
|
|||
public class Genre
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } // TODO: Rename this to Title
|
||||
public string Title { get; set; }
|
||||
public string NormalizedTitle { get; set; }
|
||||
public bool ExternalTag { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
using System.IO.Abstractions;
|
||||
using API.Data;
|
||||
using API.Helpers;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.SignalR.Presence;
|
||||
|
|
@ -37,6 +35,7 @@ namespace API.Extensions
|
|||
services.AddScoped<IVersionUpdaterService, VersionUpdaterService>();
|
||||
services.AddScoped<IDownloadService, DownloadService>();
|
||||
services.AddScoped<IReaderService, ReaderService>();
|
||||
services.AddScoped<IReadingItemService, ReadingItemService>();
|
||||
services.AddScoped<IAccountService, AccountService>();
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using API.Comparators;
|
||||
|
||||
namespace API.Extensions
|
||||
{
|
||||
public static class DirectoryInfoExtensions
|
||||
{
|
||||
private static readonly NaturalSortComparer Comparer = new NaturalSortComparer();
|
||||
public static void Empty(this DirectoryInfo directory)
|
||||
{
|
||||
// NOTE: We have this in DirectoryService.Empty(), do we need this here?
|
||||
foreach(FileInfo file in directory.EnumerateFiles()) file.Delete();
|
||||
foreach(DirectoryInfo subDirectory in directory.EnumerateDirectories()) subDirectory.Delete(true);
|
||||
}
|
||||
|
||||
public static void RemoveNonImages(this DirectoryInfo directory)
|
||||
{
|
||||
foreach (var file in directory.EnumerateFiles())
|
||||
{
|
||||
if (!Parser.Parser.IsImage(file.FullName))
|
||||
{
|
||||
file.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens all files in subfolders to the passed directory recursively.
|
||||
///
|
||||
///
|
||||
/// foo<para />
|
||||
/// ├── 1.txt<para />
|
||||
/// ├── 2.txt<para />
|
||||
/// ├── 3.txt<para />
|
||||
/// ├── 4.txt<para />
|
||||
/// └── bar<para />
|
||||
/// ├── 1.txt<para />
|
||||
/// ├── 2.txt<para />
|
||||
/// └── 5.txt<para />
|
||||
///
|
||||
/// becomes:<para />
|
||||
/// foo<para />
|
||||
/// ├── 1.txt<para />
|
||||
/// ├── 2.txt<para />
|
||||
/// ├── 3.txt<para />
|
||||
/// ├── 4.txt<para />
|
||||
/// ├── bar_1.txt<para />
|
||||
/// ├── bar_2.txt<para />
|
||||
/// └── bar_5.txt<para />
|
||||
/// </summary>
|
||||
/// <param name="directory"></param>
|
||||
public static void Flatten(this DirectoryInfo directory)
|
||||
{
|
||||
var index = 0;
|
||||
FlattenDirectory(directory, directory, ref index);
|
||||
}
|
||||
|
||||
private static void FlattenDirectory(DirectoryInfo root, DirectoryInfo directory, ref int directoryIndex)
|
||||
{
|
||||
if (!root.FullName.Equals(directory.FullName))
|
||||
{
|
||||
var fileIndex = 1;
|
||||
|
||||
foreach (var file in directory.EnumerateFiles().OrderBy(file => file.FullName, Comparer))
|
||||
{
|
||||
if (file.Directory == null) continue;
|
||||
var paddedIndex = Parser.Parser.PadZeros(directoryIndex + "");
|
||||
// We need to rename the files so that after flattening, they are in the order we found them
|
||||
var newName = $"{paddedIndex}_{Parser.Parser.PadZeros(fileIndex + "")}{file.Extension}";
|
||||
var newPath = Path.Join(root.FullName, newName);
|
||||
if (!File.Exists(newPath)) file.MoveTo(newPath);
|
||||
fileIndex++;
|
||||
}
|
||||
|
||||
directoryIndex++;
|
||||
}
|
||||
|
||||
var sort = new NaturalSortComparer();
|
||||
foreach (var subDirectory in directory.EnumerateDirectories().OrderBy(d => d.FullName, sort))
|
||||
{
|
||||
FlattenDirectory(root, subDirectory, ref directoryIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Extensions
|
||||
namespace API.Extensions
|
||||
{
|
||||
public static class EnumerableExtensions
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Intrinsics.Arm;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using API.Helpers;
|
||||
|
|
@ -30,7 +32,8 @@ namespace API.Extensions
|
|||
public static void AddCacheHeader(this HttpResponse response, byte[] content)
|
||||
{
|
||||
if (content == null || content.Length <= 0) return;
|
||||
using var sha1 = new System.Security.Cryptography.SHA256CryptoServiceProvider();
|
||||
using var sha1 = SHA256.Create();
|
||||
|
||||
response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(content).Select(x => x.ToString("X2"))));
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +46,7 @@ namespace API.Extensions
|
|||
{
|
||||
if (filename == null || filename.Length <= 0) return;
|
||||
var hashContent = filename + File.GetLastWriteTimeUtc(filename);
|
||||
using var sha1 = new System.Security.Cryptography.SHA256CryptoServiceProvider();
|
||||
using var sha1 = SHA256.Create();
|
||||
response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2"))));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using API.Entities;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Services;
|
||||
|
|
|
|||
|
|
@ -36,25 +36,13 @@ public static class GenreHelper
|
|||
|
||||
public static void KeepOnlySameGenreBetweenLists(ICollection<Genre> existingGenres, ICollection<Genre> removeAllExcept, Action<Genre> action = null)
|
||||
{
|
||||
// var normalizedNames = names.Select(s => Parser.Parser.Normalize(s.Trim()))
|
||||
// .Where(s => !string.IsNullOrEmpty(s)).ToList();
|
||||
// var localNamesNotInComicInfos = seriesGenres.Where(g =>
|
||||
// !normalizedNames.Contains(g.NormalizedName) && g.ExternalTag == isExternal);
|
||||
//
|
||||
// foreach (var nonExisting in localNamesNotInComicInfos)
|
||||
// {
|
||||
// // TODO: Maybe I need to do a cleanup here
|
||||
// action(nonExisting);
|
||||
// }
|
||||
var existing = existingGenres.ToList();
|
||||
foreach (var genre in existing)
|
||||
{
|
||||
var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle));
|
||||
if (existingPerson == null)
|
||||
{
|
||||
existingGenres.Remove(genre);
|
||||
action?.Invoke(genre);
|
||||
}
|
||||
if (existingPerson != null) continue;
|
||||
existingGenres.Remove(genre);
|
||||
action?.Invoke(genre);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Interfaces
|
||||
{
|
||||
public interface ITaskScheduler
|
||||
{
|
||||
/// <summary>
|
||||
/// For use on Server startup
|
||||
/// </summary>
|
||||
void ScheduleTasks();
|
||||
Task ScheduleStatsTasks();
|
||||
void ScheduleUpdaterTasks();
|
||||
void ScanLibrary(int libraryId, bool forceUpdate = false);
|
||||
void CleanupChapters(int[] chapterIds);
|
||||
void RefreshMetadata(int libraryId, bool forceUpdate = true);
|
||||
void CleanupTemp();
|
||||
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void CancelStatsTasks();
|
||||
Task RunStatCollection();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Interfaces.Repositories;
|
||||
|
||||
namespace API.Interfaces
|
||||
{
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
ISeriesRepository SeriesRepository { get; }
|
||||
IUserRepository UserRepository { get; }
|
||||
ILibraryRepository LibraryRepository { get; }
|
||||
IVolumeRepository VolumeRepository { get; }
|
||||
ISettingsRepository SettingsRepository { get; }
|
||||
IAppUserProgressRepository AppUserProgressRepository { get; }
|
||||
ICollectionTagRepository CollectionTagRepository { get; }
|
||||
IChapterRepository ChapterRepository { get; }
|
||||
IReadingListRepository ReadingListRepository { get; }
|
||||
ISeriesMetadataRepository SeriesMetadataRepository { get; }
|
||||
IPersonRepository PersonRepository { get; }
|
||||
IGenreRepository GenreRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
bool Rollback();
|
||||
Task<bool> RollbackAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IAppUserProgressRepository
|
||||
{
|
||||
void Update(AppUserProgress userProgress);
|
||||
Task<int> CleanupAbandonedChapters();
|
||||
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
|
||||
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IChapterRepository
|
||||
{
|
||||
void Update(Chapter chapter);
|
||||
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds);
|
||||
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
|
||||
Task<int> GetChapterTotalPagesAsync(int chapterId);
|
||||
Task<Chapter> GetChapterAsync(int chapterId);
|
||||
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
|
||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||
Task<string> GetChapterCoverImageAsync(int chapterId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface ICollectionTagRepository
|
||||
{
|
||||
void Add(CollectionTag tag);
|
||||
void Remove(CollectionTag tag);
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
|
||||
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery);
|
||||
Task<string> GetCoverImageAsync(int collectionTagId);
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync();
|
||||
Task<CollectionTag> GetTagAsync(int tagId);
|
||||
Task<CollectionTag> GetFullTagAsync(int tagId);
|
||||
void Update(CollectionTag tag);
|
||||
Task<int> RemoveTagsWithoutSeries();
|
||||
Task<IEnumerable<CollectionTag>> GetAllTagsAsync();
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IGenreRepository
|
||||
{
|
||||
void Attach(Genre genre);
|
||||
void Remove(Genre genre);
|
||||
Task<Genre> FindByNameAsync(string genreName);
|
||||
Task<IList<Genre>> GetAllGenres();
|
||||
Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface ILibraryRepository
|
||||
{
|
||||
void Add(Library library);
|
||||
void Update(Library library);
|
||||
void Delete(Library library);
|
||||
Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync();
|
||||
Task<bool> LibraryExists(string libraryName);
|
||||
Task<Library> GetLibraryForIdAsync(int libraryId, LibraryIncludes includes);
|
||||
Task<Library> GetFullLibraryForIdAsync(int libraryId);
|
||||
Task<Library> GetFullLibraryForIdAsync(int libraryId, int seriesId);
|
||||
Task<IEnumerable<LibraryDto>> GetLibraryDtosForUsernameAsync(string userName);
|
||||
Task<IEnumerable<Library>> GetLibrariesAsync();
|
||||
Task<bool> DeleteLibrary(int libraryId);
|
||||
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
|
||||
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IPersonRepository
|
||||
{
|
||||
void Attach(Person person);
|
||||
void Remove(Person person);
|
||||
Task<IList<Person>> GetAllPeople();
|
||||
Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Helpers;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IReadingListRepository
|
||||
{
|
||||
Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams);
|
||||
Task<ReadingList> GetReadingListByIdAsync(int readingListId);
|
||||
Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId);
|
||||
Task<ReadingListDto> GetReadingListDtoByIdAsync(int readingListId, int userId);
|
||||
Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
|
||||
Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title);
|
||||
Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
|
||||
void Remove(ReadingListItem item);
|
||||
void BulkRemove(IEnumerable<ReadingListItem> items);
|
||||
void Update(ReadingList list);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
using API.Entities;
|
||||
using API.Entities.Metadata;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface ISeriesMetadataRepository
|
||||
{
|
||||
void Update(SeriesMetadata seriesMetadata);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Scanner;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Helpers;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface ISeriesRepository
|
||||
{
|
||||
void Attach(Series series);
|
||||
void Update(Series series);
|
||||
void Remove(Series series);
|
||||
void Remove(IEnumerable<Series> series);
|
||||
Task<bool> DoesSeriesNameExistInLibrary(string name, MangaFormat format);
|
||||
/// <summary>
|
||||
/// Adds user information like progress, ratings, etc
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="userParams"></param>
|
||||
/// <returns></returns>
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
/// <summary>
|
||||
/// Does not add user information like progress, ratings, etc.
|
||||
/// </summary>
|
||||
/// <param name="libraryIds"></param>
|
||||
/// <param name="searchQuery">Series name to search for</param>
|
||||
/// <returns></returns>
|
||||
Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery);
|
||||
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId);
|
||||
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
||||
Task<bool> DeleteSeriesAsync(int seriesId);
|
||||
Task<Series> GetSeriesByIdAsync(int seriesId);
|
||||
Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds);
|
||||
Task<int[]> GetChapterIdsForSeriesAsync(int[] seriesIds);
|
||||
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
|
||||
/// <summary>
|
||||
/// Used to add Progress/Rating information to series list.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="series"></param>
|
||||
/// <returns></returns>
|
||||
Task AddSeriesModifiers(int userId, List<SeriesDto> series);
|
||||
Task<string> GetSeriesCoverImageAsync(int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo
|
||||
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
||||
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
Task<IEnumerable<string>> GetLockedCoverImagesAsync();
|
||||
Task<PagedList<Series>> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams);
|
||||
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
|
||||
Task<Chunk> GetChunkInfo(int libraryId = 0);
|
||||
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface ISettingsRepository
|
||||
{
|
||||
void Update(ServerSetting settings);
|
||||
Task<ServerSettingDto> GetSettingsDtoAsync();
|
||||
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
|
||||
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IUserRepository
|
||||
{
|
||||
void Update(AppUser user);
|
||||
void Update(AppUserPreferences preferences);
|
||||
void Update(AppUserBookmark bookmark);
|
||||
public void Delete(AppUser user);
|
||||
Task<IEnumerable<MemberDto>> GetMembersAsync();
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<IEnumerable<AppUser>> GetNonAdminUsersAsync();
|
||||
Task<bool> IsUserAdmin(AppUser user);
|
||||
Task<AppUserRating> GetUserRating(int seriesId, int userId);
|
||||
Task<AppUserPreferences> GetPreferencesAsync(string username);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
|
||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
|
||||
Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId);
|
||||
Task<int> GetUserIdByApiKeyAsync(string apiKey);
|
||||
Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||
Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||
Task<int> GetUserIdByUsernameAsync(string username);
|
||||
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IVolumeRepository
|
||||
{
|
||||
void Add(Volume volume);
|
||||
void Update(Volume volume);
|
||||
void Remove(Volume volume);
|
||||
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
|
||||
Task<string> GetVolumeCoverImageAsync(int volumeId);
|
||||
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
|
||||
|
||||
// From Series Repo
|
||||
Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId);
|
||||
Task<Volume> GetVolumeAsync(int volumeId);
|
||||
Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId);
|
||||
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
|
||||
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
|
||||
Task<Volume> GetVolumeByIdAsync(int volumeId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Errors;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IAccountService
|
||||
{
|
||||
Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Compression;
|
||||
using System.Threading.Tasks;
|
||||
using API.Archive;
|
||||
using API.Data.Metadata;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IArchiveService
|
||||
{
|
||||
void ExtractArchive(string archivePath, string extractPath);
|
||||
int GetNumberOfPagesFromArchive(string archivePath);
|
||||
string GetCoverImage(string archivePath, string fileName);
|
||||
bool IsValidArchive(string archivePath);
|
||||
ComicInfo GetComicInfo(string archivePath);
|
||||
ArchiveLibrary CanOpen(string archivePath);
|
||||
bool ArchiveNeedsFlattening(ZipArchive archive);
|
||||
Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IBackupService
|
||||
{
|
||||
Task BackupDatabase();
|
||||
/// <summary>
|
||||
/// Returns a list of full paths of the logs files detailed in <see cref="IConfiguration"/>.
|
||||
/// </summary>
|
||||
/// <param name="maxRollingFiles"></param>
|
||||
/// <param name="logFileName"></param>
|
||||
/// <returns></returns>
|
||||
IEnumerable<string> LogFiles(int maxRollingFiles, string logFileName);
|
||||
|
||||
void CleanupBackups();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Metadata;
|
||||
using API.Parser;
|
||||
using VersOne.Epub;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IBookService
|
||||
{
|
||||
int GetNumberOfPages(string filePath);
|
||||
string GetCoverImage(string fileFilePath, string fileName);
|
||||
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||
|
||||
/// <summary>
|
||||
/// Scopes styles to .reading-section and replaces img src to the passed apiBase
|
||||
/// </summary>
|
||||
/// <param name="stylesheetHtml"></param>
|
||||
/// <param name="apiBase"></param>
|
||||
/// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
|
||||
/// <param name="book">Book Reference, needed for if you expect Import statements</param>
|
||||
/// <returns></returns>
|
||||
Task<string> ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book);
|
||||
ComicInfo GetComicInfo(string filePath);
|
||||
ParserInfo ParseInfo(string filePath);
|
||||
/// <summary>
|
||||
/// Extracts a PDF file's pages as images to an target directory
|
||||
/// </summary>
|
||||
/// <param name="fileFilePath"></param>
|
||||
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
|
||||
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface ICacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures the cache is created for the given chapter and if not, will create it. Should be called before any other
|
||||
/// cache operations (except cleanup).
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns>Chapter for the passed chapterId. Side-effect from ensuring cache.</returns>
|
||||
Task<Chapter> Ensure(int chapterId);
|
||||
|
||||
/// <summary>
|
||||
/// Clears cache directory of all folders and files.
|
||||
/// </summary>
|
||||
void Cleanup();
|
||||
|
||||
/// <summary>
|
||||
/// Clears cache directory of all volumes. This can be invoked from deleting a library or a series.
|
||||
/// </summary>
|
||||
/// <param name="chapterIds">Volumes that belong to that library. Assume the library might have been deleted before this invocation.</param>
|
||||
void CleanupChapters(IEnumerable<int> chapterIds);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns the absolute path of a cached page.
|
||||
/// </summary>
|
||||
/// <param name="chapter">Chapter entity with Files populated.</param>
|
||||
/// <param name="page">Page number to look for</param>
|
||||
/// <returns></returns>
|
||||
Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page);
|
||||
|
||||
void EnsureCacheDirectory();
|
||||
string GetCachedEpubFile(int chapterId, Chapter chapter);
|
||||
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface ICleanupService
|
||||
{
|
||||
Task Cleanup();
|
||||
void CleanupCacheDirectory();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IDirectoryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Absolute path of directory to scan.</param>
|
||||
/// <returns>List of folder names</returns>
|
||||
IEnumerable<string> ListDirectory(string rootPath);
|
||||
Task<byte[]> ReadFileAsync(string path);
|
||||
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
|
||||
bool Exists(string directory);
|
||||
void CopyFileToDirectory(string fullFilePath, string targetDirectory);
|
||||
int TraverseTreeParallelForEach(string root, Action<string> action, string searchPattern, ILogger logger);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
using API.Entities;
|
||||
using API.Services;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IImageService
|
||||
{
|
||||
string GetCoverImage(string path, string fileName);
|
||||
string GetCoverFile(MangaFile file);
|
||||
/// <summary>
|
||||
/// Creates a Thumbnail version of an image
|
||||
/// </summary>
|
||||
/// <param name="path">Path to the image file</param>
|
||||
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
||||
public string CreateThumbnail(string path, string fileName);
|
||||
/// <summary>
|
||||
/// Creates a Thumbnail version of a base64 image
|
||||
/// </summary>
|
||||
/// <param name="encodedImage">base64 encoded image</param>
|
||||
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IMetadataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Recalculates metadata for all entities in a library.
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
Task RefreshMetadata(int libraryId, bool forceUpdate = false);
|
||||
/// <summary>
|
||||
/// Performs a forced refresh of metatdata just for a series and it's nested entities
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IReaderService
|
||||
{
|
||||
void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
|
||||
Task<int> CapPageToChapter(int chapterId, int page);
|
||||
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
||||
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IScannerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite
|
||||
/// cover images if forceUpdate is true.
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to scan against</param>
|
||||
Task ScanLibrary(int libraryId);
|
||||
Task ScanLibraries();
|
||||
Task ScanSeries(int libraryId, int seriesId, CancellationToken token);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IStartupTask
|
||||
{
|
||||
Task ExecuteAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.DTOs.Stats;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IStatsService
|
||||
{
|
||||
Task Send();
|
||||
Task<ServerInfoDto> GetServerInfo();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface ITokenService
|
||||
{
|
||||
Task<string> CreateToken(AppUser user);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Update;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IVersionUpdaterService
|
||||
{
|
||||
Task<UpdateNotificationDto> CheckForUpdate();
|
||||
Task PushUpdate(UpdateNotificationDto update);
|
||||
Task<IEnumerable<UpdateNotificationDto>> GetAllReleases();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public class ReaderService : IReaderService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReaderService> _logger;
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
|
||||
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="chapters"></param>
|
||||
public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters)
|
||||
{
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = chapter.Pages,
|
||||
VolumeId = chapter.VolumeId,
|
||||
SeriesId = seriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = chapter.Pages;
|
||||
userProgress.SeriesId = seriesId;
|
||||
userProgress.VolumeId = chapter.VolumeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="chapters"></param>
|
||||
public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters)
|
||||
{
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = 0,
|
||||
VolumeId = chapter.VolumeId,
|
||||
SeriesId = seriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = 0;
|
||||
userProgress.SeriesId = seriesId;
|
||||
userProgress.VolumeId = chapter.VolumeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="chapter"></param>
|
||||
/// <returns></returns>
|
||||
public static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
|
||||
{
|
||||
AppUserProgress userProgress = null;
|
||||
try
|
||||
{
|
||||
userProgress =
|
||||
user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages
|
||||
var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
|
||||
if (progresses.Count > 1)
|
||||
{
|
||||
user.Progresses = new List<AppUserProgress>()
|
||||
{
|
||||
user.Progresses.First()
|
||||
};
|
||||
userProgress = user.Progresses.First();
|
||||
}
|
||||
}
|
||||
|
||||
return userProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves progress to DB
|
||||
/// </summary>
|
||||
/// <param name="progressDto"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId)
|
||||
{
|
||||
// Don't let user save past total pages.
|
||||
progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum);
|
||||
|
||||
try
|
||||
{
|
||||
var userProgress =
|
||||
await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
// Create a user object
|
||||
var userWithProgress =
|
||||
await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
|
||||
userWithProgress.Progresses ??= new List<AppUserProgress>();
|
||||
userWithProgress.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = progressDto.PageNum,
|
||||
VolumeId = progressDto.VolumeId,
|
||||
SeriesId = progressDto.SeriesId,
|
||||
ChapterId = progressDto.ChapterId,
|
||||
BookScrollId = progressDto.BookScrollId,
|
||||
LastModified = DateTime.Now
|
||||
});
|
||||
_unitOfWork.UserRepository.Update(userWithProgress);
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = progressDto.PageNum;
|
||||
userProgress.SeriesId = progressDto.SeriesId;
|
||||
userProgress.VolumeId = progressDto.VolumeId;
|
||||
userProgress.BookScrollId = progressDto.BookScrollId;
|
||||
userProgress.LastModified = DateTime.Now;
|
||||
_unitOfWork.AppUserProgressRepository.Update(userProgress);
|
||||
}
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Could not save progress");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <param name="page"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> CapPageToChapter(int chapterId, int page)
|
||||
{
|
||||
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
|
||||
if (page > totalPages)
|
||||
{
|
||||
page = totalPages;
|
||||
}
|
||||
|
||||
if (page < 0)
|
||||
{
|
||||
page = 0;
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find the next logical Chapter
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02
|
||||
/// </example>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="currentChapterId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns>-1 if nothing can be found</returns>
|
||||
public async Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
||||
{
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
|
||||
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
||||
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
||||
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
// Handle specials by sorting on their Filename aka Range
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1)
|
||||
{
|
||||
// Handle Chapters within current Volume
|
||||
// In this case, i need 0 first because 0 represents a full volume file.
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
if (volume.Number != currentVolume.Number + 1) continue;
|
||||
|
||||
// Handle Chapters within next Volume
|
||||
// ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
|
||||
var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
|
||||
if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0"))
|
||||
{
|
||||
return chapters.Last().Id;
|
||||
}
|
||||
|
||||
var firstChapter = chapters.FirstOrDefault();
|
||||
if (firstChapter == null) return -1;
|
||||
return firstChapter.Id;
|
||||
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
/// <summary>
|
||||
/// Tries to find the prev logical Chapter
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02
|
||||
/// </example>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="currentChapterId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns>-1 if nothing can be found</returns>
|
||||
public async Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
||||
{
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList();
|
||||
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
||||
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
||||
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
if (volume.Number == currentVolume.Number)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
if (volume.Number == currentVolume.Number - 1)
|
||||
{
|
||||
var lastChapter = volume.Chapters
|
||||
.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
|
||||
if (lastChapter == null) return -1;
|
||||
return lastChapter.Id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, string currentChapterNumber)
|
||||
{
|
||||
var next = false;
|
||||
var chaptersList = chapters.ToList();
|
||||
foreach (var chapter in chaptersList)
|
||||
{
|
||||
if (next)
|
||||
{
|
||||
return chapter.Id;
|
||||
}
|
||||
if (currentChapterNumber.Equals(chapter.Number)) next = true;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
160
API/Parser/DefaultParser.cs
Normal file
160
API/Parser/DefaultParser.cs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using API.Entities.Enums;
|
||||
using API.Services;
|
||||
|
||||
namespace API.Parser;
|
||||
|
||||
/// <summary>
|
||||
/// This is an implementation of the Parser that is the basis for everything
|
||||
/// </summary>
|
||||
public class DefaultParser
|
||||
{
|
||||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
public DefaultParser(IDirectoryService directoryService)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed
|
||||
/// from filename.
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="rootPath">Root folder</param>
|
||||
/// <param name="type">Defaults to Manga. Allows different Regex to be used for parsing.</param>
|
||||
/// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
|
||||
public ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga)
|
||||
{
|
||||
var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
|
||||
ParserInfo ret;
|
||||
|
||||
if (Parser.IsEpub(filePath))
|
||||
{
|
||||
ret = new ParserInfo()
|
||||
{
|
||||
Chapters = Parser.ParseChapter(fileName) ?? Parser.ParseComicChapter(fileName),
|
||||
Series = Parser.ParseSeries(fileName) ?? Parser.ParseComicSeries(fileName),
|
||||
Volumes = Parser.ParseVolume(fileName) ?? Parser.ParseComicVolume(fileName),
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = Parser.ParseFormat(filePath),
|
||||
FullFilePath = filePath
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
ret = new ParserInfo()
|
||||
{
|
||||
Chapters = type == LibraryType.Manga ? Parser.ParseChapter(fileName) : Parser.ParseComicChapter(fileName),
|
||||
Series = type == LibraryType.Manga ? Parser.ParseSeries(fileName) : Parser.ParseComicSeries(fileName),
|
||||
Volumes = type == LibraryType.Manga ? Parser.ParseVolume(fileName) : Parser.ParseComicVolume(fileName),
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = Parser.ParseFormat(filePath),
|
||||
Title = Path.GetFileNameWithoutExtension(fileName),
|
||||
FullFilePath = filePath
|
||||
};
|
||||
}
|
||||
|
||||
if (Parser.IsImage(filePath) && Parser.IsCoverImage(filePath)) return null;
|
||||
|
||||
if (Parser.IsImage(filePath))
|
||||
{
|
||||
// Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders.
|
||||
ret.Volumes = Parser.DefaultVolume;
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
ret.Series = string.Empty;
|
||||
}
|
||||
|
||||
if (ret.Series == string.Empty || Parser.IsImage(filePath))
|
||||
{
|
||||
// Try to parse information out of each folder all the way to rootPath
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
var edition = Parser.ParseEdition(fileName);
|
||||
if (!string.IsNullOrEmpty(edition))
|
||||
{
|
||||
ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic);
|
||||
ret.Edition = edition;
|
||||
}
|
||||
|
||||
var isSpecial = type == LibraryType.Comic ? Parser.ParseComicSpecial(fileName) : Parser.ParseMangaSpecial(fileName);
|
||||
// We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that
|
||||
// could cause a problem as Omake is a special term, but there is valid volume/chapter information.
|
||||
if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.DefaultVolume && !string.IsNullOrEmpty(isSpecial))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
// If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name
|
||||
if (Parser.HasSpecialMarker(fileName))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
ret.Volumes = Parser.DefaultVolume;
|
||||
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(ret.Series))
|
||||
{
|
||||
ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic);
|
||||
}
|
||||
|
||||
// Pdfs may have .pdf in the series name, remove that
|
||||
if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
{
|
||||
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
|
||||
}
|
||||
|
||||
return ret.Series == string.Empty ? null : ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills out <see cref="ParserInfo"/> by trying to parse volume, chapters, and series from folders
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="rootPath"></param>
|
||||
/// <param name="type"></param>
|
||||
/// <param name="ret">Expects a non-null ParserInfo which this method will populate</param>
|
||||
public void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret)
|
||||
{
|
||||
var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath).ToList();
|
||||
for (var i = 0; i < fallbackFolders.Count; i++)
|
||||
{
|
||||
var folder = fallbackFolders[i];
|
||||
if (!string.IsNullOrEmpty(Parser.ParseMangaSpecial(folder))) continue;
|
||||
|
||||
var parsedVolume = type is LibraryType.Manga ? Parser.ParseVolume(folder) : Parser.ParseComicVolume(folder);
|
||||
var parsedChapter = type is LibraryType.Manga ? Parser.ParseChapter(folder) : Parser.ParseComicChapter(folder);
|
||||
|
||||
if (!parsedVolume.Equals(Parser.DefaultVolume) || !parsedChapter.Equals(Parser.DefaultChapter))
|
||||
{
|
||||
if ((ret.Volumes.Equals(Parser.DefaultVolume) || string.IsNullOrEmpty(ret.Volumes)) && !parsedVolume.Equals(Parser.DefaultVolume))
|
||||
{
|
||||
ret.Volumes = parsedVolume;
|
||||
}
|
||||
if ((ret.Chapters.Equals(Parser.DefaultChapter) || string.IsNullOrEmpty(ret.Chapters)) && !parsedChapter.Equals(Parser.DefaultChapter))
|
||||
{
|
||||
ret.Chapters = parsedChapter;
|
||||
}
|
||||
}
|
||||
|
||||
var series = Parser.ParseSeries(folder);
|
||||
|
||||
if ((string.IsNullOrEmpty(series) && i == fallbackFolders.Count - 1))
|
||||
{
|
||||
ret.Series = Parser.CleanTitle(folder, type is LibraryType.Comic);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(series))
|
||||
{
|
||||
ret.Series = series;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -491,146 +491,146 @@ namespace API.Parser
|
|||
);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed
|
||||
/// from filename.
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="rootPath">Root folder</param>
|
||||
/// <param name="type">Defaults to Manga. Allows different Regex to be used for parsing.</param>
|
||||
/// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
|
||||
public static ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
ParserInfo ret;
|
||||
|
||||
if (IsEpub(filePath))
|
||||
{
|
||||
ret = new ParserInfo()
|
||||
{
|
||||
Chapters = ParseChapter(fileName) ?? ParseComicChapter(fileName),
|
||||
Series = ParseSeries(fileName) ?? ParseComicSeries(fileName),
|
||||
Volumes = ParseVolume(fileName) ?? ParseComicVolume(fileName),
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = ParseFormat(filePath),
|
||||
FullFilePath = filePath
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
ret = new ParserInfo()
|
||||
{
|
||||
Chapters = type == LibraryType.Manga ? ParseChapter(fileName) : ParseComicChapter(fileName),
|
||||
Series = type == LibraryType.Manga ? ParseSeries(fileName) : ParseComicSeries(fileName),
|
||||
Volumes = type == LibraryType.Manga ? ParseVolume(fileName) : ParseComicVolume(fileName),
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = ParseFormat(filePath),
|
||||
Title = Path.GetFileNameWithoutExtension(fileName),
|
||||
FullFilePath = filePath
|
||||
};
|
||||
}
|
||||
|
||||
if (IsImage(filePath) && IsCoverImage(filePath)) return null;
|
||||
|
||||
if (IsImage(filePath))
|
||||
{
|
||||
// Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders.
|
||||
ret.Volumes = DefaultVolume;
|
||||
ret.Chapters = DefaultChapter;
|
||||
ret.Series = string.Empty;
|
||||
}
|
||||
|
||||
if (ret.Series == string.Empty || IsImage(filePath))
|
||||
{
|
||||
// Try to parse information out of each folder all the way to rootPath
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
var edition = ParseEdition(fileName);
|
||||
if (!string.IsNullOrEmpty(edition))
|
||||
{
|
||||
ret.Series = CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic);
|
||||
ret.Edition = edition;
|
||||
}
|
||||
|
||||
var isSpecial = type == LibraryType.Comic ? ParseComicSpecial(fileName) : ParseMangaSpecial(fileName);
|
||||
// We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that
|
||||
// could cause a problem as Omake is a special term, but there is valid volume/chapter information.
|
||||
if (ret.Chapters == DefaultChapter && ret.Volumes == DefaultVolume && !string.IsNullOrEmpty(isSpecial))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
// If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name
|
||||
if (HasSpecialMarker(fileName))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.Chapters = DefaultChapter;
|
||||
ret.Volumes = DefaultVolume;
|
||||
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(ret.Series))
|
||||
{
|
||||
ret.Series = CleanTitle(fileName, type is LibraryType.Comic);
|
||||
}
|
||||
|
||||
// Pdfs may have .pdf in the series name, remove that
|
||||
if (IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
{
|
||||
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
|
||||
}
|
||||
|
||||
return ret.Series == string.Empty ? null : ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="rootPath"></param>
|
||||
/// <param name="type"></param>
|
||||
/// <param name="ret">Expects a non-null ParserInfo which this method will populate</param>
|
||||
public static void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret)
|
||||
{
|
||||
var fallbackFolders = DirectoryService.GetFoldersTillRoot(rootPath, filePath).ToList();
|
||||
for (var i = 0; i < fallbackFolders.Count; i++)
|
||||
{
|
||||
var folder = fallbackFolders[i];
|
||||
if (!string.IsNullOrEmpty(ParseMangaSpecial(folder))) continue;
|
||||
|
||||
var parsedVolume = type is LibraryType.Manga ? ParseVolume(folder) : ParseComicVolume(folder);
|
||||
var parsedChapter = type is LibraryType.Manga ? ParseChapter(folder) : ParseComicChapter(folder);
|
||||
|
||||
if (!parsedVolume.Equals(DefaultVolume) || !parsedChapter.Equals(DefaultChapter))
|
||||
{
|
||||
if ((ret.Volumes.Equals(DefaultVolume) || string.IsNullOrEmpty(ret.Volumes)) && !parsedVolume.Equals(DefaultVolume))
|
||||
{
|
||||
ret.Volumes = parsedVolume;
|
||||
}
|
||||
if ((ret.Chapters.Equals(DefaultChapter) || string.IsNullOrEmpty(ret.Chapters)) && !parsedChapter.Equals(DefaultChapter))
|
||||
{
|
||||
ret.Chapters = parsedChapter;
|
||||
}
|
||||
}
|
||||
|
||||
var series = ParseSeries(folder);
|
||||
|
||||
if ((string.IsNullOrEmpty(series) && i == fallbackFolders.Count - 1))
|
||||
{
|
||||
ret.Series = CleanTitle(folder, type is LibraryType.Comic);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(series))
|
||||
{
|
||||
ret.Series = series;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// /// <summary>
|
||||
// /// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed
|
||||
// /// from filename.
|
||||
// /// </summary>
|
||||
// /// <param name="filePath"></param>
|
||||
// /// <param name="rootPath">Root folder</param>
|
||||
// /// <param name="type">Defaults to Manga. Allows different Regex to be used for parsing.</param>
|
||||
// /// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
|
||||
// public static ParserInfo Parse(string filePath, string rootPath, IDirectoryService directoryService, LibraryType type = LibraryType.Manga)
|
||||
// {
|
||||
// var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
|
||||
// ParserInfo ret;
|
||||
//
|
||||
// if (IsEpub(filePath))
|
||||
// {
|
||||
// ret = new ParserInfo()
|
||||
// {
|
||||
// Chapters = ParseChapter(fileName) ?? ParseComicChapter(fileName),
|
||||
// Series = ParseSeries(fileName) ?? ParseComicSeries(fileName),
|
||||
// Volumes = ParseVolume(fileName) ?? ParseComicVolume(fileName),
|
||||
// Filename = Path.GetFileName(filePath),
|
||||
// Format = ParseFormat(filePath),
|
||||
// FullFilePath = filePath
|
||||
// };
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// ret = new ParserInfo()
|
||||
// {
|
||||
// Chapters = type == LibraryType.Manga ? ParseChapter(fileName) : ParseComicChapter(fileName),
|
||||
// Series = type == LibraryType.Manga ? ParseSeries(fileName) : ParseComicSeries(fileName),
|
||||
// Volumes = type == LibraryType.Manga ? ParseVolume(fileName) : ParseComicVolume(fileName),
|
||||
// Filename = Path.GetFileName(filePath),
|
||||
// Format = ParseFormat(filePath),
|
||||
// Title = Path.GetFileNameWithoutExtension(fileName),
|
||||
// FullFilePath = filePath
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// if (IsImage(filePath) && IsCoverImage(filePath)) return null;
|
||||
//
|
||||
// if (IsImage(filePath))
|
||||
// {
|
||||
// // Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders.
|
||||
// ret.Volumes = DefaultVolume;
|
||||
// ret.Chapters = DefaultChapter;
|
||||
// ret.Series = string.Empty;
|
||||
// }
|
||||
//
|
||||
// if (ret.Series == string.Empty || IsImage(filePath))
|
||||
// {
|
||||
// // Try to parse information out of each folder all the way to rootPath
|
||||
// ParseFromFallbackFolders(filePath, rootPath, type, directoryService, ref ret);
|
||||
// }
|
||||
//
|
||||
// var edition = ParseEdition(fileName);
|
||||
// if (!string.IsNullOrEmpty(edition))
|
||||
// {
|
||||
// ret.Series = CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic);
|
||||
// ret.Edition = edition;
|
||||
// }
|
||||
//
|
||||
// var isSpecial = type == LibraryType.Comic ? ParseComicSpecial(fileName) : ParseMangaSpecial(fileName);
|
||||
// // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that
|
||||
// // could cause a problem as Omake is a special term, but there is valid volume/chapter information.
|
||||
// if (ret.Chapters == DefaultChapter && ret.Volumes == DefaultVolume && !string.IsNullOrEmpty(isSpecial))
|
||||
// {
|
||||
// ret.IsSpecial = true;
|
||||
// ParseFromFallbackFolders(filePath, rootPath, type, directoryService, ref ret);
|
||||
// }
|
||||
//
|
||||
// // If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name
|
||||
// if (HasSpecialMarker(fileName))
|
||||
// {
|
||||
// ret.IsSpecial = true;
|
||||
// ret.Chapters = DefaultChapter;
|
||||
// ret.Volumes = DefaultVolume;
|
||||
//
|
||||
// ParseFromFallbackFolders(filePath, rootPath, type, directoryService, ref ret);
|
||||
// }
|
||||
//
|
||||
// if (string.IsNullOrEmpty(ret.Series))
|
||||
// {
|
||||
// ret.Series = CleanTitle(fileName, type is LibraryType.Comic);
|
||||
// }
|
||||
//
|
||||
// // Pdfs may have .pdf in the series name, remove that
|
||||
// if (IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
// {
|
||||
// ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
|
||||
// }
|
||||
//
|
||||
// return ret.Series == string.Empty ? null : ret;
|
||||
// }
|
||||
//
|
||||
// /// <summary>
|
||||
// ///
|
||||
// /// </summary>
|
||||
// /// <param name="filePath"></param>
|
||||
// /// <param name="rootPath"></param>
|
||||
// /// <param name="type"></param>
|
||||
// /// <param name="ret">Expects a non-null ParserInfo which this method will populate</param>
|
||||
// public static void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, IDirectoryService directoryService, ref ParserInfo ret)
|
||||
// {
|
||||
// var fallbackFolders = directoryService.GetFoldersTillRoot(rootPath, filePath).ToList();
|
||||
// for (var i = 0; i < fallbackFolders.Count; i++)
|
||||
// {
|
||||
// var folder = fallbackFolders[i];
|
||||
// if (!string.IsNullOrEmpty(ParseMangaSpecial(folder))) continue;
|
||||
//
|
||||
// var parsedVolume = type is LibraryType.Manga ? ParseVolume(folder) : ParseComicVolume(folder);
|
||||
// var parsedChapter = type is LibraryType.Manga ? ParseChapter(folder) : ParseComicChapter(folder);
|
||||
//
|
||||
// if (!parsedVolume.Equals(DefaultVolume) || !parsedChapter.Equals(DefaultChapter))
|
||||
// {
|
||||
// if ((ret.Volumes.Equals(DefaultVolume) || string.IsNullOrEmpty(ret.Volumes)) && !parsedVolume.Equals(DefaultVolume))
|
||||
// {
|
||||
// ret.Volumes = parsedVolume;
|
||||
// }
|
||||
// if ((ret.Chapters.Equals(DefaultChapter) || string.IsNullOrEmpty(ret.Chapters)) && !parsedChapter.Equals(DefaultChapter))
|
||||
// {
|
||||
// ret.Chapters = parsedChapter;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// var series = ParseSeries(folder);
|
||||
//
|
||||
// if ((string.IsNullOrEmpty(series) && i == fallbackFolders.Count - 1))
|
||||
// {
|
||||
// ret.Series = CleanTitle(folder, type is LibraryType.Comic);
|
||||
// break;
|
||||
// }
|
||||
//
|
||||
// if (!string.IsNullOrEmpty(series))
|
||||
// {
|
||||
// ret.Series = series;
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
public static MangaFormat ParseFormat(string filePath)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
|
|
@ -15,6 +16,7 @@ using Microsoft.Extensions.Configuration;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
|
||||
namespace API
|
||||
{
|
||||
|
|
@ -31,7 +33,19 @@ namespace API
|
|||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
var isDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker;
|
||||
|
||||
MigrateConfigFiles.Migrate(isDocker);
|
||||
var migrateLogger = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder
|
||||
//.AddConfiguration(Configuration.GetSection("Logging"))
|
||||
.AddFilter("Microsoft", LogLevel.Warning)
|
||||
.AddFilter("System", LogLevel.Warning)
|
||||
.AddFilter("SampleApp.Program", LogLevel.Debug)
|
||||
.AddConsole()
|
||||
.AddEventLog();
|
||||
});
|
||||
var mLogger = migrateLogger.CreateLogger<DirectoryService>();
|
||||
|
||||
MigrateConfigFiles.Migrate(isDocker, new DirectoryService(mLogger, new FileSystem()));
|
||||
|
||||
// Before anything, check if JWT has been generated properly or if user still has default
|
||||
if (!Configuration.CheckIfJwtTokenSet() &&
|
||||
|
|
@ -60,14 +74,16 @@ namespace API
|
|||
return;
|
||||
}
|
||||
|
||||
var directoryService = services.GetRequiredService<DirectoryService>();
|
||||
|
||||
var requiresCoverImageMigration = !Directory.Exists(DirectoryService.CoverImageDirectory);
|
||||
|
||||
var requiresCoverImageMigration = !Directory.Exists(directoryService.CoverImageDirectory);
|
||||
try
|
||||
{
|
||||
// If this is a new install, tables wont exist yet
|
||||
if (requiresCoverImageMigration)
|
||||
{
|
||||
MigrateCoverImages.ExtractToImages(context);
|
||||
MigrateCoverImages.ExtractToImages(context, directoryService, services.GetRequiredService<ImageService>());
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
|
|
@ -80,11 +96,11 @@ namespace API
|
|||
|
||||
if (requiresCoverImageMigration)
|
||||
{
|
||||
await MigrateCoverImages.UpdateDatabaseWithImages(context);
|
||||
await MigrateCoverImages.UpdateDatabaseWithImages(context, directoryService);
|
||||
}
|
||||
|
||||
await Seed.SeedRoles(roleManager);
|
||||
await Seed.SeedSettings(context);
|
||||
await Seed.SeedSettings(context, directoryService);
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ using System.Linq;
|
|||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Errors;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services
|
||||
{
|
||||
public interface IAccountService
|
||||
{
|
||||
Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
|
||||
}
|
||||
|
||||
public class AccountService : IAccountService
|
||||
{
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ using API.Archive;
|
|||
using API.Comparators;
|
||||
using API.Data.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services.Tasks;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
|
@ -19,6 +18,18 @@ using SharpCompress.Common;
|
|||
|
||||
namespace API.Services
|
||||
{
|
||||
public interface IArchiveService
|
||||
{
|
||||
void ExtractArchive(string archivePath, string extractPath);
|
||||
int GetNumberOfPagesFromArchive(string archivePath);
|
||||
string GetCoverImage(string archivePath, string fileName);
|
||||
bool IsValidArchive(string archivePath);
|
||||
ComicInfo GetComicInfo(string archivePath);
|
||||
ArchiveLibrary CanOpen(string archivePath);
|
||||
bool ArchiveNeedsFlattening(ZipArchive archive);
|
||||
Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for manipulating Archive files. Used by <see cref="CacheService"/> and <see cref="ScannerService"/>
|
||||
/// </summary>
|
||||
|
|
@ -27,12 +38,14 @@ namespace API.Services
|
|||
{
|
||||
private readonly ILogger<ArchiveService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IImageService _imageService;
|
||||
private const string ComicInfoFilename = "comicinfo";
|
||||
|
||||
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService)
|
||||
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService, IImageService imageService)
|
||||
{
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_imageService = imageService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -42,7 +55,7 @@ namespace API.Services
|
|||
/// <returns></returns>
|
||||
public virtual ArchiveLibrary CanOpen(string archivePath)
|
||||
{
|
||||
if (!(File.Exists(archivePath) && Parser.Parser.IsArchive(archivePath) || Parser.Parser.IsEpub(archivePath))) return ArchiveLibrary.NotSupported;
|
||||
if (string.IsNullOrEmpty(archivePath) || !(File.Exists(archivePath) && Parser.Parser.IsArchive(archivePath) || Parser.Parser.IsEpub(archivePath))) return ArchiveLibrary.NotSupported;
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -239,14 +252,14 @@ namespace API.Services
|
|||
{
|
||||
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_");
|
||||
|
||||
var tempLocation = Path.Join(DirectoryService.TempDirectory, $"{tempFolder}_{dateString}");
|
||||
DirectoryService.ExistOrCreate(tempLocation);
|
||||
var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}");
|
||||
_directoryService.ExistOrCreate(tempLocation);
|
||||
if (!_directoryService.CopyFilesToDirectory(files, tempLocation))
|
||||
{
|
||||
throw new KavitaException("Unable to copy files to temp directory archive download.");
|
||||
}
|
||||
|
||||
var zipPath = Path.Join(DirectoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
|
||||
var zipPath = Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
|
||||
try
|
||||
{
|
||||
ZipFile.CreateFromDirectory(tempLocation, zipPath);
|
||||
|
|
@ -260,7 +273,7 @@ namespace API.Services
|
|||
|
||||
var fileBytes = await _directoryService.ReadFileAsync(zipPath);
|
||||
|
||||
DirectoryService.ClearAndDeleteDirectory(tempLocation);
|
||||
_directoryService.ClearAndDeleteDirectory(tempLocation); // NOTE: For sending back just zip, just schedule this to be called after the file is returned or let next temp storage cleanup take care of it
|
||||
(new FileInfo(zipPath)).Delete();
|
||||
|
||||
return Tuple.Create(fileBytes, zipPath);
|
||||
|
|
@ -270,7 +283,7 @@ namespace API.Services
|
|||
{
|
||||
try
|
||||
{
|
||||
return ImageService.WriteCoverThumbnail(stream, fileName);
|
||||
return _imageService.WriteCoverThumbnail(stream, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -413,9 +426,9 @@ namespace API.Services
|
|||
}
|
||||
|
||||
|
||||
private static void ExtractArchiveEntities(IEnumerable<IArchiveEntry> entries, string extractPath)
|
||||
private void ExtractArchiveEntities(IEnumerable<IArchiveEntry> entries, string extractPath)
|
||||
{
|
||||
DirectoryService.ExistOrCreate(extractPath);
|
||||
_directoryService.ExistOrCreate(extractPath);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
entry.WriteToDirectory(extractPath, new ExtractionOptions()
|
||||
|
|
@ -428,7 +441,7 @@ namespace API.Services
|
|||
|
||||
private void ExtractArchiveEntries(ZipArchive archive, string extractPath)
|
||||
{
|
||||
// NOTE: In cases where we try to extract, but there are InvalidPathChars, we need to inform the user
|
||||
// TODO: In cases where we try to extract, but there are InvalidPathChars, we need to inform the user
|
||||
var needsFlattening = ArchiveNeedsFlattening(archive);
|
||||
if (!archive.HasFiles() && !needsFlattening) return;
|
||||
|
||||
|
|
@ -436,7 +449,7 @@ namespace API.Services
|
|||
if (!needsFlattening) return;
|
||||
|
||||
_logger.LogDebug("Extracted archive is nested in root folder, flattening...");
|
||||
new DirectoryInfo(extractPath).Flatten();
|
||||
_directoryService.Flatten(extractPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ using System.Threading.Tasks;
|
|||
using System.Web;
|
||||
using API.Data.Metadata;
|
||||
using API.Entities.Enums;
|
||||
using API.Interfaces.Services;
|
||||
using API.Parser;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Converters;
|
||||
|
|
@ -25,17 +24,45 @@ using VersOne.Epub;
|
|||
|
||||
namespace API.Services
|
||||
{
|
||||
public interface IBookService
|
||||
{
|
||||
int GetNumberOfPages(string filePath);
|
||||
string GetCoverImage(string fileFilePath, string fileName);
|
||||
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||
|
||||
/// <summary>
|
||||
/// Scopes styles to .reading-section and replaces img src to the passed apiBase
|
||||
/// </summary>
|
||||
/// <param name="stylesheetHtml"></param>
|
||||
/// <param name="apiBase"></param>
|
||||
/// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
|
||||
/// <param name="book">Book Reference, needed for if you expect Import statements</param>
|
||||
/// <returns></returns>
|
||||
Task<string> ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book);
|
||||
ComicInfo GetComicInfo(string filePath);
|
||||
ParserInfo ParseInfo(string filePath);
|
||||
/// <summary>
|
||||
/// Extracts a PDF file's pages as images to an target directory
|
||||
/// </summary>
|
||||
/// <param name="fileFilePath"></param>
|
||||
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
|
||||
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
||||
}
|
||||
|
||||
public class BookService : IBookService
|
||||
{
|
||||
private readonly ILogger<BookService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly StylesheetParser _cssParser = new ();
|
||||
private static readonly RecyclableMemoryStreamManager StreamManager = new ();
|
||||
private const string CssScopeClass = ".book-content";
|
||||
|
||||
public BookService(ILogger<BookService> logger)
|
||||
public BookService(ILogger<BookService> logger, IDirectoryService directoryService, IImageService imageService)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
_directoryService = directoryService;
|
||||
_imageService = imageService;
|
||||
}
|
||||
|
||||
private static bool HasClickableHrefPart(HtmlNode anchor)
|
||||
|
|
@ -431,7 +458,7 @@ namespace API.Services
|
|||
|
||||
public void ExtractPdfImages(string fileFilePath, string targetDirectory)
|
||||
{
|
||||
DirectoryService.ExistOrCreate(targetDirectory);
|
||||
_directoryService.ExistOrCreate(targetDirectory);
|
||||
|
||||
using var docReader = DocLib.Instance.GetDocReader(fileFilePath, new PageDimensions(1080, 1920));
|
||||
var pages = docReader.GetPageCount();
|
||||
|
|
@ -473,7 +500,7 @@ namespace API.Services
|
|||
if (coverImageContent == null) return string.Empty;
|
||||
using var stream = coverImageContent.GetContentStream();
|
||||
|
||||
return ImageService.WriteCoverThumbnail(stream, fileName);
|
||||
return _imageService.WriteCoverThumbnail(stream, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -494,7 +521,7 @@ namespace API.Services
|
|||
using var stream = StreamManager.GetStream("BookService.GetPdfPage");
|
||||
GetPdfPage(docReader, 0, stream);
|
||||
|
||||
return ImageService.WriteCoverThumbnail(stream, fileName);
|
||||
return _imageService.WriteCoverThumbnail(stream, fileName);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -4,43 +4,50 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services
|
||||
{
|
||||
public interface ICacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures the cache is created for the given chapter and if not, will create it. Should be called before any other
|
||||
/// cache operations (except cleanup).
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns>Chapter for the passed chapterId. Side-effect from ensuring cache.</returns>
|
||||
Task<Chapter> Ensure(int chapterId);
|
||||
/// <summary>
|
||||
/// Clears cache directory of all volumes. This can be invoked from deleting a library or a series.
|
||||
/// </summary>
|
||||
/// <param name="chapterIds">Volumes that belong to that library. Assume the library might have been deleted before this invocation.</param>
|
||||
void CleanupChapters(IEnumerable<int> chapterIds);
|
||||
string GetCachedPagePath(Chapter chapter, int page);
|
||||
string GetCachedEpubFile(int chapterId, Chapter chapter);
|
||||
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
|
||||
}
|
||||
public class CacheService : ICacheService
|
||||
{
|
||||
private readonly ILogger<CacheService> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IBookService _bookService;
|
||||
private readonly IReadingItemService _readingItemService;
|
||||
private readonly NumericComparer _numericComparer;
|
||||
|
||||
public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork, IArchiveService archiveService,
|
||||
IDirectoryService directoryService, IBookService bookService)
|
||||
public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork,
|
||||
IDirectoryService directoryService, IReadingItemService readingItemService)
|
||||
{
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_archiveService = archiveService;
|
||||
_directoryService = directoryService;
|
||||
_bookService = bookService;
|
||||
_readingItemService = readingItemService;
|
||||
_numericComparer = new NumericComparer();
|
||||
}
|
||||
|
||||
public void EnsureCacheDirectory()
|
||||
{
|
||||
if (!DirectoryService.ExistOrCreate(DirectoryService.CacheDirectory))
|
||||
{
|
||||
_logger.LogError("Cache directory {CacheDirectory} is not accessible or does not exist. Creating...", DirectoryService.CacheDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full path to the cached epub file. If the file does not exist, will fallback to the original.
|
||||
/// </summary>
|
||||
|
|
@ -50,8 +57,8 @@ namespace API.Services
|
|||
public string GetCachedEpubFile(int chapterId, Chapter chapter)
|
||||
{
|
||||
var extractPath = GetCachePath(chapterId);
|
||||
var path = Path.Join(extractPath, Path.GetFileName(chapter.Files.First().FilePath));
|
||||
if (!(new FileInfo(path).Exists))
|
||||
var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(chapter.Files.First().FilePath));
|
||||
if (!(_directoryService.FileSystem.FileInfo.FromFileName(path).Exists))
|
||||
{
|
||||
path = chapter.Files.First().FilePath;
|
||||
}
|
||||
|
|
@ -62,14 +69,14 @@ namespace API.Services
|
|||
/// Caches the files for the given chapter to CacheDirectory
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns>This will always return the Chapter for the chpaterId</returns>
|
||||
/// <returns>This will always return the Chapter for the chapterId</returns>
|
||||
public async Task<Chapter> Ensure(int chapterId)
|
||||
{
|
||||
EnsureCacheDirectory();
|
||||
_directoryService.ExistOrCreate(_directoryService.CacheDirectory);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
var extractPath = GetCachePath(chapterId);
|
||||
|
||||
if (!Directory.Exists(extractPath))
|
||||
if (!_directoryService.Exists(extractPath))
|
||||
{
|
||||
var files = chapter.Files.ToList();
|
||||
ExtractChapterFiles(extractPath, files);
|
||||
|
|
@ -90,22 +97,12 @@ namespace API.Services
|
|||
var removeNonImages = true;
|
||||
var fileCount = files.Count;
|
||||
var extraPath = "";
|
||||
var extractDi = new DirectoryInfo(extractPath);
|
||||
var extractDi = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(extractPath);
|
||||
|
||||
if (files.Count > 0 && files[0].Format == MangaFormat.Image)
|
||||
{
|
||||
DirectoryService.ExistOrCreate(extractPath);
|
||||
if (files.Count == 1)
|
||||
{
|
||||
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
DirectoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath,
|
||||
Parser.Parser.ImageFileExtensions);
|
||||
}
|
||||
|
||||
extractDi.Flatten();
|
||||
_readingItemService.Extract(files[0].FilePath, extractPath, MangaFormat.Image, files.Count);
|
||||
_directoryService.Flatten(extractDi.FullName);
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
|
|
@ -117,63 +114,37 @@ namespace API.Services
|
|||
|
||||
if (file.Format == MangaFormat.Archive)
|
||||
{
|
||||
_archiveService.ExtractArchive(file.FilePath, Path.Join(extractPath, extraPath));
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
}
|
||||
else if (file.Format == MangaFormat.Pdf)
|
||||
{
|
||||
_bookService.ExtractPdfImages(file.FilePath, Path.Join(extractPath, extraPath));
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
}
|
||||
else if (file.Format == MangaFormat.Epub)
|
||||
{
|
||||
removeNonImages = false;
|
||||
DirectoryService.ExistOrCreate(extractPath);
|
||||
_directoryService.ExistOrCreate(extractPath);
|
||||
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
|
||||
}
|
||||
}
|
||||
|
||||
extractDi.Flatten();
|
||||
_directoryService.Flatten(extractDi.FullName);
|
||||
if (removeNonImages)
|
||||
{
|
||||
extractDi.RemoveNonImages();
|
||||
_directoryService.RemoveNonImages(extractDi.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void Cleanup()
|
||||
{
|
||||
_logger.LogInformation("Performing cleanup of Cache directory");
|
||||
EnsureCacheDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
DirectoryService.ClearDirectory(DirectoryService.CacheDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cache directory purged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the cached files and folders for a set of chapterIds
|
||||
/// </summary>
|
||||
/// <param name="chapterIds"></param>
|
||||
public void CleanupChapters(IEnumerable<int> chapterIds)
|
||||
{
|
||||
_logger.LogInformation("Running Cache cleanup on Chapters");
|
||||
|
||||
foreach (var chapter in chapterIds)
|
||||
{
|
||||
var di = new DirectoryInfo(GetCachePath(chapter));
|
||||
if (di.Exists)
|
||||
{
|
||||
di.Delete(true);
|
||||
}
|
||||
|
||||
_directoryService.ClearDirectory(GetCachePath(chapter));
|
||||
}
|
||||
_logger.LogInformation("Cache directory purged");
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -184,46 +155,29 @@ namespace API.Services
|
|||
/// <returns></returns>
|
||||
private string GetCachePath(int chapterId)
|
||||
{
|
||||
return Path.GetFullPath(Path.Join(DirectoryService.CacheDirectory, $"{chapterId}/"));
|
||||
return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{chapterId}/"));
|
||||
}
|
||||
|
||||
public async Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page)
|
||||
/// <summary>
|
||||
/// Returns the absolute path of a cached page.
|
||||
/// </summary>
|
||||
/// <param name="chapter">Chapter entity with Files populated.</param>
|
||||
/// <param name="page">Page number to look for</param>
|
||||
/// <returns>Page filepath or empty if no files found.</returns>
|
||||
public string GetCachedPagePath(Chapter chapter, int page)
|
||||
{
|
||||
// Calculate what chapter the page belongs to
|
||||
var pagesSoFar = 0;
|
||||
var chapterFiles = chapter.Files ?? await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
||||
foreach (var mangaFile in chapterFiles)
|
||||
var path = GetCachePath(chapter.Id);
|
||||
var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions);
|
||||
Array.Sort(files, _numericComparer);
|
||||
|
||||
if (files.Length == 0)
|
||||
{
|
||||
if (page <= (mangaFile.Pages + pagesSoFar))
|
||||
{
|
||||
var path = GetCachePath(chapter.Id);
|
||||
var files = DirectoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions);
|
||||
Array.Sort(files, _numericComparer);
|
||||
|
||||
if (files.Length == 0)
|
||||
{
|
||||
return (files.ElementAt(0), mangaFile);
|
||||
}
|
||||
|
||||
// Since array is 0 based, we need to keep that in account (only affects last image)
|
||||
if (page == files.Length)
|
||||
{
|
||||
return (files.ElementAt(page - 1 - pagesSoFar), mangaFile);
|
||||
}
|
||||
|
||||
if (mangaFile.Format == MangaFormat.Image && mangaFile.Pages == 1)
|
||||
{
|
||||
// Each file is one page, meaning we should just get element at page
|
||||
return (files.ElementAt(page), mangaFile);
|
||||
}
|
||||
|
||||
return (files.ElementAt(page - pagesSoFar), mangaFile);
|
||||
}
|
||||
|
||||
pagesSoFar += mangaFile.Pages;
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return (string.Empty, null);
|
||||
// Since array is 0 based, we need to keep that in account (only affects last image)
|
||||
return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
|
|
@ -6,30 +7,74 @@ using System.IO.Abstractions;
|
|||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Interfaces.Services;
|
||||
using API.Comparators;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services
|
||||
{
|
||||
public interface IDirectoryService
|
||||
{
|
||||
IFileSystem FileSystem { get; }
|
||||
string CacheDirectory { get; }
|
||||
string CoverImageDirectory { get; }
|
||||
string LogDirectory { get; }
|
||||
string TempDirectory { get; }
|
||||
string ConfigDirectory { get; }
|
||||
/// <summary>
|
||||
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Absolute path of directory to scan.</param>
|
||||
/// <returns>List of folder names</returns>
|
||||
IEnumerable<string> ListDirectory(string rootPath);
|
||||
Task<byte[]> ReadFileAsync(string path);
|
||||
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
|
||||
bool Exists(string directory);
|
||||
void CopyFileToDirectory(string fullFilePath, string targetDirectory);
|
||||
int TraverseTreeParallelForEach(string root, Action<string> action, string searchPattern, ILogger logger);
|
||||
bool IsDriveMounted(string path);
|
||||
long GetTotalSize(IEnumerable<string> paths);
|
||||
void ClearDirectory(string directoryPath);
|
||||
void ClearAndDeleteDirectory(string directoryPath);
|
||||
string[] GetFilesWithExtension(string path, string searchPatternExpression = "");
|
||||
bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "");
|
||||
|
||||
Dictionary<string, string> FindHighestDirectoriesFromFiles(IEnumerable<string> libraryFolders,
|
||||
IList<string> filePaths);
|
||||
|
||||
IEnumerable<string> GetFoldersTillRoot(string rootPath, string fullPath);
|
||||
|
||||
IEnumerable<string> GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly);
|
||||
|
||||
bool ExistOrCreate(string directoryPath);
|
||||
void DeleteFiles(IEnumerable<string> files);
|
||||
void RemoveNonImages(string directoryName);
|
||||
void Flatten(string directoryName);
|
||||
|
||||
}
|
||||
public class DirectoryService : IDirectoryService
|
||||
{
|
||||
private readonly ILogger<DirectoryService> _logger;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
public IFileSystem FileSystem { get; }
|
||||
public string CacheDirectory { get; }
|
||||
public string CoverImageDirectory { get; }
|
||||
public string LogDirectory { get; }
|
||||
public string TempDirectory { get; }
|
||||
public string ConfigDirectory { get; }
|
||||
private readonly ILogger<DirectoryService> _logger;
|
||||
|
||||
private static readonly Regex ExcludeDirectories = new Regex(
|
||||
@"@eaDir|\.DS_Store",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
public static readonly string TempDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "temp");
|
||||
public static readonly string LogDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "logs");
|
||||
public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "cache");
|
||||
public static readonly string CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "covers");
|
||||
public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups");
|
||||
public static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
|
||||
|
||||
public DirectoryService(ILogger<DirectoryService> logger, IFileSystem fileSystem)
|
||||
{
|
||||
_logger = logger;
|
||||
_fileSystem = fileSystem;
|
||||
FileSystem = fileSystem;
|
||||
CoverImageDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "covers");
|
||||
CacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache");
|
||||
LogDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "logs");
|
||||
TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp");
|
||||
ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -40,16 +85,16 @@ namespace API.Services
|
|||
/// <param name="searchPatternExpression">Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files.</param>
|
||||
/// <param name="searchOption">SearchOption to use, defaults to TopDirectoryOnly</param>
|
||||
/// <returns>List of file paths</returns>
|
||||
private static IEnumerable<string> GetFilesWithCertainExtensions(string path,
|
||||
private IEnumerable<string> GetFilesWithCertainExtensions(string path,
|
||||
string searchPatternExpression = "",
|
||||
SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
if (!Directory.Exists(path)) return ImmutableList<string>.Empty;
|
||||
if (!FileSystem.Directory.Exists(path)) return ImmutableList<string>.Empty;
|
||||
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
|
||||
|
||||
return Directory.EnumerateFiles(path, "*", searchOption)
|
||||
return FileSystem.Directory.EnumerateFiles(path, "*", searchOption)
|
||||
.Where(file =>
|
||||
reSearchPattern.IsMatch(Path.GetExtension(file)) && !Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
|
||||
reSearchPattern.IsMatch(FileSystem.Path.GetExtension(file)) && !FileSystem.Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -61,17 +106,17 @@ namespace API.Services
|
|||
/// <param name="rootPath"></param>
|
||||
/// <param name="fullPath"></param>
|
||||
/// <returns></returns>
|
||||
public static IEnumerable<string> GetFoldersTillRoot(string rootPath, string fullPath)
|
||||
public IEnumerable<string> GetFoldersTillRoot(string rootPath, string fullPath)
|
||||
{
|
||||
var separator = Path.AltDirectorySeparatorChar;
|
||||
if (fullPath.Contains(Path.DirectorySeparatorChar))
|
||||
var separator = FileSystem.Path.AltDirectorySeparatorChar;
|
||||
if (fullPath.Contains(FileSystem.Path.DirectorySeparatorChar))
|
||||
{
|
||||
fullPath = fullPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
fullPath = fullPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
|
||||
if (rootPath.Contains(Path.DirectorySeparatorChar))
|
||||
{
|
||||
rootPath = rootPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
rootPath = rootPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -80,14 +125,15 @@ namespace API.Services
|
|||
var root = rootPath.EndsWith(separator) ? rootPath.Substring(0, rootPath.Length - 1) : rootPath;
|
||||
var paths = new List<string>();
|
||||
// If a file is at the end of the path, remove it before we start processing folders
|
||||
if (Path.GetExtension(path) != string.Empty)
|
||||
if (FileSystem.Path.GetExtension(path) != string.Empty)
|
||||
{
|
||||
path = path.Substring(0, path.LastIndexOf(separator));
|
||||
}
|
||||
|
||||
while (Path.GetDirectoryName(path) != Path.GetDirectoryName(root))
|
||||
while (FileSystem.Path.GetDirectoryName(path) != Path.GetDirectoryName(root))
|
||||
{
|
||||
var folder = new DirectoryInfo(path).Name;
|
||||
//var folder = new DirectoryInfo(path).Name;
|
||||
var folder = FileSystem.DirectoryInfo.FromDirectoryName(path).Name;
|
||||
paths.Add(folder);
|
||||
path = path.Substring(0, path.LastIndexOf(separator));
|
||||
}
|
||||
|
|
@ -102,33 +148,54 @@ namespace API.Services
|
|||
/// <returns></returns>
|
||||
public bool Exists(string directory)
|
||||
{
|
||||
var di = new DirectoryInfo(directory);
|
||||
return di.Exists;
|
||||
var di = FileSystem.DirectoryInfo.FromDirectoryName(directory);
|
||||
return di.Exists;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",
|
||||
SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
/// <summary>
|
||||
/// Get files given a path.
|
||||
/// </summary>
|
||||
/// <remarks>This will automatically filter out restricted files, like MacOsMetadata files</remarks>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="fileNameRegex">An optional regex string to search against. Will use file path to match against.</param>
|
||||
/// <param name="searchOption">Defaults to top level directory only, can be given all to provide recursive searching</param>
|
||||
/// <returns></returns>
|
||||
public IEnumerable<string> GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
if (searchPatternExpression != string.Empty)
|
||||
// TODO: Refactor this and GetFilesWithCertainExtensions to use same implementation
|
||||
if (!FileSystem.Directory.Exists(path)) return ImmutableList<string>.Empty;
|
||||
|
||||
if (fileNameRegex != string.Empty)
|
||||
{
|
||||
if (!Directory.Exists(path)) return ImmutableList<string>.Empty;
|
||||
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
|
||||
return Directory.EnumerateFiles(path, "*", searchOption)
|
||||
var reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase);
|
||||
return FileSystem.Directory.EnumerateFiles(path, "*", searchOption)
|
||||
.Where(file =>
|
||||
reSearchPattern.IsMatch(file) && !file.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
|
||||
{
|
||||
var fileName = FileSystem.Path.GetFileName(file);
|
||||
return reSearchPattern.IsMatch(fileName) &&
|
||||
!fileName.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith);
|
||||
});
|
||||
}
|
||||
|
||||
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
|
||||
return FileSystem.Directory.EnumerateFiles(path, "*", searchOption).Where(file =>
|
||||
!FileSystem.Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a file into a directory. Does not maintain parent folder of file.
|
||||
/// Will create target directory if doesn't exist. Automatically overwrites what is there.
|
||||
/// </summary>
|
||||
/// <param name="fullFilePath"></param>
|
||||
/// <param name="targetDirectory"></param>
|
||||
public void CopyFileToDirectory(string fullFilePath, string targetDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(fullFilePath);
|
||||
var fileInfo = FileSystem.FileInfo.FromFileName(fullFilePath);
|
||||
if (fileInfo.Exists)
|
||||
{
|
||||
fileInfo.CopyTo(Path.Join(targetDirectory, fileInfo.Name), true);
|
||||
ExistOrCreate(targetDirectory);
|
||||
fileInfo.CopyTo(FileSystem.Path.Join(targetDirectory, fileInfo.Name), true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -138,19 +205,19 @@ namespace API.Services
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a Directory with all files and subdirectories to a target location
|
||||
/// Copies all files and subdirectories within a directory to a target location
|
||||
/// </summary>
|
||||
/// <param name="sourceDirName"></param>
|
||||
/// <param name="destDirName"></param>
|
||||
/// <param name="searchPattern">Defaults to *, meaning all files</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="DirectoryNotFoundException"></exception>
|
||||
public static bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "")
|
||||
/// <param name="sourceDirName">Directory to copy from. Does not copy the parent folder</param>
|
||||
/// <param name="destDirName">Destination to copy to. Will be created if doesn't exist</param>
|
||||
/// <param name="searchPattern">Defaults to all files</param>
|
||||
/// <returns>If was successful</returns>
|
||||
/// <exception cref="DirectoryNotFoundException">Thrown when source directory does not exist</exception>
|
||||
public bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "")
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceDirName)) return false;
|
||||
|
||||
// Get the subdirectories for the specified directory.
|
||||
var dir = new DirectoryInfo(sourceDirName);
|
||||
var dir = FileSystem.DirectoryInfo.FromDirectoryName(sourceDirName);
|
||||
|
||||
if (!dir.Exists)
|
||||
{
|
||||
|
|
@ -165,17 +232,17 @@ namespace API.Services
|
|||
ExistOrCreate(destDirName);
|
||||
|
||||
// Get the files in the directory and copy them to the new location.
|
||||
var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => new FileInfo(n));
|
||||
var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => FileSystem.FileInfo.FromFileName(n));
|
||||
foreach (var file in files)
|
||||
{
|
||||
var tempPath = Path.Combine(destDirName, file.Name);
|
||||
var tempPath = FileSystem.Path.Combine(destDirName, file.Name);
|
||||
file.CopyTo(tempPath, false);
|
||||
}
|
||||
|
||||
// If copying subdirectories, copy them and their contents to new location.
|
||||
foreach (var subDir in dirs)
|
||||
{
|
||||
var tempPath = Path.Combine(destDirName, subDir.Name);
|
||||
var tempPath = FileSystem.Path.Combine(destDirName, subDir.Name);
|
||||
CopyDirectoryToDirectory(subDir.FullName, tempPath);
|
||||
}
|
||||
|
||||
|
|
@ -187,19 +254,20 @@ namespace API.Services
|
|||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
public static bool IsDriveMounted(string path)
|
||||
public bool IsDriveMounted(string path)
|
||||
{
|
||||
return new DirectoryInfo(Path.GetPathRoot(path) ?? string.Empty).Exists;
|
||||
return FileSystem.DirectoryInfo.FromDirectoryName(FileSystem.Path.GetPathRoot(path) ?? string.Empty).Exists;
|
||||
}
|
||||
|
||||
public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
|
||||
public string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
|
||||
{
|
||||
// TODO: Use GitFiles instead
|
||||
if (searchPatternExpression != string.Empty)
|
||||
{
|
||||
return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray();
|
||||
}
|
||||
|
||||
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
|
||||
return !FileSystem.Directory.Exists(path) ? Array.Empty<string>() : FileSystem.Directory.GetFiles(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -207,9 +275,9 @@ namespace API.Services
|
|||
/// </summary>
|
||||
/// <param name="paths"></param>
|
||||
/// <returns>Total bytes</returns>
|
||||
public static long GetTotalSize(IEnumerable<string> paths)
|
||||
public long GetTotalSize(IEnumerable<string> paths)
|
||||
{
|
||||
return paths.Sum(path => new FileInfo(path).Length);
|
||||
return paths.Sum(path => FileSystem.FileInfo.FromFileName(path).Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -217,13 +285,13 @@ namespace API.Services
|
|||
/// </summary>
|
||||
/// <param name="directoryPath"></param>
|
||||
/// <returns></returns>
|
||||
public static bool ExistOrCreate(string directoryPath)
|
||||
public bool ExistOrCreate(string directoryPath)
|
||||
{
|
||||
var di = new DirectoryInfo(directoryPath);
|
||||
var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath);
|
||||
if (di.Exists) return true;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
FileSystem.Directory.CreateDirectory(directoryPath);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
@ -236,11 +304,11 @@ namespace API.Services
|
|||
/// Deletes all files within the directory, then the directory itself.
|
||||
/// </summary>
|
||||
/// <param name="directoryPath"></param>
|
||||
public static void ClearAndDeleteDirectory(string directoryPath)
|
||||
public void ClearAndDeleteDirectory(string directoryPath)
|
||||
{
|
||||
if (!Directory.Exists(directoryPath)) return;
|
||||
if (!FileSystem.Directory.Exists(directoryPath)) return;
|
||||
|
||||
DirectoryInfo di = new DirectoryInfo(directoryPath);
|
||||
var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath);
|
||||
|
||||
ClearDirectory(directoryPath);
|
||||
|
||||
|
|
@ -248,13 +316,13 @@ namespace API.Services
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all files within the directory.
|
||||
/// Deletes all files and folders within the directory path
|
||||
/// </summary>
|
||||
/// <param name="directoryPath"></param>
|
||||
/// <returns></returns>
|
||||
public static void ClearDirectory(string directoryPath)
|
||||
public void ClearDirectory(string directoryPath)
|
||||
{
|
||||
var di = new DirectoryInfo(directoryPath);
|
||||
var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath);
|
||||
if (!di.Exists) return;
|
||||
|
||||
foreach (var file in di.EnumerateFiles())
|
||||
|
|
@ -274,7 +342,7 @@ namespace API.Services
|
|||
/// <param name="directoryPath"></param>
|
||||
/// <param name="prepend">An optional string to prepend to the target file's name</param>
|
||||
/// <returns></returns>
|
||||
public static bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "", ILogger logger = null)
|
||||
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
|
||||
{
|
||||
ExistOrCreate(directoryPath);
|
||||
string currentFile = null;
|
||||
|
|
@ -283,36 +351,36 @@ namespace API.Services
|
|||
foreach (var file in filePaths)
|
||||
{
|
||||
currentFile = file;
|
||||
var fileInfo = new FileInfo(file);
|
||||
var fileInfo = FileSystem.FileInfo.FromFileName(file);
|
||||
if (fileInfo.Exists)
|
||||
{
|
||||
fileInfo.CopyTo(Path.Join(directoryPath, prepend + fileInfo.Name));
|
||||
fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, prepend + fileInfo.Name));
|
||||
}
|
||||
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)
|
||||
{
|
||||
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 true;
|
||||
}
|
||||
|
||||
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
|
||||
{
|
||||
return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all directories in a root path. Will exclude Hidden or System directories.
|
||||
/// </summary>
|
||||
/// <param name="rootPath"></param>
|
||||
/// <returns></returns>
|
||||
public IEnumerable<string> ListDirectory(string rootPath)
|
||||
{
|
||||
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
||||
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
||||
|
||||
var di = new DirectoryInfo(rootPath);
|
||||
var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath);
|
||||
var dirs = di.GetDirectories()
|
||||
.Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System)))
|
||||
.Select(d => d.Name).ToImmutableList();
|
||||
|
|
@ -320,20 +388,26 @@ namespace API.Services
|
|||
return dirs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file's into byte[]. Returns empty array if file doesn't exist.
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<byte[]> ReadFileAsync(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return Array.Empty<byte>();
|
||||
return await File.ReadAllBytesAsync(path);
|
||||
if (!FileSystem.File.Exists(path)) return Array.Empty<byte>();
|
||||
return await FileSystem.File.ReadAllBytesAsync(path);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Finds the highest directories from a set of MangaFiles
|
||||
/// Finds the highest directories from a set of file paths. Does not return the root path, will always select the highest non-root path.
|
||||
/// </summary>
|
||||
/// <remarks>If the file paths do not contain anything from libraryFolders, this returns an empty dictionary back</remarks>
|
||||
/// <param name="libraryFolders">List of top level folders which files belong to</param>
|
||||
/// <param name="filePaths">List of file paths that belong to libraryFolders</param>
|
||||
/// <returns></returns>
|
||||
public static Dictionary<string, string> FindHighestDirectoriesFromFiles(IEnumerable<string> libraryFolders, IList<string> filePaths)
|
||||
public Dictionary<string, string> FindHighestDirectoriesFromFiles(IEnumerable<string> libraryFolders, IList<string> filePaths)
|
||||
{
|
||||
var stopLookingForDirectories = false;
|
||||
var dirs = new Dictionary<string, string>();
|
||||
|
|
@ -385,9 +459,10 @@ namespace API.Services
|
|||
// Data structure to hold names of subfolders to be examined for files.
|
||||
var dirs = new Stack<string>();
|
||||
|
||||
if (!Directory.Exists(root)) {
|
||||
throw new ArgumentException("The directory doesn't exist");
|
||||
if (!FileSystem.Directory.Exists(root)) {
|
||||
throw new ArgumentException("The directory doesn't exist");
|
||||
}
|
||||
|
||||
dirs.Push(root);
|
||||
|
||||
while (dirs.Count > 0) {
|
||||
|
|
@ -396,7 +471,7 @@ namespace API.Services
|
|||
string[] files;
|
||||
|
||||
try {
|
||||
subDirs = Directory.GetDirectories(currentDir).Where(path => ExcludeDirectories.Matches(path).Count == 0);
|
||||
subDirs = FileSystem.Directory.GetDirectories(currentDir).Where(path => ExcludeDirectories.Matches(path).Count == 0);
|
||||
}
|
||||
// Thrown if we do not have discovery permission on the directory.
|
||||
catch (UnauthorizedAccessException e) {
|
||||
|
|
@ -412,6 +487,7 @@ namespace API.Services
|
|||
}
|
||||
|
||||
try {
|
||||
// TODO: Replace this with GetFiles - It's the same code
|
||||
files = GetFilesWithCertainExtensions(currentDir, searchPattern)
|
||||
.ToArray();
|
||||
}
|
||||
|
|
@ -457,6 +533,7 @@ namespace API.Services
|
|||
if (ex is UnauthorizedAccessException) {
|
||||
// Here we just output a message and go on.
|
||||
Console.WriteLine(ex.Message);
|
||||
_logger.LogError(ex, "Unauthorized access on file");
|
||||
return true;
|
||||
}
|
||||
// Handle other exceptions here if necessary...
|
||||
|
|
@ -478,13 +555,13 @@ namespace API.Services
|
|||
/// Attempts to delete the files passed to it. Swallows exceptions.
|
||||
/// </summary>
|
||||
/// <param name="files">Full path of files to delete</param>
|
||||
public static void DeleteFiles(IEnumerable<string> files)
|
||||
public void DeleteFiles(IEnumerable<string> files)
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
new FileInfo(file).Delete();
|
||||
FileSystem.FileInfo.FromFileName(file).Delete();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
@ -547,5 +624,78 @@ namespace API.Services
|
|||
// Return formatted number with suffix
|
||||
return readable.ToString("0.## ") + suffix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all files except images from the directory. Includes sub directories.
|
||||
/// </summary>
|
||||
/// <param name="directoryName">Fully qualified directory</param>
|
||||
public void RemoveNonImages(string directoryName)
|
||||
{
|
||||
DeleteFiles(GetFiles(directoryName, searchOption:SearchOption.AllDirectories).Where(file => !Parser.Parser.IsImage(file)));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Flattens all files in subfolders to the passed directory recursively.
|
||||
///
|
||||
///
|
||||
/// foo<para />
|
||||
/// ├── 1.txt<para />
|
||||
/// ├── 2.txt<para />
|
||||
/// ├── 3.txt<para />
|
||||
/// ├── 4.txt<para />
|
||||
/// └── bar<para />
|
||||
/// ├── 1.txt<para />
|
||||
/// ├── 2.txt<para />
|
||||
/// └── 5.txt<para />
|
||||
///
|
||||
/// becomes:<para />
|
||||
/// foo<para />
|
||||
/// ├── 1.txt<para />
|
||||
/// ├── 2.txt<para />
|
||||
/// ├── 3.txt<para />
|
||||
/// ├── 4.txt<para />
|
||||
/// ├── bar_1.txt<para />
|
||||
/// ├── bar_2.txt<para />
|
||||
/// └── bar_5.txt<para />
|
||||
/// </summary>
|
||||
/// <param name="directoryName">Fully qualified Directory name</param>
|
||||
public void Flatten(string directoryName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directoryName) || !FileSystem.Directory.Exists(directoryName)) return;
|
||||
|
||||
var directory = FileSystem.DirectoryInfo.FromDirectoryName(directoryName);
|
||||
|
||||
var index = 0;
|
||||
FlattenDirectory(directory, directory, ref index);
|
||||
}
|
||||
|
||||
|
||||
private void FlattenDirectory(IDirectoryInfo root, IDirectoryInfo directory, ref int directoryIndex)
|
||||
{
|
||||
if (!root.FullName.Equals(directory.FullName))
|
||||
{
|
||||
var fileIndex = 1;
|
||||
|
||||
foreach (var file in directory.EnumerateFiles().OrderBy(file => file.FullName, new NaturalSortComparer()))
|
||||
{
|
||||
if (file.Directory == null) continue;
|
||||
var paddedIndex = Parser.Parser.PadZeros(directoryIndex + "");
|
||||
// We need to rename the files so that after flattening, they are in the order we found them
|
||||
var newName = $"{paddedIndex}_{Parser.Parser.PadZeros(fileIndex + "")}{file.Extension}";
|
||||
var newPath = Path.Join(root.FullName, newName);
|
||||
if (!File.Exists(newPath)) file.MoveTo(newPath);
|
||||
fileIndex++;
|
||||
}
|
||||
|
||||
directoryIndex++;
|
||||
}
|
||||
|
||||
var sort = new NaturalSortComparer();
|
||||
foreach (var subDirectory in directory.EnumerateDirectories().OrderBy(d => d.FullName, sort))
|
||||
{
|
||||
FlattenDirectory(root, subDirectory, ref directoryIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,56 +3,54 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
namespace API.Services
|
||||
namespace API.Services;
|
||||
|
||||
public interface IDownloadService
|
||||
{
|
||||
public interface IDownloadService
|
||||
Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files);
|
||||
string GetContentTypeFromFile(string filepath);
|
||||
}
|
||||
public class DownloadService : IDownloadService
|
||||
{
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider();
|
||||
|
||||
public DownloadService(IDirectoryService directoryService)
|
||||
{
|
||||
Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files);
|
||||
string GetContentTypeFromFile(string filepath);
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
public class DownloadService : IDownloadService
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the first file in the file enumerable for download
|
||||
/// </summary>
|
||||
/// <param name="files"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||
{
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider();
|
||||
var firstFile = files.Select(c => c.FilePath).First();
|
||||
return (await _directoryService.ReadFileAsync(firstFile), GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile));
|
||||
}
|
||||
|
||||
public DownloadService(IDirectoryService directoryService)
|
||||
public string GetContentTypeFromFile(string filepath)
|
||||
{
|
||||
// Figures out what the content type should be based on the file name.
|
||||
if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType))
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the first file in the file enumerable for download
|
||||
/// </summary>
|
||||
/// <param name="files"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||
{
|
||||
var firstFile = files.Select(c => c.FilePath).First();
|
||||
return (await _directoryService.ReadFileAsync(firstFile), GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile));
|
||||
}
|
||||
|
||||
public string GetContentTypeFromFile(string filepath)
|
||||
{
|
||||
// Figures out what the content type should be based on the file name.
|
||||
if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType))
|
||||
contentType = Path.GetExtension(filepath).ToLowerInvariant() switch
|
||||
{
|
||||
contentType = Path.GetExtension(filepath).ToLowerInvariant() switch
|
||||
{
|
||||
".cbz" => "application/zip",
|
||||
".cbr" => "application/vnd.rar",
|
||||
".cb7" => "application/x-compressed",
|
||||
".epub" => "application/epub+zip",
|
||||
".7z" => "application/x-7z-compressed",
|
||||
".7zip" => "application/x-7z-compressed",
|
||||
".pdf" => "application/pdf",
|
||||
_ => contentType
|
||||
};
|
||||
}
|
||||
|
||||
return contentType;
|
||||
".cbz" => "application/zip",
|
||||
".cbr" => "application/vnd.rar",
|
||||
".cb7" => "application/x-compressed",
|
||||
".epub" => "application/epub+zip",
|
||||
".7z" => "application/x-7z-compressed",
|
||||
".7zip" => "application/x-7z-compressed",
|
||||
".pdf" => "application/pdf",
|
||||
_ => contentType
|
||||
};
|
||||
}
|
||||
|
||||
return contentType;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,40 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using API.Comparators;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
|
||||
namespace API.Services
|
||||
{
|
||||
namespace API.Services;
|
||||
|
||||
public class ImageService : IImageService
|
||||
{
|
||||
public interface IImageService
|
||||
{
|
||||
void ExtractImages(string fileFilePath, string targetDirectory, int fileCount);
|
||||
string GetCoverImage(string path, string fileName);
|
||||
string GetCoverFile(MangaFile file);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Thumbnail version of an image
|
||||
/// </summary>
|
||||
/// <param name="path">Path to the image file</param>
|
||||
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
||||
//string CreateThumbnail(string path, string fileName);
|
||||
/// <summary>
|
||||
/// Creates a Thumbnail version of a base64 image
|
||||
/// </summary>
|
||||
/// <param name="encodedImage">base64 encoded image</param>
|
||||
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
||||
string CreateThumbnailFromBase64(string encodedImage, string fileName);
|
||||
|
||||
string WriteCoverThumbnail(Stream stream, string fileName);
|
||||
}
|
||||
|
||||
public class ImageService : IImageService
|
||||
{
|
||||
private readonly ILogger<ImageService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
public const string ChapterCoverImageRegex = @"v\d+_c\d+";
|
||||
public const string SeriesCoverImageRegex = @"seres\d+";
|
||||
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
||||
public const string SeriesCoverImageRegex = @"series_\d+";
|
||||
public const string CollectionTagCoverImageRegex = @"tag_\d+";
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -23,9 +44,24 @@ namespace API.Services
|
|||
/// </summary>
|
||||
private const int ThumbnailWidth = 320;
|
||||
|
||||
public ImageService(ILogger<ImageService> logger)
|
||||
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService)
|
||||
{
|
||||
_logger = logger;
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1)
|
||||
{
|
||||
_directoryService.ExistOrCreate(targetDirectory);
|
||||
if (fileCount == 1)
|
||||
{
|
||||
_directoryService.CopyFileToDirectory(fileFilePath, targetDirectory);
|
||||
}
|
||||
else
|
||||
{
|
||||
_directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(fileFilePath), targetDirectory,
|
||||
Parser.Parser.ImageFileExtensions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -35,53 +71,57 @@ namespace API.Services
|
|||
/// <returns></returns>
|
||||
public string GetCoverFile(MangaFile file)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(file.FilePath);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
_logger.LogError("Could not find Directory for {File}", file.FilePath);
|
||||
return null;
|
||||
}
|
||||
var directory = Path.GetDirectoryName(file.FilePath);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
_logger.LogError("Could not find Directory for {File}", file.FilePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstImage = DirectoryService.GetFilesWithExtension(directory, Parser.Parser.ImageFileExtensions)
|
||||
.OrderBy(f => f, new NaturalSortComparer()).FirstOrDefault();
|
||||
var firstImage = _directoryService.GetFilesWithExtension(directory, Parser.Parser.ImageFileExtensions)
|
||||
.OrderBy(f => f, new NaturalSortComparer()).FirstOrDefault();
|
||||
|
||||
return firstImage;
|
||||
return firstImage;
|
||||
}
|
||||
|
||||
public string GetCoverImage(string path, string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return string.Empty;
|
||||
if (string.IsNullOrEmpty(path)) return string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
return CreateThumbnail(path, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {ImageFile}. Defaulting to no cover image", path);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateThumbnail(string path, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
//return CreateThumbnail(path, fileName);
|
||||
using var thumbnail = Image.Thumbnail(path, ThumbnailWidth);
|
||||
var filename = fileName + ".png";
|
||||
thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, filename));
|
||||
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, filename));
|
||||
return filename;
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(e, "Error creating thumbnail from url");
|
||||
_logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {ImageFile}. Defaulting to no cover image", path);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// public string CreateThumbnail(string path, string fileName)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// using var thumbnail = Image.Thumbnail(path, ThumbnailWidth);
|
||||
// var filename = fileName + ".png";
|
||||
// thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, filename));
|
||||
// return filename;
|
||||
// }
|
||||
// catch (Exception e)
|
||||
// {
|
||||
// _logger.LogError(e, "Error creating thumbnail from url");
|
||||
// }
|
||||
//
|
||||
// return string.Empty;
|
||||
// }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a thumbnail out of a memory stream and saves to <see cref="DirectoryService.CoverImageDirectory"/> with the passed
|
||||
/// fileName and .png extension.
|
||||
|
|
@ -89,11 +129,11 @@ namespace API.Services
|
|||
/// <param name="stream">Stream to write to disk. Ensure this is rewinded.</param>
|
||||
/// <param name="fileName">filename to save as without extension</param>
|
||||
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
||||
public static string WriteCoverThumbnail(Stream stream, string fileName)
|
||||
public string WriteCoverThumbnail(Stream stream, string fileName)
|
||||
{
|
||||
using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth);
|
||||
var filename = fileName + ".png";
|
||||
thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, fileName + ".png"));
|
||||
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName + ".png"));
|
||||
return filename;
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +145,7 @@ namespace API.Services
|
|||
{
|
||||
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), ThumbnailWidth);
|
||||
var filename = fileName + ".png";
|
||||
thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, fileName + ".png"));
|
||||
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName + ".png"));
|
||||
return filename;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
@ -146,5 +186,4 @@ namespace API.Services
|
|||
{
|
||||
return $"tag{tagId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,70 +6,53 @@ using System.Linq;
|
|||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data;
|
||||
using API.Data.Metadata;
|
||||
using API.Data.Repositories;
|
||||
using API.Data.Scanner;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IMetadataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Recalculates metadata for all entities in a library.
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
Task RefreshMetadata(int libraryId, bool forceUpdate = false);
|
||||
/// <summary>
|
||||
/// Performs a forced refresh of metatdata just for a series and it's nested entities
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
}
|
||||
|
||||
public class MetadataService : IMetadataService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<MetadataService> _logger;
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly IBookService _bookService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly ICacheHelper _cacheHelper;
|
||||
private readonly IReadingItemService _readingItemService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
|
||||
IArchiveService archiveService, IBookService bookService, IImageService imageService,
|
||||
IHubContext<MessageHub> messageHub, ICacheHelper cacheHelper)
|
||||
IHubContext<MessageHub> messageHub, ICacheHelper cacheHelper,
|
||||
IReadingItemService readingItemService, IDirectoryService directoryService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_archiveService = archiveService;
|
||||
_bookService = bookService;
|
||||
_imageService = imageService;
|
||||
_messageHub = messageHub;
|
||||
_cacheHelper = cacheHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cover image for the file
|
||||
/// </summary>
|
||||
/// <remarks>Has side effect of marking the file as updated</remarks>
|
||||
/// <param name="file"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
private string GetCoverImage(MangaFile file, int volumeId, int chapterId)
|
||||
{
|
||||
//file.UpdateLastModified();
|
||||
switch (file.Format)
|
||||
{
|
||||
case MangaFormat.Pdf:
|
||||
case MangaFormat.Epub:
|
||||
return _bookService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId));
|
||||
case MangaFormat.Image:
|
||||
var coverImage = _imageService.GetCoverFile(file);
|
||||
return _imageService.GetCoverImage(coverImage, ImageService.GetChapterFormat(chapterId, volumeId));
|
||||
case MangaFormat.Archive:
|
||||
return _archiveService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId));
|
||||
case MangaFormat.Unknown:
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
_readingItemService = readingItemService;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -81,11 +64,13 @@ public class MetadataService : IMetadataService
|
|||
{
|
||||
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
|
||||
|
||||
if (!_cacheHelper.ShouldUpdateCoverImage(Path.Join(DirectoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
|
||||
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
|
||||
return false;
|
||||
|
||||
if (firstFile == null) return false;
|
||||
|
||||
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath);
|
||||
chapter.CoverImage = GetCoverImage(firstFile, chapter.VolumeId, chapter.Id);
|
||||
chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -101,7 +86,8 @@ public class MetadataService : IMetadataService
|
|||
|
||||
private void UpdateChapterFromComicInfo(Chapter chapter, ICollection<Person> allPeople, MangaFile firstFile)
|
||||
{
|
||||
var comicInfo = GetComicInfo(firstFile); // TODO: Think about letting the higher level loop have access for series to avoid duplicate IO operations
|
||||
// TODO: Think about letting the higher level loop have access for series to avoid duplicate IO operations
|
||||
var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format);
|
||||
if (comicInfo == null) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Title))
|
||||
|
|
@ -183,7 +169,7 @@ public class MetadataService : IMetadataService
|
|||
private bool UpdateVolumeCoverImage(Volume volume, bool forceUpdate)
|
||||
{
|
||||
// We need to check if Volume coverImage matches first chapters if forceUpdate is false
|
||||
if (volume == null || !_cacheHelper.ShouldUpdateCoverImage(Path.Join(DirectoryService.CoverImageDirectory, volume.CoverImage), null, volume.Created, forceUpdate)) return false;
|
||||
if (volume == null || !_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), null, volume.Created, forceUpdate)) return false;
|
||||
|
||||
volume.Chapters ??= new List<Chapter>();
|
||||
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault();
|
||||
|
|
@ -203,7 +189,7 @@ public class MetadataService : IMetadataService
|
|||
if (series == null) return;
|
||||
|
||||
// NOTE: This will fail if we replace the cover of the first volume on a first scan. Because the series will already have a cover image
|
||||
if (!_cacheHelper.ShouldUpdateCoverImage(Path.Join(DirectoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked))
|
||||
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked))
|
||||
return;
|
||||
|
||||
series.Volumes ??= new List<Volume>();
|
||||
|
|
@ -237,7 +223,7 @@ public class MetadataService : IMetadataService
|
|||
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(firstChapter, forceUpdate, firstFile)) return;
|
||||
if (Parser.Parser.IsPdf(firstFile.FilePath)) return;
|
||||
|
||||
var comicInfo = GetComicInfo(firstFile);
|
||||
var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format);
|
||||
if (comicInfo == null) return;
|
||||
|
||||
|
||||
|
|
@ -280,7 +266,7 @@ public class MetadataService : IMetadataService
|
|||
var comicInfos = series.Volumes
|
||||
.SelectMany(volume => volume.Chapters)
|
||||
.SelectMany(c => c.Files)
|
||||
.Select(GetComicInfo)
|
||||
.Select(file => _readingItemService.GetComicInfo(file.FilePath, file.Format))
|
||||
.Where(ci => ci != null)
|
||||
.ToList();
|
||||
|
||||
|
|
@ -297,23 +283,12 @@ public class MetadataService : IMetadataService
|
|||
|
||||
}
|
||||
|
||||
private ComicInfo GetComicInfo(MangaFile firstFile)
|
||||
{
|
||||
if (firstFile?.Format is MangaFormat.Archive or MangaFormat.Epub)
|
||||
{
|
||||
return Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetComicInfo(firstFile.FilePath) : _archiveService.GetComicInfo(firstFile.FilePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <remarks>This cannot have any Async code within. It is used within Parallel.ForEach</remarks>
|
||||
/// <param name="series"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
private void ProcessSeriesMetadataUpdate(Series series, IDictionary<int, IList<int>> chapterIds, ICollection<Person> allPeople, ICollection<Genre> allGenres, bool forceUpdate)
|
||||
private void ProcessSeriesMetadataUpdate(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, bool forceUpdate)
|
||||
{
|
||||
_logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName);
|
||||
try
|
||||
|
|
@ -376,7 +351,6 @@ public class MetadataService : IMetadataService
|
|||
});
|
||||
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
|
||||
|
||||
var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(nonLibrarySeries.Select(s => s.Id).ToArray());
|
||||
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||
var allGenres = await _unitOfWork.GenreRepository.GetAllGenres();
|
||||
|
||||
|
|
@ -386,7 +360,7 @@ public class MetadataService : IMetadataService
|
|||
{
|
||||
try
|
||||
{
|
||||
ProcessSeriesMetadataUpdate(series, chapterIds, allPeople, allGenres, forceUpdate);
|
||||
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, forceUpdate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -497,11 +471,10 @@ public class MetadataService : IMetadataService
|
|||
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
|
||||
MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F));
|
||||
|
||||
var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(new [] { seriesId });
|
||||
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||
var allGenres = await _unitOfWork.GenreRepository.GetAllGenres();
|
||||
|
||||
ProcessSeriesMetadataUpdate(series, chapterIds, allPeople, allGenres, forceUpdate);
|
||||
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, forceUpdate);
|
||||
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
|
||||
MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F));
|
||||
|
|
|
|||
319
API/Services/ReaderService.cs
Normal file
319
API/Services/ReaderService.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IReaderService
|
||||
{
|
||||
void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
|
||||
Task<int> CapPageToChapter(int chapterId, int page);
|
||||
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
||||
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
||||
}
|
||||
|
||||
public class ReaderService : IReaderService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReaderService> _logger;
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
|
||||
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="chapters"></param>
|
||||
public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters)
|
||||
{
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = chapter.Pages,
|
||||
VolumeId = chapter.VolumeId,
|
||||
SeriesId = seriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = chapter.Pages;
|
||||
userProgress.SeriesId = seriesId;
|
||||
userProgress.VolumeId = chapter.VolumeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="chapters"></param>
|
||||
public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters)
|
||||
{
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = 0,
|
||||
VolumeId = chapter.VolumeId,
|
||||
SeriesId = seriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = 0;
|
||||
userProgress.SeriesId = seriesId;
|
||||
userProgress.VolumeId = chapter.VolumeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="chapter"></param>
|
||||
/// <returns></returns>
|
||||
public static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
|
||||
{
|
||||
AppUserProgress userProgress = null;
|
||||
try
|
||||
{
|
||||
userProgress =
|
||||
user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages
|
||||
var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
|
||||
if (progresses.Count > 1)
|
||||
{
|
||||
user.Progresses = new List<AppUserProgress>()
|
||||
{
|
||||
user.Progresses.First()
|
||||
};
|
||||
userProgress = user.Progresses.First();
|
||||
}
|
||||
}
|
||||
|
||||
return userProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves progress to DB
|
||||
/// </summary>
|
||||
/// <param name="progressDto"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId)
|
||||
{
|
||||
// Don't let user save past total pages.
|
||||
progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum);
|
||||
|
||||
try
|
||||
{
|
||||
var userProgress =
|
||||
await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
// Create a user object
|
||||
var userWithProgress =
|
||||
await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
|
||||
userWithProgress.Progresses ??= new List<AppUserProgress>();
|
||||
userWithProgress.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = progressDto.PageNum,
|
||||
VolumeId = progressDto.VolumeId,
|
||||
SeriesId = progressDto.SeriesId,
|
||||
ChapterId = progressDto.ChapterId,
|
||||
BookScrollId = progressDto.BookScrollId,
|
||||
LastModified = DateTime.Now
|
||||
});
|
||||
_unitOfWork.UserRepository.Update(userWithProgress);
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = progressDto.PageNum;
|
||||
userProgress.SeriesId = progressDto.SeriesId;
|
||||
userProgress.VolumeId = progressDto.VolumeId;
|
||||
userProgress.BookScrollId = progressDto.BookScrollId;
|
||||
userProgress.LastModified = DateTime.Now;
|
||||
_unitOfWork.AppUserProgressRepository.Update(userProgress);
|
||||
}
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Could not save progress");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <param name="page"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> CapPageToChapter(int chapterId, int page)
|
||||
{
|
||||
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
|
||||
if (page > totalPages)
|
||||
{
|
||||
page = totalPages;
|
||||
}
|
||||
|
||||
if (page < 0)
|
||||
{
|
||||
page = 0;
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find the next logical Chapter
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02
|
||||
/// </example>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="currentChapterId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns>-1 if nothing can be found</returns>
|
||||
public async Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
||||
{
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
|
||||
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
||||
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
||||
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
// Handle specials by sorting on their Filename aka Range
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1)
|
||||
{
|
||||
// Handle Chapters within current Volume
|
||||
// In this case, i need 0 first because 0 represents a full volume file.
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
if (volume.Number != currentVolume.Number + 1) continue;
|
||||
|
||||
// Handle Chapters within next Volume
|
||||
// ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
|
||||
var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
|
||||
if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0"))
|
||||
{
|
||||
return chapters.Last().Id;
|
||||
}
|
||||
|
||||
var firstChapter = chapters.FirstOrDefault();
|
||||
if (firstChapter == null) return -1;
|
||||
return firstChapter.Id;
|
||||
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
/// <summary>
|
||||
/// Tries to find the prev logical Chapter
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02
|
||||
/// </example>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="currentChapterId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns>-1 if nothing can be found</returns>
|
||||
public async Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
||||
{
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList();
|
||||
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
||||
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
||||
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
if (volume.Number == currentVolume.Number)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
if (volume.Number == currentVolume.Number - 1)
|
||||
{
|
||||
var lastChapter = volume.Chapters
|
||||
.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
|
||||
if (lastChapter == null) return -1;
|
||||
return lastChapter.Id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, string currentChapterNumber)
|
||||
{
|
||||
var next = false;
|
||||
var chaptersList = chapters.ToList();
|
||||
foreach (var chapter in chaptersList)
|
||||
{
|
||||
if (next)
|
||||
{
|
||||
return chapter.Id;
|
||||
}
|
||||
if (currentChapterNumber.Equals(chapter.Number)) next = true;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
126
API/Services/ReadingItemService.cs
Normal file
126
API/Services/ReadingItemService.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
using System;
|
||||
using API.Data.Metadata;
|
||||
using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IReadingItemService
|
||||
{
|
||||
ComicInfo GetComicInfo(string filePath, MangaFormat format);
|
||||
int GetNumberOfPages(string filePath, MangaFormat format);
|
||||
string GetCoverImage(string fileFilePath, string fileName, MangaFormat format);
|
||||
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
|
||||
ParserInfo Parse(string path, string rootPath, LibraryType type);
|
||||
}
|
||||
|
||||
public class ReadingItemService : IReadingItemService
|
||||
{
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly IBookService _bookService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly DefaultParser _defaultParser;
|
||||
|
||||
public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService, IDirectoryService directoryService)
|
||||
{
|
||||
_archiveService = archiveService;
|
||||
_bookService = bookService;
|
||||
_imageService = imageService;
|
||||
|
||||
_defaultParser = new DefaultParser(directoryService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ComicInfo for the file if it exists. Null otherewise.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Fully qualified path of file</param>
|
||||
/// <param name="format">Format of the file determines how we open it (epub vs comicinfo.xml)</param>
|
||||
/// <returns></returns>
|
||||
public ComicInfo? GetComicInfo(string filePath, MangaFormat format)
|
||||
{
|
||||
if (format is MangaFormat.Archive or MangaFormat.Epub)
|
||||
{
|
||||
return Parser.Parser.IsEpub(filePath) ? _bookService.GetComicInfo(filePath) : _archiveService.GetComicInfo(filePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="format"></param>
|
||||
/// <returns></returns>
|
||||
public int GetNumberOfPages(string filePath, MangaFormat format)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case MangaFormat.Archive:
|
||||
{
|
||||
return _archiveService.GetNumberOfPagesFromArchive(filePath);
|
||||
}
|
||||
case MangaFormat.Pdf:
|
||||
case MangaFormat.Epub:
|
||||
{
|
||||
return _bookService.GetNumberOfPages(filePath);
|
||||
}
|
||||
case MangaFormat.Image:
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
case MangaFormat.Unknown:
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public string GetCoverImage(string filePath, string fileName, MangaFormat format)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
return format switch
|
||||
{
|
||||
MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName),
|
||||
MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName),
|
||||
MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the reading item to the target directory using the appropriate method
|
||||
/// </summary>
|
||||
/// <param name="fileFilePath">File to extract</param>
|
||||
/// <param name="targetDirectory">Where to extract to. Will be created if does not exist</param>
|
||||
/// <param name="format">Format of the File</param>
|
||||
/// <param name="imageCount">If the file is of type image, pass number of files needed. If > 0, will copy the whole directory.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
||||
public void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case MangaFormat.Pdf:
|
||||
_bookService.ExtractPdfImages(fileFilePath, targetDirectory);
|
||||
break;
|
||||
case MangaFormat.Archive:
|
||||
_archiveService.ExtractArchive(fileFilePath, targetDirectory);
|
||||
break;
|
||||
case MangaFormat.Image:
|
||||
_imageService.ExtractImages(fileFilePath, targetDirectory, imageCount);
|
||||
break;
|
||||
case MangaFormat.Unknown:
|
||||
case MangaFormat.Epub:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(format), format, null);
|
||||
}
|
||||
}
|
||||
|
||||
public ParserInfo Parse(string path, string rootPath, LibraryType type)
|
||||
{
|
||||
return Parser.Parser.IsEpub(path) ? _bookService.ParseInfo(path) : _defaultParser.Parse(path, rootPath, type);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,177 +1,187 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers.Converters;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services.Tasks;
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services
|
||||
namespace API.Services;
|
||||
|
||||
public interface ITaskScheduler
|
||||
{
|
||||
public class TaskScheduler : ITaskScheduler
|
||||
void ScheduleTasks();
|
||||
Task ScheduleStatsTasks();
|
||||
void ScheduleUpdaterTasks();
|
||||
void ScanLibrary(int libraryId, bool forceUpdate = false);
|
||||
void CleanupChapters(int[] chapterIds);
|
||||
void RefreshMetadata(int libraryId, bool forceUpdate = true);
|
||||
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void CancelStatsTasks();
|
||||
Task RunStatCollection();
|
||||
}
|
||||
public class TaskScheduler : ITaskScheduler
|
||||
{
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly ILogger<TaskScheduler> _logger;
|
||||
private readonly IScannerService _scannerService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMetadataService _metadataService;
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly ICleanupService _cleanupService;
|
||||
|
||||
private readonly IStatsService _statsService;
|
||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
public static BackgroundJobServer Client => new BackgroundJobServer();
|
||||
private static readonly Random Rnd = new Random();
|
||||
|
||||
|
||||
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
||||
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
|
||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
|
||||
IDirectoryService directoryService)
|
||||
{
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly ILogger<TaskScheduler> _logger;
|
||||
private readonly IScannerService _scannerService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMetadataService _metadataService;
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly ICleanupService _cleanupService;
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
_scannerService = scannerService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_metadataService = metadataService;
|
||||
_backupService = backupService;
|
||||
_cleanupService = cleanupService;
|
||||
_statsService = statsService;
|
||||
_versionUpdaterService = versionUpdaterService;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
private readonly IStatsService _statsService;
|
||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||
public void ScheduleTasks()
|
||||
{
|
||||
_logger.LogInformation("Scheduling reoccurring tasks");
|
||||
|
||||
public static BackgroundJobServer Client => new BackgroundJobServer();
|
||||
private static readonly Random Rnd = new Random();
|
||||
|
||||
|
||||
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
||||
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
|
||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService)
|
||||
var setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).GetAwaiter().GetResult().Value;
|
||||
if (setting != null)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
_scannerService = scannerService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_metadataService = metadataService;
|
||||
_backupService = backupService;
|
||||
_cleanupService = cleanupService;
|
||||
_statsService = statsService;
|
||||
_versionUpdaterService = versionUpdaterService;
|
||||
var scanLibrarySetting = setting;
|
||||
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
|
||||
RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(),
|
||||
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local);
|
||||
}
|
||||
else
|
||||
{
|
||||
RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local);
|
||||
}
|
||||
|
||||
public void ScheduleTasks()
|
||||
setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Result.Value;
|
||||
if (setting != null)
|
||||
{
|
||||
_logger.LogInformation("Scheduling reoccurring tasks");
|
||||
|
||||
var setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).GetAwaiter().GetResult().Value;
|
||||
if (setting != null)
|
||||
{
|
||||
var scanLibrarySetting = setting;
|
||||
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
|
||||
RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(),
|
||||
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local);
|
||||
}
|
||||
else
|
||||
{
|
||||
RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local);
|
||||
}
|
||||
|
||||
setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Result.Value;
|
||||
if (setting != null)
|
||||
{
|
||||
_logger.LogDebug("Scheduling Backup Task for {Setting}", setting);
|
||||
RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local);
|
||||
}
|
||||
else
|
||||
{
|
||||
RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local);
|
||||
}
|
||||
|
||||
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
|
||||
|
||||
_logger.LogDebug("Scheduling Backup Task for {Setting}", setting);
|
||||
RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local);
|
||||
}
|
||||
else
|
||||
{
|
||||
RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local);
|
||||
}
|
||||
|
||||
#region StatsTasks
|
||||
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
|
||||
|
||||
}
|
||||
|
||||
#region StatsTasks
|
||||
|
||||
|
||||
public async Task ScheduleStatsTasks()
|
||||
public async Task ScheduleStatsTasks()
|
||||
{
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
if (!allowStatCollection)
|
||||
{
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
if (!allowStatCollection)
|
||||
{
|
||||
_logger.LogDebug("User has opted out of stat collection, not registering tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Scheduling stat collection daily");
|
||||
RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local);
|
||||
_logger.LogDebug("User has opted out of stat collection, not registering tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
public void CancelStatsTasks()
|
||||
_logger.LogDebug("Scheduling stat collection daily");
|
||||
RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local);
|
||||
}
|
||||
|
||||
public void CancelStatsTasks()
|
||||
{
|
||||
_logger.LogDebug("Cancelling/Removing StatsTasks");
|
||||
|
||||
RecurringJob.RemoveIfExists("report-stats");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First time run stat collection. Executes immediately on a background thread. Does not block.
|
||||
/// </summary>
|
||||
public async Task RunStatCollection()
|
||||
{
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
if (!allowStatCollection)
|
||||
{
|
||||
_logger.LogDebug("Cancelling/Removing StatsTasks");
|
||||
|
||||
RecurringJob.RemoveIfExists("report-stats");
|
||||
_logger.LogDebug("User has opted out of stat collection, not sending stats");
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Enqueue(() => _statsService.Send());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First time run stat collection. Executes immediately on a background thread. Does not block.
|
||||
/// </summary>
|
||||
public async Task RunStatCollection()
|
||||
{
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
if (!allowStatCollection)
|
||||
{
|
||||
_logger.LogDebug("User has opted out of stat collection, not sending stats");
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Enqueue(() => _statsService.Send());
|
||||
}
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
#region UpdateTasks
|
||||
|
||||
#region UpdateTasks
|
||||
public void ScheduleUpdaterTasks()
|
||||
{
|
||||
_logger.LogInformation("Scheduling Auto-Update tasks");
|
||||
// Schedule update check between noon and 6pm local time
|
||||
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local);
|
||||
}
|
||||
#endregion
|
||||
|
||||
public void ScheduleUpdaterTasks()
|
||||
{
|
||||
_logger.LogInformation("Scheduling Auto-Update tasks");
|
||||
// Schedule update check between noon and 6pm local time
|
||||
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local);
|
||||
}
|
||||
#endregion
|
||||
public void ScanLibrary(int libraryId, bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
|
||||
BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId));
|
||||
// When we do a scan, force cache to re-unpack in case page numbers change
|
||||
BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory());
|
||||
}
|
||||
|
||||
public void ScanLibrary(int libraryId, bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
|
||||
BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId));
|
||||
// When we do a scan, force cache to re-unpack in case page numbers change
|
||||
BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory());
|
||||
}
|
||||
public void CleanupChapters(int[] chapterIds)
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
||||
}
|
||||
|
||||
public void CleanupChapters(int[] chapterIds)
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
||||
}
|
||||
public void RefreshMetadata(int libraryId, bool forceUpdate = true)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId);
|
||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
|
||||
}
|
||||
|
||||
public void RefreshMetadata(int libraryId, bool forceUpdate = true)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId);
|
||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
|
||||
}
|
||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
|
||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));
|
||||
}
|
||||
|
||||
public void CleanupTemp()
|
||||
{
|
||||
BackgroundJob.Enqueue(() => DirectoryService.ClearDirectory(DirectoryService.TempDirectory));
|
||||
}
|
||||
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing series scan for: {SeriesId}", seriesId);
|
||||
BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None));
|
||||
}
|
||||
|
||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
|
||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));
|
||||
}
|
||||
public void BackupDatabase()
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _backupService.BackupDatabase());
|
||||
}
|
||||
|
||||
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing series scan for: {SeriesId}", seriesId);
|
||||
BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None));
|
||||
}
|
||||
|
||||
public void BackupDatabase()
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _backupService.BackupDatabase());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Not an external call. Only public so that we can call this for a Task
|
||||
/// </summary>
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public async Task CheckForUpdate()
|
||||
{
|
||||
var update = await _versionUpdaterService.CheckForUpdate();
|
||||
await _versionUpdaterService.PushUpdate(update);
|
||||
}
|
||||
/// <summary>
|
||||
/// Not an external call. Only public so that we can call this for a Task
|
||||
/// </summary>
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public async Task CheckForUpdate()
|
||||
{
|
||||
var update = await _versionUpdaterService.CheckForUpdate();
|
||||
await _versionUpdaterService.PushUpdate(update);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,204 +4,166 @@ using System.IO;
|
|||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks
|
||||
namespace API.Services.Tasks;
|
||||
|
||||
public interface IBackupService
|
||||
{
|
||||
public class BackupService : IBackupService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<BackupService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
|
||||
private readonly IList<string> _backupFiles;
|
||||
|
||||
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger,
|
||||
IDirectoryService directoryService, IConfiguration config, IHubContext<MessageHub> messageHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_messageHub = messageHub;
|
||||
|
||||
var maxRollingFiles = config.GetMaxRollingFiles();
|
||||
var loggingSection = config.GetLoggingFileName();
|
||||
var files = LogFiles(maxRollingFiles, loggingSection);
|
||||
|
||||
|
||||
_backupFiles = new List<string>()
|
||||
{
|
||||
"appsettings.json",
|
||||
"Hangfire.db", // This is not used atm
|
||||
"Hangfire-log.db", // This is not used atm
|
||||
"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())
|
||||
{
|
||||
_backupFiles.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<string> LogFiles(int maxRollingFiles, string logFileName)
|
||||
{
|
||||
var multipleFileRegex = maxRollingFiles > 0 ? @"\d*" : string.Empty;
|
||||
var fi = new FileInfo(logFileName);
|
||||
|
||||
var files = maxRollingFiles > 0
|
||||
? DirectoryService.GetFiles(DirectoryService.LogDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
|
||||
: new[] {"kavita.log"};
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Will backup anything that needs to be backed up. This includes logs, setting files, bare minimum cover images (just locked and first cover).
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
|
||||
public async Task BackupDatabase()
|
||||
{
|
||||
_logger.LogInformation("Beginning backup of Database at {BackupTime}", DateTime.Now);
|
||||
var backupDirectory = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Result.Value;
|
||||
|
||||
_logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory);
|
||||
if (!DirectoryService.ExistOrCreate(backupDirectory))
|
||||
{
|
||||
_logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendProgress(0F);
|
||||
|
||||
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||
var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
|
||||
|
||||
if (File.Exists(zipPath))
|
||||
{
|
||||
_logger.LogInformation("{ZipFile} already exists, aborting", zipPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString);
|
||||
DirectoryService.ExistOrCreate(tempDirectory);
|
||||
DirectoryService.ClearDirectory(tempDirectory);
|
||||
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
_backupFiles.Select(file => Path.Join(DirectoryService.ConfigDirectory, file)).ToList(), tempDirectory);
|
||||
|
||||
await SendProgress(0.25F);
|
||||
|
||||
await CopyCoverImagesToBackupDirectory(tempDirectory);
|
||||
|
||||
await SendProgress(0.75F);
|
||||
|
||||
try
|
||||
{
|
||||
ZipFile.CreateFromDirectory(tempDirectory, zipPath);
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue when archiving library backup");
|
||||
}
|
||||
|
||||
DirectoryService.ClearAndDeleteDirectory(tempDirectory);
|
||||
_logger.LogInformation("Database backup completed");
|
||||
await SendProgress(1F);
|
||||
}
|
||||
|
||||
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
|
||||
{
|
||||
var outputTempDir = Path.Join(tempDirectory, "covers");
|
||||
DirectoryService.ExistOrCreate(outputTempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var seriesImages = await _unitOfWork.SeriesRepository.GetLockedCoverImagesAsync();
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
seriesImages.Select(s => Path.Join(DirectoryService.CoverImageDirectory, s)), outputTempDir);
|
||||
|
||||
var collectionTags = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync();
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
collectionTags.Select(s => Path.Join(DirectoryService.CoverImageDirectory, s)), outputTempDir);
|
||||
|
||||
var chapterImages = await _unitOfWork.ChapterRepository.GetCoverImagesForLockedChaptersAsync();
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
chapterImages.Select(s => Path.Join(DirectoryService.CoverImageDirectory, s)), outputTempDir);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Swallow exception. This can be a duplicate cover being copied as chapter and volumes can share same file.
|
||||
}
|
||||
|
||||
if (!DirectoryService.GetFiles(outputTempDir).Any())
|
||||
{
|
||||
DirectoryService.ClearAndDeleteDirectory(outputTempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendProgress(float progress)
|
||||
{
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress,
|
||||
MessageFactory.BackupDatabaseProgressEvent(progress));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept.
|
||||
/// </summary>
|
||||
public void CleanupBackups()
|
||||
{
|
||||
const int dayThreshold = 30;
|
||||
_logger.LogInformation("Beginning cleanup of Database backups at {Time}", DateTime.Now);
|
||||
var backupDirectory = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Result.Value;
|
||||
if (!_directoryService.Exists(backupDirectory)) return;
|
||||
var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold));
|
||||
var allBackups = DirectoryService.GetFiles(backupDirectory).ToList();
|
||||
var expiredBackups = allBackups.Select(filename => new FileInfo(filename))
|
||||
.Where(f => f.CreationTime > deltaTime)
|
||||
.ToList();
|
||||
if (expiredBackups.Count == allBackups.Count)
|
||||
{
|
||||
_logger.LogInformation("All expired backups are older than {Threshold} days. Removing all but last backup", dayThreshold);
|
||||
var toDelete = expiredBackups.OrderByDescending(f => f.CreationTime).ToList();
|
||||
for (var i = 1; i < toDelete.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
toDelete[i].Delete();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue deleting {FileName}", toDelete[i].Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var file in expiredBackups)
|
||||
{
|
||||
try
|
||||
{
|
||||
file.Delete();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue deleting {FileName}", file.Name);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
|
||||
}
|
||||
|
||||
}
|
||||
Task BackupDatabase();
|
||||
/// <summary>
|
||||
/// Returns a list of full paths of the logs files detailed in <see cref="IConfiguration"/>.
|
||||
/// </summary>
|
||||
/// <param name="maxRollingFiles"></param>
|
||||
/// <param name="logFileName"></param>
|
||||
/// <returns></returns>
|
||||
IEnumerable<string> GetLogFiles(int maxRollingFiles, string logFileName);
|
||||
}
|
||||
public class BackupService : IBackupService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<BackupService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
|
||||
private readonly IList<string> _backupFiles;
|
||||
|
||||
public BackupService(ILogger<BackupService> logger, IUnitOfWork unitOfWork,
|
||||
IDirectoryService directoryService, IConfiguration config, IHubContext<MessageHub> messageHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_messageHub = messageHub;
|
||||
|
||||
var maxRollingFiles = config.GetMaxRollingFiles();
|
||||
var loggingSection = config.GetLoggingFileName();
|
||||
var files = GetLogFiles(maxRollingFiles, loggingSection);
|
||||
|
||||
|
||||
_backupFiles = new List<string>()
|
||||
{
|
||||
"appsettings.json",
|
||||
"Hangfire.db", // This is not used atm
|
||||
"Hangfire-log.db", // This is not used atm
|
||||
"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 => (_directoryService.FileSystem.FileInfo.FromFileName(f)).Name).ToList())
|
||||
{
|
||||
_backupFiles.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetLogFiles(int maxRollingFiles, string logFileName)
|
||||
{
|
||||
var multipleFileRegex = maxRollingFiles > 0 ? @"\d*" : string.Empty;
|
||||
var fi = _directoryService.FileSystem.FileInfo.FromFileName(logFileName);
|
||||
|
||||
var files = maxRollingFiles > 0
|
||||
? _directoryService.GetFiles(_directoryService.LogDirectory,
|
||||
$@"{_directoryService.FileSystem.Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
|
||||
: new[] {"kavita.log"};
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Will backup anything that needs to be backed up. This includes logs, setting files, bare minimum cover images (just locked and first cover).
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
|
||||
public async Task BackupDatabase()
|
||||
{
|
||||
_logger.LogInformation("Beginning backup of Database at {BackupTime}", DateTime.Now);
|
||||
var backupDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value;
|
||||
|
||||
_logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory);
|
||||
if (!_directoryService.ExistOrCreate(backupDirectory))
|
||||
{
|
||||
_logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendProgress(0F);
|
||||
|
||||
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||
var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
|
||||
|
||||
if (File.Exists(zipPath))
|
||||
{
|
||||
_logger.LogInformation("{ZipFile} already exists, aborting", zipPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var tempDirectory = Path.Join(_directoryService.TempDirectory, dateString);
|
||||
_directoryService.ExistOrCreate(tempDirectory);
|
||||
_directoryService.ClearDirectory(tempDirectory);
|
||||
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
_backupFiles.Select(file => _directoryService.FileSystem.Path.Join(_directoryService.ConfigDirectory, file)).ToList(), tempDirectory);
|
||||
|
||||
await SendProgress(0.25F);
|
||||
|
||||
await CopyCoverImagesToBackupDirectory(tempDirectory);
|
||||
|
||||
await SendProgress(0.75F);
|
||||
|
||||
try
|
||||
{
|
||||
ZipFile.CreateFromDirectory(tempDirectory, zipPath);
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue when archiving library backup");
|
||||
}
|
||||
|
||||
_directoryService.ClearAndDeleteDirectory(tempDirectory);
|
||||
_logger.LogInformation("Database backup completed");
|
||||
await SendProgress(1F);
|
||||
}
|
||||
|
||||
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
|
||||
{
|
||||
var outputTempDir = Path.Join(tempDirectory, "covers");
|
||||
_directoryService.ExistOrCreate(outputTempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var seriesImages = await _unitOfWork.SeriesRepository.GetLockedCoverImagesAsync();
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
seriesImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir);
|
||||
|
||||
var collectionTags = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync();
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
collectionTags.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir);
|
||||
|
||||
var chapterImages = await _unitOfWork.ChapterRepository.GetCoverImagesForLockedChaptersAsync();
|
||||
_directoryService.CopyFilesToDirectory(
|
||||
chapterImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Swallow exception. This can be a duplicate cover being copied as chapter and volumes can share same file.
|
||||
}
|
||||
|
||||
if (!_directoryService.GetFiles(outputTempDir).Any())
|
||||
{
|
||||
_directoryService.ClearAndDeleteDirectory(outputTempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendProgress(float progress)
|
||||
{
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress,
|
||||
MessageFactory.BackupDatabaseProgressEvent(progress));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
using System.IO;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Data;
|
||||
using API.Entities.Enums;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
|
@ -9,32 +10,35 @@ using Microsoft.Extensions.Logging;
|
|||
|
||||
namespace API.Services.Tasks
|
||||
{
|
||||
public interface ICleanupService
|
||||
{
|
||||
Task Cleanup();
|
||||
void CleanupCacheDirectory();
|
||||
Task DeleteSeriesCoverImages();
|
||||
Task DeleteChapterCoverImages();
|
||||
Task DeleteTagCoverImages();
|
||||
Task CleanupBackups();
|
||||
}
|
||||
/// <summary>
|
||||
/// Cleans up after operations on reoccurring basis
|
||||
/// </summary>
|
||||
public class CleanupService : ICleanupService
|
||||
{
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly ILogger<CleanupService> _logger;
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
public CleanupService(ICacheService cacheService, ILogger<CleanupService> logger,
|
||||
IBackupService backupService, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
|
||||
public CleanupService(ILogger<CleanupService> logger,
|
||||
IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub,
|
||||
IDirectoryService directoryService)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
_backupService = backupService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_messageHub = messageHub;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
public void CleanupCacheDirectory()
|
||||
{
|
||||
_logger.LogInformation("Cleaning cache directory");
|
||||
_cacheService.Cleanup();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up Temp, cache, deleted cover images, and old database backups
|
||||
|
|
@ -45,12 +49,12 @@ namespace API.Services.Tasks
|
|||
_logger.LogInformation("Starting Cleanup");
|
||||
await SendProgress(0F);
|
||||
_logger.LogInformation("Cleaning temp directory");
|
||||
DirectoryService.ClearDirectory(DirectoryService.TempDirectory);
|
||||
_directoryService.ClearDirectory(_directoryService.TempDirectory);
|
||||
await SendProgress(0.1F);
|
||||
CleanupCacheDirectory();
|
||||
await SendProgress(0.25F);
|
||||
_logger.LogInformation("Cleaning old database backups");
|
||||
_backupService.CleanupBackups();
|
||||
await CleanupBackups();
|
||||
await SendProgress(0.50F);
|
||||
_logger.LogInformation("Cleaning deleted cover images");
|
||||
await DeleteSeriesCoverImages();
|
||||
|
|
@ -68,40 +72,84 @@ namespace API.Services.Tasks
|
|||
MessageFactory.CleanupProgressEvent(progress));
|
||||
}
|
||||
|
||||
private async Task DeleteSeriesCoverImages()
|
||||
/// <summary>
|
||||
/// Removes all series images that are not in the database. They must follow <see cref="ImageService.SeriesCoverImageRegex"/> filename pattern.
|
||||
/// </summary>
|
||||
public async Task DeleteSeriesCoverImages()
|
||||
{
|
||||
var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync();
|
||||
var files = DirectoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.SeriesCoverImageRegex);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (images.Contains(Path.GetFileName(file))) continue;
|
||||
File.Delete(file);
|
||||
|
||||
}
|
||||
var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.SeriesCoverImageRegex);
|
||||
_directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file))));
|
||||
}
|
||||
|
||||
private async Task DeleteChapterCoverImages()
|
||||
/// <summary>
|
||||
/// Removes all chapter/volume images that are not in the database. They must follow <see cref="ImageService.ChapterCoverImageRegex"/> filename pattern.
|
||||
/// </summary>
|
||||
public async Task DeleteChapterCoverImages()
|
||||
{
|
||||
var images = await _unitOfWork.ChapterRepository.GetAllCoverImagesAsync();
|
||||
var files = DirectoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.ChapterCoverImageRegex);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (images.Contains(Path.GetFileName(file))) continue;
|
||||
File.Delete(file);
|
||||
|
||||
}
|
||||
var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.ChapterCoverImageRegex);
|
||||
_directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file))));
|
||||
}
|
||||
|
||||
private async Task DeleteTagCoverImages()
|
||||
/// <summary>
|
||||
/// Removes all collection tag images that are not in the database. They must follow <see cref="ImageService.CollectionTagCoverImageRegex"/> filename pattern.
|
||||
/// </summary>
|
||||
public async Task DeleteTagCoverImages()
|
||||
{
|
||||
var images = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync();
|
||||
var files = DirectoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.CollectionTagCoverImageRegex);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (images.Contains(Path.GetFileName(file))) continue;
|
||||
File.Delete(file);
|
||||
var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.CollectionTagCoverImageRegex);
|
||||
_directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all files and directories in the cache directory
|
||||
/// </summary>
|
||||
public void CleanupCacheDirectory()
|
||||
{
|
||||
_logger.LogInformation("Performing cleanup of Cache directory");
|
||||
_directoryService.ExistOrCreate(_directoryService.CacheDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
_directoryService.ClearDirectory(_directoryService.CacheDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cache directory purged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept.
|
||||
/// </summary>
|
||||
public async Task CleanupBackups()
|
||||
{
|
||||
const int dayThreshold = 30;
|
||||
_logger.LogInformation("Beginning cleanup of Database backups at {Time}", DateTime.Now);
|
||||
var backupDirectory =
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value;
|
||||
if (!_directoryService.Exists(backupDirectory)) return;
|
||||
|
||||
var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold));
|
||||
var allBackups = _directoryService.GetFiles(backupDirectory).ToList();
|
||||
var expiredBackups = allBackups.Select(filename => _directoryService.FileSystem.FileInfo.FromFileName(filename))
|
||||
.Where(f => f.CreationTime < deltaTime)
|
||||
.ToList();
|
||||
|
||||
if (expiredBackups.Count == allBackups.Count)
|
||||
{
|
||||
_logger.LogInformation("All expired backups are older than {Threshold} days. Removing all but last backup", dayThreshold);
|
||||
var toDelete = expiredBackups.OrderByDescending(f => f.CreationTime).ToList();
|
||||
_directoryService.DeleteFiles(toDelete.Take(toDelete.Count - 1).Select(f => f.FullName));
|
||||
}
|
||||
else
|
||||
{
|
||||
_directoryService.DeleteFiles(expiredBackups.Select(f => f.FullName));
|
||||
}
|
||||
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ using System.Linq;
|
|||
using API.Data.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Interfaces.Services;
|
||||
using API.Parser;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
|
@ -24,25 +23,26 @@ namespace API.Services.Tasks.Scanner
|
|||
public class ParseScannedFiles
|
||||
{
|
||||
private readonly ConcurrentDictionary<ParsedSeries, List<ParserInfo>> _scannedSeries;
|
||||
private readonly IBookService _bookService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IReadingItemService _readingItemService;
|
||||
private readonly DefaultParser _defaultParser;
|
||||
|
||||
/// <summary>
|
||||
/// An instance of a pipeline for processing files and returning a Map of Series -> ParserInfos.
|
||||
/// Each instance is separate from other threads, allowing for no cross over.
|
||||
/// </summary>
|
||||
/// <param name="bookService"></param>
|
||||
/// <param name="logger"></param>
|
||||
public ParseScannedFiles(IBookService bookService, ILogger logger, IArchiveService archiveService,
|
||||
IDirectoryService directoryService)
|
||||
/// <param name="logger">Logger of the parent class that invokes this</param>
|
||||
/// <param name="directoryService">Directory Service</param>
|
||||
/// <param name="readingItemService">ReadingItemService Service for extracting information on a number of formats</param>
|
||||
public ParseScannedFiles(ILogger logger, IDirectoryService directoryService,
|
||||
IReadingItemService readingItemService)
|
||||
{
|
||||
_bookService = bookService;
|
||||
_logger = logger;
|
||||
_archiveService = archiveService;
|
||||
_directoryService = directoryService;
|
||||
_readingItemService = readingItemService;
|
||||
_scannedSeries = new ConcurrentDictionary<ParsedSeries, List<ParserInfo>>();
|
||||
_defaultParser = new DefaultParser(_directoryService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -63,12 +63,12 @@ namespace API.Services.Tasks.Scanner
|
|||
{
|
||||
if (Parser.Parser.IsEpub(path))
|
||||
{
|
||||
return _bookService.GetComicInfo(path);
|
||||
return _readingItemService.GetComicInfo(path, MangaFormat.Epub);
|
||||
}
|
||||
|
||||
if (Parser.Parser.IsComicInfoExtension(path))
|
||||
{
|
||||
return _archiveService.GetComicInfo(path);
|
||||
return _readingItemService.GetComicInfo(path, MangaFormat.Archive);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -82,15 +82,15 @@ namespace API.Services.Tasks.Scanner
|
|||
/// <param name="type">Library type to determine parsing to perform</param>
|
||||
private void ProcessFile(string path, string rootPath, LibraryType type)
|
||||
{
|
||||
ParserInfo info;
|
||||
ParserInfo info = null;
|
||||
|
||||
if (Parser.Parser.IsEpub(path))
|
||||
{
|
||||
info = _bookService.ParseInfo(path);
|
||||
info = _readingItemService.Parse(path, rootPath, type);
|
||||
}
|
||||
else
|
||||
{
|
||||
info = Parser.Parser.Parse(path, rootPath, type);
|
||||
info = _readingItemService.Parse(path, rootPath, type);
|
||||
}
|
||||
|
||||
// If we couldn't match, log. But don't log if the file parses as a cover image
|
||||
|
|
@ -105,8 +105,8 @@ namespace API.Services.Tasks.Scanner
|
|||
|
||||
if (Parser.Parser.IsEpub(path) && Parser.Parser.ParseVolume(info.Series) != Parser.Parser.DefaultVolume)
|
||||
{
|
||||
info = Parser.Parser.Parse(path, rootPath, type);
|
||||
var info2 = _bookService.ParseInfo(path);
|
||||
info = _defaultParser.Parse(path, rootPath, LibraryType.Book); // TODO: Why do I reparse?
|
||||
var info2 = _readingItemService.Parse(path, rootPath, type);
|
||||
info.Merge(info2);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ using API.Entities;
|
|||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Parser;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using API.SignalR;
|
||||
|
|
@ -22,33 +20,42 @@ using Microsoft.AspNetCore.SignalR;
|
|||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks;
|
||||
public interface IScannerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite
|
||||
/// cover images if forceUpdate is true.
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to scan against</param>
|
||||
Task ScanLibrary(int libraryId);
|
||||
Task ScanLibraries();
|
||||
Task ScanSeries(int libraryId, int seriesId, CancellationToken token);
|
||||
}
|
||||
|
||||
public class ScannerService : IScannerService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ScannerService> _logger;
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly IMetadataService _metadataService;
|
||||
private readonly IBookService _bookService;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly IFileService _fileService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IReadingItemService _readingItemService;
|
||||
private readonly NaturalSortComparer _naturalSort = new ();
|
||||
|
||||
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger, IArchiveService archiveService,
|
||||
IMetadataService metadataService, IBookService bookService, ICacheService cacheService, IHubContext<MessageHub> messageHub,
|
||||
IFileService fileService, IDirectoryService directoryService)
|
||||
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger,
|
||||
IMetadataService metadataService, ICacheService cacheService, IHubContext<MessageHub> messageHub,
|
||||
IFileService fileService, IDirectoryService directoryService, IReadingItemService readingItemService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_archiveService = archiveService;
|
||||
_metadataService = metadataService;
|
||||
_bookService = bookService;
|
||||
_cacheService = cacheService;
|
||||
_messageHub = messageHub;
|
||||
_fileService = fileService;
|
||||
_directoryService = directoryService;
|
||||
_readingItemService = readingItemService;
|
||||
}
|
||||
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 360)]
|
||||
|
|
@ -63,16 +70,16 @@ public class ScannerService : IScannerService
|
|||
var folderPaths = library.Folders.Select(f => f.Path).ToList();
|
||||
|
||||
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
|
||||
if (folderPaths.Any(f => !DirectoryService.IsDriveMounted(f)))
|
||||
if (folderPaths.Any(f => !_directoryService.IsDriveMounted(f)))
|
||||
{
|
||||
_logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList());
|
||||
var dirs = _directoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList());
|
||||
|
||||
_logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
|
||||
var scanner = new ParseScannedFiles(_bookService, _logger, _archiveService, _directoryService);
|
||||
var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService);
|
||||
var parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles, out var scanElapsedTime);
|
||||
|
||||
// Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder
|
||||
|
|
@ -120,7 +127,7 @@ public class ScannerService : IScannerService
|
|||
}
|
||||
|
||||
_logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName);
|
||||
scanner = new ParseScannedFiles(_bookService, _logger, _archiveService, _directoryService);
|
||||
scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService);
|
||||
parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2);
|
||||
totalFiles += totalFiles2;
|
||||
scanElapsedTime += scanElapsedTime2;
|
||||
|
|
@ -208,7 +215,7 @@ public class ScannerService : IScannerService
|
|||
}
|
||||
|
||||
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
|
||||
if (library.Folders.Any(f => !DirectoryService.IsDriveMounted(f.Path)))
|
||||
if (library.Folders.Any(f => !_directoryService.IsDriveMounted(f.Path)))
|
||||
{
|
||||
_logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
|
||||
return;
|
||||
|
|
@ -218,7 +225,7 @@ public class ScannerService : IScannerService
|
|||
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
||||
MessageFactory.ScanLibraryProgressEvent(libraryId, 0));
|
||||
|
||||
var scanner = new ParseScannedFiles(_bookService, _logger, _archiveService, _directoryService);
|
||||
var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService);
|
||||
var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime);
|
||||
|
||||
foreach (var folderPath in library.Folders)
|
||||
|
|
@ -618,28 +625,7 @@ public class ScannerService : IScannerService
|
|||
|
||||
private MangaFile CreateMangaFile(ParserInfo info)
|
||||
{
|
||||
var pages = 0;
|
||||
switch (info.Format)
|
||||
{
|
||||
case MangaFormat.Archive:
|
||||
{
|
||||
pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath);
|
||||
break;
|
||||
}
|
||||
case MangaFormat.Pdf:
|
||||
case MangaFormat.Epub:
|
||||
{
|
||||
pages = _bookService.GetNumberOfPages(info.FullFilePath);
|
||||
break;
|
||||
}
|
||||
case MangaFormat.Image:
|
||||
{
|
||||
pages = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return DbFactory.MangaFile(info.FullFilePath, info.Format, pages);
|
||||
return DbFactory.MangaFile(info.FullFilePath, info.Format, _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format));
|
||||
}
|
||||
|
||||
private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info)
|
||||
|
|
@ -650,23 +636,7 @@ public class ScannerService : IScannerService
|
|||
{
|
||||
existingFile.Format = info.Format;
|
||||
if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return;
|
||||
switch (existingFile.Format)
|
||||
{
|
||||
case MangaFormat.Epub:
|
||||
case MangaFormat.Pdf:
|
||||
existingFile.Pages = _bookService.GetNumberOfPages(info.FullFilePath);
|
||||
break;
|
||||
case MangaFormat.Image:
|
||||
existingFile.Pages = 1;
|
||||
break;
|
||||
case MangaFormat.Unknown:
|
||||
existingFile.Pages = 0;
|
||||
break;
|
||||
case MangaFormat.Archive:
|
||||
existingFile.Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath);
|
||||
break;
|
||||
}
|
||||
//existingFile.LastModified = File.GetLastWriteTime(info.FullFilePath); // This is messing up our logic on when last modified
|
||||
existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,108 +2,111 @@
|
|||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Stats;
|
||||
using API.Entities.Enums;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks
|
||||
namespace API.Services.Tasks;
|
||||
|
||||
public interface IStatsService
|
||||
{
|
||||
public class StatsService : IStatsService
|
||||
Task Send();
|
||||
Task<ServerInfoDto> GetServerInfo();
|
||||
}
|
||||
public class StatsService : IStatsService
|
||||
{
|
||||
private readonly ILogger<StatsService> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private const string ApiUrl = "https://stats.kavitareader.com";
|
||||
|
||||
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork)
|
||||
{
|
||||
private readonly ILogger<StatsService> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private const string ApiUrl = "https://stats.kavitareader.com";
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
|
||||
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork)
|
||||
FlurlHttp.ConfigureClient(ApiUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Due to all instances firing this at the same time, we can DDOS our server. This task when fired will schedule the task to be run
|
||||
/// randomly over a 6 hour spread
|
||||
/// </summary>
|
||||
public async Task Send()
|
||||
{
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
if (!allowStatCollection)
|
||||
{
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
|
||||
FlurlHttp.ConfigureClient(ApiUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Due to all instances firing this at the same time, we can DDOS our server. This task when fired will schedule the task to be run
|
||||
/// randomly over a 6 hour spread
|
||||
/// </summary>
|
||||
public async Task Send()
|
||||
await SendData();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This must be public for Hangfire. Do not call this directly.
|
||||
/// </summary>
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public async Task SendData()
|
||||
{
|
||||
var data = await GetServerInfo();
|
||||
await SendDataToStatsServer(data);
|
||||
}
|
||||
|
||||
|
||||
private async Task SendDataToStatsServer(ServerInfoDto data)
|
||||
{
|
||||
var responseContent = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||
if (!allowStatCollection)
|
||||
var response = await (ApiUrl + "/api/v2/stats")
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||
.PostJsonAsync(data);
|
||||
|
||||
if (response.StatusCode != StatusCodes.Status200OK)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SendData();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This must be public for Hangfire. Do not call this directly.
|
||||
/// </summary>
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public async Task SendData()
|
||||
{
|
||||
var data = await GetServerInfo();
|
||||
await SendDataToStatsServer(data);
|
||||
}
|
||||
|
||||
|
||||
private async Task SendDataToStatsServer(ServerInfoDto data)
|
||||
{
|
||||
var responseContent = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await (ApiUrl + "/api/v2/stats")
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||
.PostJsonAsync(data);
|
||||
|
||||
if (response.StatusCode != StatusCodes.Status200OK)
|
||||
{
|
||||
_logger.LogError("KavitaStats did not respond successfully. {Content}", response);
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
var info = new
|
||||
{
|
||||
dataSent = data,
|
||||
response = responseContent
|
||||
};
|
||||
|
||||
_logger.LogError(e, "KavitaStats did not respond successfully. {Content}", info);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to KavitaStats");
|
||||
_logger.LogError("KavitaStats did not respond successfully. {Content}", response);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ServerInfoDto> GetServerInfo()
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId);
|
||||
var serverInfo = new ServerInfoDto
|
||||
var info = new
|
||||
{
|
||||
InstallId = installId.Value,
|
||||
Os = RuntimeInformation.OSDescription,
|
||||
KavitaVersion = BuildInfo.Version.ToString(),
|
||||
DotnetVersion = Environment.Version.ToString(),
|
||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
||||
NumOfCores = Math.Max(Environment.ProcessorCount, 1)
|
||||
dataSent = data,
|
||||
response = responseContent
|
||||
};
|
||||
|
||||
return serverInfo;
|
||||
_logger.LogError(e, "KavitaStats did not respond successfully. {Content}", info);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to KavitaStats");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ServerInfoDto> GetServerInfo()
|
||||
{
|
||||
var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId);
|
||||
var serverInfo = new ServerInfoDto
|
||||
{
|
||||
InstallId = installId.Value,
|
||||
Os = RuntimeInformation.OSDescription,
|
||||
KavitaVersion = BuildInfo.Version.ToString(),
|
||||
DotnetVersion = Environment.Version.ToString(),
|
||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
||||
NumOfCores = Math.Max(Environment.ProcessorCount, 1)
|
||||
};
|
||||
|
||||
return serverInfo;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ using System.Linq;
|
|||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Update;
|
||||
using API.Interfaces.Services;
|
||||
using API.SignalR;
|
||||
using API.SignalR.Presence;
|
||||
using Flurl.Http;
|
||||
|
|
@ -15,149 +14,155 @@ using Microsoft.AspNetCore.SignalR;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks
|
||||
namespace API.Services.Tasks;
|
||||
|
||||
internal class GithubReleaseMetadata
|
||||
{
|
||||
internal class GithubReleaseMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the Tag
|
||||
/// <example>v0.4.3</example>
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public string Tag_Name { get; init; }
|
||||
/// <summary>
|
||||
/// Name of the Release
|
||||
/// </summary>
|
||||
public string Name { get; init; }
|
||||
/// <summary>
|
||||
/// Body of the Release
|
||||
/// </summary>
|
||||
public string Body { get; init; }
|
||||
/// <summary>
|
||||
/// Url of the release on Github
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public string Html_Url { get; init; }
|
||||
/// <summary>
|
||||
/// Date Release was Published
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public string Published_At { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Name of the Tag
|
||||
/// <example>v0.4.3</example>
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public string Tag_Name { get; init; }
|
||||
/// <summary>
|
||||
/// Name of the Release
|
||||
/// </summary>
|
||||
public string Name { get; init; }
|
||||
/// <summary>
|
||||
/// Body of the Release
|
||||
/// </summary>
|
||||
public string Body { get; init; }
|
||||
/// <summary>
|
||||
/// Url of the release on Github
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public string Html_Url { get; init; }
|
||||
/// <summary>
|
||||
/// Date Release was Published
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public string Published_At { get; init; }
|
||||
}
|
||||
|
||||
public class UntrustedCertClientFactory : DefaultHttpClientFactory
|
||||
{
|
||||
public override HttpMessageHandler CreateMessageHandler() {
|
||||
return new HttpClientHandler {
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class VersionUpdaterService : IVersionUpdaterService
|
||||
{
|
||||
private readonly ILogger<VersionUpdaterService> _logger;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly IPresenceTracker _tracker;
|
||||
private readonly Markdown _markdown = new MarkdownDeep.Markdown();
|
||||
#pragma warning disable S1075
|
||||
private static readonly string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest";
|
||||
private static readonly string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases";
|
||||
#pragma warning restore S1075
|
||||
|
||||
public VersionUpdaterService(ILogger<VersionUpdaterService> logger, IHubContext<MessageHub> messageHub, IPresenceTracker tracker)
|
||||
{
|
||||
_logger = logger;
|
||||
_messageHub = messageHub;
|
||||
_tracker = tracker;
|
||||
|
||||
FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
FlurlHttp.ConfigureClient(GithubAllReleasesUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the latest release from Github
|
||||
/// </summary>
|
||||
public async Task<UpdateNotificationDto> CheckForUpdate()
|
||||
{
|
||||
var update = await GetGithubRelease();
|
||||
return CreateDto(update);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UpdateNotificationDto>> GetAllReleases()
|
||||
{
|
||||
var updates = await GetGithubReleases();
|
||||
return updates.Select(CreateDto);
|
||||
}
|
||||
|
||||
private UpdateNotificationDto CreateDto(GithubReleaseMetadata update)
|
||||
{
|
||||
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
||||
var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty));
|
||||
var currentVersion = BuildInfo.Version.ToString();
|
||||
|
||||
if (updateVersion.Revision == -1)
|
||||
{
|
||||
currentVersion = currentVersion.Substring(0, currentVersion.LastIndexOf(".", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
return new UpdateNotificationDto()
|
||||
{
|
||||
CurrentVersion = currentVersion,
|
||||
UpdateVersion = updateVersion.ToString(),
|
||||
UpdateBody = _markdown.Transform(update.Body.Trim()),
|
||||
UpdateTitle = update.Name,
|
||||
UpdateUrl = update.Html_Url,
|
||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
||||
PublishDate = update.Published_At
|
||||
};
|
||||
}
|
||||
|
||||
public async Task PushUpdate(UpdateNotificationDto update)
|
||||
{
|
||||
if (update == null) return;
|
||||
|
||||
var admins = await _tracker.GetOnlineAdmins();
|
||||
var updateVersion = new Version(update.CurrentVersion);
|
||||
|
||||
if (BuildInfo.Version < updateVersion)
|
||||
{
|
||||
_logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion);
|
||||
await SendEvent(update, admins);
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development)
|
||||
{
|
||||
_logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version);
|
||||
await SendEvent(update, admins);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendEvent(UpdateNotificationDto update, IReadOnlyList<string> admins)
|
||||
{
|
||||
await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateAvailable, MessageFactory.UpdateVersionEvent(update));
|
||||
}
|
||||
|
||||
|
||||
private static async Task<GithubReleaseMetadata> GetGithubRelease()
|
||||
{
|
||||
var update = await GithubLatestReleasesUrl
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.GetJsonAsync<GithubReleaseMetadata>();
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<GithubReleaseMetadata>> GetGithubReleases()
|
||||
{
|
||||
var update = await GithubAllReleasesUrl
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.GetJsonAsync<IEnumerable<GithubReleaseMetadata>>();
|
||||
|
||||
return update;
|
||||
}
|
||||
public class UntrustedCertClientFactory : DefaultHttpClientFactory
|
||||
{
|
||||
public override HttpMessageHandler CreateMessageHandler() {
|
||||
return new HttpClientHandler {
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public interface IVersionUpdaterService
|
||||
{
|
||||
Task<UpdateNotificationDto> CheckForUpdate();
|
||||
Task PushUpdate(UpdateNotificationDto update);
|
||||
Task<IEnumerable<UpdateNotificationDto>> GetAllReleases();
|
||||
}
|
||||
|
||||
public class VersionUpdaterService : IVersionUpdaterService
|
||||
{
|
||||
private readonly ILogger<VersionUpdaterService> _logger;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly IPresenceTracker _tracker;
|
||||
private readonly Markdown _markdown = new MarkdownDeep.Markdown();
|
||||
#pragma warning disable S1075
|
||||
private static readonly string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest";
|
||||
private static readonly string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases";
|
||||
#pragma warning restore S1075
|
||||
|
||||
public VersionUpdaterService(ILogger<VersionUpdaterService> logger, IHubContext<MessageHub> messageHub, IPresenceTracker tracker)
|
||||
{
|
||||
_logger = logger;
|
||||
_messageHub = messageHub;
|
||||
_tracker = tracker;
|
||||
|
||||
FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
FlurlHttp.ConfigureClient(GithubAllReleasesUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the latest release from Github
|
||||
/// </summary>
|
||||
public async Task<UpdateNotificationDto> CheckForUpdate()
|
||||
{
|
||||
var update = await GetGithubRelease();
|
||||
return CreateDto(update);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UpdateNotificationDto>> GetAllReleases()
|
||||
{
|
||||
var updates = await GetGithubReleases();
|
||||
return updates.Select(CreateDto);
|
||||
}
|
||||
|
||||
private UpdateNotificationDto CreateDto(GithubReleaseMetadata update)
|
||||
{
|
||||
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
||||
var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty));
|
||||
var currentVersion = BuildInfo.Version.ToString();
|
||||
|
||||
if (updateVersion.Revision == -1)
|
||||
{
|
||||
currentVersion = currentVersion.Substring(0, currentVersion.LastIndexOf(".", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
return new UpdateNotificationDto()
|
||||
{
|
||||
CurrentVersion = currentVersion,
|
||||
UpdateVersion = updateVersion.ToString(),
|
||||
UpdateBody = _markdown.Transform(update.Body.Trim()),
|
||||
UpdateTitle = update.Name,
|
||||
UpdateUrl = update.Html_Url,
|
||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
||||
PublishDate = update.Published_At
|
||||
};
|
||||
}
|
||||
|
||||
public async Task PushUpdate(UpdateNotificationDto update)
|
||||
{
|
||||
if (update == null) return;
|
||||
|
||||
var admins = await _tracker.GetOnlineAdmins();
|
||||
var updateVersion = new Version(update.CurrentVersion);
|
||||
|
||||
if (BuildInfo.Version < updateVersion)
|
||||
{
|
||||
_logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion);
|
||||
await SendEvent(update, admins);
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development)
|
||||
{
|
||||
_logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version);
|
||||
await SendEvent(update, admins);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendEvent(UpdateNotificationDto update, IReadOnlyList<string> admins)
|
||||
{
|
||||
await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateAvailable, MessageFactory.UpdateVersionEvent(update));
|
||||
}
|
||||
|
||||
|
||||
private static async Task<GithubReleaseMetadata> GetGithubRelease()
|
||||
{
|
||||
var update = await GithubLatestReleasesUrl
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.GetJsonAsync<GithubReleaseMetadata>();
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<GithubReleaseMetadata>> GetGithubReleases()
|
||||
{
|
||||
var update = await GithubAllReleasesUrl
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.GetJsonAsync<IEnumerable<GithubReleaseMetadata>>();
|
||||
|
||||
return update;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,51 +6,54 @@ using System.Security.Claims;
|
|||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;
|
||||
|
||||
|
||||
namespace API.Services
|
||||
namespace API.Services;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
public class TokenService : ITokenService
|
||||
Task<string> CreateToken(AppUser user);
|
||||
}
|
||||
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly SymmetricSecurityKey _key;
|
||||
|
||||
public TokenService(IConfiguration config, UserManager<AppUser> userManager)
|
||||
{
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly SymmetricSecurityKey _key;
|
||||
|
||||
public TokenService(IConfiguration config, UserManager<AppUser> userManager)
|
||||
{
|
||||
|
||||
_userManager = userManager;
|
||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]));
|
||||
}
|
||||
|
||||
public async Task<string> CreateToken(AppUser user)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.NameId, user.UserName)
|
||||
};
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor()
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.Now.AddDays(7),
|
||||
SigningCredentials = creds
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
_userManager = userManager;
|
||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> CreateToken(AppUser user)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.NameId, user.UserName)
|
||||
};
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor()
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.Now.AddDays(7),
|
||||
SigningCredentials = creds
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Services
|
||||
{
|
||||
public class WarmupServicesStartupTask : IStartupTask
|
||||
{
|
||||
private readonly IServiceCollection _services;
|
||||
private readonly IServiceProvider _provider;
|
||||
public WarmupServicesStartupTask(IServiceCollection services, IServiceProvider provider)
|
||||
{
|
||||
_services = services;
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
foreach (var singleton in GetServices(_services))
|
||||
{
|
||||
Console.WriteLine("DI preloading of " + singleton.FullName);
|
||||
scope.ServiceProvider.GetServices(singleton);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static IEnumerable<Type> GetServices(IServiceCollection services)
|
||||
{
|
||||
return services
|
||||
.Where(descriptor => descriptor.ImplementationType != typeof(WarmupServicesStartupTask))
|
||||
.Where(descriptor => !descriptor.ServiceType.ContainsGenericParameters)
|
||||
.Select(descriptor => descriptor.ServiceType)
|
||||
.Distinct();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Interfaces;
|
||||
using API.Data;
|
||||
|
||||
namespace API.SignalR.Presence
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue