Added the ability to browse different genres (still needs polish).

Fixed a rare bug with crypto.UUID by backfilling it.
This commit is contained in:
Joseph Milazzo 2025-06-06 17:02:42 -05:00
parent d400938610
commit 2e80316057
19 changed files with 288 additions and 33 deletions

View file

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person; using API.DTOs.Person;
@ -46,6 +47,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
} }
/// <summary>
/// Returns a list of Genres with counts for counts when Genre is on Series/Chapter
/// </summary>
/// <returns></returns>
[HttpPost("genres-with-counts")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
public async Task<ActionResult<PagedList<BrowseGenreDto>>> GetBrowseGenres(UserParams? userParams = null)
{
userParams ??= UserParams.Default;
var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
/// <summary> /// <summary>
/// Fetches people from the instance by role /// Fetches people from the instance by role
/// </summary> /// </summary>

View file

@ -0,0 +1,13 @@
namespace API.DTOs.Metadata;
public sealed record BrowseGenreDto : GenreTagDto
{
/// <summary>
/// Number of Series this Entity is on
/// </summary>
public int SeriesCount { get; set; }
/// <summary>
/// Number of Issues this Entity is on
/// </summary>
public int IssueCount { get; set; }
}

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Metadata; namespace API.DTOs.Metadata;
public sealed record GenreTagDto public record GenreTagDto
{ {
public int Id { get; set; } public int Id { get; set; }
public required string Title { get; set; } public required string Title { get; set; }

View file

@ -12,7 +12,7 @@ public class BrowsePersonDto : PersonDto
/// </summary> /// </summary>
public int SeriesCount { get; set; } public int SeriesCount { get; set; }
/// <summary> /// <summary>
/// Number or Issues this Person is the Writer for /// Number of Issues this Person is the Writer for
/// </summary> /// </summary>
public int IssueCount { get; set; } public int IssueCount { get; set; }
} }

View file

@ -6,6 +6,7 @@ using API.DTOs.Metadata;
using API.Entities; using API.Entities;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using API.Helpers;
using API.Services.Tasks.Scanner.Parser; using API.Services.Tasks.Scanner.Parser;
using AutoMapper; using AutoMapper;
using AutoMapper.QueryableExtensions; using AutoMapper.QueryableExtensions;
@ -27,6 +28,7 @@ public interface IGenreRepository
Task<GenreTagDto> GetRandomGenre(); Task<GenreTagDto> GetRandomGenre();
Task<GenreTagDto> GetGenreById(int id); Task<GenreTagDto> GetGenreById(int id);
Task<List<string>> GetAllGenresNotInListAsync(ICollection<string> genreNames); Task<List<string>> GetAllGenresNotInListAsync(ICollection<string> genreNames);
Task<PagedList<BrowseGenreDto>> GetBrowseableGenre(int userId, UserParams userParams);
} }
public class GenreRepository : IGenreRepository public class GenreRepository : IGenreRepository
@ -165,4 +167,28 @@ public class GenreRepository : IGenreRepository
// Return the original non-normalized genres for the missing ones // Return the original non-normalized genres for the missing ones
return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList();
} }
public async Task<PagedList<BrowseGenreDto>> GetBrowseableGenre(int userId, UserParams userParams)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
var query = _context.Genre
.RestrictAgainstAgeRestriction(ageRating)
.Select(g => new BrowseGenreDto
{
Id = g.Id,
Title = g.Title,
SeriesCount = g.SeriesMetadatas
.Select(sm => sm.Id)
.Distinct()
.Count(),
IssueCount = g.Chapters
.Select(ch => ch.Id)
.Distinct()
.Count()
})
.OrderBy(g => g.Title);
return await PagedList<BrowseGenreDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
} }

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using API.Data.Misc; using API.Data.Misc;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata;
namespace API.Extensions; namespace API.Extensions;
#nullable enable #nullable enable
@ -42,4 +43,16 @@ public static class EnumerableExtensions
return q; return q;
} }
public static IEnumerable<SeriesMetadata> RestrictAgainstAgeRestriction(this IEnumerable<SeriesMetadata> items, AgeRestriction restriction)
{
if (restriction.AgeRating == AgeRating.NotApplicable) return items;
var q = items.Where(s => s.AgeRating <= restriction.AgeRating);
if (!restriction.IncludeUnknowns)
{
return q.Where(s => s.AgeRating != AgeRating.Unknown);
}
return q;
}
} }

View file

@ -3,6 +3,7 @@ using System.Linq;
using API.Data.Misc; using API.Data.Misc;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata;
using API.Entities.Person; using API.Entities.Person;
namespace API.Extensions.QueryExtensions; namespace API.Extensions.QueryExtensions;
@ -26,6 +27,19 @@ public static class RestrictByAgeExtensions
return q; return q;
} }
public static IQueryable<SeriesMetadata> RestrictAgainstAgeRestriction(this IQueryable<SeriesMetadata> queryable, AgeRestriction restriction)
{
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
var q = queryable.Where(s => s.AgeRating <= restriction.AgeRating);
if (!restriction.IncludeUnknowns)
{
return q.Where(s => s.AgeRating != AgeRating.Unknown);
}
return q;
}
public static IQueryable<Chapter> RestrictAgainstAgeRestriction(this IQueryable<Chapter> queryable, AgeRestriction restriction) public static IQueryable<Chapter> RestrictAgainstAgeRestriction(this IQueryable<Chapter> queryable, AgeRestriction restriction)
{ {
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;

View file

@ -0,0 +1,6 @@
import {Genre} from "./genre";
export interface BrowseGenre extends Genre {
seriesCount: number;
issueCount: number;
}

View file

@ -1,5 +1,4 @@
import { Routes } from "@angular/router"; import {Routes} from "@angular/router";
import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component";
import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component"; import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component";

View file

@ -0,0 +1,7 @@
import {Routes} from "@angular/router";
import {BrowseGenresComponent} from "../all-genres/browse-genres.component";
export const routes: Routes = [
{path: '', component: BrowseGenresComponent, pathMatch: 'full'},
];

View file

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { JumpKey } from '../_models/jumpbar/jump-key'; import {JumpKey} from '../_models/jumpbar/jump-key';
const keySize = 25; // Height of the JumpBar button const keySize = 25; // Height of the JumpBar button
@ -105,14 +105,18 @@ export class JumpbarService {
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) { getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
const keys: {[key: string]: number} = {}; const keys: {[key: string]: number} = {};
data.forEach(obj => { data.forEach(obj => {
let ch = keySelector(obj).charAt(0).toUpperCase(); try {
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { let ch = keySelector(obj).charAt(0).toUpperCase();
ch = '#'; if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
ch = '#';
}
if (!keys.hasOwnProperty(ch)) {
keys[ch] = 0;
}
keys[ch] += 1;
} catch (e) {
console.error('Failed to calculate jump key for ', obj, e);
} }
if (!keys.hasOwnProperty(ch)) {
keys[ch] = 0;
}
keys[ch] += 1;
}); });
return Object.keys(keys).map(k => { return Object.keys(keys).map(k => {
k = k.toUpperCase(); k = k.toUpperCase();

View file

@ -1,4 +1,4 @@
import {HttpClient} from '@angular/common/http'; import {HttpClient, HttpParams} from '@angular/common/http';
import {inject, Injectable} from '@angular/core'; import {inject, Injectable} from '@angular/core';
import {tap} from 'rxjs/operators'; import {tap} from 'rxjs/operators';
import {map, of} from 'rxjs'; import {map, of} from 'rxjs';
@ -25,6 +25,9 @@ import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {TranslocoService} from "@jsverse/transloco"; import {TranslocoService} from "@jsverse/transloco";
import {LibraryService} from './library.service'; import {LibraryService} from './library.service';
import {CollectionTagService} from "./collection-tag.service"; import {CollectionTagService} from "./collection-tag.service";
import {PaginatedResult} from "../_models/pagination";
import {UtilityService} from "../shared/_services/utility.service";
import {BrowseGenre} from "../_models/metadata/browse-genre";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -34,6 +37,7 @@ export class MetadataService {
private readonly translocoService = inject(TranslocoService); private readonly translocoService = inject(TranslocoService);
private readonly libraryService = inject(LibraryService); private readonly libraryService = inject(LibraryService);
private readonly collectionTagService = inject(CollectionTagService); private readonly collectionTagService = inject(CollectionTagService);
private readonly utilityService = inject(UtilityService);
baseUrl = environment.apiUrl; baseUrl = environment.apiUrl;
private validLanguages: Array<Language> = []; private validLanguages: Array<Language> = [];
@ -85,6 +89,17 @@ export class MetadataService {
return this.httpClient.get<Array<Genre>>(this.baseUrl + method); return this.httpClient.get<Array<Genre>>(this.baseUrl + method);
} }
getGenreWithCounts(pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowseGenre[]>>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowseGenre[]>;
})
);
}
getAllLanguages(libraries?: Array<number>) { getAllLanguages(libraries?: Array<number>) {
let method = 'metadata/languages' let method = 'metadata/languages'
if (libraries != undefined && libraries.length > 0) { if (libraries != undefined && libraries.length > 0) {

View file

@ -0,0 +1,34 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-genres'" >
<app-side-nav-companion-bar [hasFilter]="false">
<h2 title>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle>{{t('genre-count', {num: pagination.totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="genres"
[pagination]="pagination"
[trackByIdentity]="trackByIdentity"
[jumpBarKeys]="jumpKeys"
[filteringDisabled]="true"
[refresh]="refresh"
>
<ng-template #cardItem let-item let-position="idx">
<div class="genre-card" (click)="openFilter(FilterField.Genres, item.id)">
<div class="genre-name">{{ item.title }}</div>
<div class="genre-meta">
<span>{{ item.seriesCount }} Series</span>
<span>{{ item.issueCount }} Issues</span>
</div>
</div>
</ng-template>
</app-card-detail-layout>
</ng-container>
</div>

View file

@ -0,0 +1,26 @@
.genre-card {
background-color: var(--bs-card-color, #2c2c2c);
padding: 1rem;
border-radius: 12px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: transform 0.2s ease, background 0.3s ease;
cursor: pointer;
}
.genre-card:hover {
background-color: #3a3a3a;
transform: translateY(-3px);
}
.genre-name {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.genre-meta {
font-size: 0.85rem;
display: flex;
justify-content: space-between;
color: var(--text-muted-color, #bbb);
}

View file

@ -0,0 +1,72 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
OnInit
} from '@angular/core';
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {JumpbarService} from "../_services/jumpbar.service";
import {BrowsePerson} from "../_models/person/browse-person";
import {Pagination} from "../_models/pagination";
import {JumpKey} from "../_models/jumpbar/jump-key";
import {MetadataService} from "../_services/metadata.service";
import {BrowseGenre} from "../_models/metadata/browse-genre";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
@Component({
selector: 'app-all-genres',
imports: [
CardDetailLayoutComponent,
DecimalPipe,
SideNavCompanionBarComponent,
TranslocoDirective
],
templateUrl: './browse-genres.component.html',
styleUrl: './browse-genres.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrowseGenresComponent implements OnInit {
protected readonly FilterField = FilterField;
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly metadataService = inject(MetadataService);
private readonly jumpbarService = inject(JumpbarService);
protected readonly filterUtilityService = inject(FilterUtilitiesService);
isLoading = false;
genres: Array<BrowseGenre> = [];
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = [];
trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`;
ngOnInit() {
this.isLoading = true;
this.cdRef.markForCheck();
this.metadataService.getGenreWithCounts(undefined, undefined).subscribe(d => {
this.genres = d.result;
this.pagination = d.pagination;
this.jumpKeys = this.jumpbarService.getJumpKeys(this.genres, (d: BrowseGenre) => d.title);
this.isLoading = false;
this.cdRef.markForCheck();
});
}
openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
}

View file

@ -1,8 +1,7 @@
import { NgModule } from '@angular/core'; import {NgModule} from '@angular/core';
import { Routes, RouterModule, PreloadAllModules } from '@angular/router'; import {PreloadAllModules, RouterModule, Routes} from '@angular/router';
import { AuthGuard } from './_guards/auth.guard'; import {AuthGuard} from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard'; import {LibraryAccessGuard} from './_guards/library-access.guard';
import { AdminGuard } from './_guards/admin.guard';
const routes: Routes = [ const routes: Routes = [
{ {
@ -54,6 +53,10 @@ const routes: Routes = [
path: 'browse/authors', path: 'browse/authors',
loadChildren: () => import('./_routes/browse-authors-routing.module').then(m => m.routes) loadChildren: () => import('./_routes/browse-authors-routing.module').then(m => m.routes)
}, },
{
path: 'browse/genres',
loadChildren: () => import('./_routes/browse-genres-routing.module').then(m => m.routes)
},
{ {
path: 'library', path: 'library',
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',

View file

@ -15,12 +15,7 @@ import {DecimalPipe} from "@angular/common";
import {Series} from "../_models/series"; import {Series} from "../_models/series";
import {Pagination} from "../_models/pagination"; import {Pagination} from "../_models/pagination";
import {JumpKey} from "../_models/jumpbar/jump-key"; import {JumpKey} from "../_models/jumpbar/jump-key";
import {ActivatedRoute, Router} from "@angular/router"; import {Router} from "@angular/router";
import {Title} from "@angular/platform-browser";
import {ActionFactoryService} from "../_services/action-factory.service";
import {ActionService} from "../_services/action.service";
import {MessageHubService} from "../_services/message-hub.service";
import {UtilityService} from "../shared/_services/utility.service";
import {PersonService} from "../_services/person.service"; import {PersonService} from "../_services/person.service";
import {BrowsePerson} from "../_models/person/browse-person"; import {BrowsePerson} from "../_models/person/browse-person";
import {JumpbarService} from "../_services/jumpbar.service"; import {JumpbarService} from "../_services/jumpbar.service";
@ -48,13 +43,7 @@ export class BrowseAuthorsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly titleService = inject(Title);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly hubService = inject(MessageHubService);
private readonly utilityService = inject(UtilityService);
private readonly personService = inject(PersonService); private readonly personService = inject(PersonService);
private readonly jumpbarService = inject(JumpbarService); private readonly jumpbarService = inject(JumpbarService);
protected readonly imageService = inject(ImageService); protected readonly imageService = inject(ImageService);

View file

@ -51,7 +51,7 @@ export class SettingSwitchComponent implements AfterContentInit {
const inputElement = element.querySelector('input'); const inputElement = element.querySelector('input');
// If no id, generate a random id and assign it to the input // If no id, generate a random id and assign it to the input
inputElement.id = crypto.randomUUID(); inputElement.id = this.generateId();
if (inputElement && inputElement.id) { if (inputElement && inputElement.id) {
this.labelId = inputElement.id; this.labelId = inputElement.id;
@ -62,4 +62,14 @@ export class SettingSwitchComponent implements AfterContentInit {
}); });
} }
private generateId(): string {
if (crypto && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for browsers without crypto.randomUUID (which has happened multiple times in my user base)
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now().toString(36);
}
} }

View file

@ -1111,6 +1111,13 @@
"series-count": "{{common.series-count}}" "series-count": "{{common.series-count}}"
}, },
"browse-genres": {
"title": "Browse Genres",
"genre-count": "{{num}} Genres",
"issue-count": "{{common.issue-count}}",
"series-count": "{{common.series-count}}"
},
"person-detail": { "person-detail": {
"aka-title": "Also known as ", "aka-title": "Also known as ",
"known-for-title": "Known For", "known-for-title": "Known For",
@ -2595,7 +2602,7 @@
"browse-title-pipe": { "browse-title-pipe": {
"publication-status": "{{value}} works", "publication-status": "{{value}} works",
"age-rating": "Rated {{value}}", "age-rating": "Rated {{value}}",
"user-rating": "Rated {{value}} stars", "user-rating": "{{value}} star rating",
"tag": "Has Tag {{value}}", "tag": "Has Tag {{value}}",
"translator": "Translated by {{value}}", "translator": "Translated by {{value}}",
"character": "Has character {{value}}", "character": "Has character {{value}}",