mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Reading History (#1699)
* Added new stat graph for pages read over time for all users. * Switched to reading events rather than pages read to get a better scale * Changed query to use Created date as LastModified wont work since I just did a migration on all rows. * Small cleanup on graph * Read by day completed and ready for user stats page. * Changed the initial stat report to be in 1 day, to avoid people trying and ditching the software from muddying up the stats. * Cleaned up stats page such that stats around series show their image and tweaked some layout and wordings * Fixed recently read order * Put read history on user profile * Final cleanup, Robbie needs to do a CSS pass before release.
This commit is contained in:
parent
e43ead44da
commit
1c1e48d28c
@ -79,7 +79,7 @@ public class StatsController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns
|
/// Returns users with the top reads in the server
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="days"></param>
|
/// <param name="days"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
@ -91,6 +91,10 @@ public class StatsController : BaseApiController
|
|||||||
return Ok(await _statService.GetTopUsers(days));
|
return Ok(await _statService.GetTopUsers(days));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A breakdown of different files, their size, and format
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
[Authorize("RequireAdminRole")]
|
[Authorize("RequireAdminRole")]
|
||||||
[HttpGet("server/file-breakdown")]
|
[HttpGet("server/file-breakdown")]
|
||||||
[ResponseCache(CacheProfileName = "Statistics")]
|
[ResponseCache(CacheProfileName = "Statistics")]
|
||||||
@ -100,11 +104,30 @@ public class StatsController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns reading history events for a give or all users, broken up by day, and format
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">If 0, defaults to all users, else just userId</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("reading-count-by-day")]
|
||||||
|
[ResponseCache(CacheProfileName = "Statistics")]
|
||||||
|
public async Task<ActionResult<IEnumerable<PagesReadOnADayCount<DateTime>>>> ReadCountByDay(int userId = 0)
|
||||||
|
{
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
|
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
|
||||||
|
if (!isAdmin && userId != user.Id) return BadRequest();
|
||||||
|
|
||||||
|
return Ok(await _statService.ReadCountByDay(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpGet("user/reading-history")]
|
[HttpGet("user/reading-history")]
|
||||||
[ResponseCache(CacheProfileName = "Statistics")]
|
[ResponseCache(CacheProfileName = "Statistics")]
|
||||||
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
|
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
|
||||||
{
|
{
|
||||||
// TODO: Put a check in if the calling user is said userId or has admin
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
|
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
|
||||||
|
if (!isAdmin && userId != user.Id) return BadRequest();
|
||||||
|
|
||||||
return Ok(await _statService.GetReadingHistory(userId));
|
return Ok(await _statService.GetReadingHistory(userId));
|
||||||
}
|
}
|
||||||
|
@ -55,6 +55,13 @@ public class UsersController : BaseApiController
|
|||||||
return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync());
|
return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("myself")]
|
||||||
|
public async Task<ActionResult<IEnumerable<MemberDto>>> GetMyself()
|
||||||
|
{
|
||||||
|
var users = await _unitOfWork.UserRepository.GetAllUsersAsync();
|
||||||
|
return Ok(users.Where(u => u.UserName == User.GetUsername()).DefaultIfEmpty().Select(u => _mapper.Map<MemberDto>(u)).SingleOrDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpGet("has-reading-progress")]
|
[HttpGet("has-reading-progress")]
|
||||||
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
|
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
|
||||||
|
21
API/DTOs/Statistics/PagesReadOnADayCount.cs
Normal file
21
API/DTOs/Statistics/PagesReadOnADayCount.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
|
||||||
|
namespace API.DTOs.Statistics;
|
||||||
|
|
||||||
|
public class PagesReadOnADayCount<T> : ICount<T>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The day of the readings
|
||||||
|
/// </summary>
|
||||||
|
public T Value { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Number of pages read
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Format of those files
|
||||||
|
/// </summary>
|
||||||
|
public MangaFormat Format { get; set; }
|
||||||
|
|
||||||
|
}
|
@ -27,6 +27,7 @@ public interface IStatisticService
|
|||||||
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
||||||
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
||||||
Task<IEnumerable<ReadHistoryEvent>> GetHistory();
|
Task<IEnumerable<ReadHistoryEvent>> GetHistory();
|
||||||
|
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -51,7 +52,6 @@ public class StatisticService : IStatisticService
|
|||||||
if (libraryIds.Count == 0)
|
if (libraryIds.Count == 0)
|
||||||
libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||||
|
|
||||||
|
|
||||||
// Total Pages Read
|
// Total Pages Read
|
||||||
var totalPagesRead = await _context.AppUserProgresses
|
var totalPagesRead = await _context.AppUserProgresses
|
||||||
.Where(p => p.AppUserId == userId)
|
.Where(p => p.AppUserId == userId)
|
||||||
@ -226,19 +226,20 @@ public class StatisticService : IStatisticService
|
|||||||
.OrderByDescending(d => d.Count)
|
.OrderByDescending(d => d.Count)
|
||||||
.Take(5);
|
.Take(5);
|
||||||
|
|
||||||
var seriesIds = (await _context.AppUserProgresses
|
// Remember: Ordering does not apply if there is a distinct
|
||||||
.AsSplitQuery()
|
var recentlyRead = _context.AppUserProgresses
|
||||||
.OrderByDescending(d => d.LastModified)
|
.Join(_context.Series, p => p.SeriesId, s => s.Id,
|
||||||
.Select(d => d.SeriesId)
|
(appUserProgresses, series) => new
|
||||||
.ToListAsync())
|
{
|
||||||
.Distinct()
|
Series = series,
|
||||||
|
AppUserProgresses = appUserProgresses
|
||||||
|
})
|
||||||
|
.AsEnumerable()
|
||||||
|
.DistinctBy(s => s.AppUserProgresses.SeriesId)
|
||||||
|
.OrderByDescending(x => x.AppUserProgresses.LastModified)
|
||||||
|
.Select(x => _mapper.Map<SeriesDto>(x.Series))
|
||||||
.Take(5);
|
.Take(5);
|
||||||
|
|
||||||
var recentlyRead = _context.Series
|
|
||||||
.AsSplitQuery()
|
|
||||||
.Where(s => seriesIds.Contains(s.Id))
|
|
||||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
|
||||||
.AsEnumerable();
|
|
||||||
|
|
||||||
var distinctPeople = _context.Person
|
var distinctPeople = _context.Person
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
@ -281,6 +282,7 @@ public class StatisticService : IStatisticService
|
|||||||
TotalSize = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Sum(mf2 => mf2.Bytes),
|
TotalSize = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Sum(mf2 => mf2.Bytes),
|
||||||
TotalFiles = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Count()
|
TotalFiles = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Count()
|
||||||
})
|
})
|
||||||
|
.OrderBy(d => d.TotalFiles)
|
||||||
.ToListAsync(),
|
.ToListAsync(),
|
||||||
TotalFileSize = await _context.MangaFile
|
TotalFileSize = await _context.MangaFile
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@ -310,14 +312,36 @@ public class StatisticService : IStatisticService
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ReadCountByDay()
|
public async Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0)
|
||||||
{
|
{
|
||||||
// _context.AppUserProgresses
|
var query = _context.AppUserProgresses
|
||||||
// .GroupBy(p => p.LastModified.Day)
|
.AsSplitQuery()
|
||||||
// .Select(g =>
|
.AsNoTracking()
|
||||||
// {
|
.Join(_context.Chapter, appUserProgresses => appUserProgresses.ChapterId, chapter => chapter.Id,
|
||||||
// Day = g.Key,
|
(appUserProgresses, chapter) => new {appUserProgresses, chapter})
|
||||||
// })
|
.Join(_context.Volume, x => x.chapter.VolumeId, volume => volume.Id,
|
||||||
|
(x, volume) => new {x.appUserProgresses, x.chapter, volume})
|
||||||
|
.Join(_context.Series, x => x.appUserProgresses.SeriesId, series => series.Id,
|
||||||
|
(x, series) => new {x.appUserProgresses, x.chapter, x.volume, series});
|
||||||
|
|
||||||
|
if (userId > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.appUserProgresses.AppUserId == userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.GroupBy(x => new
|
||||||
|
{
|
||||||
|
Day = x.appUserProgresses.Created.Date,
|
||||||
|
x.series.Format
|
||||||
|
})
|
||||||
|
.Select(g => new PagesReadOnADayCount<DateTime>
|
||||||
|
{
|
||||||
|
Value = g.Key.Day,
|
||||||
|
Format = g.Key.Format,
|
||||||
|
Count = g.Count()
|
||||||
|
})
|
||||||
|
.OrderBy(d => d.Value)
|
||||||
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IEnumerable<ReadHistoryEvent>> GetHistory()
|
public Task<IEnumerable<ReadHistoryEvent>> GetHistory()
|
||||||
|
@ -147,6 +147,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// First time run stat collection. Executes immediately on a background thread. Does not block.
|
/// First time run stat collection. Executes immediately on a background thread. Does not block.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>Schedules it for 1 day in the future to ensure we don't have users that try the software out</remarks>
|
||||||
public async Task RunStatCollection()
|
public async Task RunStatCollection()
|
||||||
{
|
{
|
||||||
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
|
||||||
@ -155,7 +156,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
_logger.LogDebug("User has opted out of stat collection, not sending stats");
|
_logger.LogDebug("User has opted out of stat collection, not sending stats");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BackgroundJob.Enqueue(() => _statsService.Send());
|
BackgroundJob.Schedule(() => _statsService.Send(), DateTimeOffset.Now.AddDays(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ScanSiteThemes()
|
public void ScanSiteThemes()
|
||||||
|
@ -48,4 +48,8 @@ export class MemberService {
|
|||||||
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/remove-series', {seriesIds});
|
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/remove-series', {seriesIds});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMember() {
|
||||||
|
return this.httpClient.get<Member>(this.baseUrl + 'users/myself');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -81,4 +81,8 @@ export class StatisticsService {
|
|||||||
getFileBreakdown() {
|
getFileBreakdown() {
|
||||||
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
|
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getReadCountByDay(userId: number = 0) {
|
||||||
|
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,11 +17,11 @@ export class ManagaReaderService {
|
|||||||
this.renderer = rendererFactory.createRenderer(null, null);
|
this.renderer = rendererFactory.createRenderer(null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
loadPageDimensions(dims: Array<FileDimension>) {
|
loadPageDimensions(dims: Array<FileDimension>) {
|
||||||
this.pageDimensions = {};
|
this.pageDimensions = {};
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
dims.forEach(d => {
|
dims.forEach(d => {
|
||||||
const isWide = (d.width > d.height);
|
const isWide = (d.width > d.height);
|
||||||
this.pageDimensions[d.pageNumber] = {
|
this.pageDimensions[d.pageNumber] = {
|
||||||
@ -30,16 +30,22 @@ export class ManagaReaderService {
|
|||||||
isWide: isWide
|
isWide: isWide
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//console.log('Page Number: ', d.pageNumber);
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
|
console.log('\tPage is wide, counter: ', counter, 'i: ', i);
|
||||||
this.pairs[d.pageNumber] = d.pageNumber;
|
this.pairs[d.pageNumber] = d.pageNumber;
|
||||||
|
//this.pairs[d.pageNumber] = this.pairs[d.pageNumber - 1] + 1;
|
||||||
} else {
|
} else {
|
||||||
|
//console.log('\tPage is single, counter: ', counter, 'i: ', i);
|
||||||
this.pairs[d.pageNumber] = counter % 2 === 0 ? Math.max(i - 1, 0) : counter;
|
this.pairs[d.pageNumber] = counter % 2 === 0 ? Math.max(i - 1, 0) : counter;
|
||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
|
//console.log('\t\tMapped to ', this.pairs[d.pageNumber]);
|
||||||
|
|
||||||
i++;
|
i++;
|
||||||
});
|
});
|
||||||
console.log('pairs: ', this.pairs);
|
//console.log('pairs: ', this.pairs);
|
||||||
}
|
}
|
||||||
|
|
||||||
adjustForDoubleReader(page: number) {
|
adjustForDoubleReader(page: number) {
|
||||||
|
@ -72,8 +72,6 @@ export class FileBreakdownStatsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.onDestroy.next();
|
|
||||||
this.onDestroy.complete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
@ -0,0 +1,44 @@
|
|||||||
|
<ng-container>
|
||||||
|
<div class="row g-0 mb-2 align-items-center">
|
||||||
|
<div class="col-4">
|
||||||
|
<h4>Top Readers</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<form [formGroup]="formGroup" class="d-inline-flex float-end" *ngIf="isAdmin">
|
||||||
|
<div class="d-flex">
|
||||||
|
<label for="time-select-read-by-day" class="form-check-label"></label>
|
||||||
|
<select id="time-select-read-by-day" class="form-select" formControlName="users"
|
||||||
|
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
|
||||||
|
<option [value]="0">All Users</option>
|
||||||
|
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-0">
|
||||||
|
<ng-container *ngIf="data$ | async as data">
|
||||||
|
<ngx-charts-line-chart
|
||||||
|
*ngIf="data.length > 0; else noData"
|
||||||
|
class="dark"
|
||||||
|
[legend]="true"
|
||||||
|
legendTitle="Formats"
|
||||||
|
[showXAxisLabel]="true"
|
||||||
|
[showYAxisLabel]="true"
|
||||||
|
[xAxis]="true"
|
||||||
|
[yAxis]="true"
|
||||||
|
[showGridLines]="false"
|
||||||
|
[showRefLines]="true"
|
||||||
|
[roundDomains]="true"
|
||||||
|
xAxisLabel="Time"
|
||||||
|
yAxisLabel="Reading Events"
|
||||||
|
[timeline]="false"
|
||||||
|
[results]="data"
|
||||||
|
>
|
||||||
|
</ngx-charts-line-chart>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<ng-template #noData>
|
||||||
|
No Reading progress
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
@ -0,0 +1,3 @@
|
|||||||
|
::ng-deep .dark .ngx-charts text {
|
||||||
|
fill: #a0aabe;
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup } from '@angular/forms';
|
||||||
|
import { filter, map, Observable, of, shareReplay, Subject, switchMap, takeUntil } from 'rxjs';
|
||||||
|
import { MangaFormatPipe } from 'src/app/pipe/manga-format.pipe';
|
||||||
|
import { Member } from 'src/app/_models/auth/member';
|
||||||
|
import { MemberService } from 'src/app/_services/member.service';
|
||||||
|
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||||
|
import { PieDataItem } from '../../_models/pie-data-item';
|
||||||
|
|
||||||
|
const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
|
||||||
|
const mangaFormatPipe = new MangaFormatPipe();
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-read-by-day-and',
|
||||||
|
templateUrl: './read-by-day-and.component.html',
|
||||||
|
styleUrls: ['./read-by-day-and.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ReadByDayAndComponent implements OnInit, OnDestroy {
|
||||||
|
/**
|
||||||
|
* Only show for one user
|
||||||
|
*/
|
||||||
|
@Input() userId: number = 0;
|
||||||
|
@Input() isAdmin: boolean = true;
|
||||||
|
|
||||||
|
view: [number, number] = [0, 400];
|
||||||
|
formGroup: FormGroup = new FormGroup({
|
||||||
|
'users': new FormControl(-1, []),
|
||||||
|
});
|
||||||
|
users$: Observable<Member[]> | undefined;
|
||||||
|
data$: Observable<Array<PieDataItem>>;
|
||||||
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(private statService: StatisticsService, private memberService: MemberService) {
|
||||||
|
this.data$ = this.formGroup.get('users')!.valueChanges.pipe(
|
||||||
|
switchMap(uId => this.statService.getReadCountByDay(uId)),
|
||||||
|
map(data => {
|
||||||
|
const gList = data.reduce((formats, entry) => {
|
||||||
|
const formatTranslated = mangaFormatPipe.transform(entry.format);
|
||||||
|
if (!formats[formatTranslated]) {
|
||||||
|
formats[formatTranslated] = {
|
||||||
|
name: formatTranslated,
|
||||||
|
value: 0,
|
||||||
|
series: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
formats[formatTranslated].series.push({name: new Date(entry.value).toLocaleDateString("en-US", options), value: entry.count});
|
||||||
|
|
||||||
|
return formats;
|
||||||
|
}, {});
|
||||||
|
return Object.keys(gList).map(format => {
|
||||||
|
return {name: format, value: 0, series: gList[format].series}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.onDestroy),
|
||||||
|
shareReplay(),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.data$.subscribe(_ => console.log('hi'));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.users$ = (this.isAdmin ? this.memberService.getMembers() : of([])).pipe(filter(_ => this.isAdmin), takeUntil(this.onDestroy), shareReplay());
|
||||||
|
this.formGroup.get('users')?.setValue(this.userId, {emitValue: true});
|
||||||
|
|
||||||
|
if (!this.isAdmin) {
|
||||||
|
this.formGroup.get('users')?.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.onDestroy.next();
|
||||||
|
this.onDestroy.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -72,21 +72,22 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid row g-0 pt-2 pb-2">
|
<div class="grid row g-0 pt-2 pb-2 d-flex justify-content-around">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-stat-list [data$]="releaseYears$" title="Release Years"></app-stat-list>
|
<app-stat-list [data$]="releaseYears$" title="Release Years" lable="series"></app-stat-list>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="events"></app-stat-list>
|
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="reads"></app-stat-list>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-stat-list [data$]="mostActiveLibrary$" title="Popular Libraries" label="events"></app-stat-list>
|
<app-stat-list [data$]="mostActiveLibrary$" title="Popular Libraries" label="reads"></app-stat-list>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-stat-list [data$]="mostActiveSeries$" title="Popular Series"></app-stat-list>
|
<app-stat-list [data$]="mostActiveSeries$" title="Popular Series" [image]="seriesImage" [handleClick]="openSeries">
|
||||||
|
</app-stat-list>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-stat-list [data$]="recentlyRead$" title="Recently Read" [handleClick]="handleRecentlyReadClick"></app-stat-list>
|
<app-stat-list [data$]="recentlyRead$" title="Recently Read" [image]="seriesImage" [handleClick]="openSeries"></app-stat-list>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -94,7 +95,7 @@
|
|||||||
<app-top-readers></app-top-readers>
|
<app-top-readers></app-top-readers>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 pt-2 pb-2 " style="height: 242px">
|
<div class="row g-0 pt-4 pb-2" style="height: 242px">
|
||||||
<div class="col-md-6 col-sm-12">
|
<div class="col-md-6 col-sm-12">
|
||||||
<app-file-breakdown-stats></app-file-breakdown-stats>
|
<app-file-breakdown-stats></app-file-breakdown-stats>
|
||||||
</div>
|
</div>
|
||||||
@ -102,5 +103,10 @@
|
|||||||
<app-publication-status-stats></app-publication-status-stats>
|
<app-publication-status-stats></app-publication-status-stats>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
||||||
|
<div class="col-md-12 col-sm-12 mt-4 pt-2">
|
||||||
|
<app-read-by-day-and [isAdmin]="true"></app-read-by-day-and>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { map, Observable, shareReplay, Subject, takeUntil } from 'rxjs';
|
import { map, Observable, shareReplay, Subject, takeUntil, tap } from 'rxjs';
|
||||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||||
import { Series } from 'src/app/_models/series';
|
import { Series } from 'src/app/_models/series';
|
||||||
import { User } from 'src/app/_models/user';
|
import { User } from 'src/app/_models/user';
|
||||||
|
import { ImageService } from 'src/app/_services/image.service';
|
||||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||||
import { FileExtensionBreakdown } from '../../_models/file-breakdown';
|
|
||||||
import { PieDataItem } from '../../_models/pie-data-item';
|
import { PieDataItem } from '../../_models/pie-data-item';
|
||||||
import { ServerStatistics } from '../../_models/server-statistics';
|
import { ServerStatistics } from '../../_models/server-statistics';
|
||||||
import { StatCount } from '../../_models/stat-count';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-stats',
|
selector: 'app-server-stats',
|
||||||
@ -24,9 +23,15 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||||||
mostActiveSeries$!: Observable<Array<PieDataItem>>;
|
mostActiveSeries$!: Observable<Array<PieDataItem>>;
|
||||||
recentlyRead$!: Observable<Array<PieDataItem>>;
|
recentlyRead$!: Observable<Array<PieDataItem>>;
|
||||||
stats$!: Observable<ServerStatistics>;
|
stats$!: Observable<ServerStatistics>;
|
||||||
|
seriesImage: (data: PieDataItem) => string;
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(private statService: StatisticsService, private router: Router) {
|
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService) {
|
||||||
|
this.seriesImage = (data: PieDataItem) => {
|
||||||
|
if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay());
|
this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay());
|
||||||
this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy));
|
this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy));
|
||||||
this.mostActiveUsers$ = this.stats$.pipe(
|
this.mostActiveUsers$ = this.stats$.pipe(
|
||||||
@ -46,9 +51,9 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.mostActiveSeries$ = this.stats$.pipe(
|
this.mostActiveSeries$ = this.stats$.pipe(
|
||||||
map(d => d.mostActiveLibraries),
|
map(d => d.mostReadSeries),
|
||||||
map(counts => counts.map(count => {
|
map(counts => counts.map(count => {
|
||||||
return {name: count.value.name, value: count.count};
|
return {name: count.value.name, value: count.count, extra: count.value};
|
||||||
})),
|
})),
|
||||||
takeUntil(this.onDestroy)
|
takeUntil(this.onDestroy)
|
||||||
);
|
);
|
||||||
@ -60,8 +65,6 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||||||
})),
|
})),
|
||||||
takeUntil(this.onDestroy)
|
takeUntil(this.onDestroy)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@ -72,7 +75,7 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||||||
this.onDestroy.complete();
|
this.onDestroy.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRecentlyReadClick = (data: PieDataItem) => {
|
openSeries = (data: PieDataItem) => {
|
||||||
const series = data.extra as Series;
|
const series = data.extra as Series;
|
||||||
this.router.navigate(['library', series.libraryId, 'series', series.id]);
|
this.router.navigate(['library', series.libraryId, 'series', series.id]);
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
<li class="list-group-item" [ngClass]="{'underline': handleClick != undefined}" *ngFor="let item of data" (click)="doClick(item)">
|
<li class="list-group-item" [ngClass]="{'underline': handleClick != undefined}" *ngFor="let item of data" (click)="doClick(item)">
|
||||||
|
<ng-container *ngIf="image && image(item) as url">
|
||||||
|
<app-image *ngIf="url && url.length > 0" width="32px" maxHeight="32px" class="img-top me-1" [imageUrl]="url"></app-image>
|
||||||
|
</ng-container>
|
||||||
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value}} {{label}}</span>
|
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value}} {{label}}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -24,6 +24,7 @@ export class StatListComponent {
|
|||||||
*/
|
*/
|
||||||
@Input() description: string = '';
|
@Input() description: string = '';
|
||||||
@Input() data$!: Observable<PieDataItem[]>;
|
@Input() data$!: Observable<PieDataItem[]>;
|
||||||
|
@Input() image: ((data: PieDataItem) => string) | undefined = undefined;
|
||||||
/**
|
/**
|
||||||
* Optional callback handler when an item is clicked
|
* Optional callback handler when an item is clicked
|
||||||
*/
|
*/
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<div class="row g-0 mt-4 mb-3">
|
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around">
|
||||||
<ng-container>
|
<ng-container>
|
||||||
<div class="col-auto mb-2">
|
<div class="col-auto mb-2">
|
||||||
<app-icon-and-title label="Total Pages Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Pages Read">
|
<app-icon-and-title label="Total Pages Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Pages Read">
|
||||||
{{totalPagesRead | number}}
|
{{totalPagesRead | compactNumber}}
|
||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
<div class="vr d-none d-lg-block m-2"></div>
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid" *ngIf="userId">
|
||||||
|
|
||||||
<!-- High level stats (use same design as series metadata info cards)-->
|
<!-- High level stats (use same design as series metadata info cards)-->
|
||||||
<div class="row g-0">
|
<div class="row g-0 d-flex justify-content-around">
|
||||||
<ng-container *ngIf="userStats$ | async as userStats">
|
<ng-container *ngIf="userStats$ | async as userStats">
|
||||||
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
|
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
|
||||||
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
|
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
||||||
|
<div class="col-md-12 col-sm-12 mt-4 pt-2">
|
||||||
|
<app-read-by-day-and [userId]="userId" [isAdmin]="(isAdmin$ | async) || false"></app-read-by-day-and>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- <div class="row g-0">
|
<!-- <div class="row g-0">
|
||||||
Books Read (this can be chapters read fully)
|
Books Read (this can be chapters read fully)
|
||||||
Number of bookmarks
|
Number of bookmarks
|
||||||
|
@ -7,6 +7,8 @@ import { SeriesService } from 'src/app/_services/series.service';
|
|||||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||||
import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
||||||
import { ReadHistoryEvent } from '../../_models/read-history-event';
|
import { ReadHistoryEvent } from '../../_models/read-history-event';
|
||||||
|
import { MemberService } from 'src/app/_services/member.service';
|
||||||
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
|
|
||||||
type SeriesWithProgress = Series & {progress: number};
|
type SeriesWithProgress = Series & {progress: number};
|
||||||
|
|
||||||
@ -18,25 +20,35 @@ type SeriesWithProgress = Series & {progress: number};
|
|||||||
})
|
})
|
||||||
export class UserStatsComponent implements OnInit, OnDestroy {
|
export class UserStatsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
@Input() userId!: number;
|
userId: number | undefined = undefined;
|
||||||
|
|
||||||
@ViewChildren(SortableHeader) headers!: QueryList<SortableHeader<SeriesWithProgress>>;
|
|
||||||
|
|
||||||
userStats$!: Observable<UserReadStatistics>;
|
userStats$!: Observable<UserReadStatistics>;
|
||||||
readSeries$!: Observable<ReadHistoryEvent[]>;
|
readSeries$!: Observable<ReadHistoryEvent[]>;
|
||||||
|
isAdmin$: Observable<boolean>;
|
||||||
|
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService, private seriesService: SeriesService,
|
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService,
|
||||||
private filterService: FilterUtilitiesService) { }
|
private filterService: FilterUtilitiesService, private accountService: AccountService, private memberService: MemberService) {
|
||||||
|
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), map(u => {
|
||||||
|
if (!u) return false;
|
||||||
|
return this.accountService.hasAdminRole(u);
|
||||||
|
}));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const filter = this.filterService.createSeriesFilter();
|
const filter = this.filterService.createSeriesFilter();
|
||||||
filter.readStatus = {read: true, notRead: false, inProgress: true};
|
filter.readStatus = {read: true, notRead: false, inProgress: true};
|
||||||
|
this.memberService.getMember().subscribe(me => {
|
||||||
|
this.userId = me.id;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
|
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
|
||||||
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
|
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
|
||||||
takeUntil(this.onDestroy),
|
takeUntil(this.onDestroy),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@ -44,23 +56,4 @@ export class UserStatsComponent implements OnInit, OnDestroy {
|
|||||||
this.onDestroy.complete();
|
this.onDestroy.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSort({ column, direction }: SortEvent<SeriesWithProgress>) {
|
|
||||||
// resetting other headers
|
|
||||||
this.headers.forEach((header) => {
|
|
||||||
if (header.sortable !== column) {
|
|
||||||
header.direction = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// sorting countries
|
|
||||||
// if (direction === '' || column === '') {
|
|
||||||
// this.countries = COUNTRIES;
|
|
||||||
// } else {
|
|
||||||
// this.countries = [...COUNTRIES].sort((a, b) => {
|
|
||||||
// const res = compare(a[column], b[column]);
|
|
||||||
// return direction === 'asc' ? res : -res;
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
5
UI/Web/src/app/statistics/_models/line-data-item.ts
Normal file
5
UI/Web/src/app/statistics/_models/line-data-item.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface LineDataItem {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
extra?: any;
|
||||||
|
}
|
@ -14,6 +14,6 @@ export interface ServerStatistics {
|
|||||||
totalPeople: number;
|
totalPeople: number;
|
||||||
mostActiveUsers: Array<StatCount<User>>;
|
mostActiveUsers: Array<StatCount<User>>;
|
||||||
mostActiveLibraries: Array<StatCount<Library>>;
|
mostActiveLibraries: Array<StatCount<Library>>;
|
||||||
mostActiveSeries: Array<StatCount<Series>>;
|
mostReadSeries: Array<StatCount<Series>>;
|
||||||
recentlyRead: Array<Series>;
|
recentlyRead: Array<Series>;
|
||||||
}
|
}
|
@ -14,6 +14,7 @@ import { MangaFormatStatsComponent } from './_components/manga-format-stats/mang
|
|||||||
import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component';
|
import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component';
|
||||||
import { PipeModule } from '../pipe/pipe.module';
|
import { PipeModule } from '../pipe/pipe.module';
|
||||||
import { TopReadersComponent } from './_components/top-readers/top-readers.component';
|
import { TopReadersComponent } from './_components/top-readers/top-readers.component';
|
||||||
|
import { ReadByDayAndComponent } from './_components/read-by-day-and/read-by-day-and.component';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -26,7 +27,8 @@ import { TopReadersComponent } from './_components/top-readers/top-readers.compo
|
|||||||
PublicationStatusStatsComponent,
|
PublicationStatusStatsComponent,
|
||||||
MangaFormatStatsComponent,
|
MangaFormatStatsComponent,
|
||||||
FileBreakdownStatsComponent,
|
FileBreakdownStatsComponent,
|
||||||
TopReadersComponent
|
TopReadersComponent,
|
||||||
|
ReadByDayAndComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -321,7 +321,7 @@
|
|||||||
<app-manage-devices></app-manage-devices>
|
<app-manage-devices></app-manage-devices>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
|
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
|
||||||
<app-user-stats [userId]="1"></app-user-stats>
|
<app-user-stats></app-user-stats>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
113
openapi.json
113
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.6.1.15"
|
"version": "0.6.1.16"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -7874,7 +7874,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Stats"
|
"Stats"
|
||||||
],
|
],
|
||||||
"summary": "Returns",
|
"summary": "Returns users with the top reads in the server",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "days",
|
"name": "days",
|
||||||
@ -7925,6 +7925,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Stats"
|
"Stats"
|
||||||
],
|
],
|
||||||
|
"summary": "A breakdown of different files, their size, and format",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@ -7958,6 +7959,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Stats/reading-count-by-day": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Stats"
|
||||||
|
],
|
||||||
|
"summary": "Returns reading history events for a give or all users, broken up by day, and format",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"in": "query",
|
||||||
|
"description": "If 0, defaults to all users, else just userId",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DateTimePagesReadOnADayCount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DateTimePagesReadOnADayCount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DateTimePagesReadOnADayCount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Stats/user/reading-history": {
|
"/api/Stats/user/reading-history": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -8562,6 +8614,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Users/myself": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MemberDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MemberDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MemberDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Users/has-reading-progress": {
|
"/api/Users/has-reading-progress": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -10161,6 +10251,25 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"DateTimePagesReadOnADayCount": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The day of the readings",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of pages read",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"$ref": "#/components/schemas/MangaFormat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"DeleteSeriesDto": {
|
"DeleteSeriesDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user