Skip to content

Commit 34332d2

Browse files
EstrellaXDclaudehappy-otter
committed
fix(renamer): resolve multiple rows error for multi-subscription seasons
When multiple bangumi subscriptions share the same save_path (e.g., split-cour anime with S01E1-12 and S01E13-24), the renamer's match_by_save_path() query returned multiple rows causing "Multiple rows were found" errors. Changes: - Add qb_hash field to Torrent model for direct hash-to-bangumi linking - Add database migration v6 for qb_hash column with index - Add tags parameter to add_torrents() in all downloader clients - Tag new torrents with ab:{bangumi_id} for offset lookup during rename - Implement multi-tier lookup in renamer: qb_hash -> tags -> torrent_name -> save_path - Fix auth tests by mocking DEV_AUTH_BYPASS for proper 401 testing The renamer now reliably finds the correct bangumi and its offsets even when multiple subscriptions download to the same directory. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent 12ac30c commit 34332d2

12 files changed

Lines changed: 443 additions & 60 deletions

File tree

backend/src/module/database/bangumi.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ def _is_duplicate(self, data: Bangumi) -> bool:
3838

3939
def add(self, data: Bangumi) -> bool:
4040
if self._is_duplicate(data):
41-
logger.debug(f"[Database] Skipping duplicate: {data.official_title} ({data.group_name})")
41+
logger.debug(
42+
f"[Database] Skipping duplicate: {data.official_title} ({data.group_name})"
43+
)
4244
return False
4345
self.session.add(data)
4446
self.session.commit()
@@ -58,10 +60,7 @@ def add_all(self, datas: list[Bangumi]) -> int:
5860
existing.add((data.title_raw, data.group_name))
5961

6062
# Filter out duplicates
61-
to_add = [
62-
d for d in datas
63-
if (d.title_raw, d.group_name) not in existing
64-
]
63+
to_add = [d for d in datas if (d.title_raw, d.group_name) not in existing]
6564

6665
# Also deduplicate within the batch itself
6766
seen = set()
@@ -73,17 +72,23 @@ def add_all(self, datas: list[Bangumi]) -> int:
7372
unique_to_add.append(d)
7473

7574
if not unique_to_add:
76-
logger.debug(f"[Database] All {len(datas)} bangumi already exist, skipping.")
75+
logger.debug(
76+
f"[Database] All {len(datas)} bangumi already exist, skipping."
77+
)
7778
return 0
7879

7980
self.session.add_all(unique_to_add)
8081
self.session.commit()
8182
_invalidate_bangumi_cache()
8283
skipped = len(datas) - len(unique_to_add)
8384
if skipped > 0:
84-
logger.debug(f"[Database] Insert {len(unique_to_add)} bangumi, skipped {skipped} duplicates.")
85+
logger.debug(
86+
f"[Database] Insert {len(unique_to_add)} bangumi, skipped {skipped} duplicates."
87+
)
8588
else:
86-
logger.debug(f"[Database] Insert {len(unique_to_add)} bangumi into database.")
89+
logger.debug(
90+
f"[Database] Insert {len(unique_to_add)} bangumi into database."
91+
)
8792
return len(unique_to_add)
8893

8994
def update(self, data: Bangumi | BangumiUpdate, _id: int = None) -> bool:
@@ -152,7 +157,10 @@ def delete_all(self):
152157
def search_all(self) -> list[Bangumi]:
153158
global _bangumi_cache, _bangumi_cache_time
154159
now = time.time()
155-
if _bangumi_cache is not None and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL:
160+
if (
161+
_bangumi_cache is not None
162+
and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL
163+
):
156164
return _bangumi_cache
157165
statement = select(Bangumi)
158166
result = self.session.execute(statement)
@@ -199,7 +207,10 @@ def match_list(self, torrent_list: list, rss_link: str) -> list:
199207
matched = False
200208
for title_raw, match_data in title_index.items():
201209
if title_raw in torrent.name:
202-
if rss_link not in match_data.rss_link and title_raw not in rss_updated:
210+
if (
211+
rss_link not in match_data.rss_link
212+
and title_raw not in rss_updated
213+
):
203214
match_data.rss_link += f",{rss_link}"
204215
match_data.added = False
205216
rss_updated.add(title_raw)
@@ -211,7 +222,9 @@ def match_list(self, torrent_list: list, rss_link: str) -> list:
211222
if rss_updated:
212223
self.session.commit()
213224
_invalidate_bangumi_cache()
214-
logger.debug(f"[Database] Batch updated rss_link for {len(rss_updated)} bangumi.")
225+
logger.debug(
226+
f"[Database] Batch updated rss_link for {len(rss_updated)} bangumi."
227+
)
215228
return unmatched
216229

