Skip to content

Add missing_since file/folder field and add verify and purge missing tasks - #7126

Open
WithoutPants wants to merge 23 commits into
stashapp:developfrom
WithoutPants:clean-overhaul
Open

Add missing_since file/folder field and add verify and purge missing tasks#7126
WithoutPants wants to merge 23 commits into
stashapp:developfrom
WithoutPants:clean-overhaul

Conversation

@WithoutPants

Copy link
Copy Markdown
Collaborator

Description

Adds a missing_since field to files and folders and replaces the existing Clean task with Verify files and Purge Missing tasks.

The Verify files task walks the filesystem and marks files and folders as missing where the system cannot read the file/folder. This works similarly to the existing Clean task, but does not remove any objects. Can optionally run as a dry run, which does not make any changes, logging only missing files and folders. Defaults to not check zip file contents, with an option to include them. Can also optionally purge the discovered missing files, folders and their associated objects and generated files, like the existing Clean task. The UI displays a confirmation dialog when this option is enabled. Can be run selectively with a list of paths.

The Purge missing task purges files and folders previously marked as missing. Can optionally run as a dry run, which does not make any changes, logging only the files and folders that would be purged. Can be run with the missingSinceBefore parameter which only purges files/folders where the missing_since time is before the provided value. This option is not currently implemented in the UI. The UI displays a confirmation dialog when not in dry mode. Can be run selectively with a list of paths.

Existing metadataClean mutation has been marked as deprecated and runs the Verify files task with the purge missing behaviour (unless dry run is enabled).

Related Issue

None.

Testing

From memory, I've done the following testing, but this should be verified independently:

  • ensured verify files task marks files/folders as missing when no longer accessible or in the stash library
  • ensured verify files task purges files/folders and their associated objects and generated files when the purge option is enabled
  • ensured verify files task does not make any filesystem changes unless purge option is enabled
  • ensured purge missing task purges marked files/folders and their associated objects and generated files
  • ensured purge missing task does not remove or mark any newly missing (not marked) files/folders
  • ensured purge missing task uses the missingSinceBefore option correctly
  • ensured provided paths are used for both new tasks
  • ensured nothing is changed when dry run is enabled for both new tasks
  • tested findFiles and findFolders queries with the missing_since filter criteria
  • ensured scan task clears the missing_since field when it finds a previously marked missing file/folder

Screenshots

New tasks, replacing Clean task:
image

Verify files confirmation dialog - only used when purge missing is enabled:
image

Purge files confirmation dialog - used when dry run is disabled:
image

Additional Context

Future work will be to provide a mechanism to view missing files and folders.

The task names aren't set in stone and I'm open to suggestions for better terms.

@WithoutPants WithoutPants added this to the Version 0.32.0 milestone Jul 24, 2026
@WithoutPants
WithoutPants requested review from Gykes and Copilot July 24, 2026 08:11
@WithoutPants WithoutPants added the improvement Something needed tweaking. label Jul 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a missing_since timestamp for files and folders, replaces the old “Clean” behavior with a safer “Verify files” workflow, and adds a dedicated “Purge missing” task to remove already-marked missing items. It extends the DB schema, repository APIs, GraphQL schema/mutations, backend task implementations, and the UI tasks panel to support verifying/purging missing entries.

