Merge pull request #46 from Kareadita/feature/compress-overhaul

server settings
This commit is contained in:
Joseph Milazzo 2021-02-04 17:55:29 -06:00 committed by GitHub
commit 3548a3811c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 3482 additions and 441 deletions

1
.gitignore vendored
View File

@ -450,3 +450,4 @@ appsettings.json
/API/Hangfire-log.db
cache/
/API/wwwroot/
/API/cache/

View File

@ -25,7 +25,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Services\Test Data" />
<Folder Include="Services\Test Data\ArchiveService" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,19 @@
using System.Linq;
using API.Comparators;
using Xunit;
namespace API.Tests
{
public class ChapterSortComparerTest
{
[Theory]
[InlineData(new[] {1, 2, 0}, new[] {1, 2, 0})]
[InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})]
[InlineData(new[] {1, 0, 0}, new[] {1, 0, 0})]
public void ChapterSortTest(int[] input, int[] expected)
{
Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparer()).ToArray());
}
}
}

View File

@ -0,0 +1,25 @@
using API.Helpers.Converters;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Converters
{
public class CronConverterTests
{
private readonly ITestOutputHelper _testOutputHelper;
public CronConverterTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
}
[Theory]
[InlineData("daily", "0 0 * * *")]
[InlineData("disabled", "0 0 31 2 *")]
[InlineData("weekly", "0 0 * * 1")]
public void ConvertTest(string input, string expected)
{
Assert.Equal(expected, CronConverter.ConvertToCronNotation(input));
}
}
}

View File

@ -33,6 +33,9 @@ namespace API.Tests
[InlineData("Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", "1")]
[InlineData("Dorohedoro v11 (2013) (Digital) (LostNerevarine-Empire).cbz", "11")]
[InlineData("Dorohedoro v12 (2013) (Digital) (LostNerevarine-Empire).cbz", "12")]
[InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")]
[InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "0")]
public void ParseVolumeTest(string filename, string expected)
{
Assert.Equal(expected, ParseVolume(filename));
@ -74,6 +77,8 @@ namespace API.Tests
[InlineData("Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip", "Ichinensei ni Nacchattara")]
[InlineData("Chrno_Crusade_Dragon_Age_All_Stars[AS].zip", "")]
[InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip", "Ichiban Ushiro no Daimaou")]
[InlineData("Rent a Girlfriend v01.cbr", "Rent a Girlfriend")]
[InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "Yumekui Merry")]
//[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "Epigraph of the Closed Curve")]
public void ParseSeriesTest(string filename, string expected)
{
@ -102,6 +107,9 @@ namespace API.Tests
[InlineData("Mujaki no Rakuen Vol12 ch76", "76")]
[InlineData("Beelzebub_01_[Noodles].zip", "1")]
[InlineData("Yumekui-Merry_DKThias_Chapter21.zip", "21")]
[InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")]
[InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "11")]
[InlineData("Beelzebub_53[KSH].zip", "53")]
//[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "6")]
public void ParseChaptersTest(string filename, string expected)
{
@ -158,6 +166,16 @@ namespace API.Tests
{
Assert.Equal(expected, ParseEdition(input));
}
[Theory]
[InlineData("12-14", 12)]
[InlineData("24", 24)]
[InlineData("18-04", 4)]
public void MinimumNumberFromRangeTest(string input, int expected)
{
Assert.Equal(expected, MinimumNumberFromRange(input));
}
[Fact]
public void ParseInfoTest()

View File

@ -0,0 +1,73 @@
using System.IO;
using System.IO.Compression;
using API.Interfaces;
using API.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services
{
public class ArchiveServiceTests
{
private readonly IArchiveService _archiveService;
private readonly ILogger<ArchiveService> _logger = Substitute.For<ILogger<ArchiveService>>();
public ArchiveServiceTests()
{
_archiveService = new ArchiveService(_logger);
}
[Theory]
[InlineData("flat file.zip", false)]
[InlineData("file in folder in folder.zip", true)]
[InlineData("file in folder.zip", true)]
[InlineData("file in folder_alt.zip", true)]
public void ArchiveNeedsFlatteningTest(string archivePath, bool expected)
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives");
var file = Path.Join(testDirectory, archivePath);
using ZipArchive archive = ZipFile.OpenRead(file);
Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive));
}
[Theory]
[InlineData("non existent file.zip", false)]
[InlineData("wrong extension.rar", false)]
[InlineData("empty.zip", false)]
[InlineData("flat file.zip", true)]
[InlineData("file in folder in folder.zip", true)]
[InlineData("file in folder.zip", true)]
[InlineData("file in folder_alt.zip", true)]
public void IsValidArchiveTest(string archivePath, bool expected)
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives");
Assert.Equal(expected, _archiveService.IsValidArchive(Path.Join(testDirectory, archivePath)));
}
[Theory]
[InlineData("non existent file.zip", 0)]
[InlineData("wrong extension.rar", 0)]
[InlineData("empty.zip", 0)]
[InlineData("flat file.zip", 1)]
[InlineData("file in folder in folder.zip", 1)]
[InlineData("file in folder.zip", 1)]
[InlineData("file in folder_alt.zip", 1)]
public void GetNumberOfPagesFromArchiveTest(string archivePath, int expected)
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives");
Assert.Equal(expected, _archiveService.GetNumberOfPagesFromArchive(Path.Join(testDirectory, archivePath)));
}
[Theory]
[InlineData("v10.cbz", "v10.expected.jpg")]
[InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")]
[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
public void GetCoverImageTest(string inputFile, string expectedOutputFile)
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages");
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
Assert.Equal(expectedBytes, _archiveService.GetCoverImage(Path.Join(testDirectory, inputFile)));
}
}
}

View File

@ -0,0 +1,98 @@
using API.Interfaces;
using API.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services
{
public class CacheServiceTests
{
// private readonly CacheService _cacheService;
// private readonly ILogger<CacheService> _logger = Substitute.For<ILogger<CacheService>>();
// private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
// private readonly IArchiveService _archiveService = Substitute.For<IArchiveService>();
// private readonly IDirectoryService _directoryService = Substitute.For<DirectoryService>();
public CacheServiceTests()
{
//_cacheService = new CacheService(_logger, _unitOfWork, _archiveService, _directoryService);
}
//string GetCachedPagePath(Volume volume, int page)
[Fact]
//[InlineData("", 0, "")]
public void GetCachedPagePathTest_Should()
{
// TODO: Figure out how to test this
// string archivePath = "flat file.zip";
// int pageNum = 0;
// string expected = "cache/1/pexels-photo-6551949.jpg";
//
// var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives");
// var file = Path.Join(testDirectory, archivePath);
// var volume = new Volume
// {
// Id = 1,
// Files = new List<MangaFile>()
// {
// new()
// {
// Id = 1,
// Chapter = 0,
// FilePath = archivePath,
// Format = MangaFormat.Archive,
// NumberOfPages = 1,
// }
// },
// Name = "1",
// Number = 1
// };
//
// var cacheService = Substitute.ForPartsOf<CacheService>();
// cacheService.Configure().CacheDirectoryIsAccessible().Returns(true);
// cacheService.Configure().GetVolumeCachePath(1, volume.Files.ElementAt(0)).Returns("cache/1/");
// _directoryService.Configure().GetFiles("cache/1/").Returns(new string[] {"pexels-photo-6551949.jpg"});
// Assert.Equal(expected, _cacheService.GetCachedPagePath(volume, pageNum));
Assert.True(true);
}
[Fact]
public void GetOrderedChaptersTest()
{
// var files = new List<Chapter>()
// {
// new()
// {
// Number = "1"
// },
// new()
// {
// Chapter = 2
// },
// new()
// {
// Chapter = 0
// },
// };
// var expected = new List<MangaFile>()
// {
// new()
// {
// Chapter = 1
// },
// new()
// {
// Chapter = 2
// },
// new()
// {
// Chapter = 0
// },
// };
// Assert.NotStrictEqual(expected, _cacheService.GetOrderedChapters(files));
}
}
}

View File

@ -1,20 +0,0 @@
using System.IO;
using Xunit;
namespace API.Tests.Services
{
public class ImageProviderTest
{
// [Theory]
// [InlineData("v10.cbz", "v10.expected.jpg")]
// [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")]
// [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
// public void GetCoverImageTest(string inputFile, string expectedOutputFile)
// {
// var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageProvider");
// var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
// // TODO: Implement this with ScannerService
// //Assert.Equal(expectedBytes, ImageProvider.GetCoverImage(Path.Join(testDirectory, inputFile)));
// }
}
}

View File

@ -1,20 +0,0 @@
using API.Interfaces;
using API.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace API.Tests.Services
{
public class ScannerServiceTests
{
private readonly ScannerService _scannerService;
private readonly ILogger<ScannerService> _logger = Substitute.For<ILogger<ScannerService>>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
public ScannerServiceTests()
{
_scannerService = new ScannerService(_unitOfWork, _logger);
}
// TODO: Start adding tests for how scanner works so we can ensure fallbacks, etc work
}
}

View File

@ -11,7 +11,6 @@ namespace API.Tests.Services
new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"},
new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"}
)]
public void TestLogicalComparer(string[] input, string[] expected)
{
NumericComparer nc = new NumericComparer();

View File

@ -0,0 +1,2 @@
Files in this test are all royalty free and can be found here:
https://www.pexels.com/photo/snow-wood-light-art-6551949/

View File

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 395 KiB

View File

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 385 KiB

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace API.Comparators
{
public class ChapterSortComparer : IComparer<int>
{
public int Compare(int x, int y)
{
if (x == 0 && y == 0) return 0;
// if x is 0, it comes second
if (x == 0) return 1;
// if y is 0, it comes second
if (y == 0) return -1;
return x.CompareTo(y);
}
}
}

View File

@ -20,16 +20,16 @@ namespace API.Comparators
if (string.IsNullOrEmpty(s2)) return -1;
//WE style, special case
bool sp1 = Char.IsLetterOrDigit(s1, 0);
bool sp2 = Char.IsLetterOrDigit(s2, 0);
var sp1 = Char.IsLetterOrDigit(s1, 0);
var sp2 = Char.IsLetterOrDigit(s2, 0);
if(sp1 && !sp2) return 1;
if(!sp1 && sp2) return -1;
int i1 = 0, i2 = 0; //current index
while(true)
{
bool c1 = Char.IsDigit(s1, i1);
bool c2 = Char.IsDigit(s2, i2);
var c1 = Char.IsDigit(s1, i1);
var c2 = Char.IsDigit(s2, i2);
int r; // temp result
if(!c1 && !c2)
{

View File

@ -20,9 +20,5 @@ namespace API.Controllers
var users = await _userManager.GetUsersInRoleAsync("Admin");
return users.Count > 0;
}
}
}

View File

@ -1,5 +1,4 @@
using System.IO;
using API.Services;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers

View File

@ -145,7 +145,7 @@ namespace API.Controllers
[HttpPost("scan")]
public ActionResult Scan(int libraryId)
{
_taskScheduler.ScanLibrary(libraryId, false);
_taskScheduler.ScanLibrary(libraryId);
return Ok();
}
@ -177,13 +177,13 @@ namespace API.Controllers
var username = User.GetUsername();
_logger.LogInformation($"Library {libraryId} is being deleted by {username}.");
var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId);
var volumes = (await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(series.Select(x => x.Id).ToArray()))
.Select(x => x.Id).ToArray();
var chapterIds =
await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(series.Select(x => x.Id).ToArray());
var result = await _unitOfWork.LibraryRepository.DeleteLibrary(libraryId);
if (result && volumes.Any())
if (result && chapterIds.Any())
{
_taskScheduler.CleanupVolumes(volumes);
_taskScheduler.CleanupChapters(chapterIds);
}
return Ok(result);

View File

@ -28,24 +28,28 @@ namespace API.Controllers
}
[HttpGet("image")]
public async Task<ActionResult<ImageDto>> GetImage(int volumeId, int page)
public async Task<ActionResult<ImageDto>> GetImage(int chapterId, int page)
{
// Temp let's iterate the directory each call to get next image
var volume = await _cacheService.Ensure(volumeId);
var chapter = await _cacheService.Ensure(chapterId);
var path = _cacheService.GetCachedPagePath(volume, page);
if (chapter == null) return BadRequest("There was an issue finding image file for reading.");
var (path, mangaFile) = await _cacheService.GetCachedPagePath(chapter, page);
if (string.IsNullOrEmpty(path)) return BadRequest($"No such image for page {page}");
var file = await _directoryService.ReadImageAsync(path);
file.Page = page;
file.MangaFileName = mangaFile.FilePath;
return Ok(file);
}
[HttpGet("get-bookmark")]
public async Task<ActionResult<int>> GetBookmark(int volumeId)
public async Task<ActionResult<int>> GetBookmark(int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Progresses == null) return Ok(0);
var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.VolumeId == volumeId);
var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId);
if (progress != null) return Ok(progress.PagesRead);
@ -56,10 +60,12 @@ namespace API.Controllers
public async Task<ActionResult> Bookmark(BookmarkDto bookmarkDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
_logger.LogInformation($"Saving {user.UserName} progress for {bookmarkDto.VolumeId} to page {bookmarkDto.PageNum}");
_logger.LogInformation($"Saving {user.UserName} progress for Chapter {bookmarkDto.ChapterId} to page {bookmarkDto.PageNum}");
// TODO: Don't let user bookmark past total pages.
user.Progresses ??= new List<AppUserProgress>();
var userProgress = user.Progresses.SingleOrDefault(x => x.VolumeId == bookmarkDto.VolumeId && x.AppUserId == user.Id);
var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id);
if (userProgress == null)
{
@ -69,13 +75,14 @@ namespace API.Controllers
PagesRead = bookmarkDto.PageNum,
VolumeId = bookmarkDto.VolumeId,
SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId
});
}
else
{
userProgress.PagesRead = bookmarkDto.PageNum;
userProgress.SeriesId = bookmarkDto.SeriesId;
userProgress.VolumeId = bookmarkDto.VolumeId;
}
_unitOfWork.UserRepository.Update(user);
@ -84,8 +91,7 @@ namespace API.Controllers
{
return Ok();
}
return BadRequest("Could not save progress");
}
}

View File

@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
@ -36,17 +35,22 @@ namespace API.Controllers
public async Task<ActionResult<bool>> DeleteSeries(int seriesId)
{
var username = User.GetUsername();
var volumes = (await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(new []{seriesId})).Select(x => x.Id).ToArray();
var chapterIds = (await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{seriesId}));
_logger.LogInformation($"Series {seriesId} is being deleted by {username}.");
var result = await _unitOfWork.SeriesRepository.DeleteSeriesAsync(seriesId);
if (result)
{
_taskScheduler.CleanupVolumes(volumes);
_taskScheduler.CleanupChapters(chapterIds);
}
return Ok(result);
}
/// <summary>
/// Returns All volumes for a series with progress information and Chapters
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("volumes")]
public async Task<ActionResult<IEnumerable<VolumeDto>>> GetVolumes(int seriesId)
{
@ -61,6 +65,12 @@ namespace API.Controllers
return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, user.Id));
}
[HttpGet("chapter")]
public async Task<ActionResult<VolumeDto>> GetChapter(int chapterId)
{
return Ok(await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("scan")]
public ActionResult Scan(int libraryId, int seriesId)

View File

@ -1,17 +1,13 @@
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Entities;
using API.Extensions;
using API.Helpers.Converters;
using API.Interfaces;
using API.Services;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Controllers
@ -19,32 +15,27 @@ namespace API.Controllers
[Authorize]
public class SettingsController : BaseApiController
{
private readonly DataContext _dataContext;
private readonly ILogger<SettingsController> _logger;
private readonly IMapper _mapper;
private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork;
public SettingsController(DataContext dataContext, ILogger<SettingsController> logger, IMapper mapper, ITaskScheduler taskScheduler)
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork)
{
_dataContext = dataContext;
_logger = logger;
_mapper = mapper;
_taskScheduler = taskScheduler;
_unitOfWork = unitOfWork;
}
[HttpGet("")]
public async Task<ActionResult<ServerSettingDto>> GetSettings()
{
var settings = await _dataContext.ServerSetting.Select(x => x).ToListAsync();
return _mapper.Map<ServerSettingDto>(settings);
return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync());
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("")]
public async Task<ActionResult> UpdateSettings(ServerSettingDto updateSettingsDto)
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
{
_logger.LogInformation($"{User.GetUsername()} is updating Server Settings");
if (updateSettingsDto.CacheDirectory.Equals(string.Empty))
{
return BadRequest("Cache Directory cannot be empty");
@ -54,13 +45,39 @@ namespace API.Controllers
{
return BadRequest("Directory does not exist or is not accessible.");
}
// TODO: Figure out how to handle a change. This means that on clean, we need to clean up old cache
// directory and new one, but what if someone is reading?
// I can just clean both always, /cache/ is an owned folder, so users shouldn't use it.
//_dataContext.ServerSetting.Update
return BadRequest("Not Implemented");
// We do not allow CacheDirectory changes, so we will ignore.
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
foreach (var setting in currentSettings)
{
if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
{
setting.Value = updateSettingsDto.TaskBackup;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
{
setting.Value = updateSettingsDto.TaskScan;
_unitOfWork.SettingsRepository.Update(setting);
}
}
if (_unitOfWork.HasChanges() && await _unitOfWork.Complete())
{
_logger.LogInformation("Server Settings updated.");
return Ok(updateSettingsDto);
}
return BadRequest("There was a critical issue. Please try again.");
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("task-frequencies")]
public ActionResult<IEnumerable<string>> GetTaskFrequencies()
{
return Ok(CronConverter.Options);
}
}
}

31
API/DTOs/ChapterDto.cs Normal file
View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace API.DTOs
{
public class ChapterDto
{
public int Id { get; set; }
/// <summary>
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2".
/// </summary>
public string Range { get; set; }
/// <summary>
/// Smallest number of the Range.
/// </summary>
public string Number { get; set; }
public byte[] CoverImage { get; set; }
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>
public int Pages { get; set; }
/// <summary>
/// The files that represent this Chapter
/// </summary>
public ICollection<MangaFileDto> Files { get; set; }
/// <summary>
/// Calculated at API time. Number of pages read for this Chapter for logged in user.
/// </summary>
public int PagesRead { get; set; }
public int VolumeId { get; set; }
}
}

View File

@ -9,5 +9,7 @@
public int Height { get; init; }
public string Format { get; init; }
public byte[] Content { get; init; }
public int Chapter { get; set; }
public string MangaFileName { get; set; }
}
}

12
API/DTOs/MangaFileDto.cs Normal file
View File

@ -0,0 +1,12 @@
using API.Entities;
namespace API.DTOs
{
public class MangaFileDto
{
public string FilePath { get; set; }
public int NumberOfPages { get; set; }
public MangaFormat Format { get; set; }
}
}

View File

@ -3,7 +3,8 @@
public class ServerSettingDto
{
public string CacheDirectory { get; set; }
// public string Kind { get; init; }
// public string Value { get; init; }
public string TaskScan { get; set; }
public string LoggingLevel { get; set; }
public string TaskBackup { get; set; }
}
}

View File

@ -1,4 +1,6 @@

using System.Collections.Generic;
namespace API.DTOs
{
public class VolumeDto
@ -9,5 +11,6 @@ namespace API.DTOs
public byte[] CoverImage { get; set; }
public int Pages { get; set; }
public int PagesRead { get; set; }
public ICollection<ChapterDto> Chapters { get; set; }
}
}

View File

@ -3,6 +3,7 @@
public class BookmarkDto
{
public int VolumeId { get; init; }
public int ChapterId { get; init; }
public int PageNum { get; init; }
public int SeriesId { get; init; }
}

View File

@ -20,8 +20,11 @@ namespace API.Data
}
public DbSet<Library> Library { get; set; }
public DbSet<Series> Series { get; set; }
public DbSet<Chapter> Chapter { get; set; }
public DbSet<Volume> Volume { get; set; }
public DbSet<AppUser> AppUser { get; set; }
public DbSet<MangaFile> MangaFile { get; set; }
public DbSet<AppUserProgress> AppUserProgresses { get; set; }
public DbSet<AppUserRating> AppUserRating { get; set; }
public DbSet<ServerSetting> ServerSetting { get; set; }
@ -30,10 +33,6 @@ namespace API.Data
{
base.OnModelCreating(builder);
// builder.Entity<ServerSetting>()
// .HasAlternateKey(s => s.Key)
// .HasName("AlternateKey_Key");
builder.Entity<AppUser>()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.User)

View File

@ -0,0 +1,688 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210128143348_SeriesVolumeChapterChange")]
partial class SeriesVolumeChapterChange
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.1");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Chapter")
.HasColumnType("INTEGER");
b.Property<int?>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<int>("NumberOfPages")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.HasIndex("VolumeId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", null)
.WithMany("Files")
.HasForeignKey("ChapterId");
b.HasOne("API.Entities.Volume", "Volume")
.WithMany()
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,111 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class SeriesVolumeChapterChange : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsSpecial",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "ChapterId",
table: "MangaFile",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastScanned",
table: "FolderPath",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<int>(
name: "ChapterId",
table: "AppUserProgresses",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Chapter",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Range = table.Column<string>(type: "TEXT", nullable: true),
Number = table.Column<string>(type: "TEXT", nullable: true),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
CoverImage = table.Column<byte[]>(type: "BLOB", nullable: true),
Pages = table.Column<int>(type: "INTEGER", nullable: false),
VolumeId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapter", x => x.Id);
table.ForeignKey(
name: "FK_Chapter_Volume_VolumeId",
column: x => x.VolumeId,
principalTable: "Volume",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MangaFile_ChapterId",
table: "MangaFile",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_Chapter_VolumeId",
table: "Chapter",
column: "VolumeId");
migrationBuilder.AddForeignKey(
name: "FK_MangaFile_Chapter_ChapterId",
table: "MangaFile",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MangaFile_Chapter_ChapterId",
table: "MangaFile");
migrationBuilder.DropTable(
name: "Chapter");
migrationBuilder.DropIndex(
name: "IX_MangaFile_ChapterId",
table: "MangaFile");
migrationBuilder.DropColumn(
name: "IsSpecial",
table: "Volume");
migrationBuilder.DropColumn(
name: "ChapterId",
table: "MangaFile");
migrationBuilder.DropColumn(
name: "LastScanned",
table: "FolderPath");
migrationBuilder.DropColumn(
name: "ChapterId",
table: "AppUserProgresses");
}
}
}

View File

@ -0,0 +1,676 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210128201832_MangaFileChapterRelationship")]
partial class MangaFileChapterRelationship
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.1");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<int>("NumberOfPages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class MangaFileChapterRelationship : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MangaFile_Chapter_ChapterId",
table: "MangaFile");
migrationBuilder.DropForeignKey(
name: "FK_MangaFile_Volume_VolumeId",
table: "MangaFile");
migrationBuilder.DropIndex(
name: "IX_MangaFile_VolumeId",
table: "MangaFile");
migrationBuilder.DropColumn(
name: "Chapter",
table: "MangaFile");
migrationBuilder.DropColumn(
name: "VolumeId",
table: "MangaFile");
migrationBuilder.AlterColumn<int>(
name: "ChapterId",
table: "MangaFile",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_MangaFile_Chapter_ChapterId",
table: "MangaFile",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MangaFile_Chapter_ChapterId",
table: "MangaFile");
migrationBuilder.AlterColumn<int>(
name: "ChapterId",
table: "MangaFile",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AddColumn<int>(
name: "Chapter",
table: "MangaFile",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "VolumeId",
table: "MangaFile",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_MangaFile_VolumeId",
table: "MangaFile",
column: "VolumeId");
migrationBuilder.AddForeignKey(
name: "FK_MangaFile_Chapter_ChapterId",
table: "MangaFile",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_MangaFile_Volume_VolumeId",
table: "MangaFile",
column: "VolumeId",
principalTable: "Volume",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@ -0,0 +1,676 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210203164258_ServerSettingsKey")]
partial class ServerSettingsKey
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.1");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<int>("NumberOfPages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class ServerSettingsKey : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Key",
table: "ServerSetting",
type: "INTEGER",
nullable: false,
oldClrType: typeof(string),
oldType: "TEXT");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "ServerSetting",
type: "TEXT",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
}
}
}

View File

@ -127,6 +127,9 @@ namespace API.Data.Migrations
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
@ -183,12 +186,49 @@ namespace API.Data.Migrations
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
@ -234,7 +274,7 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Chapter")
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
@ -246,12 +286,9 @@ namespace API.Data.Migrations
b.Property<int>("NumberOfPages")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
@ -298,8 +335,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
@ -325,6 +362,9 @@ namespace API.Data.Migrations
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
@ -487,6 +527,17 @@ namespace API.Data.Migrations
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
@ -500,13 +551,13 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("VolumeId")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
@ -596,6 +647,11 @@ namespace API.Data.Migrations
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
@ -610,7 +666,7 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Files");
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}

View File

@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
@ -31,20 +30,24 @@ namespace API.Data
public static async Task SeedSettings(DataContext context)
{
// NOTE: This needs to check if settings already exists before inserting.
// IList<ServerSetting> defaultSettings = new List<ServerSetting>()
// {
// new ServerSetting() {Key = "CacheDirectory", Value = CacheService.CacheDirectory}
// };
//
// await context.ServerSetting.AddRangeAsync(defaultSettings);
// await context.SaveChangesAsync();
// await context.ServerSetting.AddAsync(new ServerSetting
// {
// CacheDirectory = CacheService.CacheDirectory
// });
//
// await context.SaveChangesAsync();
context.Database.EnsureCreated();
IList<ServerSetting> defaultSettings = new List<ServerSetting>()
{
new() {Key = ServerSettingKey.CacheDirectory, Value = CacheService.CacheDirectory},
new () {Key = ServerSettingKey.TaskScan, Value = "daily"}
};
foreach (var defaultSetting in defaultSettings)
{
var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key);
if (existing == null)
{
context.ServerSetting.Add(defaultSetting);
}
}
await context.SaveChangesAsync();
}
}
}

View File

@ -82,6 +82,7 @@ namespace API.Data
{
var volumes = await _context.Volume
.Where(vol => vol.SeriesId == seriesId)
.Include(vol => vol.Chapters)
.OrderBy(volume => volume.Number)
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
@ -98,7 +99,8 @@ namespace API.Data
{
return _context.Volume
.Where(vol => vol.SeriesId == seriesId)
.Include(vol => vol.Files)
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.OrderBy(vol => vol.Number)
.ToList();
}
@ -118,7 +120,8 @@ namespace API.Data
public async Task<Volume> GetVolumeAsync(int volumeId)
{
return await _context.Volume
.Include(vol => vol.Files)
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
}
@ -126,10 +129,11 @@ namespace API.Data
{
var volume = await _context.Volume
.Where(vol => vol.Id == volumeId)
.Include(vol => vol.Files)
.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);
@ -169,6 +173,30 @@ namespace API.Data
.SingleOrDefaultAsync();
}
public async Task<int[]> GetChapterIdsForSeriesAsync(int[] seriesIds)
{
var series = await _context.Series
.Where(s => seriesIds.Contains(s.Id))
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters)
.ToListAsync();
// TODO: refactor this
IList<int> chapterIds = new List<int>();
foreach (var s in series)
{
foreach (var v in s.Volumes)
{
foreach (var c in v.Chapters)
{
chapterIds.Add(c.Id);
}
}
}
return chapterIds.ToArray();
}
private async Task AddSeriesModifiers(int userId, List<SeriesDto> series)
{
var userProgress = await _context.AppUserProgresses
@ -197,6 +225,11 @@ namespace API.Data
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);
}
}

View File

@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Interfaces;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
namespace API.Data
{
public class SettingsRepository : ISettingsRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public SettingsRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
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 Task<ServerSetting> GetSettingAsync(ServerSettingKey key)
{
return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key);
}
public async Task<IEnumerable<ServerSetting>> GetSettingsAsync()
{
return await _context.ServerSetting.ToListAsync();
}
}
}

View File

@ -22,6 +22,10 @@ namespace API.Data
public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper);
public IUserRepository UserRepository => new UserRepository(_context, _userManager);
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper);
public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper);
public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper);
public async Task<bool> Complete()
{

View File

@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Interfaces;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data
{
public class VolumeRepository : IVolumeRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public VolumeRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Update(Volume volume)
{
_context.Entry(volume).State = EntityState.Modified;
}
/// <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)
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Id == chapterId);
}
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;
}
public async Task<IList<MangaFile>> GetFilesForChapter(int chapterId)
{
return await _context.MangaFile
.Where(c => chapterId == c.Id)
.AsNoTracking()
.ToListAsync();
}
}
}

View File

@ -2,7 +2,7 @@
namespace API.Entities
{
/// <summary>
/// Represents the progress a single user has on a given Volume.
/// Represents the progress a single user has on a given Volume. Progress is realistically tracked against the Volume's chapters.
/// </summary>
public class AppUserProgress
{
@ -11,6 +11,8 @@ namespace API.Entities
public int VolumeId { get; set; }
public int SeriesId { get; set; }
public int ChapterId { get; set; }
// Relationships
public AppUser AppUser { get; set; }
public int AppUserId { get; set; }

35
API/Entities/Chapter.cs Normal file
View File

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using API.Entities.Interfaces;
namespace API.Entities
{
public class Chapter : IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2".
/// </summary>
public string Range { get; set; }
/// <summary>
/// Smallest number of the Range. Can be a partial like Chapter 4.5
/// </summary>
public string Number { get; set; }
/// <summary>
/// The files that represent this Chapter
/// </summary>
public ICollection<MangaFile> Files { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public byte[] CoverImage { get; set; }
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>
public int Pages { get; set; }
// Relationships
public Volume Volume { get; set; }
public int VolumeId { get; set; }
}
}

View File

@ -1,10 +1,18 @@

using System;
namespace API.Entities
{
public class FolderPath
{
public int Id { get; set; }
public string Path { get; set; }
/// <summary>
/// Used when scanning to see if we can skip if nothing has changed.
/// </summary>
public DateTime LastScanned { get; set; }
// Relationship
public Library Library { get; set; }
public int LibraryId { get; set; }
}

View File

@ -9,18 +9,14 @@ namespace API.Entities
/// </summary>
public string FilePath { get; set; }
/// <summary>
/// Used to track if multiple MangaFiles (archives) represent a single Volume. If only one volume file, this will be 0.
/// </summary>
public int Chapter { get; set; }
/// <summary>
/// Number of pages for the given file
/// </summary>
public int NumberOfPages { get; set; }
public MangaFormat Format { get; set; }
// Relationship Mapping
public Volume Volume { get; set; }
public int VolumeId { get; set; }
public Chapter Chapter { get; set; }
public int ChapterId { get; set; }
}
}

View File

@ -12,14 +12,14 @@ namespace API.Entities
/// </summary>
public string Name { get; set; }
/// <summary>
/// Original Japanese Name
/// </summary>
public string OriginalName { get; set; }
/// <summary>
/// The name used to sort the Series. By default, will be the same as Name.
/// </summary>
public string SortName { get; set; }
/// <summary>
/// Original Name on disk. Not exposed to UI.
/// </summary>
public string OriginalName { get; set; }
/// <summary>
/// Summary information related to the Series
/// </summary>
public string Summary { get; set; }
@ -27,10 +27,10 @@ namespace API.Entities
public DateTime LastModified { get; set; }
public byte[] CoverImage { get; set; }
/// <summary>
/// Sum of all Volume pages
/// Sum of all Volume page counts
/// </summary>
public int Pages { get; set; }
// Relationships
public ICollection<Volume> Volumes { get; set; }
public Library Library { get; set; }

View File

@ -1,13 +1,12 @@
using System.ComponentModel.DataAnnotations;
using API.Entities.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace API.Entities
{
public class ServerSetting : IHasConcurrencyToken
{
[Key]
public string Key { get; set; }
public ServerSettingKey Key { get; set; }
public string Value { get; set; }
[ConcurrencyCheck]

View File

@ -0,0 +1,10 @@
namespace API.Entities
{
public enum ServerSettingKey
{
TaskScan = 0,
CacheDirectory = 1,
TaskBackup = 2,
LoggingLevel = 3
}
}

View File

@ -9,11 +9,16 @@ namespace API.Entities
public int Id { get; set; }
public string Name { get; set; }
public int Number { get; set; }
public ICollection<MangaFile> Files { get; set; }
public ICollection<Chapter> Chapters { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public byte[] CoverImage { get; set; }
public int Pages { get; set; }
/// <summary>
/// Represents a Side story that is linked to the original Series. Omake, One Shot, etc.
/// </summary>
public bool IsSpecial { get; set; } = false;

View File

@ -23,6 +23,7 @@ namespace API.Extensions
services.AddScoped<ICacheService, CacheService>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IArchiveService, ArchiveService>();

View File

@ -1,6 +1,4 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO;
namespace API.Extensions
{
@ -51,8 +49,8 @@ namespace API.Extensions
if (file.Directory == null) continue;
var newName = $"{file.Directory.Name}_{file.Name}";
var newPath = Path.Join(root.FullName, newName);
file.MoveTo(newPath);
if (!File.Exists(newPath)) file.MoveTo(newPath);
}
}

View File

@ -15,6 +15,10 @@ namespace API.Helpers
CreateMap<Volume, VolumeDto>();
CreateMap<MangaFile, MangaFileDto>();
CreateMap<Chapter, ChapterDto>();
CreateMap<Series, SeriesDto>();
CreateMap<Library, LibraryDto>()

View File

@ -0,0 +1,41 @@
using System.Collections.Generic;
using Hangfire;
namespace API.Helpers.Converters
{
public static class CronConverter
{
public static readonly IEnumerable<string> Options = new []
{
"disabled",
"daily",
"weekly",
};
public static string ConvertToCronNotation(string source)
{
string destination = "";
destination = source.ToLower() switch
{
"daily" => Cron.Daily(),
"weekly" => Cron.Weekly(),
"disabled" => Cron.Never(),
"" => Cron.Never(),
_ => destination
};
return destination;
}
public static string ConvertFromCronNotation(string cronNotation)
{
string destination = "";
destination = cronNotation.ToLower() switch
{
"0 0 31 2 *" => "disabled",
_ => destination
};
return destination;
}
}
}

View File

@ -9,16 +9,24 @@ namespace API.Helpers.Converters
{
public ServerSettingDto Convert(IEnumerable<ServerSetting> source, ServerSettingDto destination, ResolutionContext context)
{
destination = new ServerSettingDto();
destination ??= new ServerSettingDto();
foreach (var row in source)
{
switch (row.Key)
{
case "CacheDirectory":
case ServerSettingKey.CacheDirectory:
destination.CacheDirectory = row.Value;
break;
default:
case ServerSettingKey.TaskScan:
destination.TaskScan = row.Value;
break;
case ServerSettingKey.LoggingLevel:
destination.LoggingLevel = row.Value;
break;
case ServerSettingKey.TaskBackup:
destination.TaskBackup = row.Value;
break;
}
}

View File

@ -0,0 +1,13 @@
using System.IO.Compression;
namespace API.Interfaces
{
public interface IArchiveService
{
bool ArchiveNeedsFlattening(ZipArchive archive);
void ExtractArchive(string archivePath, string extractPath);
int GetNumberOfPagesFromArchive(string archivePath);
byte[] GetCoverImage(string filepath, bool createThumbnail = false);
bool IsValidArchive(string archivePath);
}
}

View File

@ -6,12 +6,12 @@ namespace API.Interfaces
public interface ICacheService
{
/// <summary>
/// Ensures the cache is created for the given volume and if not, will create it. Should be called before any other
/// 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="volumeId"></param>
/// <returns>Volume for the passed volumeId. Side-effect from ensuring cache.</returns>
Task<Volume> Ensure(int volumeId);
/// <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.
@ -21,16 +21,18 @@ namespace API.Interfaces
/// <summary>
/// Clears cache directory of all volumes. This can be invoked from deleting a library or a series.
/// </summary>
/// <param name="volumeIds">Volumes that belong to that library. Assume the library might have been deleted before this invocation.</param>
void CleanupVolumes(int[] volumeIds);
/// <param name="chapterIds">Volumes that belong to that library. Assume the library might have been deleted before this invocation.</param>
void CleanupChapters(int[] chapterIds);
/// <summary>
/// Returns the absolute path of a cached page.
/// </summary>
/// <param name="volume"></param>
/// <param name="chapter">Chapter entity with Files populated.</param>
/// <param name="page">Page number to look for</param>
/// <returns></returns>
string GetCachedPagePath(Volume volume, int page);
Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page);
void EnsureCacheDirectory();
}
}

View File

@ -14,5 +14,12 @@ namespace API.Interfaces
IEnumerable<string> ListDirectory(string rootPath);
Task<ImageDto> ReadImageAsync(string imagePath);
/// <summary>
/// Gets files in a directory. If searchPatternExpression is passed, will match the regex against for filtering.
/// </summary>
/// <param name="path"></param>
/// <param name="searchPatternExpression"></param>
/// <returns></returns>
string[] GetFiles(string path, string searchPatternExpression = "");
}
}

View File

@ -1,7 +1,4 @@
using System.Threading.Tasks;
using API.DTOs;
namespace API.Interfaces
namespace API.Interfaces
{
public interface IScannerService
{

View File

@ -16,14 +16,12 @@ namespace API.Interfaces
Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId);
IEnumerable<Volume> GetVolumes(int seriesId);
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<Volume> GetVolumeAsync(int volumeId);
Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(int[] seriesIds);
Task<bool> DeleteSeriesAsync(int seriesId);
Task<Volume> GetVolumeByIdAsync(int volumeId);
Task<Series> GetSeriesByIdAsync(int seriesId);
Task<int[]> GetChapterIdsForSeriesAsync(int[] seriesIds);
}
}

View File

@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces
{
public interface ISettingsRepository
{
void Update(ServerSetting settings);
Task<ServerSettingDto> GetSettingsDtoAsync();
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
}
}

View File

@ -3,7 +3,7 @@
public interface ITaskScheduler
{
void ScanLibrary(int libraryId, bool forceUpdate = false);
void CleanupVolumes(int[] volumeIds);
void CleanupChapters(int[] chapterIds);
void ScanSeries(int libraryId, int seriesId);
}
}

View File

@ -7,6 +7,8 @@ namespace API.Interfaces
ISeriesRepository SeriesRepository { get; }
IUserRepository UserRepository { get; }
ILibraryRepository LibraryRepository { get; }
IVolumeRepository VolumeRepository { get; }
ISettingsRepository SettingsRepository { get; }
Task<bool> Complete();
bool HasChanges();
}

View File

@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces
{
public interface IVolumeRepository
{
void Update(Volume volume);
Task<Chapter> GetChapterAsync(int chapterId);
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChapter(int chapterId);
}
}

View File

