The code powering m.abunchtell.com https://m.abunchtell.com
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

239 line
9.1 KiB

  1. # frozen_string_literal: true
  2. require_relative '../../config/boot'
  3. require_relative '../../config/environment'
  4. require_relative 'cli_helper'
  5. module Mastodon
  6. class MediaCLI < Thor
  7. include ActionView::Helpers::NumberHelper
  8. include CLIHelper
  9. def self.exit_on_failure?
  10. true
  11. end
  12. option :days, type: :numeric, default: 7, aliases: [:d]
  13. option :concurrency, type: :numeric, default: 5, aliases: [:c]
  14. option :verbose, type: :boolean, default: false, aliases: [:v]
  15. option :dry_run, type: :boolean, default: false
  16. desc 'remove', 'Remove remote media files'
  17. long_desc <<-DESC
  18. Removes locally cached copies of media attachments from other servers.
  19. The --days option specifies how old media attachments have to be before
  20. they are removed. It defaults to 7 days.
  21. DESC
  22. def remove
  23. time_ago = options[:days].days.ago
  24. dry_run = options[:dry_run] ? '(DRY RUN)' : ''
  25. processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where('created_at < ?', time_ago)) do |media_attachment|
  26. next if media_attachment.file.blank?
  27. size = media_attachment.file_file_size
  28. unless options[:dry_run]
  29. media_attachment.file.destroy
  30. media_attachment.save
  31. end
  32. size
  33. end
  34. say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)}) #{dry_run}", :green, true)
  35. end
  36. option :start_after
  37. option :dry_run, type: :boolean, default: false
  38. desc 'remove-orphans', 'Scan storage and check for files that do not belong to existing media attachments'
  39. long_desc <<~LONG_DESC
  40. Scans file storage for files that do not belong to existing media attachments. Because this operation
  41. requires iterating over every single file individually, it will be slow.
  42. Please mind that some storage providers charge for the necessary API requests to list objects.
  43. LONG_DESC
  44. def remove_orphans
  45. progress = create_progress_bar(nil)
  46. reclaimed_bytes = 0
  47. removed = 0
  48. dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
  49. case Paperclip::Attachment.default_options[:storage]
  50. when :s3
  51. paperclip_instance = MediaAttachment.new.file
  52. s3_interface = paperclip_instance.s3_interface
  53. bucket = s3_interface.bucket(Paperclip::Attachment.default_options[:s3_credentials][:bucket])
  54. last_key = options[:start_after]
  55. loop do
  56. objects = begin
  57. begin
  58. bucket.objects(start_after: last_key, prefix: 'media_attachments/files/').limit(1000).map { |x| x }
  59. rescue => e
  60. progress.log(pastel.red("Error fetching list of files: #{e}"))
  61. progress.log("If you want to continue from this point, add --start-after=#{last_key} to your command") if last_key
  62. break
  63. end
  64. end
  65. break if objects.empty?
  66. last_key = objects.last.key
  67. attachments_map = MediaAttachment.where(id: objects.map { |object| object.key.split('/')[2..-2].join.to_i }).each_with_object({}) { |attachment, map| map[attachment.id] = attachment }
  68. objects.each do |object|
  69. attachment_id = object.key.split('/')[2..-2].join.to_i
  70. filename = object.key.split('/').last
  71. progress.increment
  72. next unless attachments_map[attachment_id].nil? || !attachments_map[attachment_id].variant?(filename)
  73. begin
  74. object.delete unless options[:dry_run]
  75. reclaimed_bytes += object.size
  76. removed += 1
  77. progress.log("Found and removed orphan: #{object.key}")
  78. rescue => e
  79. progress.log(pastel.red("Error processing #{object.key}: #{e}"))
  80. end
  81. end
  82. end
  83. when :fog
  84. say('The fog storage driver is not supported for this operation at this time', :red)
  85. exit(1)
  86. when :filesystem
  87. require 'find'
  88. root_path = ENV.fetch('RAILS_ROOT_PATH', File.join(':rails_root', 'public', 'system')).gsub(':rails_root', Rails.root.to_s)
  89. Find.find(File.join(root_path, 'media_attachments', 'files')) do |path|
  90. next if File.directory?(path)
  91. key = path.gsub("#{root_path}#{File::SEPARATOR}", '')
  92. attachment_id = key.split(File::SEPARATOR)[2..-2].join.to_i
  93. filename = key.split(File::SEPARATOR).last
  94. attachment = MediaAttachment.find_by(id: attachment_id)
  95. progress.increment
  96. next unless attachment.nil? || !attachment.variant?(filename)
  97. begin
  98. size = File.size(path)
  99. File.delete(path) unless options[:dry_run]
  100. reclaimed_bytes += size
  101. removed += 1
  102. progress.log("Found and removed orphan: #{key}")
  103. rescue => e
  104. progress.log(pastel.red("Error processing #{key}: #{e}"))
  105. end
  106. end
  107. end
  108. progress.total = progress.progress
  109. progress.finish
  110. say("Removed #{removed} orphans (approx. #{number_to_human_size(reclaimed_bytes)})#{dry_run}", :green, true)
  111. end
  112. option :account, type: :string
  113. option :domain, type: :string
  114. option :status, type: :numeric
  115. option :concurrency, type: :numeric, default: 5, aliases: [:c]
  116. option :verbose, type: :boolean, default: false, aliases: [:v]
  117. option :dry_run, type: :boolean, default: false
  118. option :force, type: :boolean, default: false
  119. desc 'refresh', 'Fetch remote media files'
  120. long_desc <<-DESC
  121. Re-downloads media attachments from other servers. You must specify the
  122. source of media attachments with one of the following options:
  123. Use the --status option to download attachments from a specific status,
  124. using the status local numeric ID.
  125. Use the --account option to download attachments from a specific account,
  126. using username@domain handle of the account.
  127. Use the --domain option to download attachments from a specific domain.
  128. By default, attachments that are believed to be already downloaded will
  129. not be re-downloaded. To force re-download of every URL, use --force.
  130. DESC
  131. def refresh
  132. dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
  133. if options[:status]
  134. scope = MediaAttachment.where(status_id: options[:status])
  135. elsif options[:account]
  136. username, domain = username.split('@')
  137. account = Account.find_remote(username, domain)
  138. if account.nil?
  139. say('No such account', :red)
  140. exit(1)
  141. end
  142. scope = MediaAttachment.where(account_id: account.id)
  143. elsif options[:domain]
  144. scope = MediaAttachment.joins(:account).merge(Account.by_domain_and_subdomains(options[:domain]))
  145. else
  146. exit(1)
  147. end
  148. processed, aggregate = parallelize_with_progress(scope) do |media_attachment|
  149. next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?)
  150. unless options[:dry_run]
  151. media_attachment.reset_file!
  152. media_attachment.save
  153. end
  154. media_attachment.file_file_size
  155. end
  156. say("Downloaded #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true)
  157. end
  158. desc 'usage', 'Calculate disk space consumed by Mastodon'
  159. def usage
  160. say("Attachments:\t#{number_to_human_size(MediaAttachment.sum(:file_file_size))} (#{number_to_human_size(MediaAttachment.where(account: Account.local).sum(:file_file_size))} local)")
  161. say("Custom emoji:\t#{number_to_human_size(CustomEmoji.sum(:image_file_size))} (#{number_to_human_size(CustomEmoji.local.sum(:image_file_size))} local)")
  162. say("Preview cards:\t#{number_to_human_size(PreviewCard.sum(:image_file_size))}")
  163. say("Avatars:\t#{number_to_human_size(Account.sum(:avatar_file_size))} (#{number_to_human_size(Account.local.sum(:avatar_file_size))} local)")
  164. say("Headers:\t#{number_to_human_size(Account.sum(:header_file_size))} (#{number_to_human_size(Account.local.sum(:header_file_size))} local)")
  165. say("Backups:\t#{number_to_human_size(Backup.sum(:dump_file_size))}")
  166. say("Imports:\t#{number_to_human_size(Import.sum(:data_file_size))}")
  167. say("Settings:\t#{number_to_human_size(SiteUpload.sum(:file_file_size))}")
  168. end
  169. desc 'lookup', 'Lookup where media is displayed by passing a media URL'
  170. def lookup
  171. prompt = TTY::Prompt.new
  172. url = prompt.ask('Please enter a URL to the media to lookup:', required: true)
  173. attachment_id = url
  174. .split('/')[0..-2]
  175. .grep(/\A\d+\z/)
  176. .join('')
  177. if url.split('/')[0..-2].include? 'media_attachments'
  178. model = MediaAttachment.find(attachment_id).status
  179. prompt.say(ActivityPub::TagManager.instance.url_for(model))
  180. elsif url.split('/')[0..-2].include? 'accounts'
  181. model = Account.find(attachment_id)
  182. prompt.say(ActivityPub::TagManager.instance.url_for(model))
  183. else
  184. prompt.say('Not found')
  185. end
  186. end
  187. end
  188. end