Add SmartResolve plugin for duplicate scene workflows (#697)

* Add SmartResolve plugin for duplicate-checker workflow.

Introduce SmartResolve with rule-based scene selection, protection/sync safeguards, in-row sync tooling, and documented settings for tuning Smart Resolve behavior.

Made-with: Cursor

* Update SmartResolve plugin to improve synchronization behavior and version increment to 1.0.02. Added a function to determine if a refresh is needed after sync, optimizing group loading based on cache state.

* Revert Smart Resolve query-cache reuse in selection flow.

Restore unconditional duplicate-group reload when running Select Smart Resolve to avoid the browser instability introduced by cache-key reuse, while keeping sync no-refresh behavior unchanged.

Made-with: Cursor

---------

Co-authored-by: KennyG <kennyg@kennyg.com>
This commit is contained in:
Stash-KennyG
2026-04-09 10:52:24 -04:00
committed by GitHub
parent eff9999aa8
commit 68dbccbe31
5 changed files with 3464 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
# Smart Resolve
UI plugin for Stashs **Scene Duplicate Checker** (`Settings → Tools → Scene Duplicate Checker`).
## What it does
1. **Smart Resolve analysis/tagging** — Adds `Select Smart Resolve` to Stashs native **Select Options** menu and annotates duplicate rows with reason text plus sync recommendations.
2. **Row tools** — Adds per-row **Sync data** / **Sync rec.** buttons and stash ID badges in the scene details icon row.
3. **Safe selection** — Auto-selects delete candidates only when rules say it is safe; unresolved/sync-required sets are left unchecked.
## Smart Resolve rule flow
*Goal:* Identify a single candidate keeper regardless of initial candidate scene count.
Rules run per each duplicate group on the page.
1. **Determine a primary keep candidate**
- Process each rule in order.
- Any scene which is deficient of the identified criteria level is eliminated from being the potential keeper and any remaining tied survivors are evaluated as the next sub-step.
- When a sub-step leaves only one file as the potential keeper, move to step 2.
- For any step where the value is not known or null, assume 0 for this phase
- Store step result as selection reason.
- Rules 1-13 are toggleable via plugin settings; rule 14 remains always-on, deterministic fallback.
1) Prefer the scene with the greatest total pixel resolution (product of x and y, 1920x1080=2073600)
- Apply a 1% pixel-area tolerance: candidates within 1% of the top area are treated as tied for this step.
2) Prefer the scene with the greatest framerate
3) Prefer the scene with the better codec (AV1 > H265 > H264 > Others)
3b) Prefer candidates whose primary file path includes `upgrade` (toggleable)
4) Prefer the scene with greater duration
5) Prefer the scene with smaller size (unless file name includes `upgrade`)
- Files with the word `upgrade` cannot be eliminated from candidacy as per having a larger file size.
- Apply file-size tolerance when eliminating larger files: allow `max(1MB, 1% of min file size)` above the smallest file size.
- In a multi-file scenario, files larger than `min + tolerance` may be eliminated (unless `upgrade` token applies).
6) Prefer the scene with an older scene date (2 day tollerance)
7) Prefer the scene with more groups
8) Prefer the scene with stashID
9) Prefer the scene with more performers
10) Prefer the scene with markers
11) Prefer the scene with more tags
12) Prefer the scene with LESS associated files
13) Prefer the scene with more non-null metadata elements (title, studio_code, urls, date, director, galleries, studio, performers, groups, tags, details)
14) Final deterministic tiebreaker, the scene with a lower scene_id
2. **Evaluate each non-keeper iteratively to prevent data loss**
Process each scene and each rule to determine status:
```
markForDeletion: (boolean)
markParentForSync: (boolean)
exceptions: ([array](string))
```
Exception rules (any of these will trigger markForDeletion=false)
- Protection rules a-f are toggleable via plugin settings.
a) Protect O-count: O-count scenes should never be marked for deletion
b) Protect Group associations: Only mark for deletion if the same or more group information is attached to the primary candidate (i.e. k.groups{id,index} contains all (n.groups{id,index})
- null allows a match with only other null
- null does not match a non-null
- Scenes may be members of multiple groups. A primary source (k) must replicate all non-keeper (n) sources to not have an exception
- Miss-matched scenes should be flagged according to reason message and marked for manual resolution
c) Protect performer mismatch by ID (markParentForSync=true)
- If non-keeper has any performer ID not present on keeper, trigger exception
- Only identical performer ID sets avoid this exception
d) Protect tag loss >1 for non-stash'd scenes (markParentForSync=true)
e) Protect older dates
if K.date > n.date (markParentForSync=true)
if K.date == null && n.date != null (markParentForSync=true)
if K.date != null && n.date == null (no action)
f) Protect scenes tagged with "Ignore:Smart Resolve" (case-insensitive); never auto-delete these scenes
3. **Generate decision reason (`reasonAgainst`)**
- Generate message from decision code
- If exception code array is not empty, expand message. Block marking, recommend sync.
- row button becomes **Sync rec.**
- unresolved count increments
- smart auto-selection skips that set
Notes:
A primary file is determined, then we determine if non-primary needs to be protected from loss. The primary file is never changed mid-analysis. Exceptions do not change the primary file, only protect loss.
## Usage
1. Install the plugin folder under your Stash plugins directory and enable it in **Settings → Plugins**.
2. Open the **Scene Duplicate Checker**.
3. Open **Select Options** and click **Select Smart Resolve**.
4. Review unresolved/sync-rec rows (`Sync rec.` buttons and unresolved counter).
5. Use row **Sync data** where recommended, then re-run Smart Resolve.
6. Use Stashs native Delete/Merge actions on remaining selected rows.
Optional setting: **After Sync, mark source scenes for deletion** — default for the “check sources after sync” checkbox in the Sync modal.
## Settings UI
![Smart Resolve settings](about.png)
## Limits
- Rules evaluate **only visible duplicate groups on the current page**. Pagination and page size can change outcomes seen in a single run.
- Any missing/unknown criterion values are normalized to `0` during candidate selection (line 20 behavior). This preserves determinism but can favor records with more populated metadata fields.
- Step 1 is a strict elimination pipeline. Once a scene is eliminated by an earlier criterion, later criteria do not reintroduce it.
- The `upgrade` filename exception is a string heuristic. It is not case-sensitive. This step is not expected to be a major factor, but creates an easy way to work around the plugin.
- Group protection depends on `{group.id, scene_index}` containment semantics; mismatches are expected to force manual/sync resolution.
- Date protection assumes parseable comparable date values. Stash provides some date parse semantics. However all null dates should be assumed to be the last date of any incomplete window. (i.e. 2020 -> 2020-12-31, 2020-06 -> 2020-06-30) Null, Invalid, or unparseable values should be treated as 2999-12-31 by implementation.
- `markParentForSync` and `exceptions` are structured outputs in this spec, but UI sync-rec indicators must be wired to those flags in implementation.
- This flow is designed for deterministic outcomes, not probabilistic ranking; tie-break behavior is intentionally resolved by lower `scene_id`.
- Sync actions are `sceneUpdate`-based (metadata transfer), not full `sceneMerge`; scene IDs remain separate after sync.
## Repository
Maintained in [Stash-KennyG/CommunityScripts](https://github.com/Stash-KennyG/CommunityScripts).

View File

@@ -0,0 +1,387 @@
#duplicate-resolver-toolbar {
margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem;
background: var(--bs-secondary-bg, rgba(0, 0, 0, 0.15));
border-radius: 0.25rem;
border: 1px solid var(--bs-border-color, rgba(255, 255, 255, 0.12));
}
#duplicate-resolver-toolbar .dr-toolbar-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
#duplicate-resolver-toolbar .dr-btn-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.35rem;
}
#duplicate-resolver-toolbar .dr-btn-row:empty {
display: none;
margin: 0;
}
#scene-duplicate-checker .dr-core-actions {
display: inline-flex;
gap: 0.35rem;
align-items: center;
margin-left: 0.4rem;
}
#scene-duplicate-checker .dr-processing-indicator {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--bs-body-color, #d7dbe0);
font-size: 0.75rem;
line-height: 1;
}
#scene-duplicate-checker .dr-processing-spinner {
width: 0.85rem;
height: 0.85rem;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: var(--bs-info, #7cc7ff);
border-radius: 50%;
animation: dr-spin 0.8s linear infinite;
}
#scene-duplicate-checker .dr-processing-bar {
width: 80px;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
}
#scene-duplicate-checker .dr-processing-bar-fill {
display: block;
width: 40px;
height: 100%;
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(124, 199, 255, 0.2) 0%,
rgba(124, 199, 255, 0.95) 50%,
rgba(124, 199, 255, 0.2) 100%
);
animation: dr-progress-slide 1s ease-in-out infinite;
}
@keyframes dr-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes dr-progress-slide {
0% {
transform: translateX(-40px);
}
100% {
transform: translateX(80px);
}
}
/* Keep duplicate rows compact and prevent action/icon wrapping. */
#scene-duplicate-checker table.duplicate-checker-table td.scene-details .btn-group {
flex-wrap: nowrap !important;
}
#scene-duplicate-checker table.duplicate-checker-table td.scene-details,
#scene-duplicate-checker table.duplicate-checker-table td:last-child {
white-space: nowrap;
}
#scene-duplicate-checker .dr-inline-reason {
font-size: calc(1em + 2pt);
color: var(--bs-warning, #ffd54a);
}
#scene-duplicate-checker table.duplicate-checker-table tbody tr.dr-unresolved-highlight {
background: rgba(255, 0, 0, 0.08) !important;
}
#scene-duplicate-checker .dr-stashid-btn {
display: inline-flex !important;
align-items: center;
gap: 0.3rem;
}
#scene-duplicate-checker .dr-stashid-btn.dr-stashid-btn-link {
cursor: pointer;
}
#scene-duplicate-checker .dr-stashid-box-icon {
width: 0.95em;
height: 0.95em;
display: inline-block;
vertical-align: middle;
}
#duplicate-resolver-toolbar .dr-drawer {
margin-top: 0.15rem;
}
#duplicate-resolver-toolbar .dr-drawer-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0 !important;
font-size: 0.875rem;
text-decoration: none;
border: 0;
box-shadow: none;
}
#duplicate-resolver-toolbar .dr-drawer-toggle:hover,
#duplicate-resolver-toolbar .dr-drawer-toggle:focus {
text-decoration: underline;
}
#duplicate-resolver-toolbar .dr-drawer-panel {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--bs-border-color, rgba(255, 255, 255, 0.12));
}
#duplicate-resolver-toolbar .dr-drawer-toolbar {
margin-bottom: 0.5rem;
}
#duplicate-resolver-toolbar .dr-preview {
font-size: 0.875rem;
max-height: 14rem;
overflow: auto;
white-space: pre-wrap;
margin: 0;
padding: 0.5rem;
background: var(--bs-body-bg, #1a1a1a);
border-radius: 0.25rem;
}
#duplicate-resolver-toolbar .dr-preview .dr-match-link {
color: var(--bs-info, #7cc7ff);
text-decoration: underline;
cursor: pointer;
}
.duplicate-resolver-sync-btn {
margin-left: 0.35rem !important;
}
#duplicate-resolver-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
#duplicate-resolver-modal-overlay .dr-modal {
background: var(--bs-secondary-bg, #30404d);
color: var(--bs-body-color, #eee);
border: 1px solid var(--bs-border-color, rgba(255, 255, 255, 0.18));
border-radius: 0.35rem;
max-width: 60rem;
width: 100%;
max-height: 90vh;
overflow: auto;
padding: 1rem 1.25rem;
}
#duplicate-resolver-modal-overlay h3 {
margin-top: 0;
font-size: 1.1rem;
}
#duplicate-resolver-modal-overlay .dr-modal-header {
align-items: center;
display: flex;
gap: 0.45rem;
margin: -1rem -1.25rem 0.75rem;
padding: 0.7rem 1rem;
}
#duplicate-resolver-modal-overlay .dr-modal-options {
margin: 0.75rem 0;
}
#duplicate-resolver-modal-overlay .dr-modal-options label {
display: block;
margin: 0.25rem 0;
cursor: pointer;
}
#duplicate-resolver-modal-overlay .dr-field-title {
display: block;
margin: 0.1rem 0 0.35rem;
font-size: 0.88rem;
font-weight: 600;
line-height: 1.15;
}
#duplicate-resolver-modal-overlay .dr-opt-hint {
font-size: 0.82rem;
opacity: 0.9;
font-weight: 400;
}
#duplicate-resolver-modal-overlay .dr-sync-compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0 1rem;
}
#duplicate-resolver-modal-overlay .dr-sync-compare .dr-col {
background: var(--bs-secondary-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--bs-border-color, rgba(255, 255, 255, 0.08));
border-radius: 0.25rem;
padding: 0.5rem 0.6rem;
}
#duplicate-resolver-modal-overlay .dr-sync-compare h4 {
margin: 0 0 0.4rem;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.01em;
}
#duplicate-resolver-modal-overlay .dr-sync-compare p {
margin: 0;
font-size: 0.82rem;
opacity: 0.9;
}
#duplicate-resolver-modal-overlay .dr-field-row {
border-top: 1px solid var(--bs-border-color, rgba(255, 255, 255, 0.08));
padding-top: 0.55rem;
margin-top: 0.55rem;
}
#duplicate-resolver-modal-overlay .dr-field-desc {
font-size: 0.78rem;
opacity: 0.85;
margin: 0.15rem 0 0.35rem;
}
#duplicate-resolver-modal-overlay .dr-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
#duplicate-resolver-modal-overlay .dr-field-col {
background: transparent;
border-radius: 0.18rem;
padding: 0.35rem 0.45rem;
min-height: 3rem;
height: auto;
}
#duplicate-resolver-modal-overlay .dr-list-control {
width: 100%;
}
#duplicate-resolver-modal-overlay .dr-list-control .input-group + .input-group {
margin-top: 0.3rem;
}
#duplicate-resolver-modal-overlay .dr-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.28rem;
}
#duplicate-resolver-modal-overlay .dr-chip {
max-width: 100%;
padding: 0.10rem 0.35rem;
border-radius: 0.14rem;
line-height: 1.15;
font-size: 0.84rem;
font-weight: 400;
border: 0;
background-color: #b9c4cd;
color: #21313f;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
#duplicate-resolver-modal-overlay .dr-field-col .input-group {
align-items: stretch;
}
#duplicate-resolver-modal-overlay .dr-field-col .input-group .dr-list-control {
flex: 1 1 auto;
}
#duplicate-resolver-modal-overlay .dr-field-col .form-control,
#duplicate-resolver-modal-overlay .dr-field-col .scene-description {
min-height: calc(1.5em + 0.75rem + 2px);
}
#duplicate-resolver-modal-overlay .dr-empty {
font-size: 0.74rem;
opacity: 0.75;
}
#duplicate-resolver-modal-overlay .dr-modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
#duplicate-resolver-modal-overlay .dr-field-col .input-group-prepend .btn {
min-width: 2.2rem;
}
#duplicate-resolver-modal-overlay .dr-cover-value {
min-width: 0;
}
#duplicate-resolver-modal-overlay .dr-cover-frame {
background: rgba(0, 0, 0, 0.2);
border-radius: 0.18rem;
padding: 0.35rem;
text-align: center;
}
#duplicate-resolver-modal-overlay .dr-cover-thumb {
display: block;
max-height: 180px;
max-width: 100%;
width: auto;
height: auto;
margin: 0 auto;
object-fit: contain;
border-radius: 0.12rem;
}
#duplicate-resolver-modal-overlay .dr-cover-caption {
font-size: 0.72rem;
opacity: 0.8;
margin-top: 0.3rem;
text-align: center;
}
#duplicate-resolver-modal-overlay .dr-cover-placeholder {
font-size: 0.78rem;
opacity: 0.75;
padding: 0.35rem 0.25rem;
margin-bottom: 0.25rem;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 0.12rem;
text-align: center;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
name: Smart Resolver
description: Scene Duplicate Checker helper with Smart Select and mergeless Sync Data. Rules are processed in order to determine a primary keep candidate. Protection rules are then processed to determine if the non-primary scene should be marked for deletion.
version: 1.0.02
url: https://github.com/Stash-KennyG/CommunityScripts/tree/main/plugins/DuplicateResolver
ui:
javascript:
- SmartResolve.js
css:
- SmartResolve.css
settings:
autoCheckAfterSync:
displayName: After Sync, mark source scenes for deletion
description: >-
Successfully synced source scenes are marked by default after sync.
type: BOOLEAN
ignoreRule01TotalPixels:
displayName: Ignore 01 - Most Total pixels
description: When enabled, will not eliminate candidate with lower total pixels than the highest width*height (1% tolerance).
type: BOOLEAN
ignoreRule02Framerate:
displayName: Ignore 02 - Highest Framerate
description: When enabled, will not eliminate candidate with lower framerate than the highest file framerate.
type: BOOLEAN
ignoreRule03Codec:
displayName: Ignore 03 - Codec tier
description: When enabled, will not eliminate candidate with lower codec quality tier (AV1 > H265 > H264 > others).
type: BOOLEAN
ignoreRule04Duration:
displayName: Ignore 04 - Longest Duration
description: When enabled, will not eliminate candidate with shorter duration than the longest duration (rounded to nearest second).
type: BOOLEAN
ignoreRule05SmallerSize:
displayName: Ignore 05 - Smaller file size
description: When enabled, will not eliminate candidate with larger file size than the smallest (tollerance max(1MB or 1%)).
type: BOOLEAN
ignoreRule05bUpgradeToken:
displayName: Ignore 05b - Upgrade token preference
description: When enabled, will not eliminate candidate with primary file path containing "upgrade".
type: BOOLEAN
ignoreRule06OlderDate:
displayName: Ignore 06 - Older date
description: When enabled, will not eliminate candidate with later scene date than the oldest scene date (null is latest).
type: BOOLEAN
ignoreRule07MoreGroups:
displayName: Ignore 07 - More groups
description: When enabled, will not eliminate candidate with fewer group associations than the most groups.
type: BOOLEAN
ignoreRule08HasStashId:
displayName: Ignore 08 - Has stash ID
description: When enabled, will not eliminate candidate with fewer stash IDs than the most stash IDs.
type: BOOLEAN
ignoreRule09MorePerformers:
displayName: Ignore 09 - More performers
description: When enabled, will not eliminate candidate with fewer performer associations than the most performer associations.
type: BOOLEAN
ignoreRule10MoreMarkers:
displayName: Ignore 10 - More markers
description: When enabled, will not eliminate candidate with fewer scene markers than the most scene markers.
type: BOOLEAN
ignoreRule11MoreTags:
displayName: Ignore 11 - More tags
description: When enabled, will not eliminate candidate with fewer tags than the most tags.
type: BOOLEAN
ignoreRule12LessAssociatedFiles:
displayName: Ignore 12 - Less associated files
description: When enabled, will not eliminate candidate with more associated file entries than the least associated file entries.
type: BOOLEAN
ignoreRule13MoreMetadataCardinality:
displayName: Ignore 13 - Metadata cardinality
description: When enabled, will not eliminate candidate with fewer total populated metadata elements than the most.
type: BOOLEAN
unprotectAOCount:
displayName: Unprotect O-count
description: When enabled, will permit marking for deletion scenes with O-count > 0.
type: BOOLEAN
unprotectBGroupAssociation:
displayName: Unprotect Group association containment
description: When enabled, will permit marking for deletion scenes with group associations not present on the primary candidate.
type: BOOLEAN
unprotectCPerformerMismatch:
displayName: Unprotect Performer mismatch
description: When enabled, will permit marking for deletion scenes with performer associations not present on the primary candidate.
type: BOOLEAN
unprotectDTagLossGt1NonStashed:
displayName: Unprotect Tag loss >1 (non-stashed)
description: When enabled, will permit marking unstashed scenes for deletion with more than 1 less tags than the primary candidate.
type: BOOLEAN
unprotectEOlderDate:
displayName: Unprotect Older date
description: When enabled, will permit marking for deletion scenes with an older date than the primary candidate.
type: BOOLEAN
unprotectFIgnoreSmartResolveTag:
displayName: Unprotect Ignore:Smart Resolve tag
description: When enabled, will permit marking for deletion scenes tagged "Ignore:Smart Resolve".
type: BOOLEAN

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB