OPDS Support (#526)
* Added some basic OPDS implementation * Fixed an issue with feed href * More changes * Added library routes and moved user code to a method so we can hack in fixed code without authentication * Images now load on the OPDS reusing our existing Image infrastructure. * Added the ability to download and moved some download code to a dedicated service * Download is working, pagination is implemented. * Refactored libraries to use pagination * Laid foundation for OpenSearch implementation * Fixed up some serialization issues and some old code that wasn't referencing helper methods * Ensure chapters are sorted when we send them over OPDS * OpenSearch implemented * Removed any support for OPDS-PS due to lack of apps supporting it. * Don't distribute development.json nor stats directory on build. * Implemented In Progress feed as well. * Ability to enable OPDS for server. OPDS now accepts initial call as POST in case app uses username/password. * UI now properly renders state for OPDS enablement. Added Collections routes. * Fixed pagination startIndex on OPDS feeds when there is less than 1 page. * Chunky Reader now works. It only accepts UTF-8 encodings * More Chunky fixes * More chunky changes, such a fussy client. * Implemented the ability to have a custom api key assigned to a user and use that api key as your authentication token against OPDS routing. * Implemented the ability to reset your API Key * Fixed favicon not being sent back correctly * Fixed an issue where images wouldn't send on OPDS feed. * Implemented Page streaming and fixed a pagination bug * Hooked in the ability to save progress in Kavita when Page Streaming
This commit is contained in:
parent
2a63e5e9e2
commit
6069d93c38
50 changed files with 2409 additions and 116 deletions
|
@ -73,6 +73,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Kavita.Common\Kavita.Common.csproj" />
|
||||
<Content Condition=" '$(Configuration)' == 'Release' " Include="appsettings.json" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
@ -115,6 +116,8 @@
|
|||
<Content Remove="backups\**" />
|
||||
<Content Remove="logs\**" />
|
||||
<Content Remove="temp\**" />
|
||||
<Content Remove="stats\**" />
|
||||
<Content Condition=" '$(Configuration)' == 'Release' " Remove="appsettings.Development.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -11,6 +11,7 @@ using API.Extensions;
|
|||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -106,6 +107,7 @@ namespace API.Controllers
|
|||
|
||||
var user = _mapper.Map<AppUser>(registerDto);
|
||||
user.UserPreferences ??= new AppUserPreferences();
|
||||
user.ApiKey = HashUtil.ApiKey();
|
||||
|
||||
var result = await _userManager.CreateAsync(user, registerDto.Password);
|
||||
|
||||
|
@ -136,6 +138,7 @@ namespace API.Controllers
|
|||
{
|
||||
Username = user.UserName,
|
||||
Token = await _tokenService.CreateToken(user),
|
||||
ApiKey = user.ApiKey,
|
||||
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
|
||||
};
|
||||
}
|
||||
|
@ -180,6 +183,7 @@ namespace API.Controllers
|
|||
{
|
||||
Username = user.UserName,
|
||||
Token = await _tokenService.CreateToken(user),
|
||||
ApiKey = user.ApiKey,
|
||||
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
|
||||
};
|
||||
}
|
||||
|
@ -237,5 +241,26 @@ namespace API.Controllers
|
|||
return BadRequest("Something went wrong, unable to update user's roles");
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the API Key assigned with a user
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpPost("reset-api-key")]
|
||||
public async Task<ActionResult<string>> ResetApiKey()
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
|
||||
user.ApiKey = HashUtil.ApiKey();
|
||||
|
||||
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return Ok(user.ApiKey);
|
||||
}
|
||||
|
||||
await _unitOfWork.RollbackAsync();
|
||||
return BadRequest("Something went wrong, unable to reset key");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,15 +25,17 @@ namespace API.Controllers
|
|||
private readonly IArchiveService _archiveService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly NumericComparer _numericComparer;
|
||||
private const string DefaultContentType = "application/octet-stream"; // "application/zip"
|
||||
private const string DefaultContentType = "application/octet-stream";
|
||||
|
||||
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService)
|
||||
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService, IDownloadService downloadService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_archiveService = archiveService;
|
||||
_directoryService = directoryService;
|
||||
_cacheService = cacheService;
|
||||
_downloadService = downloadService;
|
||||
_numericComparer = new NumericComparer();
|
||||
}
|
||||
|
||||
|
@ -82,25 +84,8 @@ namespace API.Controllers
|
|||
|
||||
private async Task<ActionResult> GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||
{
|
||||
var firstFile = files.Select(c => c.FilePath).First();
|
||||
var fileProvider = new FileExtensionContentTypeProvider();
|
||||
// Figures out what the content type should be based on the file name.
|
||||
if (!fileProvider.TryGetContentType(firstFile, out var contentType))
|
||||
{
|
||||
contentType = Path.GetExtension(firstFile).ToLowerInvariant() switch
|
||||
{
|
||||
".cbz" => "application/zip",
|
||||
".cbr" => "application/vnd.rar",
|
||||
".cb7" => "application/x-compressed",
|
||||
".epub" => "application/epub+zip",
|
||||
".7z" => "application/x-7z-compressed",
|
||||
".7zip" => "application/x-7z-compressed",
|
||||
".pdf" => "application/pdf",
|
||||
_ => contentType
|
||||
};
|
||||
}
|
||||
|
||||
return File(await _directoryService.ReadFileAsync(firstFile), contentType, Path.GetFileName(firstFile));
|
||||
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
|
||||
return File(bytes, contentType, fileDownloadName);
|
||||
}
|
||||
|
||||
[HttpGet("chapter")]
|
||||
|
|
697
API/Controllers/OPDSController.cs
Normal file
697
API/Controllers/OPDSController.cs
Normal file
|
@ -0,0 +1,697 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Serialization;
|
||||
using API.Comparators;
|
||||
using API.Constants;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.OPDS;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers
|
||||
{
|
||||
public class OpdsController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IReaderService _readerService;
|
||||
|
||||
|
||||
private readonly XmlSerializer _xmlSerializer;
|
||||
private readonly XmlSerializer _xmlOpenSearchSerializer;
|
||||
private const string Prefix = "/api/opds/";
|
||||
private readonly FilterDto _filterDto = new FilterDto()
|
||||
{
|
||||
MangaFormat = null
|
||||
};
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
|
||||
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
|
||||
IDirectoryService directoryService, UserManager<AppUser> userManager,
|
||||
ICacheService cacheService, IReaderService readerService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_downloadService = downloadService;
|
||||
_directoryService = directoryService;
|
||||
_userManager = userManager;
|
||||
_cacheService = cacheService;
|
||||
_readerService = readerService;
|
||||
|
||||
|
||||
_xmlSerializer = new XmlSerializer(typeof(Feed));
|
||||
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
|
||||
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}")]
|
||||
[HttpGet("{apiKey}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> Get(string apiKey)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var feed = CreateFeed("Kavita", string.Empty, apiKey);
|
||||
feed.Id = "root";
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "inProgress",
|
||||
Title = "In Progress",
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = "Browse by In Progress"
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/in-progress"),
|
||||
}
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "recentlyAdded",
|
||||
Title = "Recently Added",
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = "Browse by Recently Added"
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/recently-added"),
|
||||
}
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "allLibraries",
|
||||
Title = "All Libraries",
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = "Browse by Libraries"
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries"),
|
||||
}
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = "allCollections",
|
||||
Title = "All Collections",
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = "Browse by Collections"
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections"),
|
||||
}
|
||||
});
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("{apiKey}/libraries")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetLibraries(string apiKey)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
|
||||
|
||||
var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey);
|
||||
|
||||
foreach (var library in libraries)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = library.Id.ToString(),
|
||||
Title = library.Name,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries/{library.Id}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/collections")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetCollections(string apiKey)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
|
||||
IEnumerable <CollectionTagDto> tags;
|
||||
if (isAdmin)
|
||||
{
|
||||
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
|
||||
}
|
||||
|
||||
|
||||
var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey);
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = tag.Id.ToString(),
|
||||
Title = tag.Title,
|
||||
Summary = tag.Summary,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections/{tag.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/collections/{collectionId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
|
||||
IEnumerable <CollectionTagDto> tags;
|
||||
if (isAdmin)
|
||||
{
|
||||
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
|
||||
}
|
||||
|
||||
var tag = tags.SingleOrDefault(t => t.Id == collectionId);
|
||||
if (tag == null)
|
||||
{
|
||||
return BadRequest("Collection does not exist or you don't have access");
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
});
|
||||
|
||||
var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey);
|
||||
AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}");
|
||||
|
||||
foreach (var seriesDto in series)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||
}
|
||||
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/libraries/{libraryId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var library =
|
||||
(await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).SingleOrDefault(l =>
|
||||
l.Id == libraryId);
|
||||
if (library == null)
|
||||
{
|
||||
return BadRequest("User does not have access to this library");
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
}, _filterDto);
|
||||
|
||||
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey);
|
||||
AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}");
|
||||
|
||||
foreach (var seriesDto in series)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/recently-added")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, user.Id, new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
}, _filterDto);
|
||||
|
||||
var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey);
|
||||
AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added");
|
||||
|
||||
foreach (var seriesDto in recentlyAdded)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||
}
|
||||
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/in-progress")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetInProgress(string apiKey, [FromQuery] int pageNumber = 1)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userParams = new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
};
|
||||
var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, 0, userParams, _filterDto);
|
||||
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
|
||||
.Take(userParams.PageSize).ToList();
|
||||
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
|
||||
|
||||
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
|
||||
|
||||
var feed = CreateFeed("In Progress", $"{apiKey}/in-progress", apiKey);
|
||||
AddPagination(feed, pagedList, $"{Prefix}{apiKey}/in-progress");
|
||||
|
||||
foreach (var seriesDto in pagedList)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/series")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> SearchSeries(string apiKey, [FromQuery] string query)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
if (string.IsNullOrEmpty(query))
|
||||
{
|
||||
return BadRequest("You must pass a query parameter");
|
||||
}
|
||||
query = query.Replace(@"%", "");
|
||||
// Get libraries user has access to
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query);
|
||||
|
||||
var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey);
|
||||
|
||||
foreach (var seriesDto in series)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/search")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetSearchDescriptor(string apiKey)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var feed = new OpenSearchDescription()
|
||||
{
|
||||
ShortName = "Search",
|
||||
Description = "Search for Series",
|
||||
Url = new SearchLink()
|
||||
{
|
||||
Type = FeedLinkType.AtomAcquisition,
|
||||
Template = $"{Prefix}{apiKey}/series?query=" + "{searchTerms}"
|
||||
}
|
||||
};
|
||||
|
||||
await using var sm = new StringWriter();
|
||||
_xmlOpenSearchSerializer.Serialize(sm, feed);
|
||||
|
||||
return CreateXmlResult(sm.ToString().Replace("utf-16", "utf-8"));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/series/{seriesId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetSeries(string apiKey, int seriesId)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id);
|
||||
var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey);
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
|
||||
foreach (var volumeDto in volumes)
|
||||
{
|
||||
feed.Entries.Add(CreateVolume(volumeDto, seriesId, apiKey));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetVolume(string apiKey, int seriesId, int volumeId)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
|
||||
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
|
||||
var chapters =
|
||||
(await _unitOfWork.VolumeRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
|
||||
_chapterSortComparer);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = chapter.Id.ToString(),
|
||||
Title = "Chapter " + chapter.Number,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapter.Id}")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetChapter(string apiKey, int seriesId, int volumeId, int chapterId)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
|
||||
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId);
|
||||
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
|
||||
foreach (var mangaFile in files)
|
||||
{
|
||||
feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a file
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <param name="filename">Not used. Only for Chunky to allow download links</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")]
|
||||
public async Task<ActionResult> DownloadFile(string apiKey, int seriesId, int volumeId, int chapterId, string filename)
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
|
||||
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
|
||||
return File(bytes, contentType, fileDownloadName);
|
||||
}
|
||||
|
||||
private static ContentResult CreateXmlResult(string xml)
|
||||
{
|
||||
return new ContentResult
|
||||
{
|
||||
ContentType = "application/xml",
|
||||
Content = xml,
|
||||
StatusCode = 200
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddPagination(Feed feed, PagedList<SeriesDto> list, string href)
|
||||
{
|
||||
var url = href;
|
||||
if (href.Contains("?"))
|
||||
{
|
||||
url += "&";
|
||||
}
|
||||
else
|
||||
{
|
||||
url += "?";
|
||||
}
|
||||
|
||||
var pageNumber = Math.Max(list.CurrentPage, 1);
|
||||
|
||||
if (pageNumber > 1)
|
||||
{
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1)));
|
||||
}
|
||||
|
||||
if (pageNumber + 1 < list.TotalPages)
|
||||
{
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1)));
|
||||
}
|
||||
|
||||
// Update self to point to current page
|
||||
var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self);
|
||||
if (selfLink != null)
|
||||
{
|
||||
selfLink.Href = url + "pageNumber=" + pageNumber;
|
||||
}
|
||||
|
||||
|
||||
feed.Total = list.TotalPages * list.PageSize;
|
||||
feed.ItemsPerPage = list.PageSize;
|
||||
feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1;
|
||||
}
|
||||
|
||||
private static FeedEntry CreateSeries(SeriesDto seriesDto, string apiKey)
|
||||
{
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = seriesDto.Id.ToString(),
|
||||
Title = $"{seriesDto.Name} ({seriesDto.Format})",
|
||||
Summary = seriesDto.Summary,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey)
|
||||
{
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = searchResultDto.SeriesId.ToString(),
|
||||
Title = $"{searchResultDto.Name} ({searchResultDto.Format})",
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{searchResultDto.SeriesId}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FeedEntry CreateVolume(VolumeDto volumeDto, int seriesId, string apiKey)
|
||||
{
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = volumeDto.Id.ToString(),
|
||||
Title = volumeDto.IsSpecial ? "Specials" : "Volume " + volumeDto.Name,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private FeedEntry CreateChapter(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, Volume volume, ChapterDto chapter, string apiKey)
|
||||
{
|
||||
var fileSize =
|
||||
DirectoryService.GetHumanReadableBytes(DirectoryService.GetTotalSize(new List<string>()
|
||||
{mangaFile.FilePath}));
|
||||
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
|
||||
var filename = Uri.EscapeUriString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty);
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = mangaFile.Id.ToString(),
|
||||
Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}",
|
||||
Extent = fileSize,
|
||||
Summary = $"{fileType.Split("/")[1]} - {fileSize}",
|
||||
Format = mangaFile.Format.ToString(),
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
// Chunky requires a file at the end. Our API ignores this
|
||||
CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"),
|
||||
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
|
||||
},
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = fileType,
|
||||
Type = "text"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/image")]
|
||||
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
|
||||
{
|
||||
if (pageNumber < 0) return BadRequest("Page cannot be less than 0");
|
||||
var chapter = await _cacheService.Ensure(chapterId);
|
||||
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
|
||||
|
||||
try
|
||||
{
|
||||
var (path, _) = await _cacheService.GetCachedPagePath(chapter, pageNumber);
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {pageNumber}");
|
||||
|
||||
var content = await _directoryService.ReadFileAsync(path);
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
// Calculates SHA1 Hash for byte[]
|
||||
Response.AddCacheHeader(content);
|
||||
|
||||
// Save progress for the user
|
||||
await _readerService.SaveReadingProgress(new ProgressDto()
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
PageNum = pageNumber,
|
||||
SeriesId = seriesId,
|
||||
VolumeId = volumeId
|
||||
}, await GetUser(apiKey));
|
||||
|
||||
return File(content, "image/" + format);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_cacheService.CleanupChapters(new []{ chapterId });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/favicon")]
|
||||
public async Task<ActionResult> GetFavicon(string apiKey)
|
||||
{
|
||||
var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
|
||||
if (files.Length == 0) return BadRequest("Cannot find icon");
|
||||
var path = files[0];
|
||||
var content = await _directoryService.ReadFileAsync(path);
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
// Calculates SHA1 Hash for byte[]
|
||||
Response.AddCacheHeader(content);
|
||||
|
||||
return File(content, "image/" + format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user from the API key
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private async Task<AppUser> GetUser(string apiKey)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByApiKeyAsync(apiKey);
|
||||
if (user == null)
|
||||
{
|
||||
throw new KavitaException("User does not exist");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
|
||||
{
|
||||
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
|
||||
link.TotalPages = mangaFile.Pages;
|
||||
return link;
|
||||
}
|
||||
|
||||
private static FeedLink CreateLink(string rel, string type, string href)
|
||||
{
|
||||
return new FeedLink()
|
||||
{
|
||||
Rel = rel,
|
||||
Href = href,
|
||||
Type = type
|
||||
};
|
||||
}
|
||||
|
||||
private static Feed CreateFeed(string title, string href, string apiKey)
|
||||
{
|
||||
var link = CreateLink(FeedLinkRelation.Self, string.IsNullOrEmpty(href) ?
|
||||
FeedLinkType.AtomNavigation :
|
||||
FeedLinkType.AtomAcquisition, Prefix + href);
|
||||
|
||||
return new Feed()
|
||||
{
|
||||
Title = title,
|
||||
Icon = Prefix + $"{apiKey}/favicon",
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
link,
|
||||
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, Prefix + apiKey),
|
||||
CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, Prefix + $"{apiKey}/search")
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private string SerializeXml(Feed feed)
|
||||
{
|
||||
if (feed == null) return string.Empty;
|
||||
using var sm = new StringWriter();
|
||||
_xmlSerializer.Serialize(sm, feed);
|
||||
return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,17 +24,20 @@ namespace API.Controllers
|
|||
private readonly ICacheService _cacheService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReaderController> _logger;
|
||||
private readonly IReaderService _readerService;
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork, ILogger<ReaderController> logger)
|
||||
public ReaderController(IDirectoryService directoryService, ICacheService cacheService,
|
||||
IUnitOfWork unitOfWork, ILogger<ReaderController> logger, IReaderService readerService)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_cacheService = cacheService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_readerService = readerService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -285,57 +288,7 @@ namespace API.Controllers
|
|||
public async Task<ActionResult> BookmarkProgress(ProgressDto progressDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
|
||||
// Don't let user save past total pages.
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
|
||||
if (progressDto.PageNum > chapter.Pages)
|
||||
{
|
||||
progressDto.PageNum = chapter.Pages;
|
||||
}
|
||||
|
||||
if (progressDto.PageNum < 0)
|
||||
{
|
||||
progressDto.PageNum = 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
var userProgress =
|
||||
user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = progressDto.PageNum,
|
||||
VolumeId = progressDto.VolumeId,
|
||||
SeriesId = progressDto.SeriesId,
|
||||
ChapterId = progressDto.ChapterId,
|
||||
BookScrollId = progressDto.BookScrollId,
|
||||
LastModified = DateTime.Now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = progressDto.PageNum;
|
||||
userProgress.SeriesId = progressDto.SeriesId;
|
||||
userProgress.VolumeId = progressDto.VolumeId;
|
||||
userProgress.BookScrollId = progressDto.BookScrollId;
|
||||
userProgress.LastModified = DateTime.Now;
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
if (await _readerService.SaveReadingProgress(progressDto, user)) return Ok(true);
|
||||
|
||||
return BadRequest("Could not save progress");
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ using Microsoft.Extensions.Logging;
|
|||
|
||||
namespace API.Controllers
|
||||
{
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
public class SettingsController : BaseApiController
|
||||
{
|
||||
private readonly ILogger<SettingsController> _logger;
|
||||
|
@ -30,6 +29,7 @@ namespace API.Controllers
|
|||
_taskScheduler = taskScheduler;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ServerSettingDto>> GetSettings()
|
||||
{
|
||||
|
@ -39,6 +39,7 @@ namespace API.Controllers
|
|||
return Ok(settingsDto);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
|
||||
{
|
||||
|
@ -86,6 +87,12 @@ namespace API.Controllers
|
|||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.EnableOpds && updateSettingsDto.EnableOpds + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.EnableOpds + string.Empty;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
|
||||
|
@ -114,22 +121,32 @@ namespace API.Controllers
|
|||
return Ok(updateSettingsDto);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("task-frequencies")]
|
||||
public ActionResult<IEnumerable<string>> GetTaskFrequencies()
|
||||
{
|
||||
return Ok(CronConverter.Options);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("library-types")]
|
||||
public ActionResult<IEnumerable<string>> GetLibraryTypes()
|
||||
{
|
||||
return Ok(Enum.GetValues<LibraryType>().Select(t => t.ToDescription()));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("log-levels")]
|
||||
public ActionResult<IEnumerable<string>> GetLogLevels()
|
||||
{
|
||||
return Ok(new [] {"Trace", "Debug", "Information", "Warning", "Critical"});
|
||||
}
|
||||
|
||||
[HttpGet("opds-enabled")]
|
||||
public async Task<ActionResult<bool>> GetOpdsEnabled()
|
||||
{
|
||||
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
return Ok(settingsDto.EnableOpds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,9 @@ namespace API.DTOs.Filtering
|
|||
{
|
||||
public class FilterDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Pass null if you want all formats
|
||||
/// </summary>
|
||||
public MangaFormat? MangaFormat { get; init; } = null;
|
||||
|
||||
}
|
||||
|
|
12
API/DTOs/OPDS/Author.cs
Normal file
12
API/DTOs/OPDS/Author.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS
|
||||
{
|
||||
public class Author
|
||||
{
|
||||
[XmlElement("name")]
|
||||
public string Name { get; set; }
|
||||
[XmlElement("uri")]
|
||||
public string Uri { get; set; }
|
||||
}
|
||||
}
|
62
API/DTOs/OPDS/Feed.cs
Normal file
62
API/DTOs/OPDS/Feed.cs
Normal file
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")]
|
||||
public class Feed
|
||||
{
|
||||
[XmlElement("updated")]
|
||||
public string Updated { get; init; } = DateTime.UtcNow.ToString("s");
|
||||
|
||||
[XmlElement("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("icon")]
|
||||
public string Icon { get; set; } = "/favicon.ico";
|
||||
|
||||
[XmlElement("author")]
|
||||
public Author Author { get; set; } = new Author()
|
||||
{
|
||||
Name = "Kavita",
|
||||
Uri = "https://kavitareader.com"
|
||||
};
|
||||
|
||||
[XmlElement("totalResults", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
|
||||
public int? Total { get; set; } = null;
|
||||
|
||||
[XmlElement("itemsPerPage", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
|
||||
public int? ItemsPerPage { get; set; } = null;
|
||||
|
||||
[XmlElement("startIndex", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
|
||||
public int? StartIndex { get; set; } = null;
|
||||
|
||||
[XmlElement("link")]
|
||||
public List<FeedLink> Links { get; set; } = new List<FeedLink>() ;
|
||||
|
||||
[XmlElement("entry")]
|
||||
public List<FeedEntry> Entries { get; set; } = new List<FeedEntry>();
|
||||
|
||||
public bool ShouldSerializeTotal()
|
||||
{
|
||||
return Total.HasValue;
|
||||
}
|
||||
|
||||
public bool ShouldSerializeItemsPerPage()
|
||||
{
|
||||
return ItemsPerPage.HasValue;
|
||||
}
|
||||
|
||||
public bool ShouldSerializeStartIndex()
|
||||
{
|
||||
return StartIndex.HasValue;
|
||||
}
|
||||
}
|
||||
}
|
51
API/DTOs/OPDS/FeedEntry.cs
Normal file
51
API/DTOs/OPDS/FeedEntry.cs
Normal file
|
@ -0,0 +1,51 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS
|
||||
{
|
||||
public class FeedEntry
|
||||
{
|
||||
[XmlElement("updated")]
|
||||
public string Updated { get; init; } = DateTime.UtcNow.ToString("s");
|
||||
|
||||
[XmlElement("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("summary")]
|
||||
public string Summary { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents Size of the Entry
|
||||
/// Tag: , ElementName = "dcterms:extent"
|
||||
/// <example>2 MB</example>
|
||||
/// </summary>
|
||||
[XmlElement("extent", Namespace = "http://purl.org/dc/terms/")]
|
||||
public string Extent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Format of the file
|
||||
/// https://dublincore.org/specifications/dublin-core/dcmi-terms/
|
||||
/// </summary>
|
||||
[XmlElement("format", Namespace = "http://purl.org/dc/terms/format")]
|
||||
public string Format { get; set; }
|
||||
|
||||
[XmlElement("language", Namespace = "http://purl.org/dc/terms/")]
|
||||
public string Language { get; set; }
|
||||
|
||||
[XmlElement("content")]
|
||||
public FeedEntryContent Content { get; set; }
|
||||
|
||||
[XmlElement("link")]
|
||||
public List<FeedLink> Links = new List<FeedLink>();
|
||||
|
||||
// [XmlElement("author")]
|
||||
// public List<FeedAuthor> Authors = new List<FeedAuthor>();
|
||||
|
||||
// [XmlElement("category")]
|
||||
// public List<FeedCategory> Categories = new List<FeedCategory>();
|
||||
}
|
||||
}
|
12
API/DTOs/OPDS/FeedEntryContent.cs
Normal file
12
API/DTOs/OPDS/FeedEntryContent.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS
|
||||
{
|
||||
public class FeedEntryContent
|
||||
{
|
||||
[XmlAttribute("type")]
|
||||
public string Type = "text";
|
||||
[XmlText]
|
||||
public string Text;
|
||||
}
|
||||
}
|
33
API/DTOs/OPDS/FeedLink.cs
Normal file
33
API/DTOs/OPDS/FeedLink.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS
|
||||
{
|
||||
public class FeedLink
|
||||
{
|
||||
/// <summary>
|
||||
/// Relation on the Link
|
||||
/// </summary>
|
||||
[XmlAttribute("rel")]
|
||||
public string Rel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Should be any of the types here <see cref="FeedLinkType"/>
|
||||
/// </summary>
|
||||
[XmlAttribute("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[XmlAttribute("href")]
|
||||
public string Href { get; set; }
|
||||
|
||||
[XmlAttribute("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")]
|
||||
public int TotalPages { get; set; } = 0;
|
||||
|
||||
public bool ShouldSerializeTotalPages()
|
||||
{
|
||||
return TotalPages > 0;
|
||||
}
|
||||
}
|
||||
}
|
24
API/DTOs/OPDS/FeedLinkRelation.cs
Normal file
24
API/DTOs/OPDS/FeedLinkRelation.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
namespace API.DTOs.OPDS
|
||||
{
|
||||
public static class FeedLinkRelation
|
||||
{
|
||||
public const string Debug = "debug";
|
||||
public const string Search = "search";
|
||||
public const string Self = "self";
|
||||
public const string Start = "start";
|
||||
public const string Next = "next";
|
||||
public const string Prev = "prev";
|
||||
public const string Alternate = "alternate";
|
||||
public const string SubSection = "subsection";
|
||||
public const string Related = "related";
|
||||
public const string Image = "http://opds-spec.org/image";
|
||||
public const string Thumbnail = "http://opds-spec.org/image/thumbnail";
|
||||
/// <summary>
|
||||
/// This will allow for a download to occur
|
||||
/// </summary>
|
||||
public const string Acquisition = "http://opds-spec.org/acquisition/open-access";
|
||||
#pragma warning disable S1075
|
||||
public const string Stream = "http://vaemendis.net/opds-pse/stream";
|
||||
#pragma warning restore S1075
|
||||
}
|
||||
}
|
11
API/DTOs/OPDS/FeedLinkType.cs
Normal file
11
API/DTOs/OPDS/FeedLinkType.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
namespace API.DTOs.OPDS
|
||||
{
|
||||
public static class FeedLinkType
|
||||
{
|
||||
public const string Atom = "application/atom+xml";
|
||||
public const string AtomSearch = "application/opensearchdescription+xml";
|
||||
public const string AtomNavigation = "application/atom+xml;profile=opds-catalog;kind=navigation";
|
||||
public const string AtomAcquisition = "application/atom+xml;profile=opds-catalog;kind=acquisition";
|
||||
public const string Image = "image/jpeg";
|
||||
}
|
||||
}
|
42
API/DTOs/OPDS/OpenSearchDescription.cs
Normal file
42
API/DTOs/OPDS/OpenSearchDescription.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS
|
||||
{
|
||||
[XmlRoot("OpenSearchDescription", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
|
||||
public class OpenSearchDescription
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains a brief human-readable title that identifies this search engine.
|
||||
/// </summary>
|
||||
public string ShortName { get; set; }
|
||||
/// <summary>
|
||||
/// Contains an extended human-readable title that identifies this search engine.
|
||||
/// </summary>
|
||||
public string LongName { get; set; }
|
||||
/// <summary>
|
||||
/// Contains a human-readable text description of the search engine.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>
|
||||
/// https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-url-element
|
||||
/// </summary>
|
||||
public SearchLink Url { get; set; }
|
||||
/// <summary>
|
||||
/// Contains a set of words that are used as keywords to identify and categorize this search content.
|
||||
/// Tags must be a single word and are delimited by the space character (' ').
|
||||
/// </summary>
|
||||
public string Tags { get; set; }
|
||||
/// <summary>
|
||||
/// Contains a URL that identifies the location of an image that can be used in association with this search content.
|
||||
/// <example><Image height="64" width="64" type="image/png">http://example.com/websearch.png</Image></example>
|
||||
/// </summary>
|
||||
public string Image { get; set; }
|
||||
public string InputEncoding { get; set; } = "UTF-8";
|
||||
public string OutputEncoding { get; set; } = "UTF-8";
|
||||
/// <summary>
|
||||
/// Contains the human-readable name or identifier of the creator or maintainer of the description document.
|
||||
/// </summary>
|
||||
public string Developer { get; set; } = "kavitareader.com";
|
||||
|
||||
}
|
||||
}
|
16
API/DTOs/OPDS/SearchLink.cs
Normal file
16
API/DTOs/OPDS/SearchLink.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS
|
||||
{
|
||||
public class SearchLink
|
||||
{
|
||||
[XmlAttribute("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[XmlAttribute("rel")]
|
||||
public string Rel { get; set; } = "results";
|
||||
|
||||
[XmlAttribute("template")]
|
||||
public string Template { get; set; }
|
||||
}
|
||||
}
|
|
@ -4,9 +4,22 @@
|
|||
{
|
||||
public string CacheDirectory { get; set; }
|
||||
public string TaskScan { get; set; }
|
||||
/// <summary>
|
||||
/// Logging level for server. Managed in appsettings.json.
|
||||
/// </summary>
|
||||
public string LoggingLevel { get; set; }
|
||||
public string TaskBackup { get; set; }
|
||||
/// <summary>
|
||||
/// Port the server listens on. Managed in appsettings.json.
|
||||
/// </summary>
|
||||
public int Port { get; set; }
|
||||
/// <summary>
|
||||
/// Allows anonymous information to be collected and sent to KavitaStats
|
||||
/// </summary>
|
||||
public bool AllowStatCollection { get; set; }
|
||||
/// <summary>
|
||||
/// Enables OPDS connections to be made to the server.
|
||||
/// </summary>
|
||||
public bool EnableOpds { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace API.DTOs
|
|||
{
|
||||
public string Username { get; init; }
|
||||
public string Token { get; init; }
|
||||
public string ApiKey { get; init; }
|
||||
public UserPreferencesDto Preferences { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
928
API/Data/Migrations/20210826203258_userApiKey.Designer.cs
generated
Normal file
928
API/Data/Migrations/20210826203258_userApiKey.Designer.cs
generated
Normal file
|
@ -0,0 +1,928 @@
|
|||
// <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("20210826203258_userApiKey")]
|
||||
partial class userApiKey
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.8");
|
||||
|
||||
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>("ApiKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
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.AppUserBookmark", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChapterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Page")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VolumeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserBookmark");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AutoCloseMenu")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BookReaderDarkMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BookReaderFontFamily")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BookReaderFontSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderLineSpacing")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderMargin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderReadingDirection")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BookReaderTapToPaginate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PageSplitOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ReaderMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ReadingDirection")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ScalingOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SiteDarkMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AppUserPreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BookScrollId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ChapterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PagesRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.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<bool>("CoverImageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSpecial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VolumeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("Chapter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte[]>("CoverImage")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<bool>("CoverImageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NormalizedTitle")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Promoted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Id", "Promoted")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CollectionTag");
|
||||
});
|
||||
|
||||
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<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Pages")
|
||||
.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<bool>("CoverImageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Format")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LibraryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LocalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.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.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeriesId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Id", "SeriesId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SeriesMetadata");
|
||||
});
|
||||
|
||||
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<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("CollectionTagSeriesMetadata", b =>
|
||||
{
|
||||
b.Property<int>("CollectionTagsId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SeriesMetadatasId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
|
||||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("CollectionTagSeriesMetadata");
|
||||
});
|
||||
|
||||
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.AppUserBookmark", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("Bookmarks")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithOne("UserPreferences")
|
||||
.HasForeignKey("API.Entities.AppUserPreferences", "AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
});
|
||||
|
||||
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.SeriesMetadata", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Series", "Series")
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
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("CollectionTagSeriesMetadata", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.CollectionTag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CollectionTagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.SeriesMetadata", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SeriesMetadatasId")
|
||||
.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("Bookmarks");
|
||||
|
||||
b.Navigation("Progresses");
|
||||
|
||||
b.Navigation("Ratings");
|
||||
|
||||
b.Navigation("UserPreferences");
|
||||
|
||||
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("Metadata");
|
||||
|
||||
b.Navigation("Volumes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
23
API/Data/Migrations/20210826203258_userApiKey.cs
Normal file
23
API/Data/Migrations/20210826203258_userApiKey.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class userApiKey : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ApiKey",
|
||||
table: "AspNetUsers",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApiKey",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -52,6 +52,9 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
@ -345,7 +348,6 @@ namespace API.Data.Migrations
|
|||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
|
|
|
@ -48,6 +48,7 @@ namespace API.Data
|
|||
new () {Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "backups/"))},
|
||||
new () {Key = ServerSettingKey.Port, Value = "5000"}, // Not used from DB, but DB is sync with appSettings.json
|
||||
new () {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
|
||||
new () {Key = ServerSettingKey.EnableOpds, Value = "false"},
|
||||
};
|
||||
|
||||
foreach (var defaultSetting in defaultSettings)
|
||||
|
@ -71,19 +72,18 @@ namespace API.Data
|
|||
|
||||
}
|
||||
|
||||
public static async Task SeedSeriesMetadata(DataContext context)
|
||||
public static async Task SeedUserApiKeys(DataContext context)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
context.Database.EnsureCreated();
|
||||
var series = await context.Series
|
||||
.Include(s => s.Metadata).ToListAsync();
|
||||
|
||||
foreach (var s in series)
|
||||
var users = await context.AppUser.ToListAsync();
|
||||
foreach (var user in users)
|
||||
{
|
||||
s.Metadata ??= new SeriesMetadata();
|
||||
if (string.IsNullOrEmpty(user.ApiKey))
|
||||
{
|
||||
user.ApiKey = HashUtil.ApiKey();
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,6 +53,19 @@ namespace API.Data
|
|||
.SingleOrDefaultAsync(x => x.UserName == username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AppUser by id. Returns back Progress information.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByIdAsync(int id)
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(u => u.Progresses)
|
||||
.Include(u => u.Bookmarks)
|
||||
.SingleOrDefaultAsync(x => x.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
|
@ -116,6 +129,12 @@ namespace API.Data
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUser> GetUserByApiKeyAsync(string apiKey)
|
||||
{
|
||||
return await _context.AppUser
|
||||
.SingleOrDefaultAsync(u => u.ApiKey.Equals(apiKey));
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
|
||||
{
|
||||
|
|
|
@ -17,6 +17,11 @@ namespace API.Entities
|
|||
public ICollection<AppUserRating> Ratings { get; set; }
|
||||
public AppUserPreferences UserPreferences { get; set; }
|
||||
public ICollection<AppUserBookmark> Bookmarks { get; set; }
|
||||
/// <summary>
|
||||
/// An API Key to interact with external services, like OPDS
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
[ConcurrencyCheck]
|
||||
|
|
|
@ -18,6 +18,8 @@ namespace API.Entities.Enums
|
|||
BackupDirectory = 5,
|
||||
[Description("AllowStatCollection")]
|
||||
AllowStatCollection = 6,
|
||||
|
||||
[Description("EnableOpds")]
|
||||
EnableOpds = 7,
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,8 @@ namespace API.Extensions
|
|||
services.AddScoped<IBookService, BookService>();
|
||||
services.AddScoped<IImageService, ImageService>();
|
||||
services.AddScoped<IVersionUpdaterService, VersionUpdaterService>();
|
||||
services.AddScoped<IDownloadService, DownloadService>();
|
||||
services.AddScoped<IReaderService, ReaderService>();
|
||||
|
||||
services.AddScoped<IPresenceTracker, PresenceTracker>();
|
||||
|
||||
|
|
|
@ -33,10 +33,13 @@ namespace API.Helpers.Converters
|
|||
case ServerSettingKey.AllowStatCollection:
|
||||
destination.AllowStatCollection = bool.Parse(row.Value);
|
||||
break;
|
||||
case ServerSettingKey.EnableOpds:
|
||||
destination.EnableOpds = bool.Parse(row.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ namespace API.Interfaces
|
|||
void Update(AppUserPreferences preferences);
|
||||
public void Delete(AppUser user);
|
||||
Task<AppUser> GetUserByUsernameAsync(string username);
|
||||
Task<AppUser> GetUserByIdAsync(int id);
|
||||
Task<IEnumerable<MemberDto>> GetMembersAsync();
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<AppUserRating> GetUserRating(int seriesId, int userId);
|
||||
|
@ -20,5 +21,6 @@ namespace API.Interfaces
|
|||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
|
||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
|
||||
Task<AppUser> GetUserByApiKeyAsync(string apiKey);
|
||||
}
|
||||
}
|
||||
|
|
11
API/Interfaces/Services/IReaderService.cs
Normal file
11
API/Interfaces/Services/IReaderService.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IReaderService
|
||||
{
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user);
|
||||
}
|
||||
}
|
82
API/Interfaces/Services/ReaderService.cs
Normal file
82
API/Interfaces/Services/ReaderService.cs
Normal file
|
@ -0,0 +1,82 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public class ReaderService : IReaderService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public ReaderService(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves progress to DB
|
||||
/// </summary>
|
||||
/// <param name="progressDto"></param>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user)
|
||||
{
|
||||
// Don't let user save past total pages.
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
|
||||
if (progressDto.PageNum > chapter.Pages)
|
||||
{
|
||||
progressDto.PageNum = chapter.Pages;
|
||||
}
|
||||
|
||||
if (progressDto.PageNum < 0)
|
||||
{
|
||||
progressDto.PageNum = 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
var userProgress =
|
||||
user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = progressDto.PageNum,
|
||||
VolumeId = progressDto.VolumeId,
|
||||
SeriesId = progressDto.SeriesId,
|
||||
ChapterId = progressDto.ChapterId,
|
||||
BookScrollId = progressDto.BookScrollId,
|
||||
LastModified = DateTime.Now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = progressDto.PageNum;
|
||||
userProgress.SeriesId = progressDto.SeriesId;
|
||||
userProgress.VolumeId = progressDto.VolumeId;
|
||||
userProgress.BookScrollId = progressDto.BookScrollId;
|
||||
userProgress.LastModified = DateTime.Now;
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Middleware
|
||||
{
|
||||
public class BookRedirectMiddleware
|
||||
{
|
||||
private readonly ILogger<BookRedirectMiddleware> _logger;
|
||||
|
||||
public BookRedirectMiddleware(ILogger<BookRedirectMiddleware> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
_logger.LogDebug("BookRedirect Path: {Path}", context.Request.Path.ToString());
|
||||
await next.Invoke(context);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -53,6 +53,7 @@ namespace API
|
|||
await context.Database.MigrateAsync();
|
||||
await Seed.SeedRoles(roleManager);
|
||||
await Seed.SeedSettings(context);
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
@ -19,6 +19,7 @@ namespace API.Services.Clients
|
|||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_client.Timeout = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public async Task SendDataToStatsServer(UsageStatisticsDto data)
|
||||
|
|
|
@ -432,5 +432,60 @@ namespace API.Services
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the human-readable file size for an arbitrary, 64-bit file size
|
||||
/// <remarks>The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB"</remarks>
|
||||
/// </summary>
|
||||
/// https://www.somacon.com/p576.php
|
||||
/// <param name="bytes"></param>
|
||||
/// <returns></returns>
|
||||
public static string GetHumanReadableBytes(long bytes)
|
||||
{
|
||||
// Get absolute value
|
||||
var absoluteBytes = (bytes < 0 ? -bytes : bytes);
|
||||
// Determine the suffix and readable value
|
||||
string suffix;
|
||||
double readable;
|
||||
switch (absoluteBytes)
|
||||
{
|
||||
// Exabyte
|
||||
case >= 0x1000000000000000:
|
||||
suffix = "EB";
|
||||
readable = (bytes >> 50);
|
||||
break;
|
||||
// Petabyte
|
||||
case >= 0x4000000000000:
|
||||
suffix = "PB";
|
||||
readable = (bytes >> 40);
|
||||
break;
|
||||
// Terabyte
|
||||
case >= 0x10000000000:
|
||||
suffix = "TB";
|
||||
readable = (bytes >> 30);
|
||||
break;
|
||||
// Gigabyte
|
||||
case >= 0x40000000:
|
||||
suffix = "GB";
|
||||
readable = (bytes >> 20);
|
||||
break;
|
||||
// Megabyte
|
||||
case >= 0x100000:
|
||||
suffix = "MB";
|
||||
readable = (bytes >> 10);
|
||||
break;
|
||||
// Kilobyte
|
||||
case >= 0x400:
|
||||
suffix = "KB";
|
||||
readable = bytes;
|
||||
break;
|
||||
default:
|
||||
return bytes.ToString("0 B"); // Byte
|
||||
}
|
||||
// Divide by 1024 to get fractional value
|
||||
readable = (readable / 1024);
|
||||
// Return formatted number with suffix
|
||||
return readable.ToString("0.## ") + suffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
59
API/Services/DownloadService.cs
Normal file
59
API/Services/DownloadService.cs
Normal file
|
@ -0,0 +1,59 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
namespace API.Services
|
||||
{
|
||||
public interface IDownloadService
|
||||
{
|
||||
Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files);
|
||||
string GetContentTypeFromFile(string filepath);
|
||||
}
|
||||
public class DownloadService : IDownloadService
|
||||
{
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider();
|
||||
|
||||
public DownloadService(IDirectoryService directoryService)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the first file in the file enumerable for download
|
||||
/// </summary>
|
||||
/// <param name="files"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||
{
|
||||
var firstFile = files.Select(c => c.FilePath).First();
|
||||
return (await _directoryService.ReadFileAsync(firstFile), GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile));
|
||||
}
|
||||
|
||||
public string GetContentTypeFromFile(string filepath)
|
||||
{
|
||||
// Figures out what the content type should be based on the file name.
|
||||
if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType))
|
||||
{
|
||||
contentType = Path.GetExtension(filepath).ToLowerInvariant() switch
|
||||
{
|
||||
".cbz" => "application/zip",
|
||||
".cbr" => "application/vnd.rar",
|
||||
".cb7" => "application/x-compressed",
|
||||
".epub" => "application/epub+zip",
|
||||
".7z" => "application/x-7z-compressed",
|
||||
".7zip" => "application/x-7z-compressed",
|
||||
".pdf" => "application/pdf",
|
||||
_ => contentType
|
||||
};
|
||||
}
|
||||
|
||||
return contentType;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue