fix(episodes): prevent duplicate-row accumulation at schema + write sites
Followup to commitc84f968(read-boundary dedup) and commitf75d591(cleanup CLI). The read boundary filters duplicates out of the in-memory episodeDict and the CLI cleans up historical duplicates in the DB, but the underlying pathology — duplicate rows being created in the first place — was still active on every rescan. Two layered prevention fixes: 1. Schema-level guard: add UNIQUE(series_id, season, episode_number) to the episodes table. SQLite's CREATE UNIQUE INDEX requires no existing duplicates, but the cleanup CLI fromf75d591has already been run (or is a one-shot prerequisite for users on older DBs). Future duplicate rows are rejected at the DB layer. 2. Write-site guard: SerieScanner.scan_single_series used to `extend` the in-memory episodeDict on every rescan of a series already in keyDict — across N rescans, the same missing list was appended N times, growing the dict with duplicates that then flowed through _update_series_in_db into the episodes table. The fix replaces the cache with the latest scan result instead of extending, and dedupes within a single call as defense in depth against a buggy upstream loader. Defensive dedup is layered three deep: - schema constraint (this commit, primary) - scan_single_series replace-not-extend (this commit, secondary) - episodeDict property read-boundary dedup (commitc84f968, tertiary — covers legacy DBs that predate the constraint) Tests: - Updated test_serie_scanner.test_scan_single_series_existing_entry to assert the new replace-not-merge behavior (the old assertion encoded the buggy extend behavior). - New test_serie_scanner_scan_dedup.py covers the regression directly: two rescans of the same series with the same missing list must yield a canonical dict, not an accumulated one. - test_database_models and test_clean_duplicate_episodes_cli now use a legacy_engine fixture that drops the UNIQUE constraint, so the duplicate-row scenarios they exercise (the read-boundary dedup and the cleanup tool, both meant to defend against pre-migration state) can still be tested under the new schema. Verified manually: clean_duplicate_episodes --apply on the user's backup DB still removes all 633 duplicate rows under the new schema (the CLI doesn't depend on the UNIQUE constraint — it operates on whatever rows already exist).
This commit is contained in:
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
@@ -29,14 +29,55 @@ from src.server.database.models import AnimeSeries, Episode
|
||||
async def in_memory_engine():
|
||||
"""Provide an in-memory async SQLite engine with the schema
|
||||
already created, and patch the connection module's session
|
||||
factory to use it for the duration of the test."""
|
||||
factory to use it for the duration of the test.
|
||||
|
||||
The CLI test suite simulates the *pre-migration* state — a DB
|
||||
that predates the UNIQUE constraint on
|
||||
``(series_id, season, episode_number)`` and has accumulated
|
||||
duplicate rows from historical scans. To create that state, we
|
||||
build the schema for everything except ``episodes``, then
|
||||
recreate ``episodes`` with raw DDL that omits the
|
||||
``uq_episode_per_series_season`` constraint. SQLite ties
|
||||
UNIQUE constraints to an internal auto-named index that can't
|
||||
be dropped directly — table recreation is the only way to
|
||||
simulate the pre-migration schema.
|
||||
"""
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Create everything except episodes.
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
t.create(sync_conn)
|
||||
for t in Base.metadata.sorted_tables
|
||||
if t.name != "episodes"
|
||||
]
|
||||
)
|
||||
# Recreate episodes without the UNIQUE constraint.
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE episodes (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
series_id INTEGER NOT NULL
|
||||
REFERENCES anime_series(id) ON DELETE CASCADE,
|
||||
season INTEGER NOT NULL,
|
||||
episode_number INTEGER NOT NULL,
|
||||
title VARCHAR(500),
|
||||
file_path VARCHAR(1000),
|
||||
is_downloaded BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text("CREATE INDEX ix_episodes_series_id ON episodes (series_id)")
|
||||
)
|
||||
|
||||
factory = async_sessionmaker(
|
||||
bind=engine,
|
||||
@@ -75,6 +116,10 @@ async def _add_episode(
|
||||
is_downloaded: bool = False,
|
||||
title: str | None = None,
|
||||
) -> int:
|
||||
"""Insert an Episode row. The test fixture drops the UNIQUE
|
||||
constraint on the ``episodes`` table, so duplicate inserts
|
||||
succeed — the cleanup tool can then find them, mirroring the
|
||||
pre-migration pathology it's meant to repair."""
|
||||
ep = Episode(
|
||||
series_id=series_id,
|
||||
season=season,
|
||||
@@ -120,8 +165,11 @@ async def test_find_groups_duplicates_per_tuple(in_memory_engine):
|
||||
count=3 and keep_id = the lowest id."""
|
||||
async with AsyncSession(in_memory_engine) as session:
|
||||
sid = await _add_series(session, "triple")
|
||||
e1 = await _add_episode(session, sid, 1, 1)
|
||||
# Three rows for (S1, E2): keep_id should be e2 (lowest)
|
||||
await _add_episode(session, sid, 1, 1)
|
||||
# Three rows for (S1, E2): the first via the normal path,
|
||||
# the next two via raw SQL to bypass the UNIQUE constraint.
|
||||
# Mirrors the pre-migration pathology the cleanup tool exists
|
||||
# to repair.
|
||||
e2 = await _add_episode(session, sid, 1, 2)
|
||||
e3 = await _add_episode(session, sid, 1, 2)
|
||||
e4 = await _add_episode(session, sid, 1, 2)
|
||||
@@ -147,8 +195,12 @@ async def test_delete_keeps_lowest_id_per_tuple(in_memory_engine):
|
||||
sid = await _add_series(session, "keep-lowest")
|
||||
e1 = await _add_episode(session, sid, 1, 1) # unique
|
||||
e2 = await _add_episode(session, sid, 1, 2) # lowest of dupes
|
||||
await _add_episode(session, sid, 1, 2) # e3 - duplicate of e2
|
||||
await _add_episode(session, sid, 1, 2) # e4 - duplicate of e2
|
||||
await _add_episode(
|
||||
session, sid, 1, 2
|
||||
) # e3 - duplicate of e2
|
||||
await _add_episode(
|
||||
session, sid, 1, 2
|
||||
) # e4 - duplicate of e2
|
||||
e5 = await _add_episode(session, sid, 1, 3) # unique
|
||||
|
||||
duplicates = await cli.find_duplicate_episodes()
|
||||
@@ -179,7 +231,11 @@ async def test_delete_keeps_lowest_even_with_title_metadata(in_memory_engine):
|
||||
sid = await _add_series(session, "metadata")
|
||||
e1 = await _add_episode(session, sid, 1, 1) # no title
|
||||
e2 = await _add_episode(
|
||||
session, sid, 1, 1, title="Better Episode 1"
|
||||
session,
|
||||
sid,
|
||||
1,
|
||||
1,
|
||||
title="Better Episode 1",
|
||||
)
|
||||
|
||||
duplicates = await cli.find_duplicate_episodes()
|
||||
@@ -203,8 +259,13 @@ async def test_full_workflow_is_idempotent(in_memory_engine):
|
||||
async with AsyncSession(in_memory_engine) as session:
|
||||
sid = await _add_series(session, "idempotent")
|
||||
for ep_num in (1, 2, 3):
|
||||
for _ in range(3): # 3 copies of each
|
||||
await _add_episode(session, sid, 1, ep_num)
|
||||
await _add_episode(session, sid, 1, ep_num)
|
||||
await _add_episode(
|
||||
session, sid, 1, ep_num
|
||||
)
|
||||
await _add_episode(
|
||||
session, sid, 1, ep_num
|
||||
)
|
||||
|
||||
first = await cli.find_duplicate_episodes()
|
||||
assert len(first) == 3
|
||||
@@ -225,8 +286,10 @@ async def test_max_series_filter_limits_report(in_memory_engine):
|
||||
s3 = await _add_series(session, "gamma")
|
||||
for sid in (s1, s2, s3):
|
||||
for ep_num in (1, 2):
|
||||
for _ in range(2): # duplicates
|
||||
await _add_episode(session, sid, 1, ep_num)
|
||||
await _add_episode(session, sid, 1, ep_num)
|
||||
await _add_episode(
|
||||
session, sid, 1, ep_num
|
||||
)
|
||||
|
||||
# Without filter: all three series have duplicates.
|
||||
full = await cli.find_duplicate_episodes()
|
||||
|
||||
Reference in New Issue
Block a user