From db3dd7e87914db305dff60c94ed2797fb63205b6 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 20 Sep 2025 11:27:12 +0100 Subject: [PATCH] Add flag to preserve cached media on cleanup Adding the flag --keep-interacted to the "media remove" tootctl command to preserve media cached from other servers if the related status has interactions from local accounts. For example, you may wish to delete all cached media from other servers older than 3 months to save disk space on the local server. However, if the status was bookmarked by a user they likely want to keep a copy locally, preserving the media in case the remote server is closed or has decided themselves to clear out all old user media. This flag was proposed in issue #30449. The change does not impact the local content retention setting, which manages automated cleanup. A new setting would need to be added there in a future improvement. This means to make use of this change a server admin would have to set a long content retention setting beyond which all remote media would be cleared, and then use a shorter range with the tootctl command where interacted media would be preserved with this new flag. The flag is only to preserve media attached to statuses, and makes no difference to the logic removing cached avatars and headers. --- app/models/status.rb | 7 +++++ lib/mastodon/cli/media.rb | 10 +++++++- spec/lib/mastodon/cli/media_spec.rb | 40 +++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/models/status.rb b/app/models/status.rb index 0bff4f2825d..c7dd9badd0c 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -90,6 +90,7 @@ class Status < ApplicationRecord has_many :local_favorited, -> { merge(Account.local) }, through: :favourites, source: :account has_many :local_reblogged, -> { merge(Account.local) }, through: :reblogs, source: :account has_many :local_bookmarked, -> { merge(Account.local) }, through: :bookmarks, source: :account + has_many :local_replied, -> { merge(Account.local) }, through: :replies, source: :account has_and_belongs_to_many :tags # rubocop:disable Rails/HasAndBelongsToMany @@ -136,6 +137,12 @@ class Status < ApplicationRecord scope :tagged_with_none, lambda { |tag_ids| where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids) } + scope :with_local_interaction, lambda { + Status.where(id: Status.joins(:local_favorited).select(:id)) + .or(Status.where(id: Status.joins(:local_bookmarked).select(:id))) + .or(Status.where(id: Status.joins(:local_replied).select(:id))) + .or(Status.where(id: Status.joins(:local_reblogged).select(:id))) + } after_create_commit :trigger_create_webhooks after_update_commit :trigger_update_webhooks diff --git a/lib/mastodon/cli/media.rb b/lib/mastodon/cli/media.rb index 02c9894c36d..b58bfac14b9 100644 --- a/lib/mastodon/cli/media.rb +++ b/lib/mastodon/cli/media.rb @@ -17,6 +17,7 @@ module Mastodon::CLI option :concurrency, type: :numeric, default: 5, aliases: [:c] option :verbose, type: :boolean, default: false, aliases: [:v] option :dry_run, type: :boolean, default: false + option :keep_interacted, type: :boolean, default: false desc 'remove', 'Remove remote media files, headers or avatars' long_desc <<-DESC Removes locally cached copies of media attachments (and optionally profile @@ -26,6 +27,9 @@ module Mastodon::CLI they are removed. In case of avatars and headers, it specifies how old the last webfinger request and update to the user has to be before they are pruned. It defaults to 7 days. + If --keep-interacted is specified, any media attached to a status that + was favourited, bookmarked, replied to, or reblogged by a local account + will be preserved. If --prune-profiles is specified, only avatars and headers are removed. If --remove-headers is specified, only headers are removed. If --include-follows is specified along with --prune-profiles or @@ -61,7 +65,11 @@ module Mastodon::CLI end unless options[:prune_profiles] || options[:remove_headers] - processed, aggregate = parallelize_with_progress(MediaAttachment.cached.remote.where(created_at: ..time_ago)) do |media_attachment| + attachment_scope = MediaAttachment.cached.remote.where(created_at: ..time_ago) + + attachment_scope = attachment_scope.where.not(status_id: Status.with_local_interaction.select(:id)) if options[:keep_interacted] + + processed, aggregate = parallelize_with_progress(attachment_scope) do |media_attachment| next if media_attachment.file.blank? size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0) diff --git a/spec/lib/mastodon/cli/media_spec.rb b/spec/lib/mastodon/cli/media_spec.rb index fa7a3161d09..712feee653f 100644 --- a/spec/lib/mastodon/cli/media_spec.rb +++ b/spec/lib/mastodon/cli/media_spec.rb @@ -73,6 +73,46 @@ RSpec.describe Mastodon::CLI::Media do expect(media_attachment.reload.thumbnail).to be_blank end end + + context 'with --keep-interacted' do + let(:options) { { keep_interacted: true } } + + let!(:local_account) { Fabricate(:account, username: 'alice') } + let!(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com') } + + let!(:favourited_status) { Fabricate(:status, account: remote_account) } + let!(:favourited_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg', status: favourited_status) } + + let!(:bookmarked_status) { Fabricate(:status, account: remote_account) } + let!(:bookmarked_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg', status: bookmarked_status) } + + let!(:replied_to_status) { Fabricate(:status, account: remote_account) } + let!(:replied_to_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg', status: replied_to_status) } + + let!(:reblogged_status) { Fabricate(:status, account: remote_account) } + let!(:reblogged_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg', status: reblogged_status) } + + let!(:non_interacted_status) { Fabricate(:status, account: remote_account) } + let!(:non_interacted_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg', status: non_interacted_status) } + + before do + Fabricate(:favourite, account: local_account, status: favourited_status) + Fabricate(:bookmark, account: local_account, status: bookmarked_status) + Fabricate(:status, account: local_account, in_reply_to_id: replied_to_status.id) + Fabricate(:status, account: local_account, reblog: reblogged_status) + end + + it 'keeps media associated with statuses that have been favourited, bookmarked, replied to, or reblogged by a local account' do + expect { subject } + .to output_results('Removed 1') + + expect(favourited_media.reload.file).to be_present + expect(bookmarked_media.reload.file).to be_present + expect(replied_to_media.reload.file).to be_present + expect(reblogged_media.reload.file).to be_present + expect(non_interacted_media.reload.file).to be_blank + end + end end end