@ -1,5 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using API.Entities;
@ -9,6 +11,8 @@ namespace API.Parser
{
public static readonly string MangaFileExtensions = @"\.cbz|\.zip"; // |\.rar|\.cbr
public static readonly string ImageFileExtensions = @"\.png|\.jpeg|\.jpg|\.gif";
private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex MangaFileRegex = new Regex(MangaFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled);
//?: is a non-capturing group in C#, else anything in () will be a group
private static readonly Regex[] MangaVolumeRegex = new[]
@ -125,7 +129,7 @@ namespace API.Parser
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Beelzebub_01_[Noodles].zip
new Regex(
@"^((?!v|vo|vol|Volume).)*( |_)(?<Chapter>\.?\d+)( |_)",
@"^((?!v|vo|vol|Volume).)*( |_)(?<Chapter>\.?\d+)( |_|\[|\()",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Yumekui-Merry_DKThias_Chapter21.zip
new Regex(
@ -168,7 +172,7 @@ namespace API.Parser
/// <param name="filePath"></param>
/// <param name="rootPath">Root folder</param>
/// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
public static ParserInfo? Parse(string filePath, string rootPath)
public static ParserInfo Parse(string filePath, string rootPath)
{
var fileName = Path.GetFileName(filePath);
var directoryName = (new FileInfo(filePath)).Directory?.Name;
@ -387,13 +391,19 @@ namespace API.Parser
public static bool IsArchive(string filePath)
{
var fileInfo = new FileInfo(filePath);
return MangaFileExtensions.Contains(fileInfo.Extension);
return MangaFileRegex.IsMatch(fileInfo.Extension);
}
public static bool IsImage(string filePath)
{
var fileInfo = new FileInfo(filePath);
return ImageFileExtensions.Contains(fileInfo.Extension);
return ImageRegex.IsMatch(fileInfo.Extension);
}
public static int MinimumNumberFromRange(string range)
{
var tokens = range.Split("-");
return tokens.Min(Int32.Parse);
}
}
}

View File

@ -44,11 +44,6 @@ namespace API
private static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
// .ConfigureLogging(logging =>
// {
// logging.ClearProviders();
// logging.AddConsole();
// })
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@ -0,0 +1,175 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using API.Extensions;
using API.Interfaces;
using Microsoft.Extensions.Logging;
using NetVips;
namespace API.Services
{
/// <summary>
/// Responsible for manipulating Archive files. Used by <see cref="CacheService"/> and <see cref="ScannerService"/>
/// </summary>
public class ArchiveService : IArchiveService
{
private readonly ILogger<ArchiveService> _logger;
private const int ThumbnailWidth = 320;
public ArchiveService(ILogger<ArchiveService> logger)
{
_logger = logger;
}
public int GetNumberOfPagesFromArchive(string archivePath)
{
if (!IsValidArchive(archivePath)) return 0;
_logger.LogDebug($"Getting Page numbers from {archivePath}");
try
{
using ZipArchive archive = ZipFile.OpenRead(archivePath);
return archive.Entries.Count(e => Parser.Parser.IsImage(e.FullName));
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when reading archive stream.");
return 0;
}
}
/// <summary>
/// Generates byte array of cover image.
/// Given a path to a compressed file (zip, rar, cbz, cbr, etc), will ensure the first image is returned unless
/// a folder.extension exists in the root directory of the compressed file.
/// </summary>
/// <param name="filepath"></param>
/// <param name="createThumbnail">Create a smaller variant of file extracted from archive. Archive images are usually 1MB each.</param>
/// <returns></returns>
public byte[] GetCoverImage(string filepath, bool createThumbnail = false)
{
try
{
if (!IsValidArchive(filepath)) return Array.Empty<byte>();
_logger.LogDebug($"Extracting Cover image from {filepath}");
using ZipArchive archive = ZipFile.OpenRead(filepath);
if (!archive.HasFiles()) return Array.Empty<byte>();
var folder = archive.Entries.SingleOrDefault(x => Path.GetFileNameWithoutExtension(x.Name).ToLower() == "folder");
var entries = archive.Entries.Where(x => Path.HasExtension(x.FullName) && Parser.Parser.IsImage(x.FullName)).OrderBy(x => x.FullName).ToList();
var entry = folder ?? entries[0];
return createThumbnail ? CreateThumbnail(entry) : ConvertEntryToByteArray(entry);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when reading archive stream.");
}
return Array.Empty<byte>();
}
private byte[] CreateThumbnail(ZipArchiveEntry entry)
{
try
{
using var stream = entry.Open();
using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
}
catch (Exception ex)
{
_logger.LogError(ex, "There was a critical error and prevented thumbnail generation. Defaulting to no cover image.");
}
return Array.Empty<byte>();
}
private static byte[] ConvertEntryToByteArray(ZipArchiveEntry entry)
{
using var stream = entry.Open();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var data = ms.ToArray();
return data;
}
/// <summary>
/// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly
/// under extract path and not nested in subfolders. See <see cref="DirectoryInfoExtensions"/> Flatten method.
/// </summary>
/// <param name="archive">An opened archive stream</param>
/// <returns></returns>
public bool ArchiveNeedsFlattening(ZipArchive archive)
{
// Sometimes ZipArchive will list the directory and others it will just keep it in the FullName
return archive.Entries.Count > 0 &&
!Path.HasExtension(archive.Entries.ElementAt(0).FullName) ||
archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar));
}
/// <summary>
/// Test if the archive path exists and there are images inside it. This will log as an error.
/// </summary>
/// <param name="archivePath"></param>
/// <returns></returns>
public bool IsValidArchive(string archivePath)
{
if (!File.Exists(archivePath))
{
_logger.LogError($"Archive {archivePath} could not be found.");
return false;
}
if (!Parser.Parser.IsArchive(archivePath))
{
_logger.LogError($"Archive {archivePath} is not a valid archive.");
return false;
}
using var archive = ZipFile.OpenRead(archivePath);
if (archive.Entries.Any(e => Parser.Parser.IsImage(e.FullName))) return true;
_logger.LogError($"Archive {archivePath} contains no images.");
return false;
}
/// <summary>
/// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists,
/// will return that without performing an extraction. Returns empty string if there are any invalidations which would
/// prevent operations to perform correctly (missing archivePath file, empty archive, etc).
/// </summary>
/// <param name="archivePath">A valid file to an archive file.</param>
/// <param name="extractPath">Path to extract to</param>
/// <returns></returns>
public void ExtractArchive(string archivePath, string extractPath)
{
if (!IsValidArchive(archivePath)) return;
if (Directory.Exists(extractPath))
{
_logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder.");
return;
}
Stopwatch sw = Stopwatch.StartNew();
using ZipArchive archive = ZipFile.OpenRead(archivePath);
var needsFlattening = ArchiveNeedsFlattening(archive);
if (!archive.HasFiles() && !needsFlattening) return;
archive.ExtractToDirectory(extractPath, true);
_logger.LogDebug($"Extracted archive to {extractPath} in {sw.ElapsedMilliseconds} milliseconds.");
if (needsFlattening)
{
sw = Stopwatch.StartNew();
_logger.LogInformation("Extracted archive is nested in root folder, flattening...");
new DirectoryInfo(extractPath).Flatten();
_logger.LogInformation($"Flattened in {sw.ElapsedMilliseconds} milliseconds");
}
}
}
}

View File

@ -1,6 +1,5 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
@ -13,54 +12,52 @@ namespace API.Services
{
public class CacheService : ICacheService
{
private readonly IDirectoryService _directoryService;
private readonly ILogger<CacheService> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IArchiveService _archiveService;
private readonly IDirectoryService _directoryService;
private readonly NumericComparer _numericComparer;
public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../cache/"));
public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "cache/"));
public CacheService(IDirectoryService directoryService, ILogger<CacheService> logger, IUnitOfWork unitOfWork)
public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService)
{
_directoryService = directoryService;
_logger = logger;
_unitOfWork = unitOfWork;
_archiveService = archiveService;
_directoryService = directoryService;
_numericComparer = new NumericComparer();
}
private bool CacheDirectoryIsAccessible()
public void EnsureCacheDirectory()
{
_logger.LogDebug($"Checking if valid Cache directory: {CacheDirectory}");
var di = new DirectoryInfo(CacheDirectory);
return di.Exists;
if (!di.Exists)
{
_logger.LogError($"Cache directory {CacheDirectory} is not accessible or does not exist. Creating...");
Directory.CreateDirectory(CacheDirectory);
}
}
public async Task<Volume> Ensure(int volumeId)
public async Task<Chapter> Ensure(int chapterId)
{
if (!CacheDirectoryIsAccessible())
EnsureCacheDirectory();
Chapter chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
foreach (var file in chapter.Files)
{
return null;
}
Volume volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
foreach (var file in volume.Files)
{
var extractPath = GetVolumeCachePath(volumeId, file);
ExtractArchive(file.FilePath, extractPath);
var extractPath = GetCachePath(chapterId);
_archiveService.ExtractArchive(file.FilePath, extractPath);
}
return volume;
return chapter;
}
public void Cleanup()
{
_logger.LogInformation("Performing cleanup of Cache directory");
if (!CacheDirectoryIsAccessible())
{
_logger.LogError($"Cache directory {CacheDirectory} is not accessible or does not exist.");
return;
}
EnsureCacheDirectory();
DirectoryInfo di = new DirectoryInfo(CacheDirectory);
try
@ -75,13 +72,13 @@ namespace API.Services
_logger.LogInformation("Cache directory purged.");
}
public void CleanupVolumes(int[] volumeIds)
public void CleanupChapters(int[] chapterIds)
{
_logger.LogInformation($"Running Cache cleanup on Volumes");
foreach (var volume in volumeIds)
foreach (var chapter in chapterIds)
{
var di = new DirectoryInfo(Path.Join(CacheDirectory, volume + ""));
var di = new DirectoryInfo(GetCachePath(chapter));
if (di.Exists)
{
di.Delete(true);
@ -90,75 +87,38 @@ namespace API.Services
}
_logger.LogInformation("Cache directory purged");
}
/// <summary>
/// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists,
/// will return that without performing an extraction. Returns empty string if there are any invalidations which would
/// prevent operations to perform correctly (missing archivePath file, empty archive, etc).
/// Returns the cache path for a given Chapter. Should be cacheDirectory/{chapterId}/
/// </summary>
/// <param name="archivePath">A valid file to an archive file.</param>
/// <param name="extractPath">Path to extract to</param>
/// <param name="chapterId"></param>
/// <returns></returns>
private string ExtractArchive(string archivePath, string extractPath)
private string GetCachePath(int chapterId)
{
// NOTE: This is used by Cache Service
if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath))
{
_logger.LogError($"Archive {archivePath} could not be found.");
return "";
}
if (Directory.Exists(extractPath))
{
_logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder.");
return extractPath;
}
using ZipArchive archive = ZipFile.OpenRead(archivePath);
// TODO: Throw error if we couldn't extract
var needsFlattening = archive.Entries.Count > 0 && !Path.HasExtension(archive.Entries.ElementAt(0).FullName);
if (!archive.HasFiles() && !needsFlattening) return "";
archive.ExtractToDirectory(extractPath);
_logger.LogDebug($"Extracting archive to {extractPath}");
if (!needsFlattening) return extractPath;
_logger.LogInformation("Extracted archive is nested in root folder, flattening...");
new DirectoryInfo(extractPath).Flatten();
return extractPath;
return Path.GetFullPath(Path.Join(CacheDirectory, $"{chapterId}/"));
}
private string GetVolumeCachePath(int volumeId, MangaFile file)
{
var extractPath = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), $"../cache/{volumeId}/"));
if (file.Chapter > 0)
{
extractPath = Path.Join(extractPath, file.Chapter + "");
}
return extractPath;
}
public string GetCachedPagePath(Volume volume, int page)
public async Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page)
{
// Calculate what chapter the page belongs to
var pagesSoFar = 0;
foreach (var mangaFile in volume.Files.OrderBy(f => f.Chapter))
var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapter(chapter.Id);
foreach (var mangaFile in chapterFiles)
{
if (page + 1 < (mangaFile.NumberOfPages + pagesSoFar))
if (page < (mangaFile.NumberOfPages + pagesSoFar))
{
var path = GetVolumeCachePath(volume.Id, mangaFile);
var files = DirectoryService.GetFiles(path);
var path = GetCachePath(chapter.Id);
var files = _directoryService.GetFiles(path, Parser.Parser.ImageFileExtensions);
Array.Sort(files, _numericComparer);
return files.ElementAt(page - pagesSoFar);
return (files.ElementAt(page - pagesSoFar), mangaFile);
}
pagesSoFar += mangaFile.NumberOfPages;
}
return "";
return ("", null);
}
}
}

View File

@ -8,12 +8,19 @@ using System.Threading;
using System.Threading.Tasks;
using API.DTOs;
using API.Interfaces;
using Microsoft.Extensions.Logging;
using NetVips;
namespace API.Services
{
public class DirectoryService : IDirectoryService
{
private readonly ILogger<DirectoryService> _logger;
public DirectoryService(ILogger<DirectoryService> logger)
{
_logger = logger;
}
/// <summary>
/// Given a set of regex search criteria, get files in the given path.
@ -33,10 +40,14 @@ namespace API.Services
reSearchPattern.IsMatch(Path.GetExtension(file)));
}
public static string[] GetFiles(string path)
public string[] GetFiles(string path, string searchPatternExpression = "")
{
if (!Directory.Exists(path)) return Array.Empty<string>();
return Directory.GetFiles(path);
if (searchPatternExpression != string.Empty)
{
return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray();
}
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
}
public IEnumerable<string> ListDirectory(string rootPath)
@ -48,12 +59,16 @@ namespace API.Services
.Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System)))
.Select(d => d.Name).ToImmutableList();
return dirs;
}
public async Task<ImageDto> ReadImageAsync(string imagePath)
{
if (!File.Exists(imagePath))
{
_logger.LogError("Image does not exist on disk.");
return null;
}
using var image = Image.NewFromFile(imagePath);
return new ImageDto
@ -63,7 +78,7 @@ namespace API.Services
FullPath = Path.GetFullPath(imagePath),
Width = image.Width,
Height = image.Height,
Format = image.Format
Format = image.Format,
};
}

View File

@ -4,16 +4,12 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Entities;
using API.Extensions;
using API.Interfaces;
using API.Parser;
using Microsoft.Extensions.Logging;
using NetVips;
namespace API.Services
{
@ -21,12 +17,14 @@ namespace API.Services
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ScannerService> _logger;
private readonly IArchiveService _archiveService;
private ConcurrentDictionary<string, List<ParserInfo>> _scannedSeries;
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger)
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger, IArchiveService archiveService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_archiveService = archiveService;
}
public void ScanLibraries()
@ -60,6 +58,12 @@ namespace API.Services
var totalFiles = 0;
foreach (var folderPath in library.Folders)
{
if (!forceUpdate && Directory.GetLastWriteTime(folderPath.Path) <= folderPath.LastScanned)
{
_logger.LogDebug($"{folderPath.Path} hasn't been updated since last scan. Skipping.");
continue;
}
try {
totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath.Path, (f) =>
{
@ -87,10 +91,12 @@ namespace API.Services
// Remove series that are no longer on disk
RemoveSeriesNotOnDisk(allSeries, series, library);
foreach (var folder in library.Folders) folder.LastScanned = DateTime.Now;
_unitOfWork.LibraryRepository.Update(library);
if (Task.Run(() => _unitOfWork.Complete()).Result)
{
_logger.LogInformation($"Scan completed on {library.Name}. Parsed {series.Keys.Count()} series.");
}
else
@ -154,7 +160,7 @@ namespace API.Services
{
if (info.Series == string.Empty) return;
_scannedSeries.AddOrUpdate(info.Series, new List<ParserInfo>() {info}, (key, oldValue) =>
_scannedSeries.AddOrUpdate(info.Series, new List<ParserInfo>() {info}, (_, oldValue) =>
{
oldValue ??= new List<ParserInfo>();
if (!oldValue.Contains(info))
@ -187,10 +193,10 @@ namespace API.Services
private Series UpdateSeries(Series series, ParserInfo[] infos, bool forceUpdate)
{
var volumes = UpdateVolumes(series, infos, forceUpdate);
var volumes = UpdateVolumesWithChapters(series, infos, forceUpdate);
series.Volumes = volumes;
series.Pages = volumes.Sum(v => v.Pages);
if (series.CoverImage == null || forceUpdate)
if (ShouldFindCoverImage(forceUpdate, series.CoverImage))
{
var firstCover = volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0);
if (firstCover == null && volumes.Any())
@ -212,191 +218,125 @@ namespace API.Services
{
_logger.LogDebug($"Creating File Entry for {info.FullFilePath}");
int.TryParse(info.Chapters, out var chapter);
_logger.LogDebug($"Found Chapter: {chapter}");
return new MangaFile()
{
FilePath = info.FullFilePath,
Chapter = chapter,
Format = info.Format,
NumberOfPages = info.Format == MangaFormat.Archive ? GetNumberOfPagesFromArchive(info.FullFilePath): 1
NumberOfPages = info.Format == MangaFormat.Archive ? _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath): 1
};
}
private int MinimumNumberFromRange(string range)
private bool ShouldFindCoverImage(bool forceUpdate, byte[] coverImage)
{
var tokens = range.Split("-");
return Int32.Parse(tokens.Length >= 1 ? tokens[0] : range);
return forceUpdate || coverImage == null || !coverImage.Any();
}
/// <summary>
/// Creates or Updates volumes for a given series
///
/// </summary>
/// <param name="series">Series wanting to be updated</param>
/// <param name="infos">Parser info</param>
/// <param name="forceUpdate">Forces metadata update (cover image) even if it's already been set.</param>
/// <returns>Updated Volumes for given series</returns>
private ICollection<Volume> UpdateVolumes(Series series, ParserInfo[] infos, bool forceUpdate)
/// <param name="volume"></param>
/// <param name="infos"></param>
/// <param name="forceUpdate"></param>
/// <returns></returns>
private ICollection<Chapter> UpdateChapters(Volume volume, IEnumerable<ParserInfo> infos, bool forceUpdate)
{
var chapters = new List<Chapter>();
foreach (var info in infos)
{
volume.Chapters ??= new List<Chapter>();
var chapter = volume.Chapters.SingleOrDefault(c => c.Range == info.Chapters) ??
chapters.SingleOrDefault(v => v.Range == info.Chapters) ??
new Chapter()
{
Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + "",
Range = info.Chapters,
};
chapter.Files ??= new List<MangaFile>();
var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath);
if (existingFile != null)
{
existingFile.Format = info.Format;
existingFile.NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath);
}
else
{
if (info.Format == MangaFormat.Archive)
{
chapter.Files.Add(CreateMangaFile(info));
}
else
{
_logger.LogDebug($"Ignoring {info.Filename} as it is not an archive.");
}
}
chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + "";
chapter.Range = info.Chapters;
chapters.Add(chapter);
}
foreach (var chapter in chapters)
{
chapter.Pages = chapter.Files.Sum(f => f.NumberOfPages);
if (ShouldFindCoverImage(forceUpdate, chapter.CoverImage))
{
chapter.Files ??= new List<MangaFile>();
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (firstFile != null) chapter.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true);
}
}
return chapters;
}
private ICollection<Volume> UpdateVolumesWithChapters(Series series, ParserInfo[] infos, bool forceUpdate)
{
ICollection<Volume> volumes = new List<Volume>();
IList<Volume> existingVolumes = _unitOfWork.SeriesRepository.GetVolumes(series.Id).ToList();
foreach (var info in infos)
{
var existingVolume = existingVolumes.SingleOrDefault(v => v.Name == info.Volumes);
if (existingVolume != null)
var volume = (existingVolumes.SingleOrDefault(v => v.Name == info.Volumes) ??
volumes.SingleOrDefault(v => v.Name == info.Volumes)) ?? new Volume
{
var existingFile = existingVolume.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath);
if (existingFile != null)
{
existingFile.Chapter = MinimumNumberFromRange(info.Chapters);
existingFile.Format = info.Format;
existingFile.NumberOfPages = GetNumberOfPagesFromArchive(info.FullFilePath);
}
else
{
if (info.Format == MangaFormat.Archive)
{
existingVolume.Files.Add(CreateMangaFile(info));
}
else
{
_logger.LogDebug($"Ignoring {info.Filename} as it is not an archive.");
}
}
volumes.Add(existingVolume);
}
else
{
// Create New Volume
existingVolume = volumes.SingleOrDefault(v => v.Name == info.Volumes);
if (existingVolume != null)
{
existingVolume.Files.Add(CreateMangaFile(info));
}
else
{
var vol = new Volume()
{
Name = info.Volumes,
Number = MinimumNumberFromRange(info.Volumes),
Files = new List<MangaFile>()
{
CreateMangaFile(info)
}
};
volumes.Add(vol);
}
}
_logger.LogInformation($"Adding volume {volumes.Last().Number} with File: {info.Filename}");
Name = info.Volumes,
Number = Parser.Parser.MinimumNumberFromRange(info.Volumes),
};
var chapters = UpdateChapters(volume, infos.Where(pi => pi.Volumes == volume.Name).ToArray(), forceUpdate);
volume.Chapters = chapters;
volume.Pages = chapters.Sum(c => c.Pages);
volumes.Add(volume);
}
foreach (var volume in volumes)
{
if (forceUpdate || volume.CoverImage == null || !volume.Files.Any())
if (ShouldFindCoverImage(forceUpdate, volume.CoverImage))
{
var firstFile = volume.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (firstFile != null) volume.CoverImage = GetCoverImage(firstFile.FilePath, true); // ZIPFILE
// TODO: Create a custom sorter for Chapters so it's consistent across the application
var firstChapter = volume.Chapters.OrderBy(x => Double.Parse(x.Number)).FirstOrDefault();
var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true);
}
volume.Pages = volume.Files.Sum(x => x.NumberOfPages);
}
return volumes;
}
public void ScanSeries(int libraryId, int seriesId)
{
throw new NotImplementedException();
}
private int GetNumberOfPagesFromArchive(string archivePath)
{
if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath))
{
_logger.LogError($"Archive {archivePath} could not be found.");
return 0;
}
_logger.LogDebug($"Getting Page numbers from {archivePath}");
using ZipArchive archive = ZipFile.OpenRead(archivePath); // ZIPFILE
return archive.Entries.Count(e => Parser.Parser.IsImage(e.FullName));
}
/// <summary>
/// Generates byte array of cover image.
/// Given a path to a compressed file (zip, rar, cbz, cbr, etc), will ensure the first image is returned unless
/// a folder.extension exists in the root directory of the compressed file.
/// </summary>
/// <param name="filepath"></param>
/// <param name="createThumbnail">Create a smaller variant of file extracted from archive. Archive images are usually 1MB each.</param>
/// <returns></returns>
public byte[] GetCoverImage(string filepath, bool createThumbnail = false)
{
try
{
if (string.IsNullOrEmpty(filepath) || !File.Exists(filepath) || !Parser.Parser.IsArchive(filepath)) return Array.Empty<byte>();
_logger.LogDebug($"Extracting Cover image from {filepath}");
using ZipArchive archive = ZipFile.OpenRead(filepath);
if (!archive.HasFiles()) return Array.Empty<byte>();
var folder = archive.Entries.SingleOrDefault(x => Path.GetFileNameWithoutExtension(x.Name).ToLower() == "folder");
var entries = archive.Entries.Where(x => Path.HasExtension(x.FullName) && Parser.Parser.IsImage(x.FullName)).OrderBy(x => x.FullName).ToList();
ZipArchiveEntry entry;
if (folder != null)
{
entry = folder;
} else if (!entries.Any())
{
return Array.Empty<byte>();
}
else
{
entry = entries[0];
}
if (createThumbnail)
{
try
{
using var stream = entry.Open();
var thumbnail = Image.ThumbnailStream(stream, 320);
return thumbnail.WriteToBuffer(".jpg");
}
catch (Exception ex)
{
_logger.LogError(ex, "There was a critical error and prevented thumbnail generation.");
}
}
return ExtractEntryToImage(entry);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when reading archive stream.");
return Array.Empty<byte>();
}
}
private static byte[] ExtractEntryToImage(ZipArchiveEntry entry)
{
using var stream = entry.Open();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var data = ms.ToArray();
return data;
}
{
throw new NotImplementedException();
}
}
}

View File

@ -1,4 +1,7 @@
using API.Interfaces;
using System.Threading.Tasks;
using API.Entities;
using API.Helpers.Converters;
using API.Interfaces;
using Hangfire;
using Microsoft.Extensions.Logging;
@ -11,15 +14,26 @@ namespace API.Services
private readonly IScannerService _scannerService;
public BackgroundJobServer Client => new BackgroundJobServer();
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService)
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService, IUnitOfWork unitOfWork)
{
_cacheService = cacheService;
_logger = logger;
_scannerService = scannerService;
_logger.LogInformation("Scheduling/Updating cache cleanup on a daily basis.");
RecurringJob.AddOrUpdate(() => _cacheService.Cleanup(), Cron.Daily);
RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), Cron.Daily);
var setting = Task.Run(() => unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Result;
if (setting != null)
{
RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), () => CronConverter.ConvertToCronNotation(setting.Value));
}
else
{
RecurringJob.AddOrUpdate(() => _cacheService.Cleanup(), Cron.Daily);
RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), Cron.Daily);
}
//JobStorage.Current.GetMonitoringApi().
}
public void ScanSeries(int libraryId, int seriesId)
@ -34,9 +48,9 @@ namespace API.Services
BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, forceUpdate));
}
public void CleanupVolumes(int[] volumeIds)
public void CleanupChapters(int[] chapterIds)
{
BackgroundJob.Enqueue(() => _cacheService.CleanupVolumes(volumeIds));
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
}

View File

@ -3,6 +3,7 @@ using API.Middleware;
using Hangfire;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@ -45,8 +46,6 @@ namespace API
app.UseHangfireDashboard();
}
//app.UseHttpsRedirection();
app.UseRouting();
// Ordering is important. Cors, authentication, authorization
@ -57,7 +56,11 @@ namespace API
app.UseAuthorization();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
ContentTypeProvider = new FileExtensionContentTypeProvider() // this is not set by default
});
app.UseEndpoints(endpoints =>
{

View File

@ -1,13 +1,13 @@
{
"ConnectionStrings": {
"DefaultConnection": "Data source=kavita.db",
"DefaultConnection": "Data source=kavita.db"
},
"TokenKey": "super secret unguessable key",
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft": "Information",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft": "Error",
"Microsoft.Hosting.Lifetime": "Error",
"Hangfire": "Information"
},
"File": {