using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.JumpBar; using API.DTOs.System; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner; using API.SignalR; using AutoMapper; using EasyCaching.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; namespace API.Controllers; #nullable enable [Authorize] public class LibraryController : BaseApiController { private readonly IDirectoryService _directoryService; private readonly ILogger _logger; private readonly IMapper _mapper; private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ILibraryWatcher _libraryWatcher; private readonly ILocalizationService _localizationService; private readonly IStreamService _streamService; private readonly IEasyCachingProvider _libraryCacheProvider; private const string CacheKey = "library_"; public LibraryController(IDirectoryService directoryService, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher, IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, IStreamService streamService) { _directoryService = directoryService; _logger = logger; _mapper = mapper; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; _eventHub = eventHub; _libraryWatcher = libraryWatcher; _localizationService = localizationService; _streamService = streamService; _libraryCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.Library); } /// /// Creates a new Library. Upon library creation, adds new library to all Admin accounts. /// /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("create")] public async Task AddLibrary(UpdateLibraryDto dto) { if (await _unitOfWork.LibraryRepository.LibraryExists(dto.Name)) { return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-name-exists")); } var library = new LibraryBuilder(dto.Name, dto.Type) .WithFolders(dto.Folders.Select(x => new FolderPath {Path = x}).Distinct().ToList()) .WithFolderWatching(dto.FolderWatching) .WithIncludeInDashboard(dto.IncludeInDashboard) .WithIncludeInRecommended(dto.IncludeInRecommended) .WithManageCollections(dto.ManageCollections) .WithManageReadingLists(dto.ManageReadingLists) .WIthAllowScrobbling(dto.AllowScrobbling) .Build(); library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() .ToList(); library.LibraryExcludePatterns = dto.ExcludePatterns .Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id}) .Distinct() .ToList(); // Override Scrobbling for Comic libraries since there are no providers to scrobble to if (library.Type == LibraryType.Comic) { _logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name); library.AllowScrobbling = false; } _unitOfWork.LibraryRepository.Add(library); var admins = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).ToList(); foreach (var admin in admins) { admin.Libraries ??= new List(); admin.Libraries.Add(library); } if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); _logger.LogInformation("Created a new library: {LibraryName}", library.Name); // Assign all the necessary users with this library side nav var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList(); var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams)) .Where(u => userIds.Contains(u.Id)) .ToList(); foreach (var user in userNeedingNewLibrary) { user.CreateSideNavFromLibrary(library); _unitOfWork.UserRepository.Update(user); } if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); await _libraryWatcher.RestartWatching(); _taskScheduler.ScanLibrary(library.Id); await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); return Ok(); } /// /// Returns a list of directories for a given path. If path is empty, returns root drives. /// /// /// [Authorize(Policy = "RequireAdminRole")] [HttpGet("list")] public ActionResult> GetDirectories(string? path) { if (string.IsNullOrEmpty(path)) { return Ok(Directory.GetLogicalDrives().Select(d => new DirectoryDto() { Name = d, FullPath = d })); } if (!Directory.Exists(path)) return Ok(_directoryService.ListDirectory(Path.GetDirectoryName(path))); return Ok(_directoryService.ListDirectory(path)); } /// /// Return all libraries in the Server /// /// [HttpGet] public async Task>> GetLibraries() { var username = User.GetUsername(); if (string.IsNullOrEmpty(username)) return Unauthorized(); var cacheKey = CacheKey + username; var result = await _libraryCacheProvider.GetAsync>(cacheKey); if (result.HasValue) return Ok(result.Value); var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret); } /// /// For a given library, generate the jump bar information /// /// /// [HttpGet("jump-bar")] public async Task>> GetJumpBar(int libraryId) { if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, User.GetUserId())) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-library-access")); return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId)); } /// /// Grants a user account access to a Library /// /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("grant-access")] public async Task> UpdateUserLibraries(UpdateLibraryForUserDto updateLibraryForUserDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(updateLibraryForUserDto.Username, AppUserIncludes.SideNavStreams); if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-doesnt-exist")); var libraryString = string.Join(',', updateLibraryForUserDto.SelectedLibraries.Select(x => x.Name)); _logger.LogInformation("Granting user {UserName} access to: {Libraries}", updateLibraryForUserDto.Username, libraryString); var allLibraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); foreach (var library in allLibraries) { library.AppUsers ??= new List(); var libraryContainsUser = library.AppUsers.Any(u => u.UserName == user.UserName); var libraryIsSelected = updateLibraryForUserDto.SelectedLibraries.Any(l => l.Id == library.Id); if (libraryContainsUser && !libraryIsSelected) { // Remove library.AppUsers.Remove(user); user.RemoveSideNavFromLibrary(library); } else if (!libraryContainsUser && libraryIsSelected) { library.AppUsers.Add(user); user.CreateSideNavFromLibrary(library); } } if (!_unitOfWork.HasChanges()) { _logger.LogInformation("No changes for update library access"); return Ok(_mapper.Map(user)); } if (await _unitOfWork.CommitAsync()) { _logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username); // Bust cache await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); _unitOfWork.UserRepository.Update(user); return Ok(_mapper.Map(user)); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); } /// /// Scans a given library for file changes. /// /// /// If true, will ignore any optimizations to avoid file I/O and will treat similar to a first scan /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] public async Task Scan(int libraryId, bool force = false) { if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId")); _taskScheduler.ScanLibrary(libraryId, force); return Ok(); } /// /// Scans a given library for file changes. If another scan task is in progress, will reschedule the invocation for 3 hours in future. /// /// If true, will ignore any optimizations to avoid file I/O and will treat similar to a first scan /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan-all")] public ActionResult ScanAll(bool force = false) { _taskScheduler.ScanLibraries(force); return Ok(); } [Authorize(Policy = "RequireAdminRole")] [HttpPost("refresh-metadata")] public ActionResult RefreshMetadata(int libraryId, bool force = true) { _taskScheduler.RefreshMetadata(libraryId, force); return Ok(); } [Authorize(Policy = "RequireAdminRole")] [HttpPost("analyze")] public ActionResult Analyze(int libraryId) { _taskScheduler.AnalyzeFilesForLibrary(libraryId, true); return Ok(); } /// /// Given a valid path, will invoke either a Scan Series or Scan Library. If the folder does not exist within Kavita, the request will be ignored /// /// /// [AllowAnonymous] [HttpPost("scan-folder")] public async Task ScanFolder(ScanFolderDto dto) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(dto.ApiKey); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); // Validate user has Admin privileges var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (!isAdmin) return BadRequest("API key must belong to an admin"); if (dto.FolderPath.Contains("..")) return BadRequest(await _localizationService.Translate(user.Id, "invalid-path")); dto.FolderPath = Services.Tasks.Scanner.Parser.Parser.NormalizePath(dto.FolderPath); var libraryFolder = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()) .SelectMany(l => l.Folders) .Distinct() .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, new List() {dto.FolderPath}); _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); return Ok(); } [Authorize(Policy = "RequireAdminRole")] [HttpDelete("delete")] public async Task> DeleteLibrary(int libraryId) { var username = User.GetUsername(); _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, username); var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId); var seriesIds = series.Select(x => x.Id).ToArray(); var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(seriesIds); try { if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) { _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); return BadRequest(await _localizationService.Translate(User.GetUserId(), "delete-library-while-scan")); } var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist")); // Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library // Aka SeriesRelation has an invalid foreign key foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id, SeriesIncludes.Related)) { s.Relations = new List(); _unitOfWork.SeriesRepository.Update(s); } await _unitOfWork.CommitAsync(); _unitOfWork.LibraryRepository.Delete(library); var streams = await _unitOfWork.UserRepository.GetSideNavStreamsByLibraryId(library.Id); _unitOfWork.UserRepository.Delete(streams); await _unitOfWork.CommitAsync(); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); if (chapterIds.Any()) { await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.CommitAsync(); _taskScheduler.CleanupChapters(chapterIds); } await _libraryWatcher.RestartWatching(); foreach (var seriesId in seriesIds) { await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, MessageFactory.SeriesRemovedEvent(seriesId, string.Empty, libraryId), false); } await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false); return Ok(true); } catch (Exception ex) { _logger.LogError(ex, "There was a critical issue. Please try again"); await _unitOfWork.RollbackAsync(); return Ok(false); } } /// /// Checks if the library name exists or not /// /// If empty or null, will return true as that is invalid /// [Authorize(Policy = "RequireAdminRole")] [HttpGet("name-exists")] public async Task> IsLibraryNameValid(string name) { if (string.IsNullOrWhiteSpace(name)) return Ok(true); return Ok(await _unitOfWork.LibraryRepository.LibraryExists(name.Trim())); } /// /// Updates an existing Library with new name, folders, and/or type. /// /// Any folder or type change will invoke a scan. /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] public async Task UpdateLibrary(UpdateLibraryDto dto) { var userId = User.GetUserId(); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); if (library == null) return BadRequest(await _localizationService.Translate(userId, "library-doesnt-exist")); var newName = dto.Name.Trim(); if (await _unitOfWork.LibraryRepository.LibraryExists(newName) && !library.Name.Equals(newName)) return BadRequest(await _localizationService.Translate(userId, "library-name-exists")); var originalFoldersCount = library.Folders.Count; library.Name = newName; library.Folders = dto.Folders.Select(s => new FolderPath() {Path = s}).Distinct().ToList(); var typeUpdate = library.Type != dto.Type; var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching; library.Type = dto.Type; library.FolderWatching = dto.FolderWatching; library.IncludeInDashboard = dto.IncludeInDashboard; library.IncludeInRecommended = dto.IncludeInRecommended; library.IncludeInSearch = dto.IncludeInSearch; library.ManageCollections = dto.ManageCollections; library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() .ToList(); library.LibraryExcludePatterns = dto.ExcludePatterns .Distinct() .Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id}) .ToList(); // Override Scrobbling for Comic libraries since there are no providers to scrobble to if (library.Type == LibraryType.Comic) { _logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name.Replace(Environment.NewLine, string.Empty)); library.AllowScrobbling = false; } _unitOfWork.LibraryRepository.Update(library); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update")); if (originalFoldersCount != dto.Folders.Count() || typeUpdate) { await _libraryWatcher.RestartWatching(); _taskScheduler.ScanLibrary(library.Id); } if (folderWatchingUpdate) { await _libraryWatcher.RestartWatching(); } await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId), false); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); return Ok(); } /// /// Returns the type of the underlying library /// /// /// [HttpGet("type")] public async Task> GetLibraryType(int libraryId) { return Ok(await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId)); } }