Skip to content

Reuse existing season items across library buckets on completion - #760

Draft
JoshDaFishy wants to merge 2 commits into
dannyvfilms:latestfrom
JoshDaFishy:fix/season-bucket-lookup
Draft

Reuse existing season items across library buckets on completion#760
JoshDaFishy wants to merge 2 commits into
dannyvfilms:latestfrom
JoshDaFishy:fix/season-bucket-lookup

Conversation

@JoshDaFishy

@JoshDaFishy JoshDaFishy commented Aug 14, 2026

Copy link
Copy Markdown

Summary

Complete a TV show without creating a second season identity when a compatible imported season already exists in another library bucket. Use local episode-count evidence when provider release events are unavailable.

This objective is valid. The current draft needs the corrections in the review section before it is safe to merge.

User-facing problem

TV._completed() creates or resolves season Item rows while it expands a completed show into seasons and episodes. Imported seasons can use the parent show's tv bucket, while this path historically looked in the season bucket. The mismatch can create a second season and episode set. Existing dates remain on the first set, while the newly generated set receives completion dates from the later action.

The draft also uses the highest watched episode number as proof that all episodes were completed when release-event rows are absent. Sparse history makes that unsafe. Watching only episode 10 of a ten-episode season is one completed episode, not ten.

Reported evidence

The contributor observed imported Breaking Bad seasons in the tv bucket with April 2025 dates. Completing the show created another set in the season bucket and duplicated 62 episodes with dates from the completion action.

Proposed solution

  1. Resolve an existing season only from buckets that are compatible with the parent TV identity.
  2. Prefer the parent show's bucket, then the canonical season bucket for normal TV.
  3. Do not allow normal TV and anime identity buckets to cross-attach.
  4. If no compatible row exists, retain get_or_create() for the intended target bucket.
  5. When local_season_episode_count is authoritative, compare it with the count of distinct completed episodes, not the highest episode position.
  6. Preserve a deliberate In progress status used for a rewatch.

Review findings — changes required

1. Sparse history can be marked complete

The new max_watched >= local_total condition is not completion evidence. Existing model behavior deliberately separates progress position from distinct completed-episode count. A season with only episode 10 watched has a progress position of 10 but a completed count of 1.

Use self.completed_episode_count >= local_total for this fallback and add tests for sparse history, duplicate plays, full distinct completion, and the manual rewatch override.

2. The create path is no longer database-safe

The draft changes an atomic get_or_create() path into a lookup followed by Item.objects.create(). Two completion requests can both see no row and then race to create the same target identity. One request can fail with a duplicate-key error.

Keep the cross-bucket lookup, but use get_or_create() when the target bucket is absent.

3. The fallback lookup is too broad for TV/anime identity

find_item_across_buckets() returns the oldest candidate from any bucket when the preferred bucket is absent. In this model path, that can select an anime-bucket season for a normal TV parent, or the reverse. Existing identity work in #623 shows why those parent identities must stay separate.

Restrict the candidate search to an ordered set of allowed buckets. For a normal TV parent, prefer the parent tv bucket and then season. For an anime parent, use the anime identity only unless a separate, tested migration rule says otherwise.

4. Regression tests are required

This PR changes persisted item selection, season relationships, episode history visibility, and status reconciliation. It currently adds no tests and records no command results.

Required cases are tracked in #812.

5. Update from current latest

The branch is 46 commits behind and 1 commit ahead of current latest as reviewed on 2026-08-16. Current model and importer changes include distinct-episode completion behavior that this PR must preserve. Update the branch before final validation.

AI assistance

Provider: Anthropic. Model: Claude Opus 5 (API identifier claude-opus-5).

Validation

No executable validation result is recorded on the current branch. GitHub Actions reports action_required; App Tests, Lint, Docker Image, and CodeQL have not executed.

Run and record at minimum after updating from latest:

Do not mark a command complete unless its result is available from the updated branch.

Contract handoff

  • Domain guide regeneration: Not applicable unless the implementation changes domain vocabulary.
  • OpenAPI regeneration: Not applicable. No API contract is proposed.
  • Contract tests: Record the result if a shared contract or serializer changes. No such change exists in the current draft.
  • Migration: No schema change is proposed. makemigrations --check --dry-run must confirm this.

Screenshots and interaction QA

Not applicable for the current scope. This is backend model behavior. It does not change templates, CSS, layout, keyboard behavior, screen-reader output, focus order, visual grouping, or cognitive load.

The user-facing result still needs a manual data check: existing dates and history must remain visible after completing the parent show.

Human review

  • Completed on 2026-08-16.
  • Objective confirmed as useful.
  • Three blocking correctness findings recorded.
  • Corrective commit reviewed.
  • Updated-branch test evidence reviewed.

/qa

  • Persisted-identity path reviewed.
  • Status-transition path reviewed.
  • Sparse-history and repeat-watch behavior reviewed.
  • TV/anime identity boundary reviewed.
  • Current-target divergence reviewed.
  • Executable QA pending corrective commit and branch update.

Post-mortem

Trigger: An imported season and the whole-show completion path used different library buckets for the same provider identity.

Failure: Completion did not find the compatible imported row and created a parallel season/episode set. A second fallback then treated the highest watched episode number as a completed-episode count.

Impact: Users can see duplicated seasons or episodes, split date history, and an incorrect completed status.

Why the defect escaped: Tests did not combine imported bucket identity, whole-show completion, existing watch dates, sparse episode history, and anime/TV identity separation.

Corrective action: Use an ordered compatible-bucket lookup, retain the atomic create path, derive completion from distinct completed episodes, and add focused model tests.

Prevention: Keep the #812 acceptance matrix as the regression contract for future import and completion changes.

Relationships

@ryan-winkler ryan-winkler left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reported failure is real and the PR is aimed at the correct user problem. The concrete imported-library evidence in the description is especially useful. I found three blocking correctness points that need to be resolved before this can merge:

  1. max_watched is an episode position, not a completed-episode count. Sparse history can therefore become Completed incorrectly.
  2. The new lookup-then-create() path removes the database-safe get_or_create() behavior and can fail under concurrent completion requests.
  3. The unbounded cross-bucket fallback can select an anime season for a normal TV parent, or the reverse, when the preferred bucket is absent.

I added exact code suggestions for the two changed paths. Equivalent code is fine if it preserves these rules. I also created #812 as the regression contract and enriched this PR with the full test matrix, relationships, /qa result, interaction-scope statement, and post-mortem.

Please add focused tests for:

  • reuse of an imported tv-bucket season without creating a second Item, Season, or episode set;
  • preservation of the existing episode dates;
  • exclusion of anime candidates for normal TV and normal-TV candidates for anime;
  • one late episode out of ten remaining In progress;
  • all ten distinct completed episodes becoming Completed;
  • repeated plays not increasing the distinct completion count;
  • a deliberate In progress rewatch remaining In progress.

The branch also needs an update from current latest before validation. Record the targeted tests, fast suite, Ruff, migration dry-run, and diff check after that update. The AI disclosure also needs the exact provider and model identifier; the current wording is not specific enough to verify.

No screenshot is required for this backend-only change. A manual data check should still confirm that old dates remain visible after whole-show completion.

Thank you for isolating the bucket mismatch and for opening a draft rather than presenting this as merge-ready. The issue is worth fixing; these changes will make the repair safe for imported libraries and future identity variants.

Comment thread src/app/models/tv.py Outdated
Comment on lines +360 to +385
from integrations.imports.helpers import find_item_across_buckets

season_identity = {
"media_id": self.item.media_id,
"source": self.item.source,
"media_type": MediaTypes.SEASON.value,
"season_number": season_number,
}
item = find_item_across_buckets(
preferred_bucket=self.item.library_media_type,
**season_identity,
)
if item is None:
item = Item.objects.create(
**season_identity,
library_media_type=(
MediaTypes.ANIME.value
if self.item.library_media_type == MediaTypes.ANIME.value
else MediaTypes.SEASON.value
),
**Item.title_fields_from_metadata(
season_metadata,
fallback_title=self.item.title,
),
"image": season_image,
},
)
image=season_image,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block needs both an allowed-bucket rule and the existing database-safe create behavior. find_item_across_buckets() can fall back to an anime candidate for a normal TV parent when the preferred bucket is absent, and the following plain create() can race after two requests both observe no item. The replacement below prefers only compatible buckets and uses get_or_create() for the target bucket.

Suggested change
from integrations.imports.helpers import find_item_across_buckets
season_identity = {
"media_id": self.item.media_id,
"source": self.item.source,
"media_type": MediaTypes.SEASON.value,
"season_number": season_number,
}
item = find_item_across_buckets(
preferred_bucket=self.item.library_media_type,
**season_identity,
)
if item is None:
item = Item.objects.create(
**season_identity,
library_media_type=(
MediaTypes.ANIME.value
if self.item.library_media_type == MediaTypes.ANIME.value
else MediaTypes.SEASON.value
),
**Item.title_fields_from_metadata(
season_metadata,
fallback_title=self.item.title,
),
"image": season_image,
},
)
image=season_image,
)
target_bucket = (
MediaTypes.ANIME.value
if self.item.library_media_type == MediaTypes.ANIME.value
else MediaTypes.SEASON.value
)
season_identity = {
"media_id": self.item.media_id,
"source": self.item.source,
"media_type": MediaTypes.SEASON.value,
"season_number": season_number,
}
allowed_buckets = [self.item.library_media_type]
if target_bucket not in allowed_buckets:
allowed_buckets.append(target_bucket)
item = None
for bucket in allowed_buckets:
item = (
Item.objects.filter(
**season_identity,
library_media_type=bucket,
)
.order_by("id")
.first()
)
if item is not None:
break
if item is None:
item, _ = Item.objects.get_or_create(
**season_identity,
library_media_type=target_bucket,
defaults={
**Item.title_fields_from_metadata(
season_metadata,
fallback_title=self.item.title,
),
"image": season_image,
},
)

Comment thread src/app/models/tv.py Outdated
# count when we have one; only assume "still watching" when we
# genuinely have no episode total from any local source.
local_total = getattr(self.item, "local_season_episode_count", None)
if local_total and max_watched >= local_total:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max_watched is the highest episode position. It is not the number of completed episodes. With local_total=10, a single watch of episode 10 satisfies this condition and marks the season complete. The model already provides a distinct completed-episode count that ignores repeated plays and non-completed rows.

Suggested change
if local_total and max_watched >= local_total:
if local_total and self.completed_episode_count >= local_total:

Please cover the sparse E10-only case, duplicate plays, full distinct completion, and the manual In progress rewatch override.

@ryan-winkler ryan-winkler added the bug Something isn't working label Aug 16, 2026 — with ChatGPT Codex Connector

ryan-winkler commented Aug 16, 2026

Copy link
Copy Markdown

One important correction after checking the current latest model in full: the existing release-event branch also compares max_watched with an episode total. Updating only the new local-count branch would leave the same sparse-history defect in the other path.

After updating the branch, reuse the existing count-based model behavior instead of adding another status algorithm:

local_total = getattr(self.item, "local_season_episode_count", None) or 0
known_total = total_eps or local_total or None

desired_status = self.derived_status_from_episode_progress(
    max_progress=known_total,
)
if (
    desired_status == Status.COMPLETED.value
    and self.status == Status.IN_PROGRESS.value
):
    # Preserve a deliberate rewatch override.
    desired_status = Status.IN_PROGRESS.value

derived_status_from_episode_progress() already uses the distinct completed-episode count while treating completed or dropped episode rows as activity. This keeps one definition of the status rule and prevents the new fallback from drifting away from the rest of the model.

Please add both regression cases:

  • release events through episode 10, with only episode 10 completed, remains In progress;
  • local_season_episode_count=10, with only episode 10 completed, remains In progress.

Also retain the full-completion, duplicate-play, dropped-episode, and manual-rewatch cases in #812. An equivalent implementation is acceptable if it preserves these outcomes.

@JoshDaFishy
JoshDaFishy force-pushed the fix/season-bucket-lookup branch from 959d815 to 12b4ac6 Compare August 16, 2026 20:20
@JoshDaFishy

Copy link
Copy Markdown
Author

Thanks for the detailed review — all three findings were real defects in my draft.

  1. Sparse history. Replaced with derived_status_from_episode_progress(max_progress=known_total) per your follow-up, so both the release-event and local-count branches use the distinct completed-episode count. E10 alone on a ten-episode season now stays In progress. The manual rewatch override is preserved.

One deliberate deviation: I kept an explicit PLANNING result when no episodes are logged. derived_status_from_episode_progress() returns the current status in that case, which would leave a season reading Completed after unwatch() removes the last episode — a regression, since this method is called from that path. Happy to drop it if you'd rather that case were handled elsewhere.

  1. Atomic create. get_or_create() restored for the create path.

  2. Bucket crossing. Dropped find_item_across_buckets() in favour of an ordered allowed-bucket search — parent bucket first, then the canonical target. An anime parent searches only the anime identity, so TV and anime can't cross-attach.

Branch updated from current latest. tv.py was unchanged across those commits, so the update was conflict-free.

Validation on the updated branch:

app.tests.models.test_tv_completed_on_create test_season test_episode_bucket — 47 tests, OK
ruff check src — clean
makemigrations --check --dry-run — no changes detected
git diff --check upstream/latest...HEAD — clean

Full suite and the #812 regression module still to come; I'll push those before marking this ready for review.

@JoshDaFishy

Copy link
Copy Markdown
Author

Regression tests added in src/app/tests/models/test_tv_completion_identity.py — 13 tests covering the #812 matrix.

Verified in both directions:

against this branch: 13 tests, OK
against latest with the tv.py change stashed: 5 failures — forked season Item, second Season row, duplicate episode rows, distinct-count completion, and the sparse release-event case

One honest caveat: test_late_single_episode_with_local_count_stays_in_progress passes on unpatched code too, because the local-count branch doesn't exist there. It guards the new behaviour rather than proving a fixed defect.

Two of the sparse tests initially passed against unpatched code for the wrong reason — Episode.save() leaves the season In progress, and the rewatch override then masks a wrong Completed. They now reset the status via a queryset update() before calling the sync, so the release-event case discriminates properly.

Concurrency is covered by patching title_fields_from_metadata to insert the conflicting row; it's evaluated while building the defaults argument, so it lands between the bucket lookup and the create. Without get_or_create this raises IntegrityError.

Validation on the updated branch:

ruff check src — clean
makemigrations --check --dry-run — no changes detected
test_tv_completion_identity — 13 OK
test_tv_completed_on_create, test_season, test_episode_bucket — 47 OK
git diff --check upstream/latest...HEAD — clean

Note: app.tests.models.test_tv.TVModel.test_tv_save fails with AssertionError: 0 != 10 on this branch and on unmodified latest, so it appears pre-existing and unrelated.

@ryan-winkler

Copy link
Copy Markdown

Review & Status Check

The goal of resolving existing seasons across compatible library buckets on completion (avoiding duplicate season/episode generation) is well aligned with #812 and #623.

Required Formatting & CI Fix

  1. Flake8 / Ruff lint violation: Add a trailing newline to src/app/tests/models/test_tv_completion_identity.py:369 (W292 No newline at end of file).
  2. CI failure: The parking test failure on CI is resolved by rebasing onto current latest (fixed in Stabilize SQLite entrypoint parking test #826).

Follow the guidance outlined in the review notes (verifying distinct completed count over position and maintaining atomic get_or_create semantics) to finalize.

…pisodes

Search compatible library buckets in priority order instead of keying
get_or_create on the season bucket alone, so an imported tv-bucket season
is reused rather than forked. Never cross normal-TV and anime parent
identities. Retain get_or_create for the create path.

Route both the release-event and local-count branches through
derived_status_from_episode_progress(), which counts distinct completed
episodes rather than the highest watched position.
@JoshDaFishy
JoshDaFishy force-pushed the fix/season-bucket-lookup branch from 2a244f6 to 6b56354 Compare August 22, 2026 15:21
@JoshDaFishy

Copy link
Copy Markdown
Author

Both addressed:

W292 fixed; ruff check src clean.
Rebased onto current latest to pick up #826.

Re-ran validation on the rebased branch: All checks passed.

The distinct-count derivation and atomic get_or_create semantics from the earlier review notes are unchanged in this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TV completion can fork imported season identities and infer completion from sparse history

2 participants