Changes:

  • Add missing_since to files and folders, plus indexes and filtering/sorting support.
  • Replace the legacy Clean task with new Verify/Purge tasks (Clean becomes deprecated wrapper behavior).
  • Add UI/GraphQL wiring for verifyPaths and purgeMissing tasks (including task options and confirmations).

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
ui/v2.5/src/locales/en-GB.json Updates task labels/descriptions and confirmation strings for Verify/Purge.
ui/v2.5/src/docs/en/Manual/Tasks.md Replaces “Cleaning” documentation with Verify/Purge task documentation.
ui/v2.5/src/core/StashService.ts Swaps metadataClean client call for verifyPaths/purgeMissing mutations.
ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx Replaces Clean UI with Verify/Purge task UI, dialogs, and options.
ui/v2.5/graphql/mutations/metadata.graphql Adds VerifyPaths and PurgeMissing mutations.
pkg/sqlite/migrations/86_file_missing_since.up.sql Adds missing_since columns and partial indexes for files/folders.
pkg/sqlite/folder.go Persists/queries missing_since; adds SetMissing and missing-only query helpers.
pkg/sqlite/folder_filter.go Enables folder filtering by missing_since.
pkg/sqlite/file.go Persists/queries missing_since; adds SetMissing, missing-only queries, and FindByFolderID.
pkg/sqlite/file_filter.go Enables file filtering by missing_since.
pkg/sqlite/database.go Bumps schema version to 86.
pkg/models/repository_folder.go Extends folder repository interfaces for missing queries + SetMissing.
pkg/models/repository_file.go Extends file repository interfaces for missing queries + SetMissing + FindByFolderID.
pkg/models/model_file.go Adds MissingSince to DirEntry (propagated onto file/folder models).
pkg/models/folder.go Adds missing_since to FolderFilterType.
pkg/models/file.go Adds missing_since to FileFilterType.
pkg/file/verify.go New verify job that marks missing items and optionally purges discovered missing items.
pkg/file/scan.go Clears missing_since when scan finds previously-missing files/folders.
pkg/file/purge_missing.go New job to purge files/folders already marked missing, optionally filtered by time.
pkg/file/handler.go Renames CleanHandler to PurgeHandler interface for purge hooks.
pkg/file/clean.go Removes legacy cleaner implementation.
internal/manager/task_verify.go Adds manager task wrapper for VerifyPaths.
internal/manager/task_purge_missing.go Replaces old clean task wrapper with purge-missing task wrapper and purge hooks.
internal/manager/manager_tasks.go Deprecates Clean by routing it through VerifyPaths (with purge behavior).
internal/api/resolver_mutation_metadata.go Adds GraphQL resolvers for verifyPaths and purgeMissing.
graphql/schema/types/metadata.graphql Adds VerifyPathsInput and PurgeMissingInput types.
graphql/schema/types/filters.graphql Adds missing_since criteria to file/folder filter inputs.
graphql/schema/types/file.graphql Adds missing_since fields to Folder and BaseFile types.
graphql/schema/schema.graphql Deprecates metadataClean and adds verifyPaths/purgeMissing mutations.
Comments suppressed due to low confidence (4)

graphql/schema/types/file.graphql:68

  • The BasicFile.missing_since field docstring incorrectly refers to a "folder" and says it is set by the clean task. This field applies to files and is set by the verify files task.
  "If not null, indicates when the folder was detected as missing. Set by the clean task"
  missing_since: Time

graphql/schema/types/file.graphql:92

  • The VideoFile.missing_since field docstring incorrectly refers to a "folder" and says it is set by the clean task. This field applies to files and is set by the verify files task.
  "If not null, indicates when the folder was detected as missing. Set by the clean task"
  missing_since: Time

graphql/schema/types/file.graphql:127

  • The ImageFile.missing_since field docstring incorrectly refers to a "folder" and says it is set by the clean task. This field applies to files and is set by the verify files task.
  "If not null, indicates when the folder was detected as missing. Set by the clean task"
  missing_since: Time

