From 2cdf146f911468f09c041a5ba34c468a63f9fe31 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Mon, 6 Jul 2026 09:42:58 +0200 Subject: [PATCH] fix(bangumi): inherit year for same-title entries across subtitle groups When a new bangumi entry is created for an already-known show (same official_title, different group/quality so no semantic merge), it now inherits the existing entry's year. The save path is built as `title (year)/Season N`, so a Mikan-parsed entry (which never fills year) previously diverged into a separate library folder from a TMDB-parsed sibling that had one. `_inherit_year` in add()/add_all() copies the year from any non-deleted same-title row when the incoming entry has none; a parsed year is never overwritten. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs --- backend/src/module/database/bangumi.py | 41 +++++++--- backend/src/test/test_database.py | 100 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 1080e2ac..5baf7ed0 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -126,6 +126,21 @@ def _find_semantic_match(data: Bangumi, candidates: list[Bangumi]) -> Optional[B return None +def _inherit_year(data: Bangumi, candidates: list[Bangumi]) -> None: + """新条目缺年份时继承同名已有条目的年份。 + + save path 由 ``official_title (year)`` 组成(downloader/path.py),同一部 + 番剧的不同订阅条目(不同字幕组/清晰度)若年份不一致,会被拆进两个媒体库 + 目录。Mikan 解析路径不填 year,因此从已有同名条目补齐。 + """ + if data.year: + return + for candidate in candidates: + if candidate.year: + data.year = candidate.year + return + + def match_bangumi_in_list( torrent_name: str, bangumi_list: list[Bangumi] ) -> Optional[Bangumi]: @@ -152,6 +167,17 @@ class BangumiDatabase: def __init__(self, session: AsyncSession): self.session = session + async def _same_title_candidates(self, official_title: str) -> list[Bangumi]: + """加载与给定 official_title 同名的所有未删除条目。""" + statement = select(Bangumi).where( + and_( + Bangumi.official_title == official_title, + Bangumi.deleted == false(), + ) + ) + result = await self.session.execute(statement) + return list(result.scalars().all()) + async def find_semantic_duplicate(self, data: Bangumi) -> Optional[Bangumi]: """ Find existing bangumi that semantically matches the new one. @@ -166,15 +192,7 @@ class BangumiDatabase: Returns the matching Bangumi if found, None otherwise. """ - statement = select(Bangumi).where( - and_( - Bangumi.official_title == data.official_title, - Bangumi.deleted == false(), - ) - ) - result = await self.session.execute(statement) - candidates = list(result.scalars().all()) - + candidates = await self._same_title_candidates(data.official_title) match = _find_semantic_match(data, candidates) if match: logger.debug( @@ -249,7 +267,8 @@ class BangumiDatabase: return False # Check for semantic duplicate (same anime, different naming pattern) - semantic_match = await self.find_semantic_duplicate(data) + candidates = await self._same_title_candidates(data.official_title) + semantic_match = _find_semantic_match(data, candidates) if semantic_match: # Add as alias instead of creating new entry await self.add_title_alias(semantic_match.id, data.title_raw) @@ -259,6 +278,7 @@ class BangumiDatabase: ) return False # Return False since we didn't add a new entry + _inherit_year(data, candidates) self.session.add(data) await self.session.commit() logger.debug("Insert %s into database.", data.official_title) @@ -328,6 +348,7 @@ class BangumiDatabase: f"'{semantic_match.title_raw}' (official: {d.official_title})" ) else: + _inherit_year(d, candidates_by_title.get(d.official_title, [])) really_to_add.append(d) # Also deduplicate within the batch itself diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index f7a14ace..ed46cbea 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -455,6 +455,106 @@ async def test_add_with_semantic_duplicate_creates_alias(db_session): assert "Frieren Beyond Journey's End" in aliases +async def test_add_same_title_new_entry_inherits_year(db_session): + """A new entry for an already-known anime (same official_title, different + group/quality so no semantic merge) must inherit the existing year, so both + entries build the same save path (title (year)/Season N).""" + db = BangumiDatabase(db_session) + + existing = Bangumi( + official_title="躲在超市后门抽烟的两人", + year="2026", + title_raw="躲在超市后门抽烟的两人", + group_name="樱都字幕组", + dpi="1080p", + source="Baha", + subtitle="CHS", + rss_link="test1", + ) + await db.add(existing) + + new_entry = Bangumi( + official_title="躲在超市后门抽烟的两人", + year=None, + title_raw="Super no Ura de Yani Suu Futari", + group_name="Dynamis One", + dpi="1080p", + source="ABEMA", + subtitle="JPSC", + rss_link="test2", + ) + result = await db.add(new_entry) + + assert result is True # different group/source: a new entry, not a merge + assert new_entry.year == "2026" + + +async def test_add_same_title_keeps_own_year(db_session): + """An explicitly parsed year must not be overwritten by an existing entry's.""" + db = BangumiDatabase(db_session) + + await db.add( + Bangumi( + official_title="Frieren", + year="2023", + title_raw="Sousou no Frieren", + group_name="LoliHouse", + dpi="1080p", + source="Web", + subtitle="CHT", + rss_link="test1", + ) + ) + + new_entry = Bangumi( + official_title="Frieren", + year="2024", + title_raw="Frieren S2", + group_name="OtherGroup", + dpi="720p", + source="Baha", + subtitle="CHS", + rss_link="test2", + ) + result = await db.add(new_entry) + + assert result is True + assert new_entry.year == "2024" + + +async def test_add_all_same_title_new_entries_inherit_year(db_session): + """add_all() applies the same year inheritance as add().""" + db = BangumiDatabase(db_session) + + await db.add( + Bangumi( + official_title="躲在超市后门抽烟的两人", + year="2026", + title_raw="躲在超市后门抽烟的两人", + group_name="樱都字幕组", + dpi="1080p", + source="Baha", + subtitle="CHS", + rss_link="test1", + ) + ) + + new_entry = Bangumi( + official_title="躲在超市后门抽烟的两人", + year=None, + title_raw="Super no Ura de Yani Suu Futari", + group_name="Dynamis One", + dpi="1080p", + source="ABEMA", + subtitle="JPSC", + rss_link="test2", + ) + added = await db.add_all([new_entry]) + + assert added == 1 + assert new_entry.year == "2026" + + async def test_add_all_semantic_duplicates_across_batch_all_merged_as_aliases( db_session, ):