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.OPDS; using API.DTOs.Search; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; using EasyCaching.Core; 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 XmlSerializer _xmlSerializer; private readonly XmlSerializer _xmlOpenSearchSerializer; private readonly FilterDto _filterDto = new FilterDto() { Formats = new List(), Character = new List(), Colorist = new List(), Editor = new List(), Genres = new List(), Inker = new List(), Languages = new List(), Letterer = new List(), Penciller = new List(), Libraries = new List(), Publisher = new List(), Rating = 0, Tags = new List(), Translators = new List(), Writers = new List(), AgeRating = new List(), CollectionTags = new List(), CoverArtist = new List(), ReadStatus = new ReadStatus(), SortOptions = null, PublicationStatus = new List() }; 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, IEasyCachingProvider provider) { _unitOfWork = unitOfWork; _downloadService = downloadService; _directoryService = directoryService; _cacheService = cacheService; _readerService = readerService; _seriesService = seriesService; _accountService = accountService; _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); } [HttpPost("{apiKey}")] [HttpGet("{apiKey}")] [Produces("application/xml")] public async Task Get(string apiKey) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix); SetFeedId(feed, "root"); feed.Entries.Add(new FeedEntry() { Id = "onDeck", Title = "On Deck", Content = new FeedEntryContent() { Text = "Browse by On Deck" }, Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"), } }); feed.Entries.Add(new FeedEntry() { Id = "recentlyAdded", Title = "Recently Added", Content = new FeedEntryContent() { Text = "Browse by Recently Added" }, Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"), } }); feed.Entries.Add(new FeedEntry() { Id = "readingList", Title = "Reading Lists", Content = new FeedEntryContent() { Text = "Browse by Reading Lists" }, Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list"), } }); feed.Entries.Add(new FeedEntry() { Id = "allLibraries", Title = "All Libraries", Content = new FeedEntryContent() { Text = "Browse by Libraries" }, Links = new List() { 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() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"), } }); return CreateXmlResult(SerializeXml(feed)); } private async Task> 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(baseUrl, prefix); } [HttpGet("{apiKey}/libraries")] [Produces("application/xml")] public async Task GetLibraries(string apiKey) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId); var feed = CreateFeed("All 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() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"), } }); } return CreateXmlResult(SerializeXml(feed)); } [HttpGet("{apiKey}/collections")] [Produces("application/xml")] public async Task GetCollections(string apiKey) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); 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("All Collections", $"{prefix}{apiKey}/collections", apiKey, prefix); SetFeedId(feed, "collections"); foreach (var tag in tags) { feed.Entries.Add(new FeedEntry() { Id = tag.Id.ToString(), Title = tag.Title, Summary = tag.Summary, Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"), CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}"), CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}") } }); } return CreateXmlResult(SerializeXml(feed)); } [HttpGet("{apiKey}/collections/{collectionId}")] [Produces("application/xml")] public async Task 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 (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); IEnumerable 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 GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); 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() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), } }); } 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 GetReadingListItems(int readingListId, string apiKey) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); 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("Reading list does not exist or you don't have access"); } var feed = CreateFeed(readingList.Title + " 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 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 (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); var library = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).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, userId, GetUserParams(pageNumber), _filterDto); 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}"); 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}/recently-added")] [Produces("application/xml")] public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, GetUserParams(pageNumber), _filterDto); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); var feed = CreateFeed("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 GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); 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("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 SearchSeries(string apiKey, [FromQuery] string query) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (string.IsNullOrEmpty(query)) { return BadRequest("You must pass a query parameter"); } query = query.Replace(@"%", string.Empty); // Get libraries user has access to var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); 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() { 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() { 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 GetSearchDescriptor(string apiKey) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (_, prefix) = await GetPrefix(); var feed = new OpenSearchDescription() { ShortName = "Search", Description = "Search for Series, Collections, or Reading Lists", 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 GetSeries(string apiKey, int seriesId) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); 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(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(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(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 GetVolume(string apiKey, int seriesId, int volumeId) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); 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(libraryType)}s ", $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{SeriesService.FormatChapterName(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(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 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 (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); 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("Chapter doesn't 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(libraryType)}s", $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{SeriesService.FormatChapterName(libraryType)}-{chapterId}-files"); foreach (var mangaFile in files) { feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl)); } return CreateXmlResult(SerializeXml(feed)); } /// /// Downloads a file /// /// 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) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); 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 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} ({seriesDto.Format})", Summary = seriesDto.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() { 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} ({searchResultDto.Format})", Links = new List() { 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() { 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 CreateChapterWithFile(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() {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) { SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType); if (volume.Name != "0") { title += $" - {volume.Name}"; } } else if (volume.Number != 0) { title = $"{series.Name} - Volume {volume.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}"; } else { title = $"{series.Name} - {SeriesService.FormatChapterTitle(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() { 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; } /// /// This returns a streamed image following OPDS-PS v1.2 /// /// /// /// /// /// /// /// [HttpGet("{apiKey}/image")] public async Task GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [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 = _cacheService.GetCachedPagePath(chapter.Id, 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); // 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 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); return File(content, MimeTypeMap.GetMimeType(format)); } /// /// Gets the user from the API key /// /// private async Task GetUser(string apiKey) { try { var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); return user; } catch { /* Do nothing */ } throw new KavitaException("User does not exist"); } private async Task 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() { 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 } }