From 9a84bbdd3d6a9f68d83008d2adc135300cb81a34 Mon Sep 17 00:00:00 2001 From: John Kattenhorn Date: Fri, 14 Aug 2026 20:47:28 +0100 Subject: [PATCH] DupFileManager: merge play and O history instead of discarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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) --- plugins/DupFileManager/StashPluginHelper.py | 36 ++++++++++++++++++++- plugins/FileMonitor/StashPluginHelper.py | 36 ++++++++++++++++++++- plugins/RenameFile/StashPluginHelper.py | 36 ++++++++++++++++++++- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/plugins/DupFileManager/StashPluginHelper.py b/plugins/DupFileManager/StashPluginHelper.py index 27bfae8e..e84fad9b 100644 --- a/plugins/DupFileManager/StashPluginHelper.py +++ b/plugins/DupFileManager/StashPluginHelper.py @@ -990,6 +990,12 @@ def __init__(self, stash, excludeMergeTags=None): def merge(self, SrcData, DestData): self.srcData = SrcData self.destData = DestData + # find_duplicate_scenes_diff returns a reduced scene fragment which has none of the + # playback fields, so re-read both scenes when they are missing. Without this the + # history merge below silently does nothing when called from the duplicate report. + if 'play_history' not in self.srcData or 'play_history' not in self.destData: + self.srcData = self.stash.find_scene(int(self.srcData['id'])) + self.destData = self.stash.find_scene(int(self.destData['id'])) ORG_DATA_DICT = {'id' : self.destData['id']} self.dataDict = ORG_DATA_DICT.copy() self.mergeItems('tags', 'tag_ids', [], excludeName=self.excludeMergeTags) @@ -1007,11 +1013,37 @@ def merge(self, SrcData, DestData): self.mergeItem('details') self.mergeItem('rating100') self.mergeItem('code') + self.mergeItem('organized') + self.mergePlayDuration() if self.dataDict != ORG_DATA_DICT: self.stash.Trace(f"Updating scene ID({self.destData['id']}) with {self.dataDict}; path={self.destData['files'][0]['path']}", toAscii=True) self.result = self.stash.update_scene(self.dataDict) + self.mergeHistory() return self.result - + + def mergePlayDuration(self): # Total time played is cumulative, so the two scenes' durations are summed + srcDuration = self.srcData['play_duration'] if 'play_duration' in self.srcData else None + if not srcDuration: + return + destDuration = self.destData['play_duration'] if 'play_duration' in self.destData else None + self.dataDict.update({'play_duration' : (destDuration if destDuration else 0) + srcDuration}) + + def mergeHistory(self): # Play and O history can not go through update_scene + # play_count and o_counter are deprecated as unsupported on SceneUpdateInput, so setting + # them there is silently dropped. sceneAddPlay and sceneAddO take a list of timestamps, + # which merges the actual history rather than only bumping a counter. + destId = self.destData['id'] + for fieldName, mutationName in (('play_history', 'sceneAddPlay'), ('o_history', 'sceneAddO')): + times = self.srcData[fieldName] if fieldName in self.srcData else None + if not times: + continue + self.stash.Trace(f"Merging {len(times)} {fieldName} entries into scene ID({destId})") + self.stash.call_GQL( + "mutation MergeHistory($id: ID!, $times: [Timestamp!]) {" + f" {mutationName}(id: $id, times: $times) " + "{ count } }", + {"id" : destId, "times" : times}) + self.result = "Merged" + def Nothing(self, Data): if not Data or Data == "" or (type(Data) is str and Data.strip() == ""): return True @@ -1020,6 +1052,8 @@ def Nothing(self, Data): def mergeItem(self,fieldName, updateFieldName=None, subField=None): if updateFieldName == None: updateFieldName = fieldName + if fieldName not in self.srcData or fieldName not in self.destData: # Not every query returns every field + return if self.Nothing(self.destData[fieldName]) and not self.Nothing(self.srcData[fieldName]): if subField == None: self.dataDict.update({ updateFieldName : self.srcData[fieldName]}) diff --git a/plugins/FileMonitor/StashPluginHelper.py b/plugins/FileMonitor/StashPluginHelper.py index 27406a2c..9df93066 100644 --- a/plugins/FileMonitor/StashPluginHelper.py +++ b/plugins/FileMonitor/StashPluginHelper.py @@ -1024,6 +1024,12 @@ def __init__(self, stash, excludeMergeTags=None): def merge(self, SrcData, DestData): self.srcData = SrcData self.destData = DestData + # find_duplicate_scenes_diff returns a reduced scene fragment which has none of the + # playback fields, so re-read both scenes when they are missing. Without this the + # history merge below silently does nothing when called from the duplicate report. + if 'play_history' not in self.srcData or 'play_history' not in self.destData: + self.srcData = self.stash.find_scene(int(self.srcData['id'])) + self.destData = self.stash.find_scene(int(self.destData['id'])) ORG_DATA_DICT = {'id' : self.destData['id']} self.dataDict = ORG_DATA_DICT.copy() self.mergeItems('tags', 'tag_ids', [], excludeName=self.excludeMergeTags) @@ -1041,11 +1047,37 @@ def merge(self, SrcData, DestData): self.mergeItem('details') self.mergeItem('rating100') self.mergeItem('code') + self.mergeItem('organized') + self.mergePlayDuration() if self.dataDict != ORG_DATA_DICT: self.stash.Trace(f"Updating scene ID({self.destData['id']}) with {self.dataDict}; path={self.destData['files'][0]['path']}", toAscii=True) self.result = self.stash.update_scene(self.dataDict) + self.mergeHistory() return self.result - + + def mergePlayDuration(self): # Total time played is cumulative, so the two scenes' durations are summed + srcDuration = self.srcData['play_duration'] if 'play_duration' in self.srcData else None + if not srcDuration: + return + destDuration = self.destData['play_duration'] if 'play_duration' in self.destData else None + self.dataDict.update({'play_duration' : (destDuration if destDuration else 0) + srcDuration}) + + def mergeHistory(self): # Play and O history can not go through update_scene + # play_count and o_counter are deprecated as unsupported on SceneUpdateInput, so setting + # them there is silently dropped. sceneAddPlay and sceneAddO take a list of timestamps, + # which merges the actual history rather than only bumping a counter. + destId = self.destData['id'] + for fieldName, mutationName in (('play_history', 'sceneAddPlay'), ('o_history', 'sceneAddO')): + times = self.srcData[fieldName] if fieldName in self.srcData else None + if not times: + continue + self.stash.Trace(f"Merging {len(times)} {fieldName} entries into scene ID({destId})") + self.stash.call_GQL( + "mutation MergeHistory($id: ID!, $times: [Timestamp!]) {" + f" {mutationName}(id: $id, times: $times) " + "{ count } }", + {"id" : destId, "times" : times}) + self.result = "Merged" + def Nothing(self, Data): if not Data or Data == "" or (type(Data) is str and Data.strip() == ""): return True @@ -1054,6 +1086,8 @@ def Nothing(self, Data): def mergeItem(self,fieldName, updateFieldName=None, subField=None): if updateFieldName == None: updateFieldName = fieldName + if fieldName not in self.srcData or fieldName not in self.destData: # Not every query returns every field + return if self.Nothing(self.destData[fieldName]) and not self.Nothing(self.srcData[fieldName]): if subField == None: self.dataDict.update({ updateFieldName : self.srcData[fieldName]}) diff --git a/plugins/RenameFile/StashPluginHelper.py b/plugins/RenameFile/StashPluginHelper.py index 27406a2c..9df93066 100644 --- a/plugins/RenameFile/StashPluginHelper.py +++ b/plugins/RenameFile/StashPluginHelper.py @@ -1024,6 +1024,12 @@ def __init__(self, stash, excludeMergeTags=None): def merge(self, SrcData, DestData): self.srcData = SrcData self.destData = DestData + # find_duplicate_scenes_diff returns a reduced scene fragment which has none of the + # playback fields, so re-read both scenes when they are missing. Without this the + # history merge below silently does nothing when called from the duplicate report. + if 'play_history' not in self.srcData or 'play_history' not in self.destData: + self.srcData = self.stash.find_scene(int(self.srcData['id'])) + self.destData = self.stash.find_scene(int(self.destData['id'])) ORG_DATA_DICT = {'id' : self.destData['id']} self.dataDict = ORG_DATA_DICT.copy() self.mergeItems('tags', 'tag_ids', [], excludeName=self.excludeMergeTags) @@ -1041,11 +1047,37 @@ def merge(self, SrcData, DestData): self.mergeItem('details') self.mergeItem('rating100') self.mergeItem('code') + self.mergeItem('organized') + self.mergePlayDuration() if self.dataDict != ORG_DATA_DICT: self.stash.Trace(f"Updating scene ID({self.destData['id']}) with {self.dataDict}; path={self.destData['files'][0]['path']}", toAscii=True) self.result = self.stash.update_scene(self.dataDict) + self.mergeHistory() return self.result - + + def mergePlayDuration(self): # Total time played is cumulative, so the two scenes' durations are summed + srcDuration = self.srcData['play_duration'] if 'play_duration' in self.srcData else None + if not srcDuration: + return + destDuration = self.destData['play_duration'] if 'play_duration' in self.destData else None + self.dataDict.update({'play_duration' : (destDuration if destDuration else 0) + srcDuration}) + + def mergeHistory(self): # Play and O history can not go through update_scene + # play_count and o_counter are deprecated as unsupported on SceneUpdateInput, so setting + # them there is silently dropped. sceneAddPlay and sceneAddO take a list of timestamps, + # which merges the actual history rather than only bumping a counter. + destId = self.destData['id'] + for fieldName, mutationName in (('play_history', 'sceneAddPlay'), ('o_history', 'sceneAddO')): + times = self.srcData[fieldName] if fieldName in self.srcData else None + if not times: + continue + self.stash.Trace(f"Merging {len(times)} {fieldName} entries into scene ID({destId})") + self.stash.call_GQL( + "mutation MergeHistory($id: ID!, $times: [Timestamp!]) {" + f" {mutationName}(id: $id, times: $times) " + "{ count } }", + {"id" : destId, "times" : times}) + self.result = "Merged" + def Nothing(self, Data): if not Data or Data == "" or (type(Data) is str and Data.strip() == ""): return True @@ -1054,6 +1086,8 @@ def Nothing(self, Data): def mergeItem(self,fieldName, updateFieldName=None, subField=None): if updateFieldName == None: updateFieldName = fieldName + if fieldName not in self.srcData or fieldName not in self.destData: # Not every query returns every field + return if self.Nothing(self.destData[fieldName]) and not self.Nothing(self.srcData[fieldName]): if subField == None: self.dataDict.update({ updateFieldName : self.srcData[fieldName]})