Repository: Kareadita/Kavita (2026-03-20T18:01:27Z)

Issue:
Add Parser Support for Korean "Completion Status" indicators
In Korean scene, users do not know about ComicInfo.xml and usually use words like `(완결)`, `(완)`, `(完)`, or `[완결]` in their filenames to indicate the Series is completed. The words are on the last file in the series. 

The goal of this story is to have Kavita add parsing support for these words. In the case the ComicInfo.xml does not have a Count, Kavita will take the highest number parsed volume/chapter from the file with these words on it and set the Count. This should bring completion status to the Series without a manual tagging mechanism. 

Special thanks to https://github.com/aha-hyeong/kumiho who provided the concept and @daydreamrabbit for providing clarification and filenames for the unit tests. 


Discussion:
(완결)
```
나에게만 허둥대는 상사의 이야기 04권 (완결)
나와 그녀의 하룻밤 영화 03권 [1512x] (완결)
데스노트 12권 [1080x] (완결)
```

(완)
```
불과 달의 노래 276화 (완)
부인함락 52권 (완)
신을 막아라 (완)
```

(完)
```
러브크래프트 전집 4권(完)
서단 결정적 순간 2권 (完)
영업사원 김유빈 150화(完)
```

[완결]
```
토지 17권 [완결]
낯선 오후 2권 [완결]
프로페서 500화 [완결]
```
This was closed as of PR #4552, it is available in v0.8.9.24 (nightly) and will be available in v0.8.10 (stable).



Patch:
diff --git a/Kavita.Common/Extensions/StringExtensions.cs b/Kavita.Common/Extensions/StringExtensions.cs
index 333b1c1692..06f51643f4 100644
--- a/Kavita.Common/Extensions/StringExtensions.cs
+++ b/Kavita.Common/Extensions/StringExtensions.cs
@@ -188,6 +188,6 @@ public long ParseHumanReadableBytes()
         }
     }
 
-    [GeneratedRegex(@"^\s*(\d+(?:\.\d+)?)\s*([KMGTPE]?B)\s*$", RegexOptions.IgnoreCase)]
+    [GeneratedRegex(@"^\s*(\d+(?:\.\d+)?)\s*([KMGTPE]?B)\s*$", RegexOptions.IgnoreCase, 500)]
     private static partial Regex HumanReadableBytesRegex();
 }
diff --git a/Kavita.Models/Defaults.cs b/Kavita.Models/Defaults.cs
index e0da65b9a2..d36996e6e0 100644
--- a/Kavita.Models/Defaults.cs
+++ b/Kavita.Models/Defaults.cs
@@ -13,7 +13,7 @@ namespace Kavita.Models;
 
 public static class Defaults
 {
-    public static readonly string DefaultFont = "Default";
+    public const string DefaultFont = "Default";
 
     /// <summary>
     /// Generated on Startup. Seed.SeedSettings must run before
diff --git a/Kavita.Models/Metadata/ComicInfo.cs b/Kavita.Models/Metadata/ComicInfo.cs
index 708e54e917..bf1c5bf490 100644
--- a/Kavita.Models/Metadata/ComicInfo.cs
+++ b/Kavita.Models/Metadata/ComicInfo.cs
@@ -158,33 +158,4 @@ public static AgeRating ConvertAgeRatingToEnum(string value)
         return Enum.GetValues<AgeRating>()
             .SingleOrDefault(t => t.ToDescription().ToUpperInvariant().Equals(value.ToUpperInvariant()), Entities.Enums.AgeRating.Unknown);
     }
-
-
-    /// <summary>
-    /// Uses both Volume and Number to make an educated guess as to what count refers to and it's highest number.
-    /// </summary>
-    /// <returns></returns>
-    public int CalculatedCount()
-    {
-        try
-        {
-            if (float.TryParse(Number, CultureInfo.InvariantCulture, out var chpCount) && chpCount > 0)
-            {
-                return (int) Math.Floor(chpCount);
-            }
-
-            if (float.TryParse(Volume, CultureInfo.InvariantCulture, out var volCount) && volCount > 0)
-            {
-                return (int) Math.Floor(volCount);
-            }
-        }
-        catch (Exception)
-        {
-            return 0;
-        }
-
-        return 0;
-    }
-
-
 }
diff --git a/Kavita.Models/Parser/ParserInfo.cs b/Kavita.Models/Parser/ParserInfo.cs
index 484580a02d..f30cf6cb02 100644
--- a/Kavita.Models/Parser/ParserInfo.cs
+++ b/Kavita.Models/Parser/ParserInfo.cs
@@ -1,6 +1,5 @@
 using Kavita.Models.Entities;
 using Kavita.Models.Entities.Enums;
-using Kavita.Models.Entities.Interfaces;
 using Kavita.Models.Metadata;
 
 namespace Kavita.Models.Parser;
@@ -113,4 +112,27 @@ public class ParserInfo
     /// Extracted from Notes field, not explicitly part of spec
     /// </summary>
     public long? MangaBakaId { get; set; }
+    /// <summary>
+    /// Has an explicit "End" marker like (완결) which tells Kavita to use that as the Count, assuming no Count defined in ComicInfo.
+    /// </summary>
+    public bool HasEndMarker { get; set; }
+
+
+    // Transformed logic
+    /// <summary>
+    /// Highest Volume from ComicInfo then Parsed
+    /// </summary>
+    public float HighestVolume { get; set; }
+    /// <summary>
+    /// Lowest Volume from ComicInfo then Parsed
+    /// </summary>
+    public float LowestVolume { get; set; }
+    /// <summary>
+    /// Highest Chapter from ComicInfo then Parsed
+    /// </summary>
+    public float HighestChapter { get; set; }
+    /// <summary>
+    /// Lowest Chapter from ComicInfo then Parsed
+    /// </summary>
+    public float LowestChapter { get; set; }
 }
diff --git a/Kavita.Services/Helpers/ParsedCountHelper.cs b/Kavita.Services/Helpers/ParsedCountHelper.cs
new file mode 100644
index 0000000000..dc8fa28332
--- /dev/null
+++ b/Kavita.Services/Helpers/ParsedCountHelper.cs
@@ -0,0 +1,34 @@
+using System;
+using Kavita.Models.Parser;
+
+namespace Kavita.Services.Helpers;
+
+/// <summary>
+/// Helps with figuring out Count and TotalCount during <c>ProcessSeries.UpdateChapters</c>
+/// </summary>
+public static class ParsedCountHelper
+{
+    /// <summary>
+    /// Uses both Volume and Number to make an educated guess as to what count refers to, and the highest number.
+    /// </summary>
+    /// <returns></returns>
+    public static int GetCalculatedCount(ParserInfo info)
+    {
+        // Prioritize Chapter over Volume as that is more common (and closer to real spec)
+        if (info.HighestChapter > 0) return (int) Math.Floor(info.HighestChapter);
+        if (info.HighestVolume > 0) return (int) Math.Floor(info.HighestVolume);
+        return 0;
+    }
+
+    /// <summary>
+    /// Returns the total Count. Will use ComicInfo.Count if exists, otherwise will fallback to the highest Volume/Number
+    /// </summary>
+    /// <param name="info"></param>
+    /// <returns></returns>
+    public static int? GetTotalCount(ParserInfo info)
+    {
+        if (info.ComicInfo?.Count is > 0) return info.ComicInfo.Count;
+        if (info.HasEndMarker) return (int) Math.Max(info.HighestVolume, info.HighestChapter);
+        return null;
+    }
+}
diff --git a/Kavita.Services/Scanner/BasicParser.cs b/Kavita.Services/Scanner/BasicParser.cs
index abccb174e9..7dc462f0fc 100644
--- a/Kavita.Services/Scanner/BasicParser.cs
+++ b/Kavita.Services/Scanner/BasicParser.cs
@@ -35,6 +35,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
             ComicInfo = comicInfo,
             Volumes = Parser.ParseVolume(fileName, type),
             Chapters = Parser.ParseChapter(fileName, type),
+            HasEndMarker = Parser.HasEndMarker(fileName)
         };
 
         ParseExternalIdsFromNotesAndWeblinks(ret);
@@ -123,6 +124,8 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
             ret.Volumes = Parser.SpecialVolume;
         }
 
+        FinalizeNumbers(ret);
+
         return ret.Series == string.Empty ? null : ret;
     }
 
diff --git a/Kavita.Services/Scanner/BookParser.cs b/Kavita.Services/Scanner/BookParser.cs
index d897c2865d..8e586ebac4 100644
--- a/Kavita.Services/Scanner/BookParser.cs
+++ b/Kavita.Services/Scanner/BookParser.cs
@@ -9,9 +9,9 @@ namespace Kavita.Services.Scanner;
 
 public class BookParser(IDirectoryService directoryService, IBookService bookService, BasicParser basicParser) : DefaultParser(directoryService)
 {
-    public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null)
+    public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null)
     {
-        ParserInfo info;
+        ParserInfo? info;
         if (enableMetadata)
         {
             info = bookService.ParseInfo(filePath);
@@ -29,6 +29,7 @@ public override ParserInfo Parse(string filePath, string rootPath, string librar
                 Series = Parser.ParseSeries(fileName, type),
                 Chapters = Parser.ParseChapter(fileName, type),
                 Volumes = Parser.ParseVolume(fileName, type),
+                HasEndMarker = Parser.HasEndMarker(fileName)
             };
         }
 
@@ -69,6 +70,8 @@ public override ParserInfo Parse(string filePath, string rootPath, string librar
             }
         }
 
+        FinalizeNumbers(info);
+
         return string.IsNullOrEmpty(info.Series) ? null : info;
     }
 
diff --git a/Kavita.Services/Scanner/ComicVineParser.cs b/Kavita.Services/Scanner/ComicVineParser.cs
index e924a7105b..a6b19298c3 100644
--- a/Kavita.Services/Scanner/ComicVineParser.cs
+++ b/Kavita.Services/Scanner/ComicVineParser.cs
@@ -19,7 +19,10 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
     /// </summary>
     /// <param name="filePath"></param>
     /// <param name="rootPath"></param>
+    /// <param name="libraryRoot"></param>
     /// <param name="type"></param>
+    /// <param name="enableMetadata"></param>
+    /// <param name="comicInfo"></param>
     /// <returns></returns>
     public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null)
     {
@@ -40,7 +43,8 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
             Series = string.Empty,
             ComicInfo = comicInfo,
             Chapters = Parser.ParseChapter(fileName, type),
-            Volumes = Parser.ParseVolume(fileName, type)
+            Volumes = Parser.ParseVolume(fileName, type),
+            HasEndMarker = Parser.HasEndMarker(fileName)
         };
 
         ParseExternalIdsFromNotesAndWeblinks(info);
@@ -95,7 +99,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
             info.Series = Parser.CleanTitle(directoryName, true);
         }
 
-
+        FinalizeNumbers(info);
         return string.IsNullOrEmpty(info.Series) ? null : info;
     }
 
diff --git a/Kavita.Services/Scanner/DefaultParser.cs b/Kavita.Services/Scanner/DefaultParser.cs
index 446f8a9ee2..6f66b58e67 100644
--- a/Kavita.Services/Scanner/DefaultParser.cs
+++ b/Kavita.Services/Scanner/DefaultParser.cs
@@ -19,15 +19,16 @@ public interface IDefaultParser
 /// </summary>
 public abstract class DefaultParser(IDirectoryService directoryService) : IDefaultParser
 {
-
     /// <summary>
     /// Parses information out of a file path. Can fallback to using directory name if Series couldn't be parsed
     /// from filename.
     /// </summary>
     /// <param name="filePath"></param>
     /// <param name="rootPath">Root folder</param>
+    /// <param name="libraryRoot"></param>
     /// <param name="type">Allows different Regex to be used for parsing.</param>
     /// <param name="enableMetadata">Allows overriding data from metadata (ComicInfo/pdf/epub)</param>
+    /// <param name="comicInfo"></param>
     /// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
     public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null);
 
@@ -174,6 +175,18 @@ protected static void ParseExternalIdsFromNotesAndWeblinks(ParserInfo info)
         info.MetronId = !string.IsNullOrEmpty(metronId)
             ? long.Parse(metronId)
             : 0L;
+    }
 
+    /// <summary>
+    /// Set the Highest Volume/Chapter numbers for utilization in the rest of the pipeline.
+    /// </summary>
+    /// <remarks>This ensures total counts work, and we avoid rechurning the strings </remarks>
+    /// <param name="info"></param>
+    protected static void FinalizeNumbers(ParserInfo info)
+    {
+        info.HighestChapter = Parser.MaxNumberFromRange(info.Chapters);
+        info.LowestChapter = Parser.MinNumberFromRange(info.Chapters);
+        info.HighestVolume = Parser.MaxNumberFromRange(info.Volumes);
+        info.LowestVolume = Parser.MinNumberFromRange(info.Volumes);
     }
 }
diff --git a/Kavita.Services/Scanner/ImageParser.cs b/Kavita.Services/Scanner/ImageParser.cs
index db39771618..eb8828b09f 100644
--- a/Kavita.Services/Scanner/ImageParser.cs
+++ b/Kavita.Services/Scanner/ImageParser.cs
@@ -25,6 +25,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
             Filename = Path.GetFileName(filePath),
             FullFilePath = Parser.NormalizePath(filePath),
             Title = fileName,
+            HasEndMarker = Parser.HasEndMarker(fileName)
         };
         ParseFromFallbackFolders(filePath, libraryRoot, LibraryType.Image, ref ret);
 
@@ -40,6 +41,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
             ret.Series = Parser.CleanTitle(directoryName);
         }
 
+        FinalizeNumbers(ret);
 
         return string.IsNullOrEmpty(ret.Series) ? null : ret;
     }
diff --git a/Kavita.Services/Scanner/Parser.cs b/Kavita.Services/Scanner/Parser.cs
index 700f0ab648..110cc2df10 100644
--- a/Kavita.Services/Scanner/Parser.cs
+++ b/Kavita.Services/Scanner/Parser.cs
@@ -4,7 +4,6 @@
 using System.IO;
 using System.Linq;
 using System.Text.RegularExpressions;
-using Kavita.Common.Constants;
 using Kavita.Common.Extensions;
 using Kavita.Models.Constants;
 using Kavita.Models.Entities.Enums;
@@ -24,7 +23,8 @@ public static partial class Parser
     public const int SpecialVolumeNumber = ParserConstants.SpecialVolumeNumber;
     public const string SpecialVolume = ParserConstants.SpecialVolume;
 
-    public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500);
+    public const int RegexTimeoutMs = 500;
+    public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(RegexTimeoutMs);
 
     public const string ImageFileExtensions = @"(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser
     public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt";
@@ -150,6 +150,10 @@ public static partial class Parser
         MatchOptions, RegexTimeout);
 
 
+
+    [GeneratedRegex(@"(\((완결?|完)\)|\[(완결?|完)\])", MatchOptions,  RegexTimeoutMs)]
+    private static partial Regex HasEndMarkerRegex();
+
     private static readonly Regex[] MangaSeriesRegex =
     [
         // Thai Volume: เล่ม n -> Volume n
@@ -1043,14 +1047,10 @@ public static float MinNumberFromRange(string range)
             }
 
             // Check if there is a range or not
-            if (NumberRangeRegex().IsMatch(range))
-            {
+            if (!NumberRangeRegex().IsMatch(range)) return range.AsFloat();
 
-                var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries);
-                return tokens.Min(t => t.AsFloat());
-            }
-
-            return range.AsFloat();
+            var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries);
+            return tokens.Min(t => t.AsFloat());
         }
         catch (Exception)
         {
@@ -1070,14 +1070,11 @@ public static float MaxNumberFromRange(string range)
             }
 
             // Check if there is a range or not
-            if (NumberRangeRegex().IsMatch(range))
-            {
+            if (!NumberRangeRegex().IsMatch(range)) return range.AsFloat();
 
-                var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries);
-                return tokens.Max(t => t.AsFloat());
-            }
+            var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries);
+            return tokens.Max(t => t.AsFloat());
 
-            return range.AsFloat();
         }
         catch (Exception)
         {
@@ -1184,6 +1181,16 @@ public static bool HasComicInfoSpecial(string comicInfoFormat)
         return FormatTagSpecialKeywords.Contains(comicInfoFormat);
     }
 
+    /// <summary>
+    /// Detects if there is an End Marker in the filename
+    /// </summary>
+    /// <param name="filename"></param>
+    /// <returns></returns>
+    public static bool HasEndMarker(string filename)
+    {
+        return HasEndMarkerRegex().IsMatch(filename);
+    }
+
     private static string ReplaceUnderscores(string name)
     {
         return string.IsNullOrEmpty(name) ? string.Empty : name.Replace('_', ' ');
@@ -1347,9 +1354,9 @@ public static bool IsLikelyValidAsin(string? asin)
     }
 
 
-    [GeneratedRegex(SupportedExtensions)]
+    [GeneratedRegex(SupportedExtensions, MatchOptions,  RegexTimeoutMs)]
     private static partial Regex SupportedExtensionsRegex();
-    [GeneratedRegex(@"\d-{1}\d")]
+    [GeneratedRegex(@"\d-{1}\d", MatchOptions,  RegexTimeoutMs)]
     private static partial Regex NumberRangeRegex();
 
     public static bool IsDefaultChapter(string? chapterNumber)
diff --git a/Kavita.Services/Scanner/PdfParser.cs b/Kavita.Services/Scanner/PdfParser.cs
index 5bb3604fd4..7c8ef41a4b 100644
--- a/Kavita.Services/Scanner/PdfParser.cs
+++ b/Kavita.Services/Scanner/PdfParser.cs
@@ -8,7 +8,7 @@ namespace Kavita.Services.Scanner;
 
 public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService)
 {
-    public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null)
+    public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null)
     {
         var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
         var ret = new ParserInfo
@@ -19,7 +19,8 @@ public override ParserInfo Parse(string filePath, string rootPath, string librar
             FullFilePath = Parser.NormalizePath(filePath),
             Series = string.Empty,
             ComicInfo = comicInfo,
-            Chapters = Parser.ParseChapter(fileName, type)
+            Chapters = Parser.ParseChapter(fileName, type),
+            HasEndMarker = Parser.HasEndMarker(fileName)
         };
 
         if (type == LibraryType.Book)
@@ -120,6 +121,8 @@ public override ParserInfo Parse(string filePath, string rootPath, string librar
             ret.Volumes = $"{Parser.SpecialVolumeNumber}";
         }
 
+        FinalizeNumbers(ret);
+
         return string.IsNullOrEmpty(ret.Series) ? null : ret;
     }
 
diff --git a/Kavita.Services/Scanner/ProcessSeries.cs b/Kavita.Services/Scanner/ProcessSeries.cs
index b77214bd7d..8e10e8afe3 100644
--- a/Kavita.Services/Scanner/ProcessSeries.cs
+++ b/Kavita.Services/Scanner/ProcessSeries.cs
@@ -696,9 +696,9 @@ private async Task UpdateChapters(UpdateChapterArgs args)
             // Add files
             AddOrUpdateFileForChapter(chapter, info, args.ForceUpdate);
 
-            chapter.Number = Parser.MinNumberFromRange(info.Chapters).ToString(CultureInfo.InvariantCulture);
-            chapter.MinNumber = Parser.MinNumberFromRange(info.Chapters);
-            chapter.MaxNumber = Parser.MaxNumberFromRange(info.Chapters);
+            chapter.Number = info.LowestChapter.ToString(CultureInfo.InvariantCulture);
+            chapter.MinNumber = info.LowestChapter;
+            chapter.MaxNumber = info.HighestChapter;
             chapter.Range = chapter.GetNumberTitle();
 
             if (!chapter.SortOrderLocked)
@@ -712,6 +712,16 @@ private async Task UpdateChapters(UpdateChapterArgs args)
                 chapter.Title = chapter.GetNumberTitle();
             }
 
+            // When setting TotalCount, we need to check against EndMarker and ComicInfo
+            var totalCount = ParsedCountHelper.GetTotalCount(info);
+            if (totalCount > 0)
+            {
+                chapter.TotalCount = totalCount.Value;
+            }
+
+            // This needs to check against both Number and Volume to calculate Count
+            chapter.Count = ParsedCountHelper.GetCalculatedCount(info);
+
             try
             {
                 await UpdateChapterFromComicInfo(new UpdateChapterComicInfoArgs
@@ -899,15 +909,6 @@ private async Task UpdateChapterFromComicInfo(UpdateChapterComicInfoArgs args)
             chapter.ISBN = comicInfo.Isbn;
         }
 
-        if (comicInfo.Count > 0)
-        {
-            chapter.TotalCount = comicInfo.Count;
-        }
-
-        // This needs to check against both Number and Volume to calculate Count
-        chapter.Count = comicInfo.CalculatedCount();
-
-
         if (!chapter.ReleaseDateLocked && comicInfo.Year > 0)
         {
             var day = Math.Max(comicInfo.Day, 1);
@@ -947,6 +948,8 @@ private async Task UpdateChapterFromComicInfo(UpdateChapterComicInfoArgs args)
         logger.LogTrace("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName);
     }
 
+
+
     private async Task UpdateChapterGenres(Chapter chapter, IEnumerable<string> genreNames)
     {
         try


Test patch:
diff --git a/Kavita.Services.Tests/Entities/ComicInfoTests.cs b/Kavita.Services.Tests/Entities/ComicInfoTests.cs
index a832166a8f..96f5b08787 100644
--- a/Kavita.Services.Tests/Entities/ComicInfoTests.cs
+++ b/Kavita.Services.Tests/Entities/ComicInfoTests.cs
@@ -36,63 +36,6 @@ public void ConvertAgeRatingToEnum_ShouldCompareCaseInsensitive()
     }
     #endregion
 
-    #region CalculatedCount
-
-    [Fact]
-    public void CalculatedCount_ReturnsVolumeCount()
-    {
-        var ci = new ComicInfo()
-        {
-            Number = "5",
-            Volume = "10",
-            Count = 10
-        };
-
-        Assert.Equal(5, ci.CalculatedCount());
-    }
-
-    [Fact]
-    public void CalculatedCount_ReturnsNoCountWhenCountNotSet()
-    {
-        var ci = new ComicInfo()
-        {
-            Number = "5",
-            Volume = "10",
-            Count = 0
-        };
-
-        Assert.Equal(5, ci.CalculatedCount());
-    }
-
-    [Fact]
-    public void CalculatedCount_ReturnsNumberCount()
-    {
-        var ci = new ComicInfo()
-        {
-            Number = "5",
-            Volume = "",
-            Count = 10
-        };
-
-        Assert.Equal(5, ci.CalculatedCount());
-    }
-
-    [Fact]
-    public void CalculatedCount_ReturnsNumberCount_OnlyWholeNumber()
-    {
-        var ci = new ComicInfo()
-        {
-            Number = "5.7",
-            Volume = "",
-            Count = 10
-        };
-
-        Assert.Equal(5, ci.CalculatedCount());
-    }
-
-
-    #endregion
-
     #region ASIN/ISBN/GTIN
 
     [Theory]
diff --git a/Kavita.Services.Tests/Helpers/ParsedCountHelperTests.cs b/Kavita.Services.Tests/Helpers/ParsedCountHelperTests.cs
new file mode 100644
index 0000000000..7fa3cb65f6
--- /dev/null
+++ b/Kavita.Services.Tests/Helpers/ParsedCountHelperTests.cs
@@ -0,0 +1,148 @@
+using Kavita.Models.Metadata;
+using Kavita.Models.Parser;
+using Kavita.Services.Helpers;
+
+namespace Kavita.Services.Tests.Helpers;
+
+public class ParsedCountHelperTests
+{
+    #region GetCalculatedCount
+
+    [Fact]
+    public void GetCalculatedCount_ChapterOnly()
+    {
+        var info = new ParserInfo { Series = "Test", HighestChapter = 5, HighestVolume = 0 };
+        Assert.Equal(5, ParsedCountHelper.GetCalculatedCount(info));
+    }
+
+    [Fact]
+    public void GetCalculatedCount_VolumeOnly()
+    {
+        var info = new ParserInfo { Series = "Test", HighestChapter = 0, HighestVolume = 10 };
+        Assert.Equal(10, ParsedCountHelper.GetCalculatedCount(info));
+    }
+
+    [Fact]
+    public void GetCalculatedCount_Both_ChapterTakesPriority()
+    {
+        var info = new ParserInfo { Series = "Test", HighestChapter = 5, HighestVolume = 10 };
+        Assert.Equal(5, ParsedCountHelper.GetCalculatedCount(info));
+    }
+
+    [Fact]
+    public void GetCalculatedCount_DecimalChapter_Floors()
+    {
+        var info = new ParserInfo { Series = "Test", HighestChapter = 5.7f, HighestVolume = 0 };
+        Assert.Equal(5, ParsedCountHelper.GetCalculatedCount(info));
+    }
+
+    [Fact]
+    public void GetCalculatedCount_DecimalVolume_Floors()
+    {
+        var info = new ParserInfo { Series = "Test", HighestChapter = 0, HighestVolume = 3.9f };
+        Assert.Equal(3, ParsedCountHelper.GetCalculatedCount(info));
+    }
+
+    [Fact]
+    public void GetCalculatedCount_NeitherSet_ReturnsZero()
+    {
+        var info = new ParserInfo { Series = "Test", HighestChapter = 0, HighestVolume = 0 };
+        Assert.Equal(0, ParsedCountHelper.GetCalculatedCount(info));
+    }
+
+    #endregion
+
+    #region GetTotalCount
+
+    [Fact]
+    public void GetTotalCount_ComicInfoCountSet()
+    {
+        var info = new ParserInfo
+        {
+            Series = "Test",
+            ComicInfo = new ComicInfo { Count = 10 },
+            HasEndMarker = false
+        };
+        Assert.Equal(10, ParsedCountHelper.GetTotalCount(info));
+    }
+
+    [Fact]
+    public void GetTotalCount_ComicInfoCount_TakesPriorityOverEndMarker()
+    {
+        var info = new ParserInfo
+        {
+            Series = "Test",
+            ComicInfo = new ComicInfo { Count = 10 },
+            HasEndMarker = true,
+            HighestChapter = 5,
+            HighestVolume = 3
+        };
+        Assert.Equal(10, ParsedCountHelper.GetTotalCount(info));
+    }
+
+    [Fact]
+    public void GetTotalCount_EndMarker_ChapterHigherThanVolume()
+    {
+        var info = new ParserInfo
+        {
+            Series = "Test",
+            HasEndMarker = true,
+            HighestChapter = 20,
+            HighestVolume = 5
+        };
+        Assert.Equal(20, ParsedCountHelper.GetTotalCount(info));
+    }
+
+    [Fact]
+    public void GetTotalCount_EndMarker_VolumeHigherThanChapter()
+    {
+        var info = new ParserInfo
+        {
+            Series = "Test",
+            HasEndMarker = true,
+            HighestChapter = 3,
+            HighestVolume = 15
+        };
+        Assert.Equal(15, ParsedCountHelper.GetTotalCount(info));
+    }
+
+    [Fact]
+    public void GetTotalCount_NoComicInfoCount_NoEndMarker_ReturnsNull()
+    {
+        var info = new ParserInfo
+        {
+            Series = "Test",
+            ComicInfo = null,
+            HasEndMarker = false
+        };
+        Assert.Null(ParsedCountHelper.GetTotalCount(info));
+    }
+
+    [Fact]
+    public void GetTotalCount_ComicInfoCountZero_NoEndMarker_ReturnsNull()
+    {
+        var info = new ParserInfo
+        {
+            Series = "Test",
+            ComicInfo = new ComicInfo { Count = 0 },
+            HasEndMarker = false
+        };
+        Assert.Null(ParsedCountHelper.GetTotalCount(info));
+    }
+
+    [Fact]
+    public void GetTotalCount_ComicInfoCountZero_WithEndMarker_UsesEndMarker()
+    {
+        var info = new ParserInfo
+        {
+            Series = "Test",
+            ComicInfo = new ComicInfo { Count = 0 },
+            HasEndMarker = true,
+            HighestChapter = 8,
+            HighestVolume = 2
+        };
+        Assert.Equal(8, ParsedCountHelper.GetTotalCount(info));
+    }
+
+    #endregion
+}
diff --git a/Kavita.Services.Tests/Parsing/ParsingTests.cs b/Kavita.Services.Tests/Parsing/ParsingTests.cs
index bce33b68dc..7a8bcd103c 100644
--- a/Kavita.Services.Tests/Parsing/ParsingTests.cs
+++ b/Kavita.Services.Tests/Parsing/ParsingTests.cs
@@ -360,4 +360,27 @@ public void BalancedBracketTest_DoesNotMatch(string input)
     {
         Assert.DoesNotMatch($@"^{BalancedBracket}$", input);
     }
+
+    [Theory]
+    // (완결)
+    [InlineData("나에게만 허둥대는 상사의 이야기 04권 (완결)", true)]
+    [InlineData("나와 그녀의 하룻밤 영화 03권 [1512x] (완결)", true)]
+    [InlineData("데스노트 12권 [1080x] (완결)", true)]
+    [InlineData("데스완노트완결 12권 [1080x]", false)]
+    // (완)
+    [InlineData("불과 달의 노래 276화 (완)", true)]
+    [InlineData("부인함락 52권 (완)", true)]
+    [InlineData("신을 막아라 (완)", true)]
+    // (完)
+    [InlineData("러브크래프트 전집 4권(完)", true)]
+    [InlineData("서단 결정적 순간 2권 (完)", true)]
+    [InlineData("完영업사원 김유빈 150화(完)", true)]
+    // [완결]
+    [InlineData("토지 17권 [완결]", true)]
+    [InlineData("낯선 오후 2권 [완결]", true)]
+    [InlineData("낯선 오후 2권 (완결)", true)]
+    public void HasEndMarkerTests(string input, bool expected)
+    {
+        Assert.Equal(expected, HasEndMarker(input));
+    }
 }