mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
* Implemented the first version of dynamic filtering which is all Extension based. * Implemented basic generic property filter for expanded metadata filtering. * Fixed up the code to allow for nested properties and fixed up the Contains to work only for IList's * Started refactoring for the new approach * More progress, need to rethink a few filters like read progress to be % based and people needs to be more explicit. * Refactored most of the existing filtering operations into dedicate extensions for the appropriate comparisons. People still need to be reworked to be more dynamic. * Fixed a bug with continue point where it fails on chapters or volumes tagged with a range * Wired up a basic api path to start building groups. No and/or support yet. * Started on the UI * Made a bit of progress on the UI as I'm putting the pieces together about how to design it. * Refactored names to make it more consistent. New thinking is we will have one row that will take a filter statement and manipulate it. It will emit said statement and a builder will turn into the higher level statement. * Started working on updating code to use new inject() method. * Fixed the code to switch the comparisons. * Added dynamic input structure in and moved add/remove to the builder. * Fixed an enum bug * Hooked in basic dropdown support that is dynamic to the field. Only language is missing as that needs a DTO change (but don't want to break API) * Fixed a bug where dropdown options wouldn't re-populate when switching fields that are both dropdowns * Started adding metadata builder * Fixed when typing on filter row the focus resetting * Refactored to add an additional component which handles the compounding of filter rows. * Started hooking up v2 dto in the UI to send to the backend. * Started working on building group UI for and/or support. * Lots of backend code fixes to ensure OR and AND statements combine correctly. * More trying to figure out how to write the UI code * Started debugging to remember what I was last doing. * Lots of progress towards building out the UI recursively * I got the dto to build and propagate up the chain * Started hooking up to the actual api to fetch the data. * Basic wire up to the backend is working. * HasName is now complete * Refactored SortOptions code into an extension and streamlined LimitTo to the correct place. * Fixed a bug where Library Filters from the Group weren't actually being taken into account. * Refactored a lot of code so builder will now export the full dto. * Cleaned up the data flow from metadata filter to library detail * Got the dropdown to load preset values on first load, but now it triggers twice. * Changed so when you add a new filter, it does it at top and fixed remove * Fixed the remove button being on the wrong row * Cleaned up the arrays to make it easier to manage * Cleaned up some of the backend to ensure it doesn't throw an incorrect exception * I'm starting to tread water, taking a break * Fixed a merge issue * Cleaned up Docker checks. * Default IpAddresses to empty string. * Refactored IsDocker to be completely static * Figured out the issue with the dropdown not working. * Almost got it, but the event isn't being called. * I think i might try something else. This doesn't seem to be working. * On the new implementation, implemented remove group. * Use enums to reduce copy/paste * the new system is working pretty well, ill go with it and move on. Can alwasy refactor. * Code is totally broken, but working the cache resume code with some hiccups. * I need to take a break * Stashing my broken code. I have an idea on how to serialize to the URL, but I need to rearchitect a lot. * Reverted last commit * remove domain * Fixed up some hardcoded caching. I'm giving up on this implementation and going to a simpler version * Refactored the backend to just allow flat filtering. * Started refactoring the components to make it flat filtering only. * Finished refactoring so that the base preset case will render. * Implemented basic query functionality on desktop. Clear needs some work and url code. * Some cleanup * Working on filtering url encode/decode * Interacting with filters now saves to url and can be reloaded from the url. Named filters is not hooked up. * Fixed a double load on the library detail page. * Moved the library filtering code out of the FilterBuilder as it needs to be handled differently. * Fixed up how we handle library statements in the filter. * Fixed up how links that perform a filter work. * Refactored a bunch of linking to a search page. * LimitTo works, my css however does not. * Switched some code to use localized strings. * Cleaned up some css * Hooked up Languages and put some additional code in so that Languages will return invalid Language codes back. * Removed a duplicate language signature. * Hooked up ability to preload collection tag. * Want To Read is converted * Converted lots of code to new filtering system. Need to do Bookmarks. * Fixed a potential bug with default filter creation. * Hooked up the ability to disable certain filter fields from appearing. * Added mobile drawer code and a hook for Robbie to take a look for some css. * Converted the APIs for dashboard along with other safety fixes to ensure bad data doesn't break any of the filtering apis * Added the backend code to handle summary query * Converted Want to Read api properly now. * Fixed the HasReadingProgress query * Hooked back the Reading Progress for legacy APIs * Fixed some bad localization strings * Wrote the filtering code for all-bookmarks. * OPDS is now using the new filter * Fixed OPDS reading lists and covers not sending their images. * Fixed up the OPDS feed and fixed a bug where libraries also weren't sending their images over OPDS * All but dropdown options have been validated and tested. * Fixed up some default cases for setting up the filter. * Sorted filter fields and re-keyed to be better suited based on user's needs. Fixed a bug where OPDS Series (from library view) wasn't showing the summary. Moved the (Format) from the title to the description to make the UX much better for OPDS. MOved * don't send empty summaries in the new summary formatting * Fixed up some default cases for setting up the filter. * Fixed the reset button * Fixed infinite scroller not having correct scope key * Added localization to the new components and removed old debug code * Styling fixes * Fixed deep linking across the app. Made it so you can click Characters from Reading list and open a filtered search. * A bit of styling for mobile * Don't show language if it's not properly set --------- Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
1007 lines
44 KiB
C#
1007 lines
44 KiB
C#
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.Data;
|
|
using API.Data.Repositories;
|
|
using API.DTOs;
|
|
using API.DTOs.CollectionTags;
|
|
using API.DTOs.Filtering;
|
|
using API.DTOs.Filtering.v2;
|
|
using API.DTOs.OPDS;
|
|
using API.DTOs.Search;
|
|
using API.Entities;
|
|
using API.Entities.Enums;
|
|
using API.Extensions;
|
|
using API.Helpers;
|
|
using API.Services;
|
|
using Kavita.Common;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using MimeTypes;
|
|
|
|
namespace API.Controllers;
|
|
|
|
#nullable enable
|
|
|
|
[AllowAnonymous]
|
|
public class OpdsController : BaseApiController
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IDownloadService _downloadService;
|
|
private readonly IDirectoryService _directoryService;
|
|
private readonly ICacheService _cacheService;
|
|
private readonly IReaderService _readerService;
|
|
private readonly ISeriesService _seriesService;
|
|
private readonly IAccountService _accountService;
|
|
private readonly ILocalizationService _localizationService;
|
|
|
|
|
|
private readonly XmlSerializer _xmlSerializer;
|
|
private readonly XmlSerializer _xmlOpenSearchSerializer;
|
|
private readonly FilterDto _filterDto = new FilterDto()
|
|
{
|
|
Formats = new List<MangaFormat>(),
|
|
Character = new List<int>(),
|
|
Colorist = new List<int>(),
|
|
Editor = new List<int>(),
|
|
Genres = new List<int>(),
|
|
Inker = new List<int>(),
|
|
Languages = new List<string>(),
|
|
Letterer = new List<int>(),
|
|
Penciller = new List<int>(),
|
|
Libraries = new List<int>(),
|
|
Publisher = new List<int>(),
|
|
Rating = 0,
|
|
Tags = new List<int>(),
|
|
Translators = new List<int>(),
|
|
Writers = new List<int>(),
|
|
AgeRating = new List<AgeRating>(),
|
|
CollectionTags = new List<int>(),
|
|
CoverArtist = new List<int>(),
|
|
ReadStatus = new ReadStatus(),
|
|
SortOptions = null,
|
|
PublicationStatus = new List<PublicationStatus>()
|
|
};
|
|
|
|
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
|
|
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
|
|
private const int PageSize = 20;
|
|
|
|
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
|
|
IDirectoryService directoryService, ICacheService cacheService,
|
|
IReaderService readerService, ISeriesService seriesService,
|
|
IAccountService accountService, ILocalizationService localizationService)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_downloadService = downloadService;
|
|
_directoryService = directoryService;
|
|
_cacheService = cacheService;
|
|
_readerService = readerService;
|
|
_seriesService = seriesService;
|
|
_accountService = accountService;
|
|
_localizationService = localizationService;
|
|
|
|
_xmlSerializer = new XmlSerializer(typeof(Feed));
|
|
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
|
|
}
|
|
|
|
[HttpPost("{apiKey}")]
|
|
[HttpGet("{apiKey}")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> Get(string apiKey)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
|
|
var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix);
|
|
SetFeedId(feed, "root");
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = "onDeck",
|
|
Title = await _localizationService.Translate(userId, "on-deck"),
|
|
Content = new FeedEntryContent()
|
|
{
|
|
Text = await _localizationService.Translate(userId, "browse-on-deck")
|
|
},
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
|
|
}
|
|
});
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = "recentlyAdded",
|
|
Title = await _localizationService.Translate(userId, "recently-added"),
|
|
Content = new FeedEntryContent()
|
|
{
|
|
Text = await _localizationService.Translate(userId, "browse-recently-added")
|
|
},
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
|
|
}
|
|
});
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = "readingList",
|
|
Title = await _localizationService.Translate(userId, "reading-lists"),
|
|
Content = new FeedEntryContent()
|
|
{
|
|
Text = await _localizationService.Translate(userId, "browse-reading-lists")
|
|
},
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list"),
|
|
}
|
|
});
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = "allLibraries",
|
|
Title = await _localizationService.Translate(userId, "libraries"),
|
|
Content = new FeedEntryContent()
|
|
{
|
|
Text = await _localizationService.Translate(userId, "browse-libraries")
|
|
},
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries"),
|
|
}
|
|
});
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = "allCollections",
|
|
Title = await _localizationService.Translate(userId, "collections"),
|
|
Content = new FeedEntryContent()
|
|
{
|
|
Text = await _localizationService.Translate(userId, "browse-collections")
|
|
},
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
|
|
}
|
|
});
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
private async Task<Tuple<string, string>> GetPrefix()
|
|
{
|
|
var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value;
|
|
var prefix = "/api/opds/";
|
|
if (!Configuration.DefaultBaseUrl.Equals(baseUrl))
|
|
{
|
|
// We need to update the Prefix to account for baseUrl
|
|
prefix = baseUrl + "api/opds/";
|
|
}
|
|
|
|
return new Tuple<string, string>(baseUrl, prefix);
|
|
}
|
|
|
|
|
|
[HttpGet("{apiKey}/libraries")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetLibraries(string apiKey)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
|
|
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix);
|
|
SetFeedId(feed, "libraries");
|
|
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}"),
|
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}")
|
|
}
|
|
});
|
|
}
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
[HttpGet("{apiKey}/collections")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetCollections(string apiKey)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
|
if (user == null) return Unauthorized();
|
|
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
|
|
|
var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
|
|
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
|
|
|
|
|
|
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
|
|
SetFeedId(feed, "collections");
|
|
|
|
feed.Entries.AddRange(tags.Select(tag => 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, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
|
|
}
|
|
}));
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
|
|
[HttpGet("{apiKey}/collections/{collectionId}")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
|
if (user == null) return Unauthorized();
|
|
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
|
|
|
IEnumerable <CollectionTagDto> tags;
|
|
if (isAdmin)
|
|
{
|
|
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
|
|
}
|
|
else
|
|
{
|
|
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
|
|
}
|
|
|
|
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, userId, GetUserParams(pageNumber));
|
|
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
|
|
|
|
var feed = CreateFeed(tag.Title + " Collection", $"{prefix}{apiKey}/collections/{collectionId}", apiKey, prefix);
|
|
SetFeedId(feed, $"collections-{collectionId}");
|
|
AddPagination(feed, series, $"{prefix}{apiKey}/collections/{collectionId}");
|
|
|
|
foreach (var seriesDto in series)
|
|
{
|
|
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
|
|
}
|
|
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
[HttpGet("{apiKey}/reading-list")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
|
|
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId,
|
|
true, GetUserParams(pageNumber), false);
|
|
|
|
|
|
var feed = CreateFeed("All Reading Lists", $"{prefix}{apiKey}/reading-list", apiKey, prefix);
|
|
SetFeedId(feed, "reading-list");
|
|
foreach (var readingListDto in readingLists)
|
|
{
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = readingListDto.Id.ToString(),
|
|
Title = readingListDto.Title,
|
|
Summary = readingListDto.Summary,
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
|
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}")
|
|
}
|
|
});
|
|
}
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
private static UserParams GetUserParams(int pageNumber)
|
|
{
|
|
return new UserParams()
|
|
{
|
|
PageNumber = pageNumber,
|
|
PageSize = PageSize
|
|
};
|
|
}
|
|
|
|
[HttpGet("{apiKey}/reading-list/{readingListId}")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
|
|
|
var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems);
|
|
if (userWithLists == null) return Unauthorized();
|
|
var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId);
|
|
if (readingList == null)
|
|
{
|
|
return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted"));
|
|
}
|
|
|
|
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{prefix}{apiKey}/reading-list/{readingListId}", apiKey, prefix);
|
|
SetFeedId(feed, $"reading-list-{readingListId}");
|
|
|
|
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
|
|
foreach (var item in items)
|
|
{
|
|
feed.Entries.Add(
|
|
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
|
|
string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
|
|
}
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
[HttpGet("{apiKey}/libraries/{libraryId}")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var library =
|
|
(await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l =>
|
|
l.Id == libraryId);
|
|
if (library == null)
|
|
{
|
|
return BadRequest(await _localizationService.Translate(userId, "no-library-access"));
|
|
}
|
|
|
|
var filter = new FilterV2Dto
|
|
{
|
|
Statements = new List<FilterStatementDto>() {
|
|
new ()
|
|
{
|
|
Comparison = FilterComparison.Equal,
|
|
Field = FilterField.Libraries,
|
|
Value = libraryId + string.Empty
|
|
}
|
|
}
|
|
};
|
|
|
|
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter);
|
|
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
|
|
|
|
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix);
|
|
SetFeedId(feed, $"library-{library.Name}");
|
|
AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}");
|
|
|
|
feed.Entries.AddRange(series.Select(seriesDto =>
|
|
CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)));
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
[HttpGet("{apiKey}/recently-added")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto);
|
|
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id));
|
|
|
|
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix);
|
|
SetFeedId(feed, "recently-added");
|
|
AddPagination(feed, recentlyAdded, $"{prefix}{apiKey}/recently-added");
|
|
|
|
foreach (var seriesDto in recentlyAdded)
|
|
{
|
|
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
|
|
}
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
[HttpGet("{apiKey}/on-deck")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
|
|
var userParams = GetUserParams(pageNumber);
|
|
var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto);
|
|
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(pagedList.Select(s => s.Id));
|
|
|
|
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
|
|
|
|
var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{prefix}{apiKey}/on-deck", apiKey, prefix);
|
|
SetFeedId(feed, "on-deck");
|
|
AddPagination(feed, pagedList, $"{prefix}{apiKey}/on-deck");
|
|
|
|
foreach (var seriesDto in pagedList)
|
|
{
|
|
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
|
|
}
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
[HttpGet("{apiKey}/series")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> SearchSeries(string apiKey, [FromQuery] string query)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
|
|
|
if (string.IsNullOrEmpty(query))
|
|
{
|
|
return BadRequest(await _localizationService.Translate(userId, "query-required"));
|
|
}
|
|
query = query.Replace(@"%", string.Empty);
|
|
// Get libraries user has access to
|
|
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
|
|
if (!libraries.Any()) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted"));
|
|
|
|
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
|
|
|
var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query);
|
|
|
|
var feed = CreateFeed(query, $"{prefix}{apiKey}/series?query=" + query, apiKey, prefix);
|
|
SetFeedId(feed, "search-series");
|
|
foreach (var seriesDto in series.Series)
|
|
{
|
|
feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl));
|
|
}
|
|
|
|
foreach (var collection in series.Collections)
|
|
{
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = collection.Id.ToString(),
|
|
Title = collection.Title,
|
|
Summary = collection.Summary,
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
|
$"{prefix}{apiKey}/collections/{collection.Id}"),
|
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
|
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
|
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}")
|
|
}
|
|
});
|
|
}
|
|
|
|
foreach (var readingListDto in series.ReadingLists)
|
|
{
|
|
feed.Entries.Add(new FeedEntry()
|
|
{
|
|
Id = readingListDto.Id.ToString(),
|
|
Title = readingListDto.Title,
|
|
Summary = readingListDto.Summary,
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
private static void SetFeedId(Feed feed, string id)
|
|
{
|
|
feed.Id = id;
|
|
}
|
|
|
|
[HttpGet("{apiKey}/search")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetSearchDescriptor(string apiKey)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (_, prefix) = await GetPrefix();
|
|
var feed = new OpenSearchDescription()
|
|
{
|
|
ShortName = await _localizationService.Translate(userId, "search"),
|
|
Description = await _localizationService.Translate(userId, "search-description"),
|
|
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)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
|
|
|
var feed = CreateFeed(series!.Name + " - Storyline", $"{prefix}{apiKey}/series/{series.Id}", apiKey, prefix);
|
|
SetFeedId(feed, $"series-{series.Id}");
|
|
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
|
|
|
|
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
|
foreach (var volume in seriesDetail.Volumes)
|
|
{
|
|
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id)).OrderBy(x => double.Parse(x.Number),
|
|
_chapterSortComparer);
|
|
|
|
foreach (var chapter in chapters)
|
|
{
|
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
|
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id);
|
|
foreach (var mangaFile in files)
|
|
{
|
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
|
|
{
|
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id);
|
|
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(storylineChapter.Id);
|
|
foreach (var mangaFile in files)
|
|
{
|
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
|
|
}
|
|
}
|
|
|
|
foreach (var special in seriesDetail.Specials)
|
|
{
|
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id);
|
|
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(special.Id);
|
|
foreach (var mangaFile in files)
|
|
{
|
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
|
|
}
|
|
}
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")]
|
|
[Produces("application/xml")]
|
|
public async Task<IActionResult> GetVolume(string apiKey, int seriesId, int volumeId)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
|
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
|
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
|
var chapters =
|
|
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
|
|
_chapterSortComparer);
|
|
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
|
|
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
|
|
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
|
|
foreach (var chapter in chapters)
|
|
{
|
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
|
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id);
|
|
foreach (var mangaFile in files)
|
|
{
|
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var (baseUrl, prefix) = await GetPrefix();
|
|
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
|
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
|
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
|
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
|
|
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
|
|
|
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s",
|
|
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
|
|
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
|
|
foreach (var mangaFile in files)
|
|
{
|
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl));
|
|
}
|
|
|
|
return CreateXmlResult(SerializeXml(feed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads a file
|
|
/// </summary>
|
|
/// <param name="apiKey">User's API Key</param>
|
|
/// <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)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey));
|
|
if (!await _accountService.HasDownloadPermission(user))
|
|
{
|
|
return BadRequest("User does not have download permissions");
|
|
}
|
|
|
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
|
var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files);
|
|
return PhysicalFile(zipFile, contentType, fileDownloadName, true);
|
|
}
|
|
|
|
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.TotalCount;
|
|
feed.ItemsPerPage = list.PageSize;
|
|
feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1;
|
|
}
|
|
|
|
private static FeedEntry CreateSeries(SeriesDto seriesDto, SeriesMetadataDto metadata, string apiKey, string prefix, string baseUrl)
|
|
{
|
|
return new FeedEntry()
|
|
{
|
|
Id = seriesDto.Id.ToString(),
|
|
Title = $"{seriesDto.Name}",
|
|
Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
|
|
? string.Empty
|
|
: $" Summary: {metadata.Summary}"),
|
|
Authors = metadata.Writers.Select(p => new FeedAuthor()
|
|
{
|
|
Name = p.Name,
|
|
Uri = "http://opds-spec.org/author/" + p.Id
|
|
}).ToList(),
|
|
Categories = metadata.Genres.Select(g => new FeedCategory()
|
|
{
|
|
Label = g.Title,
|
|
Term = string.Empty
|
|
}).ToList(),
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{seriesDto.Id}"),
|
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}")
|
|
}
|
|
};
|
|
}
|
|
|
|
private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey, string prefix, string baseUrl)
|
|
{
|
|
return new FeedEntry()
|
|
{
|
|
Id = searchResultDto.SeriesId.ToString(),
|
|
Title = $"{searchResultDto.Name}",
|
|
Summary = $"Format: {searchResultDto.Format}",
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"),
|
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}")
|
|
}
|
|
};
|
|
}
|
|
|
|
private static FeedEntry CreateChapter(string apiKey, string title, string summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl)
|
|
{
|
|
return new FeedEntry()
|
|
{
|
|
Id = chapterId.ToString(),
|
|
Title = title,
|
|
Summary = summary ?? string.Empty,
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
|
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
|
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
|
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
|
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}")
|
|
}
|
|
};
|
|
}
|
|
|
|
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
|
|
{
|
|
var fileSize =
|
|
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
|
|
DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List<string>()
|
|
{mangaFile.FilePath}));
|
|
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
|
|
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath));
|
|
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
|
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
|
|
|
|
|
|
var title = $"{series.Name}";
|
|
|
|
if (volume!.Chapters.Count == 1)
|
|
{
|
|
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
|
|
SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType, volumeLabel);
|
|
if (volume.Name != "0")
|
|
{
|
|
title += $" - {volume.Name}";
|
|
}
|
|
}
|
|
else if (volume.Number != 0)
|
|
{
|
|
title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
|
|
}
|
|
else
|
|
{
|
|
title = $"{series.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
|
|
}
|
|
|
|
// Chunky requires a file at the end. Our API ignores this
|
|
var accLink =
|
|
CreateLink(FeedLinkRelation.Acquisition, fileType,
|
|
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}",
|
|
filename);
|
|
accLink.TotalPages = chapter.Pages;
|
|
|
|
var entry = new FeedEntry()
|
|
{
|
|
Id = mangaFile.Id.ToString(),
|
|
Title = title,
|
|
Extent = fileSize,
|
|
Summary = $"{fileType.Split("/")[1]} - {fileSize}",
|
|
Format = mangaFile.Format.ToString(),
|
|
Links = new List<FeedLink>()
|
|
{
|
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
|
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
|
|
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
|
|
accLink,
|
|
await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix)
|
|
},
|
|
Content = new FeedEntryContent()
|
|
{
|
|
Text = fileType,
|
|
Type = "text"
|
|
}
|
|
};
|
|
|
|
return entry;
|
|
}
|
|
|
|
/// <summary>
|
|
/// This returns a streamed image following OPDS-PS v1.2
|
|
/// </summary>
|
|
/// <param name="apiKey"></param>
|
|
/// <param name="libraryId"></param>
|
|
/// <param name="seriesId"></param>
|
|
/// <param name="volumeId"></param>
|
|
/// <param name="chapterId"></param>
|
|
/// <param name="pageNumber"></param>
|
|
/// <returns></returns>
|
|
[HttpGet("{apiKey}/image")]
|
|
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page"));
|
|
var chapter = await _cacheService.Ensure(chapterId);
|
|
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find"));
|
|
|
|
try
|
|
{
|
|
var path = _cacheService.GetCachedPagePath(chapter.Id, pageNumber);
|
|
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path))
|
|
return BadRequest(await _localizationService.Translate(userId, "no-image-for-page", pageNumber));
|
|
|
|
var content = await _directoryService.ReadFileAsync(path);
|
|
var format = Path.GetExtension(path);
|
|
|
|
// 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,
|
|
LibraryId =libraryId
|
|
}, await GetUser(apiKey));
|
|
|
|
return File(content, MimeTypeMap.GetMimeType(format));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
_cacheService.CleanupChapters(new []{ chapterId });
|
|
throw;
|
|
}
|
|
}
|
|
|
|
[HttpGet("{apiKey}/favicon")]
|
|
[ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Client, NoStore = false)]
|
|
public async Task<ActionResult> GetFavicon(string apiKey)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
|
|
if (files.Length == 0) return BadRequest(await _localizationService.Translate(userId, "favicon-doesnt-exist"));
|
|
var path = files[0];
|
|
var content = await _directoryService.ReadFileAsync(path);
|
|
var format = Path.GetExtension(path);
|
|
|
|
return File(content, MimeTypeMap.GetMimeType(format));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the user from the API key
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
private async Task<int> GetUser(string apiKey)
|
|
{
|
|
try
|
|
{
|
|
var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
|
return user;
|
|
}
|
|
catch
|
|
{
|
|
/* Do nothing */
|
|
}
|
|
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
|
|
}
|
|
|
|
private async Task<FeedLink> CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey, string prefix)
|
|
{
|
|
var userId = await GetUser(apiKey);
|
|
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId);
|
|
|
|
// TODO: Type could be wrong
|
|
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg",
|
|
$"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
|
|
link.TotalPages = mangaFile.Pages;
|
|
if (progress != null)
|
|
{
|
|
link.LastRead = progress.PageNum;
|
|
link.LastReadDate = progress.LastModifiedUtc;
|
|
}
|
|
link.IsPageStream = true;
|
|
return link;
|
|
}
|
|
|
|
private static FeedLink CreateLink(string rel, string type, string href, string? title = null)
|
|
{
|
|
return new FeedLink()
|
|
{
|
|
Rel = rel,
|
|
Href = href,
|
|
Type = type,
|
|
Title = string.IsNullOrEmpty(title) ? string.Empty : title
|
|
};
|
|
}
|
|
|
|
private static Feed CreateFeed(string title, string href, string apiKey, string prefix)
|
|
{
|
|
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
|
|
}
|
|
}
|