Kavita/API/Controllers/OPDSController.cs
Joe Milazzo 5290fd8959
Text View, View & Filter All Annotations, and More OPDS Love (#4062)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
2025-09-28 12:28:21 -07:00

774 lines
26 KiB
C#

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]!;
}
/// <summary>
/// Returns the Catalogue for Kavita's OPDS Service
/// </summary>
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpPost("{apiKey}")]
[HttpGet("{apiKey}")]
[Produces("application/xml")]
public async Task<IActionResult> 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<Tuple<string, string>> 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<string, string>(baseUrl, prefix);
}
/// <summary>
/// Get the User's Smart Filter series - Supports Pagination
/// </summary>
/// <returns></returns>
[HttpGet("{apiKey}/smart-filters/{filterId}")]
[Produces("application/xml")]
public async Task<IActionResult> 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));
}
/// <summary>
/// Get the User's Smart Filters (Dashboard Context) - Supports Pagination
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/smart-filters")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get the User's Libraries - No Pagination Support
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get the User's Want to Read list - Supports Pagination
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/want-to-read")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get all Collections - Supports Pagination
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/collections")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get Series for a given Collection - Supports Pagination
/// </summary>
/// <param name="collectionId"></param>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/collections/{collectionId}")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get a User's Reading Lists - Supports Pagination
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/reading-list")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Returns individual items (chapters) from Reading List by ID - Supports Pagination
/// </summary>
/// <param name="readingListId"></param>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/reading-list/{readingListId}")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Returns Series from the Library - Supports Pagination
/// </summary>
/// <param name="libraryId"></param>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/libraries/{libraryId}")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Returns Recently Added (Dashboard Feed) - Supports Pagination
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/recently-added")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Returns More In a Genre (Dashboard Feed) - Supports Pagination
/// </summary>
/// <param name="apiKey"></param>
/// <param name="genreId"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/more-in-genre")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get the Recently Updated Series (Dashboard) - Pagination available, total pages will not be filled due to underlying implementation
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/recently-updated")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get the On Deck (Dashboard) - Supports Pagination
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/on-deck")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// OPDS Search endpoint
/// </summary>
/// <param name="apiKey"></param>
/// <param name="query"></param>
/// <returns></returns>
[HttpGet("{apiKey}/series")]
[Produces("application/xml")]
public async Task<IActionResult> 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<IActionResult> 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"));
}
/// <summary>
/// Returns the items within a Series (Series Detail)
/// </summary>
/// <param name="apiKey"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("{apiKey}/series/{seriesId}")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Returns items for a given Volume
/// </summary>
/// <param name="apiKey"></param>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Gets items for a given Chapter
/// </summary>
/// <param name="apiKey"></param>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")]
[Produces("application/xml")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Downloads a file (user must have download permission)
/// </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 = 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
};
}
/// <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>
/// <param name="saveProgress">Optional parameter. Can pass false and progress saving will be suppressed</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, [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<ActionResult> 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));
}
}