From d5d57ac25a9d2ee1795e6adb613b8ac8ee974b1f Mon Sep 17 00:00:00 2001 From: Daniel King Date: Thu, 29 Jan 2026 15:53:51 +0000 Subject: [PATCH] Add flag to preserve cached media on cleanup (#36200) Co-authored-by: Daniel King --- app/models/media_attachment.rb | 8 ++++ app/models/status.rb | 1 + lib/mastodon/cli/media.rb | 10 ++++- spec/lib/mastodon/cli/media_spec.rb | 60 +++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 2615eed4e3..c0c768154b 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -214,6 +214,14 @@ class MediaAttachment < ApplicationRecord scope :remote, -> { where.not(remote_url: '') } scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) } scope :updated_before, ->(value) { where(arel_table[:updated_at].lt(value)) } + scope :without_local_interaction, lambda { + where.not(Favourite.joins(:account).merge(Account.local).where(Favourite.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Bookmark.where(Bookmark.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Status.local.where(Status.arel_table[:in_reply_to_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Status.local.where(Status.arel_table[:reblog_of_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Quote.joins(:status).merge(Status.local).where(Quote.arel_table[:quoted_status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Quote.joins(:quoted_status).merge(Status.local).where(Quote.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + } attr_accessor :skip_download diff --git a/app/models/status.rb b/app/models/status.rb index a0441c0a36..2b5f8e58a3 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 diff --git a/lib/mastodon/cli/media.rb b/lib/mastodon/cli/media.rb index 02c9894c36..4a1e757406 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, quoted, 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.without_local_interaction 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 fa7a3161d0..da66951c3b 100644 --- a/spec/lib/mastodon/cli/media_spec.rb +++ b/spec/lib/mastodon/cli/media_spec.rb @@ -73,6 +73,66 @@ 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!(:favourited_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:bookmarked_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:replied_to_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:reblogged_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:remote_quoted_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:remote_quoting_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + + before do + local_account = Fabricate(:account, username: 'alice') + remote_account = Fabricate(:account, username: 'bob', domain: 'example.com') + + favourited_status = Fabricate(:status, account: remote_account) + bookmarked_status = Fabricate(:status, account: remote_account) + replied_to_status = Fabricate(:status, account: remote_account) + reblogged_status = Fabricate(:status, account: remote_account) + + favourited_media.update!(status: favourited_status) + bookmarked_media.update!(status: bookmarked_status) + replied_to_media.update!(status: replied_to_status) + reblogged_media.update!(status: reblogged_status) + + local_quoting_status = Fabricate(:status, account: local_account) + remote_quoted_status = Fabricate(:status, account: remote_account) + local_status_being_quoted = Fabricate(:status, account: local_account) + remote_quoting_status = Fabricate(:status, account: remote_account) + + remote_quoted_media.update!(status: remote_quoted_status) + remote_quoting_media.update!(status: remote_quoting_status) + + non_interacted_status = Fabricate(:status, account: remote_account) + + media_attachment.update(status: non_interacted_status) + + 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) + Fabricate(:quote, account: local_account, status: local_quoting_status, quoted_status: remote_quoted_status) + Fabricate(:quote, account: remote_account, status: remote_quoting_status, quoted_status: local_status_being_quoted) + 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(remote_quoted_media.reload.file).to be_present + expect(remote_quoting_media.reload.file).to be_present + + expect(media_attachment.reload.file).to be_blank + expect(media_attachment.reload.thumbnail).to be_blank + end + end end end