From b24b8686f36abce77c61fc0937ce51b924c3347f Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 7 Dec 2024 08:57:04 -0600 Subject: [PATCH] Stats V3 (#3443) --- API/Controllers/ServerController.cs | 11 +- API/DTOs/Stats/FileFormatDto.cs | 15 - API/DTOs/Stats/ServerInfoDto.cs | 184 ----------- API/DTOs/Stats/V3/LibraryStatV3.cs | 39 +++ API/DTOs/Stats/V3/RelationshipStatV3.cs | 12 + API/DTOs/Stats/V3/ServerInfoV3Dto.cs | 141 +++++++++ API/DTOs/Stats/V3/UserStatV3.cs | 81 +++++ API/Data/Repositories/LibraryRepository.cs | 13 +- API/Data/Repositories/SeriesRepository.cs | 5 + API/Data/Repositories/UserRepository.cs | 15 +- API/Entities/Enums/FileTypeGroup.cs | 1 - API/Entities/Library.cs | 3 - API/Services/Tasks/StatsService.cs | 337 +++++++++++++-------- 13 files changed, 510 insertions(+), 347 deletions(-) delete mode 100644 API/DTOs/Stats/FileFormatDto.cs delete mode 100644 API/DTOs/Stats/ServerInfoDto.cs create mode 100644 API/DTOs/Stats/V3/LibraryStatV3.cs create mode 100644 API/DTOs/Stats/V3/RelationshipStatV3.cs create mode 100644 API/DTOs/Stats/V3/ServerInfoV3Dto.cs create mode 100644 API/DTOs/Stats/V3/UserStatV3.cs diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 7eb7fe9108..8d95d4c236 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -129,15 +129,6 @@ public async Task AnalyzeFiles() return Ok(); } - /// - /// Returns non-sensitive information about the current system - /// - /// - [HttpGet("server-info")] - public async Task> GetVersion() - { - return Ok(await _statsService.GetServerInfo()); - } /// /// Returns non-sensitive information about the current system @@ -145,7 +136,7 @@ public async Task> GetVersion() /// This is just for the UI and is extremely lightweight /// [HttpGet("server-info-slim")] - public async Task> GetSlimVersion() + public async Task> GetSlimVersion() { return Ok(await _statsService.GetServerInfoSlim()); } diff --git a/API/DTOs/Stats/FileFormatDto.cs b/API/DTOs/Stats/FileFormatDto.cs deleted file mode 100644 index 6319bd2a95..0000000000 --- a/API/DTOs/Stats/FileFormatDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using API.Entities.Enums; - -namespace API.DTOs.Stats; - -public class FileFormatDto -{ - /// - /// The extension with the ., in lowercase - /// - public required string Extension { get; set; } - /// - /// Format of extension - /// - public required MangaFormat Format { get; set; } -} diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs deleted file mode 100644 index 41c4c8264a..0000000000 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using API.Entities.Enums; - -namespace API.DTOs.Stats; -#nullable enable - -/// -/// Represents information about a Kavita Installation -/// -public class ServerInfoDto -{ - /// - /// Unique Id that represents a unique install - /// - public required string InstallId { get; set; } - public required string Os { get; set; } - /// - /// If the Kavita install is using Docker - /// - public bool IsDocker { get; set; } - /// - /// Version of .NET instance is running - /// - public required string DotnetVersion { get; set; } - /// - /// Version of Kavita - /// - public required string KavitaVersion { get; set; } - /// - /// Number of Cores on the instance - /// - public int NumOfCores { get; set; } - /// - /// The number of libraries on the instance - /// - public int NumberOfLibraries { get; set; } - /// - /// Does any user have bookmarks - /// - public bool HasBookmarks { get; set; } - /// - /// The site theme the install is using - /// - /// Introduced in v0.5.2 - public string? ActiveSiteTheme { get; set; } - /// - /// The reading mode the main user has as a preference - /// - /// Introduced in v0.5.2 - public ReaderMode MangaReaderMode { get; set; } - - /// - /// Number of users on the install - /// - /// Introduced in v0.5.2 - public int NumberOfUsers { get; set; } - - /// - /// Number of collections on the install - /// - /// Introduced in v0.5.2 - public int NumberOfCollections { get; set; } - /// - /// Number of reading lists on the install (Sum of all users) - /// - /// Introduced in v0.5.2 - public int NumberOfReadingLists { get; set; } - /// - /// Is OPDS enabled - /// - /// Introduced in v0.5.2 - public bool OPDSEnabled { get; set; } - /// - /// Total number of files in the instance - /// - /// Introduced in v0.5.2 - public int TotalFiles { get; set; } - /// - /// Total number of Genres in the instance - /// - /// Introduced in v0.5.4 - public int TotalGenres { get; set; } - /// - /// Total number of People in the instance - /// - /// Introduced in v0.5.4 - public int TotalPeople { get; set; } - /// - /// Number of users on this instance using Card Layout - /// - /// Introduced in v0.5.4 - public int UsersOnCardLayout { get; set; } - /// - /// Number of users on this instance using List Layout - /// - /// Introduced in v0.5.4 - public int UsersOnListLayout { get; set; } - /// - /// Max number of Series for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxSeriesInALibrary { get; set; } - /// - /// Max number of Volumes for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxVolumesInASeries { get; set; } - /// - /// Max number of Chapters for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxChaptersInASeries { get; set; } - /// - /// Does this instance have relationships setup between series - /// - /// Introduced in v0.5.4 - public bool UsingSeriesRelationships { get; set; } - /// - /// A list of background colors set on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderBackgroundColors { get; set; } - /// - /// A list of Page Split defaults being used on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderPageSplittingModes { get; set; } - /// - /// A list of Layout Mode defaults being used on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderLayoutModes { get; set; } - /// - /// A list of file formats existing in the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable FileFormats { get; set; } - /// - /// If there is at least one user that is using an age restricted profile on the instance - /// - /// Introduced in v0.6.0 - public bool UsingRestrictedProfiles { get; set; } - /// - /// Number of users using the Emulate Comic Book setting - /// - /// Introduced in v0.7.0 - public int UsersWithEmulateComicBook { get; set; } - /// - /// Percent (0.0-1.0) of libraries with folder watching enabled - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesWithFolderWatchingEnabled { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Search - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInSearch { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Recommended - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInRecommended { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Dashboard - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInDashboard { get; set; } - /// - /// Total reading hours of all users - /// - /// Introduced in v0.7.0 - public long TotalReadingHours { get; set; } - /// - /// The encoding the server is using to save media - /// - /// Added in v0.7.3 - public EncodeFormat EncodeMediaAs { get; set; } - /// - /// The last user reading progress on the server (in UTC) - /// - /// Added in v0.7.4 - public DateTime LastReadTime { get; set; } -} diff --git a/API/DTOs/Stats/V3/LibraryStatV3.cs b/API/DTOs/Stats/V3/LibraryStatV3.cs new file mode 100644 index 0000000000..51af34b583 --- /dev/null +++ b/API/DTOs/Stats/V3/LibraryStatV3.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +public class LibraryStatV3 +{ + public bool IncludeInDashboard { get; set; } + public bool IncludeInSearch { get; set; } + public bool UsingFolderWatching { get; set; } + /// + /// Are any exclude patterns setup + /// + public bool UsingExcludePatterns { get; set; } + /// + /// Will this library create collections from ComicInfo + /// + public bool CreateCollectionsFromMetadata { get; set; } + /// + /// Will this library create reading lists from ComicInfo + /// + public bool CreateReadingListsFromMetadata { get; set; } + /// + /// Type of the Library + /// + public LibraryType LibraryType { get; set; } + public ICollection FileTypes { get; set; } + /// + /// Last time library was fully scanned + /// + public DateTime LastScanned { get; set; } + /// + /// Number of folders the library has + /// + public int NumberOfFolders { get; set; } + + +} diff --git a/API/DTOs/Stats/V3/RelationshipStatV3.cs b/API/DTOs/Stats/V3/RelationshipStatV3.cs new file mode 100644 index 0000000000..e8e1e74403 --- /dev/null +++ b/API/DTOs/Stats/V3/RelationshipStatV3.cs @@ -0,0 +1,12 @@ +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +/// +/// KavitaStats - Information about Series Relationships +/// +public class RelationshipStatV3 +{ + public int Count { get; set; } + public RelationKind Relationship { get; set; } +} diff --git a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs new file mode 100644 index 0000000000..edc2ad2b47 --- /dev/null +++ b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +/// +/// Represents information about a Kavita Installation for Kavita Stats v3 API +/// +public class ServerInfoV3Dto +{ + /// + /// Unique Id that represents a unique install + /// + public required string InstallId { get; set; } + public required string Os { get; set; } + /// + /// If the Kavita install is using Docker + /// + public bool IsDocker { get; set; } + /// + /// Version of .NET instance is running + /// + public required string DotnetVersion { get; set; } + /// + /// Version of Kavita + /// + public required string KavitaVersion { get; set; } + /// + /// Version of Kavita on Installation + /// + public required string InitialKavitaVersion { get; set; } + /// + /// Date of first Installation + /// + public DateTime InitialInstallDate { get; set; } + /// + /// Number of Cores on the instance + /// + public int NumOfCores { get; set; } + /// + /// OS locale on the instance + /// + public string OsLocale { get; set; } + /// + /// Milliseconds to open a random archive (zip/cbz) for reading + /// + public long TimeToOpeCbzMs { get; set; } + /// + /// Number of pages for said archive (zip/cbz) + /// + public long TimeToOpenCbzPages { get; set; } + /// + /// Milliseconds to get a response from KavitaStats API + /// + /// This pings a health check and does not capture any IP Information + public long TimeToPingKavitaStatsApi { get; set; } + + + + #region Media + /// + /// Number of collections on the install + /// + public int NumberOfCollections { get; set; } + /// + /// Number of reading lists on the install (Sum of all users) + /// + public int NumberOfReadingLists { get; set; } + /// + /// Total number of files in the instance + /// + public int TotalFiles { get; set; } + /// + /// Total number of Genres in the instance + /// + public int TotalGenres { get; set; } + /// + /// Total number of Series in the instance + /// + public int TotalSeries { get; set; } + /// + /// Total number of Libraries in the instance + /// + public int TotalLibraries { get; set; } + /// + /// Total number of People in the instance + /// + public int TotalPeople { get; set; } + /// + /// Max number of Series for any library on the instance + /// + public int MaxSeriesInALibrary { get; set; } + /// + /// Max number of Volumes for any library on the instance + /// + public int MaxVolumesInASeries { get; set; } + /// + /// Max number of Chapters for any library on the instance + /// + public int MaxChaptersInASeries { get; set; } + /// + /// Everything about the Libraries on the instance + /// + public IList Libraries { get; set; } + /// + /// Everything around Series Relationships between series + /// + public IList Relationships { get; set; } + #endregion + + #region Server + /// + /// Is OPDS enabled + /// + public bool OpdsEnabled { get; set; } + /// + /// The encoding the server is using to save media + /// + public EncodeFormat EncodeMediaAs { get; set; } + /// + /// The last user reading progress on the server (in UTC) + /// + public DateTime LastReadTime { get; set; } + /// + /// Is this server using Kavita+ + /// + public bool ActiveKavitaPlusSubscription { get; set; } + #endregion + + #region Users + /// + /// If there is at least one user that is using an age restricted profile on the instance + /// + /// Introduced in v0.6.0 + public bool UsingRestrictedProfiles { get; set; } + + public IList Users { get; set; } + + #endregion +} diff --git a/API/DTOs/Stats/V3/UserStatV3.cs b/API/DTOs/Stats/V3/UserStatV3.cs new file mode 100644 index 0000000000..7f4e080bae --- /dev/null +++ b/API/DTOs/Stats/V3/UserStatV3.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using API.Data.Misc; +using API.Entities.Enums.Device; + +namespace API.DTOs.Stats.V3; + +public class UserStatV3 +{ + public AgeRestriction AgeRestriction { get; set; } + /// + /// The last reading progress on the server (in UTC) + /// + public DateTime LastReadTime { get; set; } + /// + /// The last login on the server (in UTC) + /// + public DateTime LastLogin { get; set; } + /// + /// Has the user gone through email confirmation + /// + public bool IsEmailConfirmed { get; set; } + /// + /// Is the Email a valid address + /// + public bool HasValidEmail { get; set; } + /// + /// Float between 0-1 to showcase how much of the libraries a user has access to + /// + public float PercentageOfLibrariesHasAccess { get; set; } + /// + /// Number of reading lists this user created + /// + public int ReadingListsCreatedCount { get; set; } + /// + /// Number of collections this user created + /// + public int CollectionsCreatedCount { get; set; } + /// + /// Number of series in want to read for this user + /// + public int WantToReadSeriesCount { get; set; } + /// + /// Active locale for the user + /// + public string Locale { get; set; } + /// + /// Active Theme (name) + /// + public string ActiveTheme { get; set; } + /// + /// Number of series with Bookmarks created + /// + public int SeriesBookmarksCreatedCount { get; set; } + /// + /// Kavita+ only - Has an AniList Token set + /// + public bool HasAniListToken { get; set; } + /// + /// Kavita+ only - Has a MAL Token set + /// + public bool HasMALToken { get; set; } + /// + /// Number of Smart Filters a user has created + /// + public int SmartFilterCreatedCount { get; set; } + /// + /// Is the user sharing reviews + /// + public bool IsSharingReviews { get; set; } + /// + /// The number of devices setup and their platforms + /// + public ICollection DevicePlatforms { get; set; } + /// + /// Roles for this user + /// + public ICollection Roles { get; set; } + + +} diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 2ac4f39369..605656d912 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -39,7 +39,7 @@ public interface ILibraryRepository Task LibraryExists(string libraryName); Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes = LibraryIncludes.None); IEnumerable GetLibraryDtosForUsernameAsync(string userName); - Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None); + Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None, bool track = true); Task> GetLibrariesForUserIdAsync(int userId); IEnumerable GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None); Task GetLibraryTypeAsync(int libraryId); @@ -104,13 +104,16 @@ public IEnumerable GetLibraryDtosForUsernameAsync(string userName) /// /// /// - public async Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None) + public async Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None, bool track = true) { - return await _context.Library + var query = _context.Library .Include(l => l.AppUsers) .Includes(includes) - .AsSplitQuery() - .ToListAsync(); + .AsSplitQuery(); + + if (track) return await query.ToListAsync(); + + return await query.AsNoTracking().ToListAsync(); } /// diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index cad70c3ebb..91a186746e 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -164,6 +164,7 @@ public Task> GetAllSeriesByAnyName(string seriesName, string local Task ClearOnDeckRemoval(int seriesId, int userId); Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None); Task GetPlusSeriesDto(int seriesId); + Task GetCountAsync(); } public class SeriesRepository : ISeriesRepository @@ -726,6 +727,10 @@ public async Task> GetSeriesDtoForLibraryIdV2Async(int user .FirstOrDefaultAsync(); } + public async Task GetCountAsync() + { + return await _context.Series.CountAsync(); + } public async Task AddSeriesModifiers(int userId, IList series) { diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 07723bf1b2..40e614e594 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -78,7 +78,7 @@ public interface IUserRepository Task> GetAllPreferencesByThemeAsync(int themeId); Task HasAccessToLibrary(int libraryId, int userId); Task HasAccessToSeries(int userId, int seriesId); - Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); + Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true); Task GetUserByConfirmationToken(string token); Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None); Task> GetSeriesWithRatings(int userId); @@ -283,10 +283,17 @@ public async Task HasAccessToSeries(int userId, int seriesId) .AnyAsync(s => s.Id == seriesId); } - public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None) + public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true) { - return await _context.AppUser - .Includes(includeFlags) + var query = _context.AppUser + .Includes(includeFlags); + if (track) + { + return await query.ToListAsync(); + } + + return await query + .AsNoTracking() .ToListAsync(); } diff --git a/API/Entities/Enums/FileTypeGroup.cs b/API/Entities/Enums/FileTypeGroup.cs index 3d33aa37cb..eda039fc9f 100644 --- a/API/Entities/Enums/FileTypeGroup.cs +++ b/API/Entities/Enums/FileTypeGroup.cs @@ -15,5 +15,4 @@ public enum FileTypeGroup Pdf = 3, [Description("Images")] Images = 4 - } diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 7a413e3f44..097c382d56 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -44,9 +44,6 @@ public class Library : IEntityDate, IHasCoverImage public bool AllowScrobbling { get; set; } = true; - - - public DateTime Created { get; set; } public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 9cc93fefd4..33ad727197 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -1,19 +1,24 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; using API.Data; +using API.Data.Misc; using API.Data.Repositories; using API.DTOs.Stats; +using API.DTOs.Stats.V3; +using API.Entities; using API.Entities.Enums; -using API.Entities.Enums.UserPreferences; +using API.Services.Plus; using Flurl.Http; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -24,7 +29,6 @@ namespace API.Services.Tasks; public interface IStatsService { Task Send(); - Task GetServerInfo(); Task GetServerInfoSlim(); Task SendCancellation(); } @@ -36,15 +40,24 @@ public class StatsService : IStatsService private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly DataContext _context; - private readonly IStatisticService _statisticService; + private readonly ILicenseService _licenseService; + private readonly UserManager _userManager; + private readonly IEmailService _emailService; + private readonly ICacheService _cacheService; private const string ApiUrl = "https://stats.kavitareader.com"; + private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly - public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService) + public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, + ILicenseService licenseService, UserManager userManager, IEmailService emailService, + ICacheService cacheService) { _logger = logger; _unitOfWork = unitOfWork; _context = context; - _statisticService = statisticService; + _licenseService = licenseService; + _userManager = userManager; + _emailService = emailService; + _cacheService = cacheService; FlurlHttp.ConfigureClient(ApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); @@ -52,7 +65,7 @@ public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataCo /// /// Due to all instances firing this at the same time, we can DDOS our server. This task when fired will schedule the task to be run - /// randomly over a 6 hour spread + /// randomly over a six-hour spread /// public async Task Send() { @@ -71,21 +84,24 @@ public async Task Send() // ReSharper disable once MemberCanBePrivate.Global public async Task SendData() { - var data = await GetServerInfo(); + var sw = Stopwatch.StartNew(); + var data = await GetStatV3Payload(); + _logger.LogDebug("Collecting stats took {Time} ms", sw.ElapsedMilliseconds); + sw.Stop(); await SendDataToStatsServer(data); } - private async Task SendDataToStatsServer(ServerInfoDto data) + private async Task SendDataToStatsServer(ServerInfoV3Dto data) { var responseContent = string.Empty; try { - var response = await (ApiUrl + "/api/v2/stats") + var response = await (ApiUrl + "/api/v3/stats") .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-api-key", ApiKey) .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(30)) @@ -112,67 +128,6 @@ private async Task SendDataToStatsServer(ServerInfoDto data) } } - public async Task GetServerInfo() - { - var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - - var serverInfo = new ServerInfoDto - { - InstallId = serverSettings.InstallId, - Os = RuntimeInformation.OSDescription, - KavitaVersion = serverSettings.InstallVersion, - DotnetVersion = Environment.Version.ToString(), - IsDocker = OsInfo.IsDocker, - NumOfCores = Math.Max(Environment.ProcessorCount, 1), - UsersWithEmulateComicBook = await _context.AppUserPreferences.CountAsync(p => p.EmulateBook), - TotalReadingHours = await _statisticService.TimeSpentReadingForUsersAsync(ArraySegment.Empty, ArraySegment.Empty), - - PercentOfLibrariesWithFolderWatchingEnabled = await GetPercentageOfLibrariesWithFolderWatchingEnabled(), - PercentOfLibrariesIncludedInRecommended = await GetPercentageOfLibrariesIncludedInRecommended(), - PercentOfLibrariesIncludedInDashboard = await GetPercentageOfLibrariesIncludedInDashboard(), - PercentOfLibrariesIncludedInSearch = await GetPercentageOfLibrariesIncludedInSearch(), - - HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), - NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), - NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(), - NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(), - OPDSEnabled = serverSettings.EnableOpds, - NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(), - TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(), - TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(), - TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(), - UsingSeriesRelationships = await GetIfUsingSeriesRelationship(), - EncodeMediaAs = serverSettings.EncodeMediaAs, - MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(), - MaxVolumesInASeries = await MaxVolumesInASeries(), - MaxChaptersInASeries = await MaxChaptersInASeries(), - MangaReaderBackgroundColors = await AllMangaReaderBackgroundColors(), - MangaReaderPageSplittingModes = await AllMangaReaderPageSplitting(), - MangaReaderLayoutModes = await AllMangaReaderLayoutModes(), - FileFormats = AllFormats(), - UsingRestrictedProfiles = await GetUsingRestrictedProfiles(), - LastReadTime = await _unitOfWork.AppUserProgressRepository.GetLatestProgress() - }; - - var usersWithPref = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences)).ToList(); - serverInfo.UsersOnCardLayout = - usersWithPref.Count(u => u.UserPreferences.GlobalPageLayoutMode == PageLayoutMode.Cards); - serverInfo.UsersOnListLayout = - usersWithPref.Count(u => u.UserPreferences.GlobalPageLayoutMode == PageLayoutMode.List); - - var firstAdminUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).FirstOrDefault(); - - if (firstAdminUser != null) - { - var firstAdminUserPref = (await _unitOfWork.UserRepository.GetPreferencesAsync(firstAdminUser.UserName!)); - var activeTheme = firstAdminUserPref?.Theme ?? Seed.DefaultThemes.First(t => t.IsDefault); - - serverInfo.ActiveSiteTheme = activeTheme.Name; - if (firstAdminUserPref != null) serverInfo.MangaReaderMode = firstAdminUserPref.ReaderMode; - } - - return serverInfo; - } public async Task GetServerInfoSlim() { @@ -199,7 +154,7 @@ public async Task SendCancellation() var response = await (ApiUrl + "/api/v2/stats/opt-out?installId=" + installId) .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-api-key", ApiKey) .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(30)) @@ -220,37 +175,32 @@ public async Task SendCancellation() } } - private async Task GetPercentageOfLibrariesWithFolderWatchingEnabled() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.FolderWatching) / (1.0f * libraries.Count); - } - - private async Task GetPercentageOfLibrariesIncludedInRecommended() + private static async Task PingStatsApi() { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInRecommended) / (1.0f * libraries.Count); - } - - private async Task GetPercentageOfLibrariesIncludedInDashboard() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInDashboard) / (1.0f * libraries.Count); - } + try + { + var sw = Stopwatch.StartNew(); + var response = await (ApiUrl + "/api/health/") + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", ApiKey) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(30)) + .GetAsync(); - private async Task GetPercentageOfLibrariesIncludedInSearch() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInSearch) / (1.0f * libraries.Count); - } + if (response.StatusCode == StatusCodes.Status200OK) + { + sw.Stop(); + return sw.ElapsedMilliseconds; + } + } + catch (Exception) + { + /* Swallow */ + } - private Task GetIfUsingSeriesRelationship() - { - return _context.SeriesRelation.AnyAsync(); + return 0; } private async Task MaxSeriesInAnyLibrary() @@ -290,41 +240,178 @@ private async Task MaxChaptersInASeries() .Count()); } - private async Task> AllMangaReaderBackgroundColors() + private async Task GetStatV3Payload() { - return await _context.AppUserPreferences.Select(p => p.BackgroundColor).Distinct().ToListAsync(); - } + var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var dto = new ServerInfoV3Dto() + { + InstallId = serverSettings.InstallId, + KavitaVersion = serverSettings.InstallVersion, + InitialKavitaVersion = serverSettings.FirstInstallVersion, + InitialInstallDate = (DateTime)serverSettings.FirstInstallDate!, + IsDocker = OsInfo.IsDocker, + Os = RuntimeInformation.OSDescription, + NumOfCores = Math.Max(Environment.ProcessorCount, 1), + DotnetVersion = Environment.Version.ToString(), + OpdsEnabled = serverSettings.EnableOpds, + EncodeMediaAs = serverSettings.EncodeMediaAs, + }; - private async Task> AllMangaReaderPageSplitting() - { - return await _context.AppUserPreferences.Select(p => p.PageSplitOption).Distinct().ToListAsync(); - } + dto.OsLocale = CultureInfo.CurrentCulture.EnglishName; + dto.LastReadTime = await _unitOfWork.AppUserProgressRepository.GetLatestProgress(); + dto.MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(); + dto.MaxVolumesInASeries = await MaxVolumesInASeries(); + dto.MaxChaptersInASeries = await MaxChaptersInASeries(); + dto.TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(); + dto.TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(); + dto.TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(); + dto.TotalSeries = await _unitOfWork.SeriesRepository.GetCountAsync(); + dto.TotalLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(); + dto.NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(); + dto.NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(); + try + { + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + dto.ActiveKavitaPlusSubscription = await _licenseService.HasActiveSubscription(license); + } + catch (Exception) + { + dto.ActiveKavitaPlusSubscription = false; + } - private async Task> AllMangaReaderLayoutModes() - { - return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync(); - } - private IEnumerable AllFormats() - { + // Find a random cbz/zip file and open it for reading + await OpenRandomFile(dto); + dto.TimeToPingKavitaStatsApi = await PingStatsApi(); - var results = _context.MangaFile - .AsNoTracking() - .AsEnumerable() - .Select(m => new FileFormatDto() + #region Relationships + + dto.Relationships = await _context.SeriesRelation + .GroupBy(sr => sr.RelationKind) + .Select(g => new RelationshipStatV3 { - Format = m.Format, - Extension = m.Extension + Relationship = g.Key, + Count = g.Count() }) - .DistinctBy(f => f.Extension) - .ToList(); + .ToListAsync(); + + #endregion - return results; + #region Libraries + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.Folders | + LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns | LibraryIncludes.AppUser)).ToList(); + dto.Libraries ??= []; + foreach (var library in allLibraries) + { + var libDto = new LibraryStatV3(); + libDto.IncludeInDashboard = library.IncludeInDashboard; + libDto.IncludeInSearch = library.IncludeInSearch; + libDto.LastScanned = library.LastScanned; + libDto.NumberOfFolders = library.Folders.Count; + libDto.FileTypes = library.LibraryFileTypes.Select(s => s.FileTypeGroup).Distinct().ToList(); + libDto.UsingExcludePatterns = library.LibraryExcludePatterns.Any(p => !string.IsNullOrEmpty(p.Pattern)); + libDto.UsingFolderWatching = library.FolderWatching; + libDto.CreateCollectionsFromMetadata = library.ManageCollections; + libDto.CreateReadingListsFromMetadata = library.ManageReadingLists; + + dto.Libraries.Add(libDto); + } + #endregion + + #region Users + + // Create a dictionary mapping user IDs to the libraries they have access to + var userLibraryAccess = allLibraries + .SelectMany(l => l.AppUsers.Select(appUser => new { l, appUser.Id })) + .GroupBy(x => x.Id) + .ToDictionary(g => g.Key, g => g.Select(x => x.l).ToList()); + dto.Users ??= []; + var allUsers = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences + | AppUserIncludes.ReadingLists | AppUserIncludes.Bookmarks + | AppUserIncludes.Collections | AppUserIncludes.Devices + | AppUserIncludes.Progress | AppUserIncludes.Ratings + | AppUserIncludes.SmartFilters | AppUserIncludes.WantToRead, false); + foreach (var user in allUsers) + { + var userDto = new UserStatV3(); + userDto.HasMALToken = !string.IsNullOrEmpty(user.MalAccessToken); + userDto.HasAniListToken = !string.IsNullOrEmpty(user.AniListAccessToken); + userDto.AgeRestriction = new AgeRestriction() + { + AgeRating = user.AgeRestriction, + IncludeUnknowns = user.AgeRestrictionIncludeUnknowns + }; + + userDto.Locale = user.UserPreferences.Locale; + userDto.Roles = [.. _userManager.GetRolesAsync(user).Result]; + userDto.LastLogin = user.LastActiveUtc; + userDto.HasValidEmail = user.Email != null && _emailService.IsValidEmail(user.Email); + userDto.IsEmailConfirmed = user.EmailConfirmed; + userDto.ActiveTheme = user.UserPreferences.Theme.Name; + userDto.CollectionsCreatedCount = user.Collections.Count; + userDto.ReadingListsCreatedCount = user.ReadingLists.Count; + userDto.LastReadTime = user.Progresses + .Select(p => p.LastModifiedUtc) + .DefaultIfEmpty() + .Max(); + userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList(); + userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count; + userDto.SmartFilterCreatedCount = user.SmartFilters.Count; + userDto.WantToReadSeriesCount = user.WantToRead.Count; + + if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries)) + { + userDto.PercentageOfLibrariesHasAccess = (1f * accessibleLibraries.Count) / allLibraries.Count; + } + else + { + userDto.PercentageOfLibrariesHasAccess = 0; + } + + dto.Users.Add(userDto); + } + + #endregion + + return dto; } - private Task GetUsingRestrictedProfiles() + private async Task OpenRandomFile(ServerInfoV3Dto dto) { - return _context.Users.AnyAsync(u => u.AgeRestriction > AgeRating.NotApplicable); + var random = new Random(); + List extensions = [".cbz", ".zip"]; + + // Count the total number of files that match the criteria + var count = await _context.MangaFile.AsNoTracking() + .Where(r => r.Extension != null && extensions.Contains(r.Extension)) + .CountAsync(); + + if (count == 0) + { + dto.TimeToOpeCbzMs = 0; + dto.TimeToOpenCbzPages = 0; + + return; + } + + // Generate a random skip value + var skip = random.Next(count); + + // Fetch the random file + var randomFile = await _context.MangaFile.AsNoTracking() + .Where(r => r.Extension != null && extensions.Contains(r.Extension)) + .Skip(skip) + .Take(1) + .FirstAsync(); + + var sw = Stopwatch.StartNew(); + + await _cacheService.Ensure(randomFile.ChapterId); + var time = sw.ElapsedMilliseconds; + sw.Stop(); + + dto.TimeToOpeCbzMs = time; + dto.TimeToOpenCbzPages = randomFile.Pages; } }