graphql/schema/types/file.graphql:159

  • The GalleryFile.missing_since field docstring incorrectly refers to a "folder" and says it is set by the clean task. This field applies to files and is set by the verify files task.
  "If not null, indicates when the folder was detected as missing. Set by the clean task"
  missing_since: Time
  

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Toast.success(
intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.verify" }) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Disagree, it's defined.

Comment on lines +584 to +585
operation_name: intl.formatMessage({ id: "actions.purge_missing" }),
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Disagree, already defined.

Comment on lines +755 to +780
<VerifyDialog
dryRun={false}
purgeMissing={verifyOptions.purgeMissing ?? false}
pathSelection={dialogOpen.verify}
onClose={(p) => {
// undefined means cancelled
if (p !== undefined) {
if (dialogOpen.cleanAlert) {
if (dialogOpen.verifyAlert) {
// don't provide paths
onClean();
onVerify();
} else {
onClean(p);
onVerify(p);
}
}

setDialogOpen({
clean: false,
cleanAlert: false,
verify: false,
verifyAlert: false,
});
}}
/>
) : (
dialogOpen.clean
)}
) : null}
{dialogOpen.purgeMissingAlert || dialogOpen.purgeMissing ? (
<PurgeMissingDialog
dryRun={false}
pathSelection={dialogOpen.purgeMissing}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think it's hardcoded so I think this is incorrect. The ?? false is just a null-default, shouldn't be an issue

Comment on lines +482 to +484
function configureDefaults(partial: Record<string, object>) {
saveUI({ taskDefaults: { ...partial } });
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agree with this.

Comment on lines +477 to +479
if (taskDefaults?.verify) {
setVerifyOptions(taskDefaults.verify);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Disagree with this. I think this could happen but if we fix the issue on line 486 I don't think this will be an issue.

Comment thread pkg/file/verify.go Outdated
Comment on lines +274 to +277
// short-cut, don't assess if already missing or added to delete set
if f.Base().MissingSince != nil || j.toDelete.has(fileID) {
continue
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Disagree with this.

Comment thread ui/v2.5/src/locales/en-GB.json Outdated
Comment thread ui/v2.5/src/docs/en/Manual/Tasks.md Outdated
Comment on lines +22 to +23
"If not null, indicates when the folder was detected as missing. Set by the clean task"
missing_since: Time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed

Comment thread graphql/schema/types/file.graphql Outdated
Comment on lines +42 to +44
"If not null, indicates when the folder was detected as missing. Set by the clean task"
missing_since: Time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed

DogmaDragon and others added 2 commits July 24, 2026 13:49
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

@DogmaDragon DogmaDragon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Documentation check passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (9)

ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx:554

  • The toast uses message id "actions.verify", but that id is not defined in en-GB (and likely other locales), which will result in missing-translation output at runtime. Use the existing "actions.verify_files" id (or add "actions.verify" consistently across all locales).
          { operation_name: intl.formatMessage({ id: "actions.verify" }) }

ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx:584

  • The purge-missing toast uses message id "actions.purge_missing", but the locale entry added in this PR is under "config.tasks.purge_missing". This will render as a missing translation.
            operation_name: intl.formatMessage({ id: "actions.purge_missing" }),

ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx:756

  • VerifyDialog is always passed dryRun={false}. If the user enables Dry run and opens the selective dialog, the dialog copy/behaviour will be incorrect because it can’t reflect dry-run mode.
          dryRun={false}

ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx:779

  • PurgeMissingDialog is always passed dryRun={false}. If the user enables Dry run and opens the selective dialog, the dialog will still show the destructive confirmation copy instead of the dry-run message.
          dryRun={false}

ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx:484

  • configureDefaults overwrites ui.taskDefaults with only the provided partial, which will wipe defaults for other tasks (scan/generate/cleanGenerated/etc) because saveUI only shallow-merges. Merge into existing ui.taskDefaults instead.
  function configureDefaults(partial: Record<string, object>) {
    saveUI({ taskDefaults: { ...partial } });
  }

ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx:479

  • Only verify defaults are restored from ui.taskDefaults. Purge-missing defaults are saved (onSetPurgeMissingOptions) but never reloaded, so they won’t persist across refreshes.
    if (taskDefaults?.verify) {
      setVerifyOptions(taskDefaults.verify);
    }

pkg/file/verify.go:277

  • assessFiles skips already-missing files without incrementing progress. Because the job total includes all files, this can leave the progress bar permanently short of 100% when any files are already marked missing.
			// short-cut, don't assess if already missing or added to delete set
			if f.Base().MissingSince != nil || j.toDelete.has(fileID) {
				continue
			}

graphql/schema/types/file.graphql:23

  • The schema description still says missing_since is set by the "clean" task, but this PR deprecates metadataClean in favour of verifyPaths/purgeMissing. Update the description to match the new behaviour.
  "If not null, indicates when the folder was detected as missing. Set by the clean task"
  missing_since: Time

graphql/schema/types/file.graphql:43

  • The BaseFile missing_since description refers to a "folder" and the "clean" task. This field is for files, and is now set by verifyPaths (and cleared by scan when rediscovered).
  "If not null, indicates when the folder was detected as missing. Set by the clean task"
  missing_since: Time

Comment thread pkg/file/verify.go Outdated
Comment thread pkg/file/verify.go Outdated
Comment thread pkg/file/purge_missing.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 32 changed files in this pull request and generated 2 comments.

Files not reviewed (2)
  • pkg/models/mocks/FileReaderWriter.go: Generated file
  • pkg/models/mocks/FolderReaderWriter.go: Generated file
Suppressed comments (10)

ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx:487

  • configureDefaults replaces the entire ui.taskDefaults object with just the provided partial, which can drop previously saved defaults for other tasks (e.g. scan/autoTag/generate/cleanGenerated) in the local UI state and in the pending update payload. Merge with existing defaults instead of overwriting the whole object.
  function configureDefaults(partial: Record<string, object>) {
    saveUI({ taskDefaults: { ...partial } });
  }

graphql/schema/types/file.graphql:23

  • The missing_since field comment still says it is set by the "clean" task, but this PR deprecates clean and sets missing_since via verifyPaths. This docstring should be updated to avoid confusing API consumers.
  "If not null, indicates when the folder was detected as missing. Set by the clean task"
  missing_since: Time

graphql/schema/types/file.graphql:43

  • This missing_since docstring says "folder" was detected as missing, but BaseFile represents a file. This is a copy/paste error and should say "file".
  "If not null, indicates when the folder was detected as missing. Set by the verifyPaths task"
  missing_since: Time

graphql/schema/types/file.graphql:67

  • This missing_since docstring says "folder" was detected as missing, but BasicFile represents a file. Update the wording to "file" for correctness.
  "If not null, indicates when the folder was detected as missing. Set by the verifyPaths task"
  missing_since: Time

graphql/schema/types/file.graphql:91

  • This missing_since docstring says "folder" was detected as missing, but VideoFile represents a file. Update the wording to "file" for correctness.
  "If not null, indicates when the folder was detected as missing. Set by the verifyPaths task"
  missing_since: Time

graphql/schema/types/file.graphql:126

  • This missing_since docstring says "folder" was detected as missing, but ImageFile represents a file. Update the wording to "file" for correctness.
  "If not null, indicates when the folder was detected as missing. Set by the verifyPaths task"
  missing_since: Time

graphql/schema/types/file.graphql:158

  • This missing_since docstring says "folder" was detected as missing, but GalleryFile represents a file. Update the wording to "file" for correctness.
  "If not null, indicates when the folder was detected as missing. Set by the verifyPaths task"
  missing_since: Time

internal/manager/task_stashignore_test.go:65

  • The failure message still refers to a "clean filter" even though the test now uses verifyFilter, which makes the test output misleading when it fails.
	if verifyFilter.Accept(context.Background(), ignoredFile, info, "") {
		t.Fatalf("expected clean filter to reject file due to parent .stashignore")
	}

ui/v2.5/src/docs/en/Manual/Tasks.md:116

  • The new documentation for "Verify files" removed the prior warning about network/inaccessible media paths. Since verification marks items missing when the filesystem can’t be read (including transient network/share issues), the warning is still important to prevent accidental missing-marking/purging.
    pkg/models/folder.go:25
  • MissingSince is the only field in FolderFilterType missing omitempty in its JSON tag, which is inconsistent with the surrounding optional filter fields. This can cause missing_since: null to be serialized even when not provided.
	MissingSince *TimestampCriterionInput         `json:"missing_since"`

Comment thread pkg/file/purge_missing.go
Comment on lines +129 to +135
if len(files) != batchSize {
more = false
} else if j.options.DryRun {
// when not in dry run, we should be continuing until there's none left
// in dry run, we can just increment the offset and continue to the next batch
offset += batchSize
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed. Option 1 is probably what I would lean to. I think verify.go does this.

Comment thread pkg/file/purge_missing.go
Comment on lines +177 to +183
if len(folders) != batchSize {
more = false
} else if j.options.DryRun {
// when not in dry run, we should be continuing until there's none left
// in dry run, we can just increment the offset and continue to the next batch
offset += batchSize
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agree, same as above.

@Gykes Gykes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

So, I started a full review but decided to just review the Copilot ones first. Once we get those dealt with I can do another one.

Sorry, I kept getting distracted by its statements then going doing a rabbithole of trying to prove it right or wrong. Sadly I can't auto merge in any of the obvious comment fix stuff.

Comment on lines +482 to +484
function configureDefaults(partial: Record<string, object>) {
saveUI({ taskDefaults: { ...partial } });
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agree with this.

Comment on lines +52 to +57
// HACK - use purge job to clean empty galleries
pj := &purgeMissingJob{
// only need to provide repository
repository: j.repository,
}
pj.cleanEmptyGalleries(ctx)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This runs with empty options, so cleanEmptyGalleries skips its path filter and clears empty galleries library-wide even on a selective purge. Pass Paths: j.options.Paths through?

Comment thread pkg/file/purge_missing.go
Comment on lines +129 to +135
if len(files) != batchSize {
more = false
} else if j.options.DryRun {
// when not in dry run, we should be continuing until there's none left
// in dry run, we can just increment the offset and continue to the next batch
offset += batchSize
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed. Option 1 is probably what I would lean to. I think verify.go does this.

Comment on lines +22 to +23
"If not null, indicates when the folder was detected as missing. Set by the clean task"
missing_since: Time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed

Comment thread pkg/file/purge_missing.go
Comment on lines +177 to +183
if len(folders) != batchSize {
more = false
} else if j.options.DryRun {
// when not in dry run, we should be continuing until there's none left
// in dry run, we can just increment the offset and continue to the next batch
offset += batchSize
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agree, same as above.

Comment on lines +755 to +780
<VerifyDialog
dryRun={false}
purgeMissing={verifyOptions.purgeMissing ?? false}
pathSelection={dialogOpen.verify}
onClose={(p) => {
// undefined means cancelled
if (p !== undefined) {
if (dialogOpen.cleanAlert) {
if (dialogOpen.verifyAlert) {
// don't provide paths
onClean();
onVerify();
} else {
onClean(p);
onVerify(p);
}
}

setDialogOpen({
clean: false,
cleanAlert: false,
verify: false,
verifyAlert: false,
});
}}
/>
) : (
dialogOpen.clean
)}
) : null}
{dialogOpen.purgeMissingAlert || dialogOpen.purgeMissing ? (
<PurgeMissingDialog
dryRun={false}
pathSelection={dialogOpen.purgeMissing}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think it's hardcoded so I think this is incorrect. The ?? false is just a null-default, shouldn't be an issue

Toast.success(
intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.verify" }) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Disagree, it's defined.

Comment on lines +584 to +585
operation_name: intl.formatMessage({ id: "actions.purge_missing" }),
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Disagree, already defined.

Comment thread pkg/file/verify.go Outdated
Comment on lines +274 to +277
// short-cut, don't assess if already missing or added to delete set
if f.Base().MissingSince != nil || j.toDelete.has(fileID) {
continue
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Disagree with this.

Comment thread graphql/schema/types/file.graphql Outdated
Comment on lines +42 to +44
"If not null, indicates when the folder was detected as missing. Set by the clean task"
missing_since: Time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed

@discourse-stashapp

Copy link
Copy Markdown

This pull request has been mentioned on Stash Forum. There might be relevant details there:

https://discourse.stashapp.cc/t/librarian/13453/24

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

Labels

improvement Something needed tweaking.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants