using System; using System.IO; using System.Threading.Tasks; using System.Xml.Serialization; using API.Data; using API.DTOs.OPDS; using API.DTOs.OPDS.Requests; using API.DTOs.Progress; using API.Entities.Enums; using API.Exceptions; using API.Extensions; using API.Middleware; using API.Services; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MimeTypes; namespace API.Controllers; #nullable enable [AllowAnonymous] [ServiceFilter(typeof(OpdsActionFilterAttribute))] [ServiceFilter(typeof(OpdsActiveUserMiddlewareAttribute))] public class OpdsController : BaseApiController { private readonly IOpdsService _opdsService; private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; private readonly IDirectoryService _directoryService; private readonly ICacheService _cacheService; private readonly IReaderService _readerService; private readonly IAccountService _accountService; private readonly ILocalizationService _localizationService; private readonly XmlSerializer _xmlOpenSearchSerializer; public const string UserId = nameof(UserId); public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, IDirectoryService directoryService, ICacheService cacheService, IReaderService readerService, IAccountService accountService, ILocalizationService localizationService, IOpdsService opdsService) { _unitOfWork = unitOfWork; _downloadService = downloadService; _directoryService = directoryService; _cacheService = cacheService; _readerService = readerService; _accountService = accountService; _localizationService = localizationService; _opdsService = opdsService; _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); } private int GetUserIdFromContext() { return (int) HttpContext.Items[UserId]!; } /// /// Returns the Catalogue for Kavita's OPDS Service /// /// /// [HttpPost("{apiKey}")] [HttpGet("{apiKey}")] [Produces("application/xml")] public async Task Get(string apiKey) { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetCatalogue(new OpdsCatalogueRequest { ApiKey = apiKey, Prefix = prefix, BaseUrl = baseUrl, UserId = GetUserIdFromContext() }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } private async Task> GetPrefix() { var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value; var prefix = "/api/opds/"; if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase)) { // We need to update the Prefix to account for baseUrl prefix = baseUrl + "api/opds/"; } return new Tuple(baseUrl, prefix); } /// /// Get the User's Smart Filter series - Supports Pagination /// /// [HttpGet("{apiKey}/smart-filters/{filterId}")] [Produces("application/xml")] public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 1) { var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetSeriesFromSmartFilter(new OpdsItemsFromEntityIdRequest() { ApiKey = apiKey, Prefix = prefix, BaseUrl = baseUrl, EntityId = filterId, UserId = userId, PageNumber = pageNumber }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } /// /// Get the User's Smart Filters (Dashboard Context) - Supports Pagination /// /// /// /// [HttpGet("{apiKey}/smart-filters")] [Produces("application/xml")] public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 1) { try { var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetSmartFilters(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = userId, ApiKey = apiKey, PageNumber = pageNumber }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Get the User's Libraries - No Pagination Support /// /// /// /// [HttpGet("{apiKey}/libraries")] [Produces("application/xml")] public async Task GetLibraries(string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetLibraries(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Get the User's Want to Read list - Supports Pagination /// /// /// /// [HttpGet("{apiKey}/want-to-read")] [Produces("application/xml")] public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetWantToRead(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Get all Collections - Supports Pagination /// /// /// /// [HttpGet("{apiKey}/collections")] [Produces("application/xml")] public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetCollections(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Get Series for a given Collection - Supports Pagination /// /// /// /// /// [HttpGet("{apiKey}/collections/{collectionId}")] [Produces("application/xml")] public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetSeriesFromCollection(new OpdsItemsFromEntityIdRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber, EntityId = collectionId }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Get a User's Reading Lists - Supports Pagination /// /// /// /// [HttpGet("{apiKey}/reading-list")] [Produces("application/xml")] public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetReadingLists(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Returns individual items (chapters) from Reading List by ID - Supports Pagination /// /// /// /// /// [HttpGet("{apiKey}/reading-list/{readingListId}")] [Produces("application/xml")] public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetReadingListItems(new OpdsItemsFromEntityIdRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber, EntityId = readingListId }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Returns Series from the Library - Supports Pagination /// /// /// /// /// [HttpGet("{apiKey}/libraries/{libraryId}")] [Produces("application/xml")] public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber, EntityId = libraryId }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Returns Recently Added (Dashboard Feed) - Supports Pagination /// /// /// /// [HttpGet("{apiKey}/recently-added")] [Produces("application/xml")] public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetRecentlyAdded(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber, }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Returns More In a Genre (Dashboard Feed) - Supports Pagination /// /// /// /// /// [HttpGet("{apiKey}/more-in-genre")] [Produces("application/xml")] public async Task GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetMoreInGenre(new OpdsItemsFromEntityIdRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber, EntityId = genreId }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Get the Recently Updated Series (Dashboard) - Pagination available, total pages will not be filled due to underlying implementation /// /// /// /// [HttpGet("{apiKey}/recently-updated")] [Produces("application/xml")] public async Task GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetRecentlyUpdated(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber, }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Get the On Deck (Dashboard) - Supports Pagination /// /// /// /// [HttpGet("{apiKey}/on-deck")] [Produces("application/xml")] public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetOnDeck(new OpdsPaginatedCatalogueRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, PageNumber = pageNumber, }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// OPDS Search endpoint /// /// /// /// [HttpGet("{apiKey}/series")] [Produces("application/xml")] public async Task SearchSeries(string apiKey, [FromQuery] string query) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.Search(new OpdsSearchRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, Query = query, }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } [HttpGet("{apiKey}/search")] [Produces("application/xml")] public async Task GetSearchDescriptor(string apiKey) { var userId = GetUserIdFromContext(); 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")); } /// /// Returns the items within a Series (Series Detail) /// /// /// /// [HttpGet("{apiKey}/series/{seriesId}")] [Produces("application/xml")] public async Task GetSeriesDetail(string apiKey, int seriesId) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, EntityId = seriesId }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Returns items for a given Volume /// /// /// /// /// [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")] [Produces("application/xml")] public async Task GetVolume(string apiKey, int seriesId, int volumeId) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetItemsFromVolume(new OpdsItemsFromCompoundEntityIdsRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, SeriesId = seriesId, VolumeId = volumeId }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Gets items for a given Chapter /// /// /// /// /// /// [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")] [Produces("application/xml")] public async Task GetChapter(string apiKey, int seriesId, int volumeId, int chapterId) { try { var (baseUrl, prefix) = await GetPrefix(); var feed = await _opdsService.GetItemsFromChapter(new OpdsItemsFromCompoundEntityIdsRequest() { BaseUrl = baseUrl, Prefix = prefix, UserId = GetUserIdFromContext(), ApiKey = apiKey, SeriesId = seriesId, VolumeId = volumeId, ChapterId = chapterId }); return CreateXmlResult(_opdsService.SerializeXml(feed)); } catch (OpdsException ex) { return BadRequest(ex.Message); } } /// /// Downloads a file (user must have download permission) /// /// User's API Key /// /// /// /// Not used. Only for Chunky to allow download links /// [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")] public async Task DownloadFile(string apiKey, int seriesId, int volumeId, int chapterId, string filename) { var userId = GetUserIdFromContext(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (!await _accountService.HasDownloadPermission(user)) { return Forbid(await _localizationService.Translate(userId, "download-not-allowed")); } 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 }; } /// /// This returns a streamed image following OPDS-PS v1.2 /// /// /// /// /// /// /// /// Optional parameter. Can pass false and progress saving will be suppressed /// [HttpGet("{apiKey}/image")] public async Task GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber, [FromQuery] bool saveProgress = true) { var userId = GetUserIdFromContext(); if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page")); var chapter = await _cacheService.Ensure(chapterId, true); 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 (except Panels, they will use a direct connection) var userAgent = Request.Headers.UserAgent.ToString(); if (!userAgent.StartsWith("Panels", StringComparison.InvariantCultureIgnoreCase) || !saveProgress) { // Kavita expects 0-N for progress, KOReader doesn't respect the OPDS-PS spec and does some wierd stuff // https://github.com/Kareadita/Kavita/pull/4014#issuecomment-3313677492 var koreaderOffset = 0; if (userAgent.StartsWith("Koreader", StringComparison.InvariantCultureIgnoreCase)) { var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId); if (totalPages - pageNumber < 2) { koreaderOffset = 1; } } await _readerService.SaveReadingProgress(new ProgressDto() { ChapterId = chapterId, PageNum = pageNumber + koreaderOffset, SeriesId = seriesId, VolumeId = volumeId, LibraryId =libraryId }, userId); } return File(content, MimeTypeMap.GetMimeType(format)); } catch (Exception) { _cacheService.CleanupChapters([chapterId]); throw; } } [HttpGet("{apiKey}/favicon")] [ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetFavicon(string apiKey) { var userId = GetUserIdFromContext(); 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)); } }