diff --git a/.gitignore b/.gitignore index 75589ba35..343c37b63 100644 --- a/.gitignore +++ b/.gitignore @@ -445,4 +445,6 @@ $RECYCLE.BIN/ appsettings.json /API/kavita.db /API/kavita.db-shm -/API/kavita.db-wal \ No newline at end of file +/API/kavita.db-wal +/API/Hangfire.db +/API/Hangfire-log.db \ No newline at end of file diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj new file mode 100644 index 000000000..e19d7abc9 --- /dev/null +++ b/API.Tests/API.Tests.csproj @@ -0,0 +1,26 @@ + + + + net5.0 + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/API.Tests/ParserTest.cs b/API.Tests/ParserTest.cs new file mode 100644 index 000000000..82c4a4892 --- /dev/null +++ b/API.Tests/ParserTest.cs @@ -0,0 +1,86 @@ +using Xunit; +using static API.Parser.Parser; + +namespace API.Tests +{ + public class ParserTests + { + [Theory] + [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] + [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")] + [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "11")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "1")] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "1")] + [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1")] + //[InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "16-17")] + [InlineData("Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", "1")] + [InlineData("v001", "1")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1")] + public void ParseVolumeTest(string filename, string expected) + { + var result = ParseVolume(filename); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "Killing Bites")] + [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "My Girlfriend Is Shobitch")] + [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "Historys Strongest Disciple Kenichi")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "B Gata H Kei")] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "BTOOOM!")] + [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "Gokukoku no Brynhildr")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "Dance in the Vampire Bund")] + [InlineData("v001", "")] + [InlineData("Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)", "Akame ga KILL! ZERO")] + public void ParseSeriesTest(string filename, string expected) + { + var result = ParseSeries(filename); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] + [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "9")] + [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "90-98")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "")] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "")] + [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1-8")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "")] + [InlineData("c001", "1")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", "12")] + public void ParseChaptersTest(string filename, string expected) + { + var result = ParseChapter(filename); + Assert.Equal(expected, result); + } + + + [Theory] + [InlineData("0001", "1")] + [InlineData("1", "1")] + [InlineData("0013", "13")] + public void RemoveLeadingZeroesTest(string input, string expected) + { + Assert.Equal(expected, RemoveLeadingZeroes(input)); + } + + [Theory] + [InlineData("1", "001")] + [InlineData("10", "010")] + [InlineData("100", "100")] + public void PadZerosTest(string input, string expected) + { + Assert.Equal(expected, PadZeros(input)); + } + + [Theory] + [InlineData("Hello_I_am_here", "Hello I am here")] + [InlineData("Hello_I_am_here ", "Hello I am here")] + [InlineData("[ReleaseGroup] The Title", "The Title")] + [InlineData("[ReleaseGroup]_The_Title", "The Title")] + public void CleanTitleTest(string input, string expected) + { + Assert.Equal(expected, CleanTitle(input)); + } + } +} \ No newline at end of file diff --git a/API/API.csproj b/API/API.csproj index cdd2f9198..1b99ef72e 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -29,7 +29,7 @@ - + diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index fa495b62e..4aba6b7bd 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using API.Entities; -using API.Interfaces; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -8,12 +7,10 @@ namespace API.Controllers { public class AdminController : BaseApiController { - private readonly IUserRepository _userRepository; private readonly UserManager _userManager; - public AdminController(IUserRepository userRepository, UserManager userManager) + public AdminController(UserManager userManager) { - _userRepository = userRepository; _userManager = userManager; } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 82e88afb0..e06cba530 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -1,8 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using API.Data; using API.DTOs; using API.Entities; using API.Interfaces; @@ -23,10 +21,11 @@ namespace API.Controllers private readonly IUserRepository _userRepository; private readonly IMapper _mapper; private readonly ITaskScheduler _taskScheduler; + private readonly ISeriesRepository _seriesRepository; public LibraryController(IDirectoryService directoryService, ILibraryRepository libraryRepository, ILogger logger, IUserRepository userRepository, - IMapper mapper, ITaskScheduler taskScheduler) + IMapper mapper, ITaskScheduler taskScheduler, ISeriesRepository seriesRepository) { _directoryService = directoryService; _libraryRepository = libraryRepository; @@ -34,6 +33,7 @@ namespace API.Controllers _userRepository = userRepository; _mapper = mapper; _taskScheduler = taskScheduler; + _seriesRepository = seriesRepository; } /// @@ -81,18 +81,32 @@ namespace API.Controllers return Ok(user); } - return BadRequest("Not Implemented"); + return BadRequest("There was a critical issue. Please try again."); } [Authorize(Policy = "RequireAdminRole")] [HttpGet("scan")] public async Task ScanLibrary(int libraryId) { - var library = await _libraryRepository.GetLibraryForIdAsync(libraryId); + var library = await _libraryRepository.GetLibraryDtoForIdAsync(libraryId); + // We have to send a json encoded Library (aka a DTO) to the Background Job thread. + // Because we use EF, we have circular dependencies back to Library and it will crap out BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(library)); - return Ok(); } + + [HttpGet("libraries-for")] + public async Task>> GetLibrariesForUser(string username) + { + return Ok(await _libraryRepository.GetLibrariesForUsernameAysnc(username)); + } + + [HttpGet("series")] + public async Task>> GetSeriesForLibrary(int libraryId) + { + return Ok(await _seriesRepository.GetSeriesForLibraryIdAsync(libraryId)); + + } } } \ No newline at end of file diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs new file mode 100644 index 000000000..dbdfaf93e --- /dev/null +++ b/API/Controllers/SeriesController.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Interfaces; +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers +{ + public class SeriesController : BaseApiController + { + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly ITaskScheduler _taskScheduler; + private readonly ISeriesRepository _seriesRepository; + + public SeriesController(ILogger logger, IMapper mapper, + ITaskScheduler taskScheduler, ISeriesRepository seriesRepository) + { + _logger = logger; + _mapper = mapper; + _taskScheduler = taskScheduler; + _seriesRepository = seriesRepository; + } + + [HttpGet("{seriesId}")] + public async Task> GetSeries(int seriesId) + { + return Ok(await _seriesRepository.GetSeriesByIdAsync(seriesId)); + } + + [HttpGet("volumes")] + public async Task>> GetVolumes(int seriesId) + { + return Ok(await _seriesRepository.GetVolumesAsync(seriesId)); + } + } +} \ No newline at end of file diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 6102cc5e5..db81af51d 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.DTOs; using API.Entities; using API.Extensions; @@ -23,6 +22,7 @@ namespace API.Controllers _libraryRepository = libraryRepository; } + [Authorize(Policy = "RequireAdminRole")] [HttpPost("add-library")] public async Task AddLibrary(CreateLibraryDto createLibraryDto) { @@ -38,7 +38,6 @@ namespace API.Controllers return BadRequest("Library name already exists. Please choose a unique name to the server."); } - // TODO: We probably need to clean the folders before we insert var library = new Library { Name = createLibraryDto.Name.ToLower(), diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 31e51e173..e23268269 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -5,6 +5,7 @@ namespace API.DTOs { public class LibraryDto { + public int Id { get; init; } public string Name { get; set; } public string CoverImage { get; set; } public LibraryType Type { get; set; } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs new file mode 100644 index 000000000..9e9491d7e --- /dev/null +++ b/API/DTOs/SeriesDto.cs @@ -0,0 +1,11 @@ +namespace API.DTOs +{ + public class SeriesDto + { + public int Id { get; set; } + public string Name { get; set; } + public string OriginalName { get; set; } + public string SortName { get; set; } + public string Summary { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs new file mode 100644 index 000000000..1753d4679 --- /dev/null +++ b/API/DTOs/VolumeDto.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class VolumeDto + { + public int Id { get; set; } + public string Number { get; set; } + public string CoverImage { get; set; } + public ICollection Files { get; set; } + } +} \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 9772251b7..a83a76e9e 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -14,6 +14,9 @@ namespace API.Data } public DbSet Library { get; set; } + public DbSet Series { get; set; } + public DbSet Volume { get; set; } + protected override void OnModelCreating(ModelBuilder builder) { @@ -30,6 +33,12 @@ namespace API.Data .WithOne(u => u.Role) .HasForeignKey(ur => ur.RoleId) .IsRequired(); + + // builder.Entity() + // .HasMany(s => s.Series) + // .WithOne(l => l.Library) + // .HasForeignKey(x => x.Id) + } } } \ No newline at end of file diff --git a/API/Data/LibraryRepository.cs b/API/Data/LibraryRepository.cs index b7bd978a1..ce986279f 100644 --- a/API/Data/LibraryRepository.cs +++ b/API/Data/LibraryRepository.cs @@ -30,6 +30,28 @@ namespace API.Data { return await _context.SaveChangesAsync() > 0; } + + public bool SaveAll() + { + return _context.SaveChanges() > 0; + } + + public Library GetLibraryForName(string libraryName) + { + return _context.Library + .Where(x => x.Name == libraryName) + .Include(f => f.Folders) + .Include(s => s.Series) + .Single(); + } + + public async Task> GetLibrariesForUsernameAysnc(string userName) + { + return await _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(x => x.UserName == userName)) + .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); + } public async Task> GetLibrariesAsync() { @@ -38,7 +60,7 @@ namespace API.Data .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); } - public async Task GetLibraryForIdAsync(int libraryId) + public async Task GetLibraryDtoForIdAsync(int libraryId) { return await _context.Library .Where(x => x.Id == libraryId) @@ -46,7 +68,14 @@ namespace API.Data .ProjectTo(_mapper.ConfigurationProvider).SingleAsync(); } - + public async Task GetLibraryForIdAsync(int libraryId) + { + return await _context.Library + .Where(x => x.Id == libraryId) + .Include(f => f.Folders) + .SingleAsync(); + } + public async Task LibraryExists(string libraryName) { return await _context.Library.AnyAsync(x => x.Name == libraryName); diff --git a/API/Data/Migrations/20201229190216_SeriesAndVolumeEntities.Designer.cs b/API/Data/Migrations/20201229190216_SeriesAndVolumeEntities.Designer.cs new file mode 100644 index 000000000..5cf25a225 --- /dev/null +++ b/API/Data/Migrations/20201229190216_SeriesAndVolumeEntities.Designer.cs @@ -0,0 +1,488 @@ +// +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("20201229190216_SeriesAndVolumeEntities")] + partial class SeriesAndVolumeEntities + { + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("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.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + 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.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.Volume", "Volume") + .WithMany("Files") + .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", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", 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("UserRoles"); + }); + + 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("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20201229190216_SeriesAndVolumeEntities.cs b/API/Data/Migrations/20201229190216_SeriesAndVolumeEntities.cs new file mode 100644 index 000000000..5b4302ba3 --- /dev/null +++ b/API/Data/Migrations/20201229190216_SeriesAndVolumeEntities.cs @@ -0,0 +1,129 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class SeriesAndVolumeEntities : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordSalt", + table: "AspNetUsers"); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "AspNetUsers", + type: "TEXT", + nullable: true, + oldClrType: typeof(byte[]), + oldType: "BLOB", + oldNullable: true); + + migrationBuilder.CreateTable( + name: "Series", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + OriginalName = table.Column(type: "TEXT", nullable: true), + SortName = table.Column(type: "TEXT", nullable: true), + Summary = table.Column(type: "TEXT", nullable: true), + LibraryId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Series", x => x.Id); + table.ForeignKey( + name: "FK_Series_Library_LibraryId", + column: x => x.LibraryId, + principalTable: "Library", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Volume", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Number = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Volume", x => x.Id); + table.ForeignKey( + name: "FK_Volume_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MangaFile", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FilePath = table.Column(type: "TEXT", nullable: true), + VolumeId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MangaFile", x => x.Id); + table.ForeignKey( + name: "FK_MangaFile_Volume_VolumeId", + column: x => x.VolumeId, + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MangaFile_VolumeId", + table: "MangaFile", + column: "VolumeId"); + + migrationBuilder.CreateIndex( + name: "IX_Series_LibraryId", + table: "Series", + column: "LibraryId"); + + migrationBuilder.CreateIndex( + name: "IX_Volume_SeriesId", + table: "Volume", + column: "SeriesId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MangaFile"); + + migrationBuilder.DropTable( + name: "Volume"); + + migrationBuilder.DropTable( + name: "Series"); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "AspNetUsers", + type: "BLOB", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "PasswordSalt", + table: "AspNetUsers", + type: "BLOB", + nullable: true); + } + } +} diff --git a/API/Data/Migrations/20210101180935_AddedCoverImageToSeries.Designer.cs b/API/Data/Migrations/20210101180935_AddedCoverImageToSeries.Designer.cs new file mode 100644 index 000000000..a1a54360f --- /dev/null +++ b/API/Data/Migrations/20210101180935_AddedCoverImageToSeries.Designer.cs @@ -0,0 +1,491 @@ +// +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("20210101180935_AddedCoverImageToSeries")] + partial class AddedCoverImageToSeries + { + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("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.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + 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.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.Volume", "Volume") + .WithMany("Files") + .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", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", 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("UserRoles"); + }); + + 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("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210101180935_AddedCoverImageToSeries.cs b/API/Data/Migrations/20210101180935_AddedCoverImageToSeries.cs new file mode 100644 index 000000000..45e0fdc41 --- /dev/null +++ b/API/Data/Migrations/20210101180935_AddedCoverImageToSeries.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class AddedCoverImageToSeries : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImage", + table: "Series", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImage", + table: "Series"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index b7088bacf..110c60774 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1,7 +1,9 @@ // using System; +using API.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace API.Data.Migrations { @@ -84,11 +86,8 @@ namespace API.Data.Migrations .HasMaxLength(256) .HasColumnType("TEXT"); - b.Property("PasswordHash") - .HasColumnType("BLOB"); - - b.Property("PasswordSalt") - .HasColumnType("BLOB"); + b.Property("PasswordHash") + .HasColumnType("TEXT"); b.Property("PhoneNumber") .HasColumnType("TEXT"); @@ -176,6 +175,75 @@ namespace API.Data.Migrations b.ToTable("Library"); }); + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + modelBuilder.Entity("AppUserLibrary", b => { b.Property("AppUsersId") @@ -305,6 +373,39 @@ namespace API.Data.Migrations b.Navigation("Library"); }); + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Files") + .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) @@ -369,6 +470,18 @@ namespace API.Data.Migrations 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("Files"); }); #pragma warning restore 612, 618 } diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs new file mode 100644 index 000000000..fd109f45f --- /dev/null +++ b/API/Data/SeriesRepository.cs @@ -0,0 +1,83 @@ +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 SeriesRepository : ISeriesRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public SeriesRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(Series series) + { + _context.Entry(series).State = EntityState.Modified; + } + + public async Task SaveAllAsync() + { + return await _context.SaveChangesAsync() > 0; + } + + public bool SaveAll() + { + return _context.SaveChanges() > 0; + } + + public async Task GetSeriesByNameAsync(string name) + { + return await _context.Series.SingleOrDefaultAsync(x => x.Name == name); + } + + public Series GetSeriesByName(string name) + { + return _context.Series.SingleOrDefault(x => x.Name == name); + } + + public async Task> GetSeriesForLibraryIdAsync(int libraryId) + { + return await _context.Series + .Where(series => series.LibraryId == libraryId) + .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); + } + + public async Task> GetVolumesAsync(int seriesId) + { + return await _context.Volume + .Where(vol => vol.SeriesId == seriesId) + .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); + } + + public IEnumerable GetVolumesDto(int seriesId) + { + return _context.Volume + .Where(vol => vol.SeriesId == seriesId) + .ProjectTo(_mapper.ConfigurationProvider).ToList(); + } + + public IEnumerable GetVolumes(int seriesId) + { + return _context.Volume + .Where(vol => vol.SeriesId == seriesId) + .ToList(); + } + + public async Task GetSeriesByIdAsync(int seriesId) + { + return await _context.Series.Where(x => x.Id == seriesId) + .ProjectTo(_mapper.ConfigurationProvider).SingleAsync(); + } + } +} \ No newline at end of file diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 279498210..53b3073a7 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -10,5 +10,6 @@ namespace API.Entities public LibraryType Type { get; set; } public ICollection Folders { get; set; } public ICollection AppUsers { get; set; } + public ICollection Series { get; set; } } } \ No newline at end of file diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs new file mode 100644 index 000000000..685d399e9 --- /dev/null +++ b/API/Entities/MangaFile.cs @@ -0,0 +1,13 @@ +namespace API.Entities +{ + public class MangaFile + { + public int Id { get; set; } + public string FilePath { get; set; } + + // Relationship Mapping + public Volume Volume { get; set; } + public int VolumeId { get; set; } + + } +} \ No newline at end of file diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs new file mode 100644 index 000000000..dd47e528d --- /dev/null +++ b/API/Entities/Series.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace API.Entities +{ + public class Series + { + public int Id { get; set; } + /// + /// The UI visible Name of the Series. This may or may not be the same as the OriginalName + /// + public string Name { get; set; } + /// + /// Original Japanese Name + /// + public string OriginalName { get; set; } + /// + /// The name used to sort the Series. By default, will be the same as Name. + /// + public string SortName { get; set; } + /// + /// Summary information related to the Series + /// + public string Summary { get; set; } + public string CoverImage { get; set; } + public ICollection Volumes { get; set; } + + public Library Library { get; set; } + public int LibraryId { get; set; } + + + } +} \ No newline at end of file diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs new file mode 100644 index 000000000..bb3638323 --- /dev/null +++ b/API/Entities/Volume.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace API.Entities +{ + public class Volume + { + public int Id { get; set; } + public string Number { get; set; } + public ICollection Files { get; set; } + + // Many-to-Many relationships + public Series Series { get; set; } + public int SeriesId { get; set; } + } +} \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 08077b5a1..2e0bb342c 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -19,6 +19,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddDbContext(options => diff --git a/API/Hangfire-log.db b/API/Hangfire-log.db deleted file mode 100644 index d8fc774c2..000000000 Binary files a/API/Hangfire-log.db and /dev/null differ diff --git a/API/Hangfire.db b/API/Hangfire.db deleted file mode 100644 index db5987848..000000000 Binary files a/API/Hangfire.db and /dev/null differ diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 08e16b68a..35d7bb951 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -10,6 +10,12 @@ namespace API.Helpers public AutoMapperProfiles() { CreateMap(); + + CreateMap() + .ForMember(dest => dest.Files, + opt => opt.MapFrom(src => src.Files.Select(x => x.FilePath).ToList())); + + CreateMap(); CreateMap() .ForMember(dest => dest.Folders, diff --git a/API/Interfaces/ILibraryRepository.cs b/API/Interfaces/ILibraryRepository.cs index ae2cf88d8..409068fc3 100644 --- a/API/Interfaces/ILibraryRepository.cs +++ b/API/Interfaces/ILibraryRepository.cs @@ -16,7 +16,10 @@ namespace API.Interfaces /// /// Task LibraryExists(string libraryName); - - public Task GetLibraryForIdAsync(int libraryId); + Task GetLibraryDtoForIdAsync(int libraryId); + Task GetLibraryForIdAsync(int libraryId); + bool SaveAll(); + Library GetLibraryForName(string libraryName); + Task> GetLibrariesForUsernameAysnc(string userName); } } \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs new file mode 100644 index 000000000..ddd085cec --- /dev/null +++ b/API/Interfaces/ISeriesRepository.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface ISeriesRepository + { + void Update(Series series); + Task SaveAllAsync(); + Task GetSeriesByNameAsync(string name); + Series GetSeriesByName(string name); + bool SaveAll(); + Task> GetSeriesForLibraryIdAsync(int libraryId); + Task> GetVolumesAsync(int seriesId); + IEnumerable GetVolumesDto(int seriesId); + IEnumerable GetVolumes(int seriesId); + Task GetSeriesByIdAsync(int seriesId); + + } +} \ No newline at end of file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs new file mode 100644 index 000000000..f0633c36e --- /dev/null +++ b/API/Parser/Parser.cs @@ -0,0 +1,230 @@ +using System; +using System.Text.RegularExpressions; + +namespace API.Parser +{ + public static class Parser + { + public static readonly string MangaFileExtensions = @"\.cbz|\.cbr|\.png|\.jpeg|\.jpg|\.zip|\.rar"; + + //?: is a non-capturing group in C#, else anything in () will be a group + private static readonly Regex[] MangaVolumeRegex = new[] + { + // Historys Strongest Disciple Kenichi_v11_c90-98.zip + new Regex( + + @"(?.*)(\b|_)v(?\d+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) + new Regex( + @"(vol. ?)(?0*[1-9]+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Dance in the Vampire Bund v16-17 + new Regex( + + @"(?.*)(\b|_)v(?\d+-?\d+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + new Regex( + @"(?:v)(?0*[1-9]+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + }; + + private static readonly Regex[] MangaSeriesRegex = new[] + { + // Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto] + new Regex( + + @"(?.*)( - )(?:v|vo|c)\d", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) + new Regex( + + @"(?.*)(\b|_)v", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + // Black Bullet + new Regex( + + @"(?.*)(\b|_)(v|vo|c)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + // Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ) + new Regex( + + @"(?.*)\(\d", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + // [BAA]_Darker_than_Black_c1 (This is very greedy, make sure it's always last) + new Regex( + @"(?.*)(\b|_)(c)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Darker Than Black (This takes anything, we have to account for perfectly named folders) + new Regex( + @"(?.*)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + + }; + + private static readonly Regex[] ReleaseGroupRegex = new[] + { + // [TrinityBAKumA Finella&anon], [BAA]_, [SlowManga&OverloadScans], [batoto] + new Regex(@"(?:\[(?(?!\s).+?(?(?!\s).+?(?\d+-?\d*)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip + new Regex( + + @"v\d+\.(?\d+-?\d*)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + }; + + + public static ParserInfo Parse(string filePath) + { + return new ParserInfo() + { + Chapters = ParseChapter(filePath), + Series = ParseSeries(filePath), + Volumes = ParseVolume(filePath), + File = filePath + }; + } + + public static string ParseSeries(string filename) + { + foreach (var regex in MangaSeriesRegex) + { + var matches = regex.Matches(filename); + foreach (Match match in matches) + { + if (match.Groups["Volume"] != Match.Empty) + { + return CleanTitle(match.Groups["Series"].Value); + } + + } + } + + Console.WriteLine("Unable to parse {0}", filename); + return ""; + } + + public static string ParseVolume(string filename) + { + foreach (var regex in MangaVolumeRegex) + { + var matches = regex.Matches(filename); + foreach (Match match in matches) + { + if (match.Groups["Volume"] != Match.Empty) + { + return RemoveLeadingZeroes(match.Groups["Volume"].Value); + } + + } + } + + Console.WriteLine("Unable to parse {0}", filename); + return ""; + } + + public static string ParseChapter(string filename) + { + foreach (var regex in MangaChapterRegex) + { + var matches = regex.Matches(filename); + foreach (Match match in matches) + { + if (match.Groups["Chapter"] != Match.Empty) + { + var value = match.Groups["Chapter"].Value; + + + if (value.Contains("-")) + { + var tokens = value.Split("-"); + var from = RemoveLeadingZeroes(tokens[0]); + var to = RemoveLeadingZeroes(tokens[1]); + return $"{from}-{to}"; + } + + return RemoveLeadingZeroes(match.Groups["Chapter"].Value); + } + + } + } + + return ""; + } + + /// + /// Translates _ -> spaces, trims front and back of string, removes release groups + /// + /// + /// + public static string CleanTitle(string title) + { + foreach (var regex in ReleaseGroupRegex) + { + var matches = regex.Matches(title); + foreach (Match match in matches) + { + if (match.Success) + { + title = title.Replace(match.Value, ""); + } + } + } + + title = title.Replace("_", " "); + return title.Trim(); + } + + + /// + /// Pads the start of a number string with 0's so ordering works fine if there are over 100 items. + /// Handles ranges (ie 4-8) -> (004-008). + /// + /// + /// A zero padded number + public static string PadZeros(string number) + { + if (number.Contains("-")) + { + var tokens = number.Split("-"); + return $"{PerformPadding(tokens[0])}-{PerformPadding(tokens[1])}"; + } + + return PerformPadding(number); + } + + private static string PerformPadding(string number) + { + var num = Int32.Parse(number); + return num switch + { + < 10 => "00" + num, + < 100 => "0" + num, + _ => number + }; + } + + public static string RemoveLeadingZeroes(string title) + { + return title.TrimStart(new[] { '0' }); + } + } +} \ No newline at end of file diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs new file mode 100644 index 000000000..e1f35b597 --- /dev/null +++ b/API/Parser/ParserInfo.cs @@ -0,0 +1,16 @@ + +namespace API.Parser +{ + /// + /// This represents a single file + /// + public class ParserInfo + { + // This can be multiple + public string Chapters { get; set; } + public string Series { get; set; } + // This can be multiple + public string Volumes { get; set; } + public string File { get; init; } + } +} \ No newline at end of file diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 8f09750b2..de1cf994b 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -1,14 +1,18 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.IO; using System.Linq; using System.Security; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using API.DTOs; +using API.Entities; using API.Interfaces; +using API.Parser; using Microsoft.Extensions.Logging; namespace API.Services @@ -16,11 +20,36 @@ namespace API.Services public class DirectoryService : IDirectoryService { private readonly ILogger _logger; + private readonly ISeriesRepository _seriesRepository; + private readonly ILibraryRepository _libraryRepository; + private ConcurrentDictionary> _scannedSeries; - public DirectoryService(ILogger logger) + public DirectoryService(ILogger logger, ISeriesRepository seriesRepository, ILibraryRepository libraryRepository) { _logger = logger; + _seriesRepository = seriesRepository; + _libraryRepository = libraryRepository; } + + /// + /// Given a set of regex search criteria, get files in the given path. + /// + /// Directory to search + /// Regex version of search pattern (ie \.mp3|\.mp4) + /// SearchOption to use, defaults to TopDirectoryOnly + /// List of file paths + public static IEnumerable GetFiles(string path, + string searchPatternExpression = "", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + Regex reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase); + return Directory.EnumerateFiles(path, "*", searchOption) + .Where(file => + reSearchPattern.IsMatch(Path.GetExtension(file))); + } + + + /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. @@ -40,59 +69,200 @@ namespace API.Services return dirs; } + + /// + /// Processes files found during a library scan. + /// + /// + private void Process(string path) + { + // NOTE: In current implementation, this never runs. We can probably remove. + if (Directory.Exists(path)) + { + DirectoryInfo di = new DirectoryInfo(path); + _logger.LogDebug($"Parsing directory {di.Name}"); + + var seriesName = Parser.Parser.ParseSeries(di.Name); + if (string.IsNullOrEmpty(seriesName)) + { + return; + } + + // We don't need ContainsKey, this is a race condition. We can replace with TryAdd instead + if (!_scannedSeries.ContainsKey(seriesName)) + { + _scannedSeries.TryAdd(seriesName, new ConcurrentBag()); + } + } + else + { + var fileName = Path.GetFileName(path); + _logger.LogDebug($"Parsing file {fileName}"); + + var info = Parser.Parser.Parse(fileName); + if (info.Volumes != string.Empty) + { + ConcurrentBag tempBag; + ConcurrentBag newBag = new ConcurrentBag(); + if (_scannedSeries.TryGetValue(info.Series, out tempBag)) + { + var existingInfos = tempBag.ToArray(); + foreach (var existingInfo in existingInfos) + { + newBag.Add(existingInfo); + } + } + else + { + tempBag = new ConcurrentBag(); + } + + newBag.Add(info); + + if (!_scannedSeries.TryUpdate(info.Series, newBag, tempBag)) + { + _scannedSeries.TryAdd(info.Series, newBag); + } + + } + + + } + } + + private Series UpdateSeries(string seriesName, ParserInfo[] infos) + { + var series = _seriesRepository.GetSeriesByName(seriesName); + ICollection volumes = new List(); + + if (series == null) + { + series = new Series() + { + Name = seriesName, + OriginalName = seriesName, + SortName = seriesName, + Summary = "", + }; + } + + + // BUG: This is creating new volume entries and not resetting each run. + IList existingVolumes = _seriesRepository.GetVolumes(series.Id).ToList(); + foreach (var info in infos) + { + var existingVolume = existingVolumes.SingleOrDefault(v => v.Number == info.Volumes); + if (existingVolume != null) + { + // Temp let's overwrite all files (we need to enhance to update files) + existingVolume.Files = new List() + { + new MangaFile() + { + FilePath = info.File + } + }; + volumes.Add(existingVolume); + } + else + { + var vol = new Volume() + { + Number = info.Volumes, + Files = new List() + { + new MangaFile() + { + FilePath = info.File + } + } + }; + volumes.Add(vol); + } + + Console.WriteLine($"Adding volume {volumes.Last().Number} with File: {info.File}"); + } + + series.Volumes = volumes; + + return series; + } + public void ScanLibrary(LibraryDto library) { + _scannedSeries = new ConcurrentDictionary>(); + _logger.LogInformation($"Beginning scan on {library.Name}"); + foreach (var folderPath in library.Folders) { try { TraverseTreeParallelForEach(folderPath, (f) => { // Exceptions are no-ops. - try { - // Do nothing with the data except read it. - //byte[] data = File.ReadAllBytes(f); - ProcessManga(f); + try + { + Process(f); } catch (FileNotFoundException) {} catch (IOException) {} catch (UnauthorizedAccessException) {} catch (SecurityException) {} - // Display the filename. - Console.WriteLine(f); }); } - catch (ArgumentException) { - Console.WriteLine(@"The directory 'C:\Program Files' does not exist."); + catch (ArgumentException ex) { + _logger.LogError(ex, "The directory '{folderPath}' does not exist"); } } + + var filtered = _scannedSeries.Where(kvp => !kvp.Value.IsEmpty); + var series = filtered.ToImmutableDictionary(v => v.Key, v => v.Value); + + // Perform DB activities on ImmutableDictionary + var libraryEntity = _libraryRepository.GetLibraryForName(library.Name); + libraryEntity.Series = new List(); // Temp delete everything for testing + foreach (var seriesKey in series.Keys) + { + var s = UpdateSeries(seriesKey, series[seriesKey].ToArray()); + Console.WriteLine($"Created/Updated series {s.Name}"); + libraryEntity.Series.Add(s); + } + + _libraryRepository.Update(libraryEntity); + + if (_libraryRepository.SaveAll()) + { + _logger.LogInformation($"Scan completed on {library.Name}. Parsed {series.Keys.Count()} series."); + } + else + { + _logger.LogError("There was a critical error that resulted in a failed scan. Please rescan."); + } + + + _scannedSeries = null; } - private static void ProcessManga(string filename) - { - Console.WriteLine($"Found {filename}"); - } - - public static void TraverseTreeParallelForEach(string root, Action action) + private static void TraverseTreeParallelForEach(string root, Action action) { //Count of files traversed and timer for diagnostic output int fileCount = 0; var sw = Stopwatch.StartNew(); // Determine whether to parallelize file processing on each folder based on processor count. - int procCount = System.Environment.ProcessorCount; + int procCount = Environment.ProcessorCount; // Data structure to hold names of subfolders to be examined for files. Stack dirs = new Stack(); if (!Directory.Exists(root)) { - throw new ArgumentException(); + throw new ArgumentException("The directory doesn't exist"); } dirs.Push(root); while (dirs.Count > 0) { string currentDir = dirs.Pop(); - string[] subDirs = {}; - string[] files = {}; + string[] subDirs; + string[] files; try { subDirs = Directory.GetDirectories(currentDir); @@ -109,7 +279,8 @@ namespace API.Services } try { - files = Directory.GetFiles(currentDir); + files = DirectoryService.GetFiles(currentDir, Parser.Parser.MangaFileExtensions) + .ToArray(); } catch (UnauthorizedAccessException e) { Console.WriteLine(e.Message); @@ -135,7 +306,7 @@ namespace API.Services } } else { - Parallel.ForEach(files, () => 0, (file, loopState, localCount) => + Parallel.ForEach(files, () => 0, (file, _, localCount) => { action(file); return ++localCount; }, diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index b76d10e40..d5dcdcb5c 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -6,7 +6,7 @@ namespace API.Services public class TaskScheduler : ITaskScheduler { private readonly BackgroundJobServer _client; - + public TaskScheduler() { _client = new BackgroundJobServer(); diff --git a/API/appsettings.Development.json b/API/appsettings.Development.json index 5d8c460c5..740eb4c1e 100644 --- a/API/appsettings.Development.json +++ b/API/appsettings.Development.json @@ -1,7 +1,6 @@ { "ConnectionStrings": { "DefaultConnection": "Data source=kavita.db", - "HangfireConnection": "Data source=hangfire.db" }, "TokenKey": "super secret unguessable key", "Logging": { diff --git a/Kavita.sln b/Kavita.sln index c1f023634..74927a34f 100644 --- a/Kavita.sln +++ b/Kavita.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 15.0.26124.0 MinimumVisualStudioVersion = 15.0.26124.0 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{1BC0273F-FEBE-4DA1-BC04-3A3167E4C86C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API.Tests", "API.Tests\API.Tests.csproj", "{6F7910F2-1B95-4570-A490-519C8935B9D1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,5 +32,17 @@ Global {1BC0273F-FEBE-4DA1-BC04-3A3167E4C86C}.Release|x64.Build.0 = Release|Any CPU {1BC0273F-FEBE-4DA1-BC04-3A3167E4C86C}.Release|x86.ActiveCfg = Release|Any CPU {1BC0273F-FEBE-4DA1-BC04-3A3167E4C86C}.Release|x86.Build.0 = Release|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Debug|x64.Build.0 = Debug|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Debug|x86.Build.0 = Debug|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|Any CPU.Build.0 = Release|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x64.ActiveCfg = Release|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x64.Build.0 = Release|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x86.ActiveCfg = Release|Any CPU + {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal