mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-06-05 06:15:25 -04:00
Kavita+ Match UX Refresh (#4727)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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 {
|
||||
|
||||
+132
-59
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
-52
@@ -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>
|
||||
-33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-42
@@ -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>>({});
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+91
@@ -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>
|
||||
+80
@@ -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;
|
||||
}
|
||||
+59
@@ -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()]);
|
||||
}
|
||||
+19
@@ -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>
|
||||
+20
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user