Kavita+ Match UX Refresh (#4727)

This commit is contained in:
Joe Milazzo
2026-05-27 11:42:39 -05:00
committed by GitHub
parent 9ede20ecae
commit 384d1069b7
45 changed files with 1032 additions and 321 deletions
@@ -3,6 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using Kavita.Common;
using Kavita.Models.DTOs.Collection;
using Kavita.Models.DTOs.KavitaPlus.ExternalMetadata;
using Kavita.Models.DTOs.KavitaPlus.ExternalMetadata.Covers;
using Kavita.Models.DTOs.KavitaPlus.Metadata;
using Kavita.Models.DTOs.Metadata.Matching;
@@ -69,11 +70,9 @@ public interface IExternalMetadataService
/// This will override any sort of matching that was done prior and force it to be what the user Selected
/// </summary>
/// <param name="seriesId"></param>
/// <param name="aniListId"></param>
/// <param name="malId"></param>
/// <param name="cbrId"></param>
/// <param name="ids"></param>
/// <param name="ct"></param>
Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId, CancellationToken ct = default);
Task FixSeriesMatch(int seriesId, ExternalMetadataIdsDto ids, CancellationToken ct = default);
/// <summary>
/// Sets a series to Don't Match and removes all previously cached
@@ -131,7 +131,7 @@ public static class ScrobblingHelper
{
if (series.MalId != 0) return series.MalId;
return WeblinkParser.GetMalId(series.Metadata.WebLinks) ?? series.ExternalSeriesMetadata?.MalId;
return ExternalIdParser.GetMalId(series.Metadata.WebLinks) ?? series.ExternalSeriesMetadata?.MalId;
}
@@ -139,7 +139,7 @@ public static class ScrobblingHelper
{
if (seriesWithExternalMetadata.AniListId != 0) return seriesWithExternalMetadata.AniListId;
var aniListId = WeblinkParser.GetAniListId(seriesWithExternalMetadata.Metadata.WebLinks);
var aniListId = ExternalIdParser.GetAniListId(seriesWithExternalMetadata.Metadata.WebLinks);
return aniListId ?? seriesWithExternalMetadata.ExternalSeriesMetadata?.AniListId;
}
@@ -2,7 +2,7 @@
namespace Kavita.Common.Tests.Helpers;
public class WeblinkParserTests
public class ExternalIdParserTests
{
[Theory]
[InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)]
@@ -10,27 +10,27 @@ public class WeblinkParserTests
[InlineData("https://anilist.co/manga/30105/Kekkaishi/", 30105)]
public void CanParseWeblink_AniList(string link, int? expectedId)
{
Assert.Equal(WeblinkParser.GetAniListId(link), expectedId);
Assert.Equal(ExternalIdParser.GetAniListId(link), expectedId);
}
[Theory]
[InlineData("https://mangadex.org/title/316d3d09-bb83-49da-9d90-11dc7ce40967/honzuki-no-gekokujou-shisho-ni-naru-tame-ni-wa-shudan-wo-erandeiraremasen-dai-3-bu-ryouchi-ni-hon-o", "316d3d09-bb83-49da-9d90-11dc7ce40967")]
public void CanParseWeblink_MangaDex(string link, string expectedId)
{
Assert.Equal(WeblinkParser.GetMangaDexId(link), expectedId);
Assert.Equal(ExternalIdParser.GetMangaDexId(link), expectedId);
}
[Theory]
[InlineData("https://comicvine.gamespot.com/chew-1-taster-s-choice-part-1-of-5/4000-159233/", "159233")]
public void CanParseWeblink_ComicVine(string link, string expectedId)
{
Assert.Equal(WeblinkParser.GetComicVineId(link).Item1, expectedId);
Assert.Equal(ExternalIdParser.GetComicVineId(link).Item1, expectedId);
}
[Theory]
[InlineData("https://mangabaka.org/3391", 3391)]
public void CanParseWeblink_MangaBaka(string link, long expectedId)
{
Assert.Equal(WeblinkParser.GetMangaBakaId(link), expectedId);
Assert.Equal(ExternalIdParser.GetMangaBakaId(link), expectedId);
}
}
@@ -1,12 +1,16 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
namespace Kavita.Common.Helpers;
#nullable enable
public static class WeblinkParser
/// <summary>
/// Handles all things parsing of External Ids (weblinks, not set checks, anilist:X)
/// </summary>
public static class ExternalIdParser
{
private const string AniListWeblinkWebsite = "https://anilist.co/manga/";
private const string MalWeblinkWebsite = "https://myanimelist.net/manga/";
@@ -90,6 +94,51 @@ public static class WeblinkParser
return ExtractId<long?>(weblinks, MangaBakaWebsite) ?? 0;
}
#region Header-based Parsing
public static bool TryParseAniListHeader(string? text, out int id) =>
TryParseHeader(text, "ANILIST", out id);
public static bool TryParseHardcoverHeader(string? text, out string id) =>
TryParseHeader(text, "HARDCOVER", out id);
public static bool TryParseMangaBakaHeader(string? text, out long id) =>
TryParseHeader(text, "MANGABAKA", out id);
public static bool TryParseMalHeader(string? text, out int id) =>
TryParseHeader(text, "MAL", out id);
public static int? ParseAniListHeader(string? text) => ParseHeader<int>(text, "ANILIST");
public static string? ParseHardcoverHeader(string? text) => ParseHeader<string>(text, "HARDCOVER");
public static long? ParseMangaBakaHeader(string? text) => ParseHeader<long>(text, "MANGABAKA");
public static int? ParseMalHeader(string? text) => ParseHeader<int>(text, "MAL");
private static T? ParseHeader<T>(string? text, string header)
where T : IParsable<T>
{
if (string.IsNullOrWhiteSpace(text)) return default;
if (!text.StartsWith(header + ":", StringComparison.InvariantCultureIgnoreCase)) return default;
var valuePart = text.Split(':', 2)[1];
return T.TryParse(valuePart, CultureInfo.InvariantCulture, out var result) ? result : default;
}
private static bool TryParseHeader<T>(string? text, string header, out T id)
where T : IParsable<T>
{
var result = ParseHeader<T>(text, header);
if (result is not null)
{
id = result;
return true;
}
id = default!;
return false;
}
#endregion
/// <summary>
@@ -614,16 +614,16 @@ public class SeriesRepository(DataContext context, IMapper mapper) : ISeriesRepo
// TODO: Refactor this to check from ExternalMetadataIds first then ExternalSeriesMetadata. Weblink parsing is pointless as it updates in externalMetadata
AniListId = series.ExternalSeriesMetadata.AniListId != 0
? series.ExternalSeriesMetadata.AniListId
: WeblinkParser.GetAniListId(series.Metadata.WebLinks),
: ExternalIdParser.GetAniListId(series.Metadata.WebLinks),
MalId = series.ExternalSeriesMetadata.MalId != 0
? series.ExternalSeriesMetadata.MalId
: WeblinkParser.GetMalId(series.Metadata.WebLinks),
: ExternalIdParser.GetMalId(series.Metadata.WebLinks),
CbrId = series.ExternalSeriesMetadata.CbrId,
GoogleBooksId = !string.IsNullOrEmpty(series.ExternalSeriesMetadata.GoogleBooksId)
? series.ExternalSeriesMetadata.GoogleBooksId
: WeblinkParser.GetGoogleBooksId(series.Metadata.WebLinks),
: ExternalIdParser.GetGoogleBooksId(series.Metadata.WebLinks),
MangabakaId = (int?) series.MangaBakaId,
MangaDexId = WeblinkParser.GetMangaDexId(series.Metadata.WebLinks),
MangaDexId = ExternalIdParser.GetMangaDexId(series.Metadata.WebLinks),
VolumeCount = series.Volumes.Count,
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
Year = series.Metadata.ReleaseYear
@@ -15,7 +15,7 @@ public sealed record MatchSeriesRequestDto
public string? Query { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
public string? HardcoverId { get; set; }
public string? HardcoverSlug { get; set; }
public int? MangabakaId { get; set; }
public int? CbrId { get; set; }
public PlusMediaFormat Format { get; set; }
@@ -15,7 +15,11 @@ public sealed record ExternalSeriesDetailDto
public string Name { get; set; }
public int? AniListId { get; set; }
public long? MALId { get; set; }
/// <summary>
/// ComicBookRoundup Id for direct matching
/// </summary>
public int? CbrId { get; set; }
public int? HardcoverId { get; set; }
public int? MangabakaId { get; set; }
public IList<string> Synonyms { get; set; } = [];
public PlusMediaFormat PlusMediaFormat { get; set; }
@@ -11,6 +11,7 @@ public class PlusSeriesRequestDto
public string? GoogleBooksId { get; set; }
public string? MangaDexId { get; set; }
public int? MangabakaId { get; set; }
public int? HardcoverId { get; set; }
/// <summary>
/// ComicBookRoundup Id
/// </summary>
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using EasyCaching.Core;
using Hangfire;
@@ -17,6 +18,7 @@ using Kavita.Models.DTOs.Filtering.v2;
using Kavita.Models.DTOs.Filtering.v2.Requests;
using Kavita.Models.DTOs.Metadata.Matching;
using Kavita.Models.DTOs.Recommendation;
using Kavita.Models.DTOs.KavitaPlus.ExternalMetadata;
using Kavita.Models.DTOs.SeriesDetail;
using Kavita.Models.Entities.Enums;
using Kavita.Models.Entities.MetadataMatching;
@@ -583,18 +585,15 @@ public class SeriesController(
/// <summary>
/// This will perform the fix match
/// </summary>
/// <param name="match"></param>
/// <param name="seriesId"></param>
/// <param name="aniListId"></param>
/// <param name="malId"></param>
/// <param name="cbrId"></param>
/// <param name="ids"></param>
/// <returns></returns>
[KPlus]
[HttpPost("update-match")]
[Authorize(Policy = PolicyGroups.AdminPolicy)]
public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int? aniListId, [FromQuery] long? malId, [FromQuery] int? cbrId)
public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromBody] ExternalMetadataIdsDto ids)
{
BackgroundJob.Enqueue(() => externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId, cbrId));
BackgroundJob.Enqueue(() => externalMetadataService.FixSeriesMatch(seriesId, ids, CancellationToken.None));
return Ok();
}
+44 -21
View File
@@ -182,14 +182,31 @@ public class ExternalMetadataService : IExternalMetadataService
public async Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto, CancellationToken ct = default)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library, ct);
if (series == null) return [];
var potentialAnilistId = WeblinkParser.GetAniListId(dto.Query);
var potentialMalId = WeblinkParser.GetMalId(dto.Query);
var potentialMangabakaId = WeblinkParser.GetMangaBakaId(dto.Query);
var query = dto.Query;
var potentialAnilistId = ExternalIdParser.TryParseAniListHeader(query, out var aniListId)
? aniListId : ExternalIdParser.GetAniListId(query);
var potentialMalId = ExternalIdParser.TryParseMalHeader(query, out var malId)
? malId : ExternalIdParser.GetMalId(query);
var potentialMangabakaId = ExternalIdParser.TryParseMangaBakaHeader(query, out var mangabakaId)
? mangabakaId : ExternalIdParser.GetMangaBakaId(query);
var potentialHardcoverSlug = ExternalIdParser.TryParseHardcoverHeader(query, out var hardcoverId)
? hardcoverId : null;
// If any ID was extracted (header syntax or URL), the raw query string is meaningless to the backend
var wasHeaderQuery = potentialAnilistId.HasValue
|| potentialMalId.HasValue
|| potentialMangabakaId > 0
|| !string.IsNullOrEmpty(potentialHardcoverSlug);
query = wasHeaderQuery ? null : dto.Query;
var format = series.Library.Type.ConvertToPlusMediaFormat(series.Format);
var otherNames = ExtractAlternativeNames(series);
@@ -208,13 +225,14 @@ public class ExternalMetadataService : IExternalMetadataService
var matchRequest = new MatchSeriesRequestDto()
{
Format = format,
Query = dto.Query,
Query = query,
SeriesName = series.Name,
AlternativeNames = otherNames,
Year = year,
AniListId = potentialAnilistId ?? ScrobblingHelper.GetAniListId(series),
AniListId = potentialAnilistId ?? ScrobblingHelper.GetAniListId(series), // TODO: Opportunity to streamline this with ExternalIdParser and the default > 0/empty string checks
MalId = potentialMalId ?? ScrobblingHelper.GetMalId(series),
MangabakaId = potentialMangabakaId > 0 ? (int) potentialMangabakaId : (int?) series.MangaBakaId
MangabakaId = potentialMangabakaId > 0 ? (int) potentialMangabakaId : (int?) series.MangaBakaId,
HardcoverSlug = potentialHardcoverSlug
};
try
@@ -289,7 +307,7 @@ public class ExternalMetadataService : IExternalMetadataService
}
}
public async Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId, CancellationToken ct = default)
public async Task FixSeriesMatch(int seriesId, ExternalMetadataIdsDto ids, CancellationToken ct = default)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library, ct);
if (series == null) return;
@@ -306,17 +324,19 @@ public class ExternalMetadataService : IExternalMetadataService
var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type,
new PlusSeriesRequestDto()
{
AniListId = aniListId,
MalId = malId,
CbrId = cbrId,
AniListId = ids.AniListId,
MalId = ids.MalId,
CbrId = ids.CbrId,
MangabakaId = ids.MangabakaId,
HardcoverId = ids.HardcoverId,
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed
SeriesName = series.Name // Required field, not used since provider Ids are passed
}, true, ct);
if (metadata.Series == null)
{
_logger.LogError("Unable to Match {SeriesName} with Kavita+ Series with Id: {AniListId}/{MalId}/{CbrId}",
series.Name, aniListId, malId, cbrId);
_logger.LogError("Unable to Match {SeriesName} with Kavita+ Series with Ids: {AniListId}/{MalId}/{CbrId}/{MangabakaId}/{HardcoverId}",
series.Name, ids.AniListId, ids.MalId, ids.CbrId, ids.MangabakaId, ids.HardcoverId);
return;
}
@@ -374,6 +394,9 @@ public class ExternalMetadataService : IExternalMetadataService
await _unitOfWork.CommitAsync(ct);
// Send a series Update to ensure pages get the new information
await _eventHub.SendMessageAsync(MessageFactory.SeriesUpdated, MessageFactory.SeriesUpdatedEvent(series.Id), ct: ct);
await _auditService.LogMatchAsync(KavitaPlusEventType.SeriesDontMatchSet, seriesId,
new AuditLogMatchDontMatchParamsDto { SeriesName = series.Name, DontMatch = dontMatch }, ct: ct);
}
@@ -905,7 +928,7 @@ public class ExternalMetadataService : IExternalMetadataService
.Select(w => new PersonDto()
{
Name = w.Name.Trim(),
AniListId = WeblinkParser.GetAniListCharacterId(w.Url),
AniListId = ExternalIdParser.GetAniListCharacterId(w.Url),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
})
.Concat(series.Metadata.People
@@ -947,7 +970,7 @@ public class ExternalMetadataService : IExternalMetadataService
foreach (var character in externalCharacters)
{
var aniListId = WeblinkParser.GetAniListCharacterId(character.Url);
var aniListId = ExternalIdParser.GetAniListCharacterId(character.Url);
if (aniListId <= 0) continue;
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId);
if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
@@ -986,7 +1009,7 @@ public class ExternalMetadataService : IExternalMetadataService
.Select(w => new PersonDto()
{
Name = w.Name.Trim(),
AniListId = WeblinkParser.GetAniListStaffId(w.Url),
AniListId = ExternalIdParser.GetAniListStaffId(w.Url),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
})
.Concat(series.Metadata.People
@@ -1043,7 +1066,7 @@ public class ExternalMetadataService : IExternalMetadataService
.Select(w => new PersonDto()
{
Name = w.Name.Trim(),
AniListId = WeblinkParser.GetAniListStaffId(w.Url),
AniListId = ExternalIdParser.GetAniListStaffId(w.Url),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
})
.Concat(series.Metadata.People
@@ -1734,7 +1757,7 @@ public class ExternalMetadataService : IExternalMetadataService
{
foreach (var staff in people)
{
var aniListId = WeblinkParser.GetAniListStaffId(staff.Url);
var aniListId = ExternalIdParser.GetAniListStaffId(staff.Url);
if (aniListId <= 0) continue;
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId);
if (person == null || string.IsNullOrEmpty(staff.ImageUrl) ||
@@ -2061,11 +2084,11 @@ public class ExternalMetadataService : IExternalMetadataService
{
if (payload.AniListId <= 0)
{
payload.AniListId = WeblinkParser.GetAniListId(series.Metadata.WebLinks);
payload.AniListId = ExternalIdParser.GetAniListId(series.Metadata.WebLinks);
}
if (payload.MalId <= 0)
{
payload.MalId = WeblinkParser.GetMalId(series.Metadata.WebLinks);
payload.MalId = ExternalIdParser.GetMalId(series.Metadata.WebLinks);
}
payload.SeriesName = series.Name;
payload.LocalizedSeriesName = series.LocalizedName;
+4 -4
View File
@@ -158,12 +158,12 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
var notes = info.ComicInfo?.Notes;
var weblinks = info.ComicInfo?.Web;
info.AniListId = WeblinkParser.GetAniListId(weblinks);
info.MalId = WeblinkParser.GetMalId(weblinks);
info.MangaBakaId = WeblinkParser.GetMangaBakaId(weblinks);
info.AniListId = ExternalIdParser.GetAniListId(weblinks);
info.MalId = ExternalIdParser.GetMalId(weblinks);
info.MangaBakaId = ExternalIdParser.GetMangaBakaId(weblinks);
var comicvineId = Parser.ParseComicVineIdFromComicInfoNote(notes);
var parsedCvWeblink = WeblinkParser.GetComicVineId(weblinks);
var parsedCvWeblink = ExternalIdParser.GetComicVineId(weblinks);
info.ComicVineId = comicvineId;
// If we have a seriesId, set it. Otherwise, we set the issue id
+4 -4
View File
@@ -360,10 +360,10 @@ public class ProcessSeries(
{
// TODO: Come back and clean this up, we call this code in DefaultParser AND ProcessSeries
series.Metadata.WebLinks = firstChapter.WebLinks;
series.AniListId = WeblinkParser.GetAniListId(series.Metadata.WebLinks) ?? 0;
series.MalId = WeblinkParser.GetMalId(series.Metadata.WebLinks) ?? 0;
series.ComicVineId = WeblinkParser.GetComicVineId(series.Metadata.WebLinks).Item1;
series.MangaBakaId = WeblinkParser.GetMangaBakaId(series.Metadata.WebLinks);
series.AniListId = ExternalIdParser.GetAniListId(series.Metadata.WebLinks) ?? 0;
series.MalId = ExternalIdParser.GetMalId(series.Metadata.WebLinks) ?? 0;
series.ComicVineId = ExternalIdParser.GetComicVineId(series.Metadata.WebLinks).Item1;
series.MangaBakaId = ExternalIdParser.GetMangaBakaId(series.Metadata.WebLinks);
}
if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections)
@@ -29,6 +29,8 @@ export interface ExternalSeriesDetail {
name: string;
aniListId?: number | null;
malId?: number | null;
mangaBakaId?: number | null;
hardcoverId?: number | null;
cbrId?: number | null;
synonyms: Array<string>;
plusMediaFormat: PlusMediaFormat;
@@ -43,6 +45,9 @@ export interface ExternalSeriesDetail {
*/
volumes?: number;
chapters?: number;
startDate?: string | null;
endDate?: string | null;
averageScore?: number | null;
staff: Array<SeriesStaff>;
tags: Array<MetadataTagDto>;
provider: ScrobbleProvider;
+1 -1
View File
@@ -233,7 +233,7 @@ export class ActionService {
ref.setInput('series', series);
return from(ref.closed).pipe(
filter((saved: boolean) => saved),
map(() => this.fromAction(action, series, 'none'))
map(() => this.fromAction(action, series, 'reload'))
);
}
+8 -1
View File
@@ -250,7 +250,14 @@ export class SeriesService {
}
updateMatch(seriesId: number, series: ExternalSeriesDetail) {
return this.httpClient.post<string>(this.baseUrl + `series/update-match?seriesId=${seriesId}&aniListId=${series.aniListId || 0}&malId=${series.malId || 0}&cbrId=${series.cbrId || 0}`, {}, TextResonse);
const ids = {
aniListId: series.aniListId ?? null,
malId: series.malId ?? null,
cbrId: series.cbrId ?? null,
mangabakaId: series.mangaBakaId ?? null,
hardcoverId: series.hardcoverId ?? null,
};
return this.httpClient.post<string>(this.baseUrl + `series/update-match?seriesId=${seriesId}`, ids, TextResonse);
}
updateDontMatch(seriesId: number, dontMatch: boolean) {
@@ -6,7 +6,10 @@
</div>
</div>
} @else if (entries().length === 0) {
<div class="text-center py-5 small text-muted">{{t('no-events')}}</div>
<app-empty-state [titleKey]="'empty-title'" [descriptionKey]="'empty-description'"
[i18nPrefix]="'kavitaplus-timeline'">
<i icon class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</app-empty-state>
} @else {
@for (group of groupedEntries(); track group.key; let isFirst = $first) {
<div class="timeline-group" [class.mt-4]="!isFirst">
@@ -19,6 +19,7 @@ import {AuditLogErrorPipe} from "../../_pipes/audit-log-error.pipe";
import {
KavitaPlusAuditEventTypeIconComponent
} from "../../shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component";
import {EmptyStateComponent} from "../../shared/_components/empty-state/empty-state.component";
interface DayGroup {
key: string;
@@ -69,6 +70,7 @@ function groupByDay(entries: KavitaPlusAuditEntry[]): DayGroup[] {
AuditLogErrorPipe,
KavitaPlusAuditEventTypeIconComponent,
NgTemplateOutlet,
EmptyStateComponent,
],
})
export class KavitaplusTimelineComponent {
@@ -1,68 +1,141 @@
<ng-container *transloco="let t; prefix:'match-series-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">
{{t('title', {seriesName: series().name})}}
</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<ng-container *transloco="let t; prefix: 'match-series-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">
<span class="match-title-prefix">{{ t('title-prefix') }}</span> {{ series().name }}
</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<form [formGroup]="formGroup">
<p>{{t('description')}}</p>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<app-setting-item [title]="t('query-label')" [subtitle]="t('query-tooltip')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<div class="input-group">
@if (formGroup.get('query'); as formControl) {
<input id="query" class="form-control" formControlName="query" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (formControl.errors) {
@if (formControl.errors.required) {
<div class="invalid-feedback">{{t('required-field')}}</div>
}
}
}
</div>
</ng-template>
</app-setting-item>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<app-setting-switch [title]="t('dont-match-label')" [subtitle]="t('dont-match-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch" switch>
</div>
</ng-template>
</app-setting-switch>
<div class="modal-body scrollable-modal d-flex flex-column gap-0">
<!-- Sub-header: explainer left + In Kavita chip right -->
<div class="match-subheader d-flex align-items-center gap-3 pt-1">
<div class="match-description flex-fill">
<p class="mb-1">{{ t('description') }}</p>
<p class="mb-0 text-muted small">{{ t('dont-match-hint') }}</p>
</div>
<div class="in-kavita-chip d-flex align-items-center flex-shrink-0 gap-2">
<app-image [imageUrl]="coverImageUrl()" width="32px" height="46px" />
<div class="d-flex flex-column gap-0">
<div class="in-kavita-label text-uppercase">{{ t('in-kavita') }}</div>
<div class="in-kavita-name fw-bold">{{ series().name }}</div>
</div>
</div>
</div>
<!-- Query bar -->
<div class="py-3" [formGroup]="formGroup">
<div class="d-flex align-items-stretch gap-2">
<div class="d-flex align-items-center position-relative flex-fill">
<i class="fa-solid fa-magnifying-glass query-icon" aria-hidden="true"></i>
<label for="search-query" class="visually-hidden">{{t('search')}}</label>
<input class="form-control query-input"
id="search-query"
formControlName="query"
(keydown.enter)="search()" />
@if (formGroup.get('query')?.value) {
<button type="button" class="query-clear btn-close btn-close-white btn-sm"
[attr.aria-label]="t('clear')"
(click)="clearQuery()"></button>
}
</div>
<button type="button" class="btn btn-primary"
(click)="search()"
[disabled]="isLoading() || formGroup.get('dontMatch')?.value">
@if (isLoading()) {
<span class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">{{ t('searching-alt') }}</span>
</span>
} @else {
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
{{ t('search') }}
}
</button>
</div>
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mt-2">
<div class="provider-hints d-flex align-items-center flex-wrap gap-1">
<span class="text-muted">{{ t('try') }}</span>
<code class="provider-hint">anilist:159441</code>
<code class="provider-hint">mal:20593</code>
<code class="provider-hint">mangabaka:1331</code>
<code class="provider-hint">hardcover:flatland</code>
</div>
<div class="form-check form-switch mb-0">
<input type="checkbox" class="form-check-input" formControlName="dontMatch"
id="dont-match" role="switch" />
<label class="form-check-label" for="dont-match"
[ngbTooltip]="t('dont-match-tooltip')">
{{ t('dont-match-label') }}
</label>
</div>
</div>
</div>
<!-- Results count bar -->
@if (bodyState() === 'results') {
<div class="match-count-bar">
{{ t('match-count', { count: matches().length }) }}
</div>
}
<!-- Body -->
<div class="match-body">
@switch (bodyState()) {
@case ('loading') {
<div class="d-flex justify-content-center align-items-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ t('loading-alt') }}</span>
</div>
</div>
</div>
<div class="row g-0">
<button class="btn btn-primary" [disabled]="formGroup.get('dontMatch')?.value" (click)="search()">{{t('search')}}</button>
</div>
</form>
<div class="setting-section-break"></div>
@if (!formGroup.get('dontMatch')?.value) {
<app-loading [loading]="isLoading()" />
@for(item of matches(); track item.series.name) {
<app-match-series-result-item [item]="item" [isDarkMode]="(themeService.isDarkMode$ | async)!" (selected)="selectMatch($event)" />
} @empty {
@if (!isLoading()) {
<p>{{t('no-results')}}</p>
}
@case ('empty') {
<app-empty-state [titleKey]="'empty-title'" [descriptionKey]="'empty-description'"
[i18nPrefix]="'match-series-modal'">
<i icon class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</app-empty-state>
}
@case ('dont-match') {
<app-empty-state [titleKey]="'dont-match-active-title'"
[descriptionKey]="'dont-match-active-description'"
[i18nPrefix]="'match-series-modal'">
<i icon class="fa-solid fa-ban" aria-hidden="true"></i>
</app-empty-state>
}
@case ('no-results') {
<app-empty-state [isError]="true"
[titleKey]="'no-results-title'"
[descriptionKey]="'no-results-description'"
[descriptionParams]="{ query: lastQuery() }"
[i18nPrefix]="'match-series-modal'">
<i icon class="fa-solid fa-magnifying-glass-minus" aria-hidden="true"></i>
</app-empty-state>
}
@case ('results') {
@for (item of matches(); track item.series.name + '_' + item.series.provider) {
<app-match-series-result-item
[item]="item"
[isSelected]="selectedItem()?.series?.name === item.series.name && selectedItem()?.series?.provider === item.series.provider"
[showSynonyms]="true"
[query]="lastQuery()"
(selected)="selectItem($event)" />
}
}
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="button" class="btn btn-primary" (click)="save()">{{t('save')}}</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{ t('close') }}</button>
@if (isDontMatch()) {
<button type="button" class="btn btn-primary" [disabled]="!canSaveDontMatch()" (click)="saveDontMatch()">
{{ t('save') }}
</button>
} @else {
<button type="button" class="btn btn-primary" [disabled]="!selectedItem()" (click)="applyMatch()">
{{ t('apply-match') }}
</button>
}
</div>
</ng-container>
@@ -1,3 +1,87 @@
.setting-section-break {
margin: 0 !important;
}
.match-title-prefix {
opacity: 0.55;
font-weight: 500;
}
.match-subheader {
padding: 0.875rem 0 0.75rem;
border-bottom: 1px solid var(--hr-color);
}
.match-description {
color: var(--text-muted-color);
line-height: 1.5;
}
.in-kavita-chip {
padding: 0.375rem 0.625rem 0.375rem 0.375rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--hr-color);
border-radius: 0.375rem;
}
.in-kavita-label {
font-size: 0.65625rem;
color: var(--text-muted-color);
letter-spacing: 0.06em;
line-height: 1.2;
}
.in-kavita-name {
font-size: 0.875rem;
color: var(--body-text-color);
line-height: 1.2;
}
.query-icon {
position: absolute;
left: 0.75rem;
color: var(--text-muted-color);
font-size: 0.8125rem;
pointer-events: none;
}
.query-input {
padding-left: 2.25rem;
padding-right: 2.25rem;
}
.query-clear {
position: absolute;
right: 0.5rem;
bottom: 0.6rem;
opacity: 0.5;
&:hover {
opacity: 1;
}
}
.provider-hints {
font-size: 0.75rem;
color: var(--text-muted-color);
}
.provider-hint {
padding: 0.125rem 0.4375rem;
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-muted-color);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.6875rem;
}
.match-count-bar {
font-size: 0.71875rem;
color: var(--text-muted-color);
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 600;
padding: 0.5rem 0 0.25rem;
}
.match-body {
flex: 1;
padding-bottom: 0.5rem;
}
@@ -1,106 +1,142 @@
import {ChangeDetectionStrategy, Component, inject, input, OnInit, signal} from '@angular/core';
import {ChangeDetectionStrategy, Component, computed, effect, inject, input, OnInit, signal} from '@angular/core';
import {Series} from "../../_models/series";
import {SeriesService} from "../../_services/series.service";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {NgbActiveModal, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {MatchSeriesResultItemComponent} from "../match-series-result-item/match-series-result-item.component";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match";
import {ToastrService} from "ngx-toastr";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {ThemeService} from 'src/app/_services/theme.service';
import {AsyncPipe} from '@angular/common';
import {catchError, of, tap} from "rxjs";
import {catchError, filter, of, startWith, tap} from "rxjs";
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
import {EmptyStateComponent} from "../../shared/_components/empty-state/empty-state.component";
import {
MatchSeriesResultItemComponent
} from "../../shared/_components/match-series-result-item/match-series-result-item.component";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
@Component({
selector: 'app-match-series-modal',
imports: [
AsyncPipe,
TranslocoDirective,
MatchSeriesResultItemComponent,
LoadingComponent,
ReactiveFormsModule,
SettingItemComponent,
SettingSwitchComponent
],
templateUrl: './match-series-modal.component.html',
styleUrl: './match-series-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-match-series-modal',
imports: [
ReactiveFormsModule,
TranslocoDirective,
NgbTooltip,
EmptyStateComponent,
MatchSeriesResultItemComponent,
ImageComponent,
],
templateUrl: './match-series-modal.component.html',
styleUrl: './match-series-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatchSeriesModalComponent implements OnInit {
private readonly seriesService = inject(SeriesService);
private readonly modalService = inject(NgbActiveModal);
private readonly toastr = inject(ToastrService);
protected readonly themeService = inject(ThemeService);
private readonly imageService = inject(ImageService);
series = input.required<Series>();
formGroup = new FormGroup({});
formGroup = new FormGroup({
query: new FormControl('', []),
dontMatch: new FormControl(false, []),
});
protected readonly isDontMatch = toSignal(
this.formGroup.controls.dontMatch.valueChanges.pipe(
startWith(this.formGroup.controls.dontMatch.value)
),
{ initialValue: false }
);
private readonly _queryDisableEffect = effect(() => {
if (this.isDontMatch()) {
this.formGroup.controls.query.disable();
} else {
this.formGroup.controls.query.enable();
}
});
private readonly _autoSearchOnEnable = this.formGroup.controls.dontMatch.valueChanges.pipe(
filter(v => v === false),
takeUntilDestroyed()
).subscribe(() => this.search());
protected readonly canSaveDontMatch = computed(() =>
this.isDontMatch() === true && !this.series().dontMatch
);
matches = signal<ExternalSeriesMatch[]>([]);
isLoading = signal<boolean>(true);
isLoading = signal<boolean>(false);
hasSearched = signal<boolean>(false);
selectedItem = signal<ExternalSeriesMatch | null>(null);
lastQuery = signal<string>('');
protected bodyState = computed<'empty' | 'dont-match' | 'loading' | 'results' | 'no-results'>(() => {
if (this.isDontMatch()) return 'dont-match';
if (this.isLoading()) return 'loading';
if (!this.hasSearched()) return 'empty';
return this.matches().length > 0 ? 'results' : 'no-results';
});
protected coverImageUrl = computed(() => this.imageService.getSeriesCoverImage(this.series().id));
ngOnInit() {
this.formGroup.addControl('query', new FormControl('', []));
this.formGroup.addControl('dontMatch', new FormControl(this.series().dontMatch || false, []));
this.formGroup.patchValue({ dontMatch: this.series().dontMatch || false });
this.search();
}
search() {
if (this.isDontMatch()) return;
this.isLoading.set(true);
this.lastQuery.set(this.formGroup.value.query ?? '');
const model: any = this.formGroup.value;
model.seriesId = this.series().id;
if (model.dontMatch) {
this.isLoading.set(false);
return;
}
const model: any = { ...this.formGroup.value, seriesId: this.series().id };
this.seriesService.matchSeries(model).pipe(
tap(results => {
this.isLoading.set(false);
this.hasSearched.set(true);
this.matches.set(results);
}),
catchError(() => {
this.isLoading.set(false);
this.hasSearched.set(true);
return of([]);
})
).subscribe();
}
clearQuery() {
this.formGroup.get('query')?.setValue('');
}
selectItem(item: ExternalSeriesMatch) {
this.selectedItem.set(item);
}
close() {
this.modalService.dismiss();
}
save() {
applyMatch() {
const item = this.selectedItem();
if (!item) return;
const model: any = this.formGroup.value;
model.seriesId = this.series().id;
const dontMatchChanged = this.series().dontMatch !== model.dontMatch;
// We need to update the dontMatch status
if (dontMatchChanged) {
this.seriesService.updateDontMatch(this.series().id, model.dontMatch).subscribe(_ => {
this.modalService.close(true);
});
} else {
this.toastr.success(translate('toasts.match-success'));
this.modalService.close(true);
}
}
selectMatch(item: ExternalSeriesMatch) {
const data = item.series;
data.tags = data.tags || [];
data.genres = data.genres || [];
this.seriesService.updateMatch(this.series().id, item.series).subscribe(_ => {
this.save();
this.seriesService.updateMatch(this.series().id, data).subscribe(() => {
this.toastr.success(translate('toasts.match-success'));
this.modalService.close(true);
});
}
saveDontMatch() {
this.seriesService.updateDontMatch(this.series().id, true).subscribe(() => {
this.modalService.close(true);
});
}
}
@@ -1,52 +0,0 @@
<ng-container *transloco="let t; prefix:'match-series-result-item'">
<div class="match-item-container p-3 mt-3 {{isDarkMode() ? 'dark' : 'light'}}">
<div class="d-flex clickable match-item" (click)="selectItem()">
<div class="me-1">
@let coverUrl = item().series.coverUrl;
@if (coverUrl) {
<app-image class="me-3 search-result" width="100px" [imageUrl]="coverUrl" />
}
</div>
<div class="ms-1">
<div><span class="title">{{item().series.name}}</span> <span class="me-1 float-end">({{item().matchRating | translocoPercent}})</span></div>
<div class="text-muted">
@for(synm of item().series.synonyms; track synm; let last = $last) {
{{synm}}
@if (!last) {
<span>, </span>
}
}
</div>
@if (item().series.summary) {
<div>
<app-read-more [text]="item().series.summary ?? ''" [showToggle]="false" />
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item().series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
</div>
}
</div>
</div>
@if (isSelected()) {
<div class="d-flex p-1 justify-content-center">
<app-loading [absolute]="false" [loading]="true" />
<span class="ms-2">{{t('updating-metadata-status')}}</span>
</div>
} @else {
<div class="d-flex pt-3 justify-content-between">
@if ((item().series.volumes || 0) > 0 || (item().series.chapters || 0) > 0) {
@if (item().series.plusMediaFormat === PlusMediaFormat.Comic) {
<span class="me-1">{{t('issue-count', {num: item().series.chapters})}}</span>
} @else {
<span class="me-1">{{t('volume-count', {num: item().series.volumes})}}</span>
<span class="me-1">{{t('chapter-count', {num: item().series.chapters})}}</span>
}
} @else {
<span class="me-1">{{t('releasing')}}</span>
}
<span class="me-1">{{item().series.plusMediaFormat | plusMediaFormat}}</span>
</div>
}
</div>
</ng-container>
@@ -1,33 +0,0 @@
.search-result {
img {
max-width: 6.25rem;
min-width: 6.25rem;
}
}
.title {
font-size: 1.2rem;
font-weight: bold;
margin: 0;
padding: 0;
}
.match-item-container {
&.dark {
background-color: var(--elevation-layer6-dark);
}
&.light {
background-color: var(--elevation-layer6);
}
border-radius: 0.9375rem;
&:hover {
&.dark {
background-color: var(--elevation-layer11-dark);
}
&.light {
background-color: var(--elevation-layer11);
}
}
}
@@ -1,42 +0,0 @@
import {ChangeDetectionStrategy, Component, input, output, signal} from '@angular/core';
import {ImageComponent} from "../../shared/image/image.component";
import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match";
import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {PlusMediaFormat} from "../../_models/series-detail/external-series-detail";
@Component({
selector: 'app-match-series-result-item',
imports: [
ImageComponent,
TranslocoPercentPipe,
ReadMoreComponent,
TranslocoDirective,
PlusMediaFormatPipe,
LoadingComponent
],
templateUrl: './match-series-result-item.component.html',
styleUrl: './match-series-result-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MatchSeriesResultItemComponent {
item = input.required<ExternalSeriesMatch>();
isDarkMode = input(true);
selected = output<ExternalSeriesMatch>();
isSelected = signal<boolean>(false);
selectItem() {
if (this.isSelected()) return;
this.isSelected.set(true);
this.selected.emit(this.item());
}
protected readonly PlusMediaFormat = PlusMediaFormat;
}
@@ -0,0 +1,5 @@
<div class="confidence-chip d-flex flex-column align-items-center justify-content-center gap-0" [style.--chip-color]="colorVar()"
*transloco="let t; prefix: 'confidence-chip'">
<span class="chip-pct fw-bold lh-1">{{ pct() }}<span class="chip-unit">%</span></span>
<span class="chip-label text-uppercase fw-semibold">{{ t(labelKey()) }}</span>
</div>
@@ -0,0 +1,25 @@
.confidence-chip {
padding: 0.375rem 0.625rem;
min-width: 4rem;
border-radius: 0.375rem;
background: color-mix(in srgb, var(--chip-color) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
}
.chip-pct {
font-size: 1.125rem;
color: var(--chip-color);
}
.chip-unit {
font-size: 0.6875rem;
opacity: 0.6;
font-weight: 500;
}
.chip-label {
font-size: 0.59375rem;
color: var(--chip-color);
opacity: 0.85;
letter-spacing: 0.06em;
}
@@ -0,0 +1,29 @@
import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-confidence-chip',
imports: [TranslocoDirective],
templateUrl: './confidence-chip.component.html',
styleUrl: './confidence-chip.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConfidenceChipComponent {
pct = input.required<number>();
protected colorVar = computed(() => {
const p = this.pct();
if (p >= 90) return 'var(--match-confidence-chip-strong-color)';
if (p >= 70) return 'var(--match-confidence-chip-likely-color)';
if (p >= 55) return 'var(--match-confidence-chip-weak-color)';
return 'var(--match-confidence-chip-doubt-color)';
});
protected labelKey = computed(() => {
const p = this.pct();
if (p >= 90) return 'strong';
if (p >= 70) return 'likely';
if (p >= 55) return 'weak';
return 'doubt';
});
}
@@ -0,0 +1,9 @@
<div class="empty-state-container d-flex flex-column align-items-center text-center py-5 px-4 gap-1" *transloco="let t; prefix: i18nPrefix()">
<div class="empty-state-icon d-flex align-items-center justify-content-center rounded-circle mb-2" [class.is-error]="isError()">
<ng-content select="[icon]" />
</div>
<div class="empty-state-title fw-medium">{{ t(titleKey()) }}</div>
@if (descriptionKey()) {
<div class="empty-state-desc">{{ t(descriptionKey(), descriptionParams()) }}</div>
}
</div>
@@ -0,0 +1,30 @@
.empty-state-container {
font-size: 0.8125rem;
color: var(--text-muted-color);
line-height: 1.6;
}
.empty-state-icon {
width: 3.5rem;
height: 3.5rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--hr-color);
font-size: 1.125rem;
opacity: 0.6;
&.is-error {
background: rgba(189, 54, 47, 0.10);
border-color: rgba(189, 54, 47, 0.35);
color: var(--match-confidence-chip-doubt-color);
opacity: 1;
}
}
.empty-state-title {
font-size: 0.875rem;
color: var(--body-text-color);
}
.empty-state-desc {
color: var(--text-muted-color);
}
@@ -0,0 +1,17 @@
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-empty-state',
imports: [TranslocoDirective],
templateUrl: './empty-state.component.html',
styleUrl: './empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmptyStateComponent {
isError = input<boolean>(false);
titleKey = input.required<string>();
descriptionKey = input<string>('');
i18nPrefix = input<string>('');
descriptionParams = input<Record<string, unknown>>({});
}
@@ -9,7 +9,7 @@ const URLS = {
aniListId: 'https://anilist.co/manga/{id}/',
malId: 'https://myanimelist.net/manga/{id}/',
mangaBakaId: 'https://mangabaka.org/{id}',
hardcoverId: null,
hardcoverId: 'https://hardcover.app/id/series/{id}',
comicVineId: null,
metronId: null,
cbrId: null,
@@ -0,0 +1,91 @@
<div class="match-result-row position-relative d-flex align-items-start gap-3" [class.selected]="isSelected()"
*transloco="let t; prefix: 'match-series-result-item'"
(click)="selectItem()">
@if (isSelected()) {
<div class="selected-check position-absolute d-flex align-items-center justify-content-center rounded-circle" aria-hidden="true">
<i class="fa-solid fa-check"></i>
</div>
<span class="visually-hidden">{{ t('selected-alt') }}</span>
}
<app-image [imageUrl]="item().series.coverUrl ?? ''" width="72px" height="102px" />
<div class="match-result-content d-flex flex-column flex-fill gap-1">
<div class="match-result-title-row d-flex align-items-center flex-wrap gap-2">
<h3 class="match-result-name fw-bold">{{ item().series.name }}</h3>
<app-media-format-pill [format]="item().series.plusMediaFormat" />
<app-scrobble-provider-tag-badge [provider]="item().series.provider" />
</div>
<div class="match-result-meta d-flex align-items-center flex-wrap">
<app-match-status-dot [endDate]="item().series.endDate" />
@if (startYear()) {
<span class="dot-separator mx-1" aria-hidden="true"></span>
<span>{{ startYear() }}{{ endYear() && endYear() !== startYear() ? '' + endYear() : '' }}</span>
}
@if (firstAuthor()) {
<span class="dot-separator mx-1" aria-hidden="true"></span>
<span>{{ firstAuthor() }}</span>
}
@if ((item().series.volumes ?? 0) > 0 || (item().series.chapters ?? 0) > 0) {
<span class="dot-separator mx-1" aria-hidden="true"></span>
<span>
@if ((item().series.volumes ?? 0) > 0) {
{{ t('volume-count', {num: item().series.volumes}) }}
}
@if ((item().series.chapters ?? 0) > 0) {
{{ t('chapter-count', {num: item().series.chapters}) }}
}
</span>
}
</div>
<div class="match-result-providers d-flex align-items-center flex-wrap gap-1">
@if (item().series.mangaBakaId) {
<app-scrobble-provider-tag-badge [provider]="ScrobbleProvider.MangaBaka" [id]="item().series.mangaBakaId" />
}
@if (item().series.aniListId) {
<app-scrobble-provider-tag-badge [provider]="ScrobbleProvider.AniList" [id]="item().series.aniListId" />
}
@if (item().series.malId) {
<app-scrobble-provider-tag-badge [provider]="ScrobbleProvider.Mal" [id]="item().series.malId" />
}
@if (item().series.hardcoverId) {
<app-scrobble-provider-tag-badge [provider]="ScrobbleProvider.Hardcover" [id]="item().series.hardcoverId" />
}
</div>
@if (showSynonyms() && matchedSynonyms().length > 0) {
<div class="match-result-synonyms d-flex align-items-center flex-wrap gap-1">
<i class="fa-solid fa-language" aria-hidden="true"></i>
<span>{{ t('matched-alt') }}</span>
<span class="synonym-first">"{{ matchedSynonyms()[0] }}"</span>
@if (matchedSynonyms().length > 1) {
<span class="synonym-more d-inline-flex align-items-center rounded-pill fw-semibold"
[ngbTooltip]="synonymTooltip"
(click)="$event.stopPropagation()">
+{{ matchedSynonyms().length - 1 }} {{ t('more') }}
</span>
<ng-template #synonymTooltip>
<div>{{ matchedSynonyms().length }} {{ t('matched-alt-count') }}</div>
<ul class="list-unstyled mb-0 mt-1">
@for (s of matchedSynonyms(); track s) {
<li>"{{ s }}"</li>
}
</ul>
</ng-template>
}
</div>
}
<div class="match-result-synopsis">{{ item().series.summary }}</div>
</div>
<div class="match-result-rail d-flex flex-column flex-shrink-0 align-items-end">
<app-confidence-chip [pct]="pct()" />
</div>
</div>
@@ -0,0 +1,80 @@
.match-result-row {
margin: 0.375rem 0;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--hr-color);
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
&.selected {
background: rgba(74, 198, 148, 0.10);
border-color: rgba(74, 198, 148, 0.55);
}
}
.selected-check {
top: 0.625rem;
right: 0.625rem;
width: 1.25rem;
height: 1.25rem;
background: var(--primary-color);
font-size: 0.625rem;
color: #fff;
}
.match-result-content {
min-width: 0;
}
.match-result-name {
margin: 0;
font-size: 1.0625rem;
line-height: 1.2;
color: var(--body-text-color);
}
.match-result-meta {
font-size: 0.71875rem;
color: var(--text-muted-color);
}
.match-result-synonyms {
font-size: 0.71875rem;
color: var(--text-muted-color);
}
.synonym-first {
color: var(--text-muted-color);
}
.synonym-more {
padding: 0.0625rem 0.4375rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--text-muted-color);
font-size: 0.65625rem;
line-height: 1.4;
cursor: help;
user-select: none;
}
.match-result-synopsis {
font-size: 0.78125rem;
line-height: 1.55;
color: var(--body-text-color);
opacity: 0.78;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.match-result-rail {
padding-top: 0.25rem;
min-width: 4.75rem;
}
@@ -0,0 +1,59 @@
import {ChangeDetectionStrategy, Component, computed, input, output} from '@angular/core';
import {ExternalSeriesMatch} from "../../../_models/series-detail/external-series-match";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {TranslocoDirective} from "@jsverse/transloco";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ImageComponent} from "../../image/image.component";
import {MediaFormatPillComponent} from "../media-format-pill/media-format-pill.component";
import {ScrobbleProviderTagBadgeComponent} from "../scrobble-provider-tag-badge/scrobble-provider-tag-badge.component";
import {MatchStatusDotComponent} from "../match-status-dot/match-status-dot.component";
import {ConfidenceChipComponent} from "../confidence-chip/confidence-chip.component";
@Component({
selector: 'app-match-series-result-item',
imports: [
TranslocoDirective,
NgbTooltip,
ImageComponent,
MediaFormatPillComponent,
ScrobbleProviderTagBadgeComponent,
MatchStatusDotComponent,
ConfidenceChipComponent,
],
templateUrl: './match-series-result-item.component.html',
styleUrl: './match-series-result-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatchSeriesResultItemComponent {
item = input.required<ExternalSeriesMatch>();
isSelected = input<boolean>(false);
showSynonyms = input<boolean>(true);
query = input<string>('');
selected = output<ExternalSeriesMatch>();
protected readonly ScrobbleProvider = ScrobbleProvider;
protected matchedSynonyms = computed(() => {
const q = this.query().trim().toLowerCase();
if (!q || q.length < 2 || /^(anilist|mal|mangabaka|cbr|hardcover):/.test(q)) return [];
return (this.item().series.synonyms ?? []).filter(s => s.toLowerCase().includes(q));
});
protected startYear = computed(() =>
this.item().series.startDate ? new Date(this.item().series.startDate!).getFullYear() : null
);
protected endYear = computed(() =>
this.item().series.endDate ? new Date(this.item().series.endDate!).getFullYear() : null
);
protected firstAuthor = computed(() =>
this.item().series.staff?.find(s => s.role === 'Author')?.name ?? null
);
protected pct = computed(() => Math.round(this.item().matchRating * 100));
selectItem() {
this.selected.emit(this.item());
}
}
@@ -0,0 +1,4 @@
<span class="status-dot-wrapper d-inline-flex align-items-center gap-1" *transloco="let t; prefix: 'match-status-dot'">
<span class="status-dot" [class.ongoing]="isOngoing()" aria-hidden="true"></span>
{{ isOngoing() ? t('ongoing') : t('completed') }}
</span>
@@ -0,0 +1,17 @@
.status-dot-wrapper {
font-size: 0.71875rem;
color: var(--text-muted-color);
}
.status-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--primary-color);
&.ongoing {
background: #73c0de;
box-shadow: 0 0 0.375rem rgba(115, 192, 222, 0.5);
}
}
@@ -0,0 +1,15 @@
import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-match-status-dot',
imports: [TranslocoDirective],
templateUrl: './match-status-dot.component.html',
styleUrl: './match-status-dot.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatchStatusDotComponent {
endDate = input<string | null | undefined>(undefined);
protected isOngoing = computed(() => !this.endDate());
}
@@ -0,0 +1,4 @@
<span class="format-pill d-inline-flex align-items-center rounded-pill gap-1" [style.--pill-color]="'var(' + meta().cssVar + ')'">
<i class="fa-solid {{ meta().icon }}" aria-hidden="true"></i>
{{ format() | plusMediaFormat }}
</span>
@@ -0,0 +1,10 @@
.format-pill {
padding: 0.125rem 0.5rem;
background: color-mix(in srgb, var(--pill-color) 12%, transparent);
color: var(--pill-color);
border: 1px solid color-mix(in srgb, var(--pill-color) 27%, transparent);
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
@@ -0,0 +1,28 @@
import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
import {PlusMediaFormat} from "../../../_models/series-detail/external-series-detail";
import {PlusMediaFormatPipe} from "../../../_pipes/plus-media-format.pipe";
interface FormatMeta {
cssVar: string;
icon: string;
}
const FORMAT_META: Record<PlusMediaFormat, FormatMeta> = {
[PlusMediaFormat.Manga]: { cssVar: '--media-format-pill-manga-color', icon: 'fa-book-open' },
[PlusMediaFormat.LightNovel]: { cssVar: '--media-format-pill-light-novel-color', icon: 'fa-book' },
[PlusMediaFormat.Comic]: { cssVar: '--media-format-pill-comic-color', icon: 'fa-border-all' },
[PlusMediaFormat.Book]: { cssVar: '--media-format-pill-book-color', icon: 'fa-bookmark' },
};
@Component({
selector: 'app-media-format-pill',
imports: [PlusMediaFormatPipe],
templateUrl: './media-format-pill.component.html',
styleUrl: './media-format-pill.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MediaFormatPillComponent {
format = input.required<PlusMediaFormat>();
protected meta = computed(() => FORMAT_META[this.format()]);
}
@@ -0,0 +1,19 @@
<ng-container *transloco="let t; prefix: 'scrobble-provider-tag-badge'">
@if (showId()) {
<a class="id-pill d-inline-flex align-items-center rounded-pill text-decoration-none gap-1"
[href]="url()"
target="_blank"
rel="noopener noreferrer"
(click)="$event.stopPropagation()"
[style.--badge-color]="brandColor()"
[attr.aria-label]="t('view-on', { provider: provider() | scrobbleProviderName })">
<app-scrobble-provider-image [provider]="provider()" [size]="12" aria-hidden="true" />
<span>{{ id() }}</span>
</a>
} @else {
<span class="source-badge d-inline-flex align-items-center rounded-pill lh-1 gap-1" [style.--badge-color]="brandColor()">
<app-scrobble-provider-image [provider]="provider()" [size]="14" aria-hidden="true" />
<span>{{ provider() | scrobbleProviderName }}</span>
</span>
}
</ng-container>
@@ -0,0 +1,20 @@
.source-badge {
height: 1.375rem;
padding: 0 0.5625rem 0 0.3125rem;
background: color-mix(in srgb, var(--badge-color) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--badge-color) 33%, transparent);
color: var(--badge-color);
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
}
.id-pill {
height: 1.25rem;
padding: 0 0.4375rem 0 0.25rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-muted-color);
font-size: 0.65625rem;
white-space: nowrap;
}
@@ -0,0 +1,38 @@
import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ScrobbleProviderImageComponent} from "../scrobble-provider-image/scrobble-provider-image.component";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
import {TranslocoDirective} from "@jsverse/transloco";
import {getProviderUrl} from "../../utils/provider-url.util";
const PROVIDER_BRAND_COLORS: Partial<Record<ScrobbleProvider, string>> = {
[ScrobbleProvider.MangaBaka]: '#7c5cff',
[ScrobbleProvider.AniList]: '#02a9ff',
[ScrobbleProvider.Mal]: '#2e51a2',
[ScrobbleProvider.Cbr]: '#fc8452',
[ScrobbleProvider.Hardcover]: '#c97aff',
};
@Component({
selector: 'app-scrobble-provider-tag-badge',
imports: [
ScrobbleProviderImageComponent,
ScrobbleProviderNamePipe,
TranslocoDirective,
],
templateUrl: './scrobble-provider-tag-badge.component.html',
styleUrl: './scrobble-provider-tag-badge.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScrobbleProviderTagBadgeComponent {
provider = input.required<ScrobbleProvider>();
id = input<number | null | undefined>(undefined);
protected showId = computed(() => {
const v = this.id();
return v !== null && v !== undefined && v !== 0;
});
protected brandColor = computed(() => PROVIDER_BRAND_COLORS[this.provider()] ?? '#888');
protected url = computed(() => this.showId() ? getProviderUrl(this.provider(), this.id()!) : null);
}
@@ -0,0 +1,12 @@
import {ScrobbleProvider} from "../../_services/scrobbling.service";
export function getProviderUrl(provider: ScrobbleProvider, id: number): string | null {
switch (provider) {
case ScrobbleProvider.AniList: return `https://anilist.co/manga/${id}/`;
case ScrobbleProvider.Mal: return `https://myanimelist.net/manga/${id}/`;
case ScrobbleProvider.MangaBaka: return `https://mangabaka.org/${id}`;
case ScrobbleProvider.Cbr: return `https://comicbookroundup.com/comic-books/reviews/${id}`;
case ScrobbleProvider.Hardcover: return `https://hardcover.app/id/series/${id}`;
default: return null;
}
}
+46 -17
View File
@@ -1327,9 +1327,10 @@
"today": "Today",
"yesterday": "Yesterday",
"events-count": "{{count}} events",
"no-events": "No events to display",
"retry": "Retry",
"load-more-label": "Load more"
"load-more-label": "Load more",
"empty-title": "Nothing found",
"empty-description": "Try a different filter or perform some matches/reading to populate"
},
"audit-status-title-pipe": {
@@ -1424,25 +1425,53 @@
},
"match-series-modal": {
"title": "Match {{seriesName}}",
"description": "Select a match to rewire Kavita+ metadata and regenerate scrobble events. Don't Match can be used to restrict Kavita from matching metadata and scrobbling.",
"title-prefix": "Match",
"in-kavita": "In Kavita",
"description": "Pick a match to rewire Kavita+ metadata and regenerate scrobble events.",
"dont-match-hint": "Use \"Do not match\" to opt this series out entirely.",
"try": "Try",
"search": "Search",
"searching-alt": "Searching…",
"clear": "Clear search",
"dont-match-label": "Do not match",
"dont-match-tooltip": "Stop Kavita from auto-matching or scrobbling this series",
"match-count": "{{ count }} matches",
"apply-match": "Apply match",
"close": "{{common.close}}",
"save": "{{common.save}}",
"no-results": "Unable to find a match. Try adding the url from a supported provider and retry.",
"query-label": "Query",
"query-tooltip": "Enter series name, AniList/MyAnimeList/ComicBookRoundup url. Urls will use a direct lookup.",
"dont-match-label": "Do not Match",
"dont-match-tooltip": "Opt this series from matching and scrobbling",
"search": "Search"
"loading-alt": "Loading results",
"empty-title": "Search for a match",
"empty-description": "Try a series title or paste a direct AniList, MAL, MangaBaka, CBR or Hardcover URL.",
"no-results-title": "No matches found",
"no-results-description": "Nothing came back for \"{{ query }}\". Try a shorter query, romanized title, or a direct ID.",
"dont-match-active-title": "Do not match enabled",
"dont-match-active-description": "This series is opted out of Kavita+ matching and scrobbling.",
"save": "{{common.save}}"
},
"match-series-result-item": {
"volume-count": "{{server-stats.volume-count}}",
"chapter-count": "{{common.chapter-count}}",
"issue-count": "{{common.issue-count}}",
"releasing": "Releasing",
"details": "View page",
"updating-metadata-status": "Updating Metadata"
"matched-alt": "Matched alt:",
"more": "more",
"matched-alt-count": "matched alt titles",
"selected-alt": "Selected",
"volume-count": "{{ num }} vol",
"chapter-count": "{{ num }} ch",
"issue-count": "{{ num }} issues"
},
"scrobble-provider-tag-badge": {
"view-on": "View on {{ provider }}"
},
"confidence-chip": {
"strong": "Strong",
"likely": "Likely",
"weak": "Weak",
"doubt": "Doubt"
},
"match-status-dot": {
"ongoing": "Ongoing",
"completed": "Completed"
},
"metadata-fields": {
+16 -4
View File
@@ -517,9 +517,21 @@
--activity-card-client-device-badge-bg-color: #3b82f6;
/** KavitaPlus Audit Log **/
--audit-log-metadata-color: #4AC694;
--audit-log-scrobble-color: #FAC858;
--audit-log-match-color: #5470C6;
--audit-log-sync-color: #73C0DE;
--audit-log-metadata-color: #4AC694;
--audit-log-scrobble-color: #FAC858;
--audit-log-match-color: #5470C6; // Maybe #a5bbff is a better color (color contrast)
--audit-log-sync-color: #73C0DE;
/** Match confidence chip */
--match-confidence-chip-strong-color: var(--primary-color);
--match-confidence-chip-likely-color: #7ec99e;
--match-confidence-chip-weak-color: var(--warning-color);
--match-confidence-chip-doubt-color: var(--error-color);
/** Media format pill */
--media-format-pill-manga-color: var(--primary-color);
--media-format-pill-light-novel-color: #a5bbff;
--media-format-pill-comic-color: #fc8452;
--media-format-pill-book-color: #fac858;
}