217230
def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
@@ -290,10 +303,17 @@ def unarchive_one(self, _id: int) -> bool:
290303
return True
291304

292305
def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:
293-
"""Find bangumi by save_path to get offset."""
294-
statement = select(Bangumi).where(Bangumi.save_path == save_path)
306+
"""Find bangumi by save_path to get offset.
307+
308+
Note: When multiple subscriptions share the same save_path (e.g., different RSS
309+
sources for the same anime), this returns the first match. Use match_torrent()
310+
for more accurate matching when torrent_name is available.
311+
"""
312+
statement = select(Bangumi).where(
313+
and_(Bangumi.save_path == save_path, Bangumi.deleted == false())
314+
)
295315
result = self.session.execute(statement)
296-
return result.scalar_one_or_none()
316+
return result.scalars().first()
297317

298318
def get_needs_review(self) -> list[Bangumi]:
299319
"""Get all bangumi that need review for offset mismatch."""

backend/src/module/database/combine.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
TABLE_MODELS: list[type[SQLModel]] = [Bangumi, RSSItem, Torrent, User, Passkey]
2424

2525
# Increment this when adding new migrations to MIGRATIONS list.
26-
CURRENT_SCHEMA_VERSION = 5
26+
CURRENT_SCHEMA_VERSION = 6
2727

2828
# Each migration is a tuple of (version, description, list of SQL statements).
2929
# Migrations are applied in order. A migration at index i brings the schema
@@ -80,6 +80,14 @@
8080
"ALTER TABLE bangumi ADD COLUMN needs_review_reason TEXT DEFAULT NULL",
8181
],
8282
),
83+
(
84+
6,
85+
"add qb_hash column to torrent for downloader tracking",
86+
[
87+
"ALTER TABLE torrent ADD COLUMN qb_hash TEXT",
88+
"CREATE INDEX IF NOT EXISTS ix_torrent_qb_hash ON torrent(qb_hash)",
89+
],
90+
),
8391
]
8492

8593

@@ -163,6 +171,10 @@ def run_migrations(self):
163171
columns = [col["name"] for col in inspector.get_columns("bangumi")]
164172
if "episode_offset" in columns:
165173
needs_run = False
174+
if "torrent" in tables and version == 6:
175+
columns = [col["name"] for col in inspector.get_columns("torrent")]
176+
if "qb_hash" in columns:
177+
needs_run = False
166178
if needs_run:
167179
with self.engine.connect() as conn:
168180
for stmt in statements:

backend/src/module/database/torrent.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,28 @@ def check_new(self, torrents_list: list[Torrent]) -> list[Torrent]:
5959
result = self.session.execute(statement)
6060
existing_urls = set(result.scalars().all())
6161
return [t for t in torrents_list if t.url not in existing_urls]
62+
63+
def search_by_qb_hash(self, qb_hash: str) -> Torrent | None:
64+
"""Find torrent by qBittorrent hash."""
65+
result = self.session.execute(
66+
select(Torrent).where(Torrent.qb_hash == qb_hash)
67+
)
68+
return result.scalar_one_or_none()
69+
70+
def search_by_url(self, url: str) -> Torrent | None:
71+
"""Find torrent by URL."""
72+
result = self.session.execute(
73+
select(Torrent).where(Torrent.url == url)
74+
)
75+
return result.scalar_one_or_none()
76+
77+
def update_qb_hash(self, torrent_id: int, qb_hash: str) -> bool:
78+
"""Update the qb_hash for a torrent."""
79+
torrent = self.search(torrent_id)
80+
if torrent:
81+
torrent.qb_hash = qb_hash
82+
self.session.add(torrent)
83+
self.session.commit()
84+
logger.debug(f"Updated qb_hash for torrent {torrent_id}: {qb_hash}")
85+
return True
86+
return False

backend/src/module/downloader/client/aria2_downloader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async def logout(self):
5757
async def torrents_files(self, torrent_hash: str):
5858
return []
5959

60-
async def add_torrents(self, torrent_urls, torrent_files, save_path, category):
60+
async def add_torrents(self, torrent_urls, torrent_files, save_path, category, tags=None):
6161
import base64
6262
options = {"dir": save_path}
6363
if torrent_urls:

0 commit comments

Comments
 (0)