Skip to content

DupFileManager: merge play and O history instead of discarding it - #759

Open
johnkattenhorn wants to merge 1 commit into
stashapp:mainfrom
johnkattenhorn:fix/merge-play-and-o-history
Open

DupFileManager: merge play and O history instead of discarding it#759
johnkattenhorn wants to merge 1 commit into
stashapp:mainfrom
johnkattenhorn:fix/merge-play-and-o-history

Conversation

@johnkattenhorn

Copy link
Copy Markdown

Fixes #605.

The problem

mergeMetadata.merge() copies tags, performers, galleries, urls, studio, title, director, date, details, rating100 and code — but nothing about how the scene was watched. Play count, O count, total play duration and the organized flag are all left behind on the scene that is about to be deleted.

So merge-then-delete silently destroys watch history. For a deduplication tool that is the one thing that must not happen: the file is replaceable, the history is not.

Why the obvious fix does not work

Setting play_count or o_counter through update_scene has no effect — both are @deprecated(reason: "Unsupported") on SceneUpdateInput, and the values are dropped server-side.

sceneAddPlay and sceneAddO take a list of timestamps. That is a better fix than the issue asks for: the merged scene keeps when each play happened, not merely how many there were.

What this does

  • Play and O history — replayed onto the destination with the source's original timestamps, via sceneAddPlay / sceneAddO.
  • Play duration — summed. Total time played is cumulative across two copies of the same content.
  • organized — follows the existing fill-the-blank rule used by every other scalar field: copied only when the destination does not already have it set.

Counts and history are cumulative and therefore summed, not overwritten. Existing fill-the-blank behaviour for the other fields is untouched.

Two fixes that only appeared under end-to-end testing

Calling mergeMetadata directly passes full scene dictionaries, and against that the change worked first time. Running the plugin's real Delete Duplicates task told a different story, twice:

  1. KeyError: 'organized' — every merge failed. find_duplicate_scenes_diff returns a reduced scene fragment, and mergeItem indexes fields directly. Merging organized raised on every pair, burned all five retries, and the merge never happened — while deletion carried on regardless. mergeItem now skips fields the query did not return.

  2. The history merge silently did nothing. With the KeyError guarded, merges appeared to succeed, but that reduced fragment carries none of the playback fields, so every history field was skipped. merge() now re-reads both scenes when the playback fields are absent.

Worth flagging for maintainers: when the merge fails, deletion still proceeds. That is how a reported merge failure becomes lost data rather than a skipped merge.

Testing

Against a clean Stash v0.31.1 in Docker with two genuine phash duplicates (one source encoded at two qualities, distance 1), driven through the plugin's own Delete Duplicates task with Merge Duplicate Tags enabled.

Empty destination — the doomed copy carries play_count 4, o_counter 2, play_duration 250, rating, title and organized:

surviving scene: master_hq.mp4
  play_count=4  o_counter=2  play_duration=250  organized=True  rating=95
  play_history=['2026-08-01T10:00:00Z','2026-08-02T11:00:00Z','2026-08-03T12:00:00Z','2026-08-04T13:00:00Z']
  o_history=['2026-08-05T14:00:00Z','2026-08-06T15:00:00Z']

Destination with its own history — the case where both copies were watched:

before: low(delete) plays 4, Os 2, duration 250 | hq(keep) plays 1, O 1, duration 50
after : play_count 5 (expect 5)  o_counter 3 (expect 3)  play_duration 300 (expect 300)
        history lists are the union of both; title "HQ Keeps Its Title" preserved

Also covered: the integer-id entry point used by the report path (mergeMetadata(int, int)) — counts and duration sum correctly there too.

Before this change, the same fixture produced play_count 4 -> 0, o_counter 2 -> 0, organized true -> false on the surviving scene.

Scope

Applied to all three copies of StashPluginHelper.py, which are byte-identical in this region. FileMonitor and RenameFile are covered by a compile check only — neither appears to exercise the merge class.

Deliberately left for separate changes, to keep this reviewable:

  • stash_ids (StashDB links) and scene markers are still not merged.
  • The # ToDo: Figure out how to merge groups at the top of merge() is untouched.
  • resume_time is intentionally not merged — unlike the others it is a playback position rather than an accumulated total, and there is no obviously correct answer when both scenes have one.

Independent of #757 — different region of the same file, so the two apply in either order.

LLM-assisted contribution disclosure

Per the repository's contribution policy: this change was prepared with LLM assistance (Claude). The diff has been reviewed by me, the testing described above was carried out and its results are reproduced verbatim, and I take full responsibility for the change and its license compliance.

Merging a duplicate copied tags, performers, galleries, urls, studio, title,
director, date, details, rating and code — but not the watch history. Play
count, O count, total play duration and the organized flag were all left on the
scene being deleted, so merge-then-delete silently destroyed them. Reported in
issue stashapp#605.

Setting play_count or o_counter through update_scene does not work: both are
deprecated as unsupported on SceneUpdateInput and the values are dropped.
sceneAddPlay and sceneAddO take a list of timestamps, so the fix merges the
actual history rather than only bumping a counter — the merged scene keeps when
each play happened, not just how many there were.

Counts, history and play duration are cumulative, so they are summed across the
two scenes. The organized flag follows the existing fill-the-blank rule used for
the other fields: it is copied only when the destination does not already have
it set.

Two things were needed to make this work from the duplicate report, and both
only showed up when running the real plugin task rather than calling merge
directly:

find_duplicate_scenes_diff returns a reduced scene fragment that carries none of
the playback fields, and mergeItem indexed fields directly, so merging organized
raised KeyError and every merge failed five retries deep while deletion carried
on regardless. merge now re-reads both scenes when the playback fields are
absent, and mergeItem skips fields the query did not return.

Tested against Stash v0.31.1 in Docker with two real phash duplicates — one
source encoded at two qualities — driven through the plugin's own
Delete Duplicates task with Merge Duplicate Tags enabled.

Empty destination: play_count 4, o_counter 2, play_duration 250 and both history
lists carry onto the surviving scene with timestamps intact, and the duplicate
is deleted.

Destination with its own history: 4+1 plays, 2+1 Os, 250+50 duration all sum
correctly, the history lists are the union of both, and the destination keeps
its own title, so fill-the-blank semantics are unchanged.

Applied to all three copies of StashPluginHelper.py, which were byte-identical
in this region.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DupFileManager] merge does not merge play count or o-count

1 participant