From: Aidan Cornelius-Bell Date: Fri, 17 Jan 2025 01:56:49 +0000 (+1030) Subject: Adding audio support to the platform X-Git-Url: https://gitweb.mndrdr.org/?a=commitdiff_plain;h=72dbc70b1c73a5f23bda4a8102ad6fc65c7fec62;p=arelpe.git Adding audio support to the platform --- diff --git a/Gemfile b/Gemfile index 7fd6a65..4acd5f8 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,8 @@ gem "dotenv" gem "hcaptcha" # subscribers gem "stripe" +# for audio conversion +gem "streamio-ffmpeg" # Adding this because: ostruct.rb was loaded from the standard library, but will no longer be part of the default gems starting from Ruby 3.5.0. gem 'ostruct' # Scheduling diff --git a/Gemfile.lock b/Gemfile.lock index 00aa9c9..2d948c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -98,7 +98,7 @@ GEM logger (~> 1.5) chronic (0.10.2) chunky_png (1.4.0) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) crass (1.0.6) csv (3.3.2) @@ -201,7 +201,7 @@ GEM orm_adapter (0.5.0) ostruct (0.6.1) parallel (1.26.3) - parser (3.3.6.0) + parser (3.3.7.0) ast (~> 2.4.1) racc psych (5.2.2) @@ -254,7 +254,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.10.0) + rdoc (6.11.0) psych (>= 4.0.0) redcarpet (3.6.0) regexp_parser (2.10.0) @@ -314,6 +314,8 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) + streamio-ffmpeg (3.0.2) + multi_json (~> 1.8) stringio (3.1.2) stripe (5.55.0) stripe-ruby-mock (3.1.0) @@ -324,7 +326,7 @@ GEM timeout (0.4.3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.3) + unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uri (1.0.2) @@ -379,6 +381,7 @@ DEPENDENCIES rubocop-rails-omakase selenium-webdriver sprockets-rails + streamio-ffmpeg stripe stripe-ruby-mock (~> 3.1.0) tzinfo-data diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 014636b..75155dd 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -391,6 +391,27 @@ footer p { margin: auto; } +/* Podcast / audio views */ +.audio-player { + margin: 2em auto; + padding: 1em; + background: var(--accent-b); + border-radius: 4px; +} +.audio-player audio { + width: 100%; +} +.audio-player .duration { + color: var(--filter-button-text); + font-size: 0.9em; + margin-top: 0.5em; + text-align: right; +} + +.audio-player .use-description { + margin: 0 0 -2em 0; +} + .promo { border: 1px solid var(--code-text); border-radius: 3px; diff --git a/app/controllers/audios_controller.rb b/app/controllers/audios_controller.rb new file mode 100644 index 0000000..cbe1739 --- /dev/null +++ b/app/controllers/audios_controller.rb @@ -0,0 +1,62 @@ +class AudiosController < ApplicationController + include AdminAuthenticatable + before_action :set_audio, only: %i[ show edit update destroy ] + skip_before_action :authenticate_admin!, only: [:feed] + + def index + @audios = Audio.order(published_at: :desc).page(params[:page]).per(20) + end + + def show + end + + def new + @audio = Audio.new + @audio.post_id = params[:post_id] if params[:post_id] + end + + def edit + end + + def create + @audio = Audio.new(audio_params) + + if @audio.save + redirect_to @audio, notice: "Audio was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def update + if @audio.update(audio_params) + redirect_to @audio, notice: "Audio was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @audio.destroy! + redirect_to audios_url, notice: "Audio was successfully destroyed." + end + + def feed + @audios = Audio.published.order(published_at: :desc).limit(50) + + respond_to do |format| + format.rss do + render layout: false, content_type: 'application/rss+xml' + end + end + end + + private + def set_audio + @audio = Audio.find(params[:id]) + end + + def audio_params + params.require(:audio).permit(:title, :description, :file, :duration_in_seconds, :published_at, :post_id) + end +end diff --git a/app/models/audio.rb b/app/models/audio.rb new file mode 100644 index 0000000..110bee3 --- /dev/null +++ b/app/models/audio.rb @@ -0,0 +1,111 @@ +class Audio < ApplicationRecord + belongs_to :post, optional: true + has_one_attached :file + + validates :title, presence: true + validates :description, presence: true + validates :published_at, presence: true + validates :file, presence: true, on: :create + validate :acceptable_file_type, if: -> { file.attached? } + + before_validation :set_published_at, on: :create + after_create_commit :process_audio_file + + scope :published, -> { where("published_at <= ?", Time.current) } + + def duration_formatted + return nil unless duration_in_seconds + + minutes = duration_in_seconds / 60 + seconds = duration_in_seconds % 60 + format("%02d:%02d", minutes, seconds) + end + + def rss_duration + return nil unless duration_in_seconds + + hours = duration_in_seconds / 3600 + minutes = (duration_in_seconds % 3600) / 60 + seconds = duration_in_seconds % 60 + + if hours > 0 + format("%02d:%02d:%02d", hours, minutes, seconds) + else + format("%02d:%02d", minutes, seconds) + end + end + + def needs_conversion? + return false unless file.attached? + content_type = file.content_type.to_s + content_type.include?('m4a') || + content_type.include?('aac') || + content_type.include?('mp4') || + !content_type.include?('mpeg') + end + + private + + def set_published_at + self.published_at ||= Time.current + end + + def acceptable_file_type + accepted_types = %w[ + audio/mpeg audio/mp3 audio/ogg audio/wav audio/x-m4a + audio/aac audio/mp4 application/mp4 audio/x-hx-aac-adts + ] + + unless file.content_type.in?(accepted_types) + errors.add(:file, 'must be an MP3, M4A, OGG, or WAV file') + end + end + + def process_audio_file + return unless file.attached? + + Rails.logger.info "Processing audio file: #{file.filename}" + Rails.logger.info "Content type: #{file.content_type}" + + file.open do |tempfile| + begin + movie = FFMPEG::Movie.new(tempfile.path) + Rails.logger.info "FFMPEG opened file successfully" + + # Set duration + update_column(:duration_in_seconds, movie.duration.to_i) + Rails.logger.info "Duration set to: #{duration_in_seconds}" + + # Convert non-MP3 files to MP3 + if needs_conversion? + Rails.logger.info "Converting file to MP3" + mp3_path = File.join(Dir.tmpdir, "#{SecureRandom.uuid}.mp3") + + # Convert to mp3 with higher quality settings + movie.transcode(mp3_path, %w[-acodec libmp3lame -ab 256k -ar 44100 -map_metadata 0 -id3v2_version 3]) + Rails.logger.info "File converted successfully" + + # Attach the converted file + file.attach( + io: File.open(mp3_path), + filename: file.filename.to_s.sub(/\.[^.]+\z/, '.mp3'), + content_type: 'audio/mpeg' + ) + Rails.logger.info "Converted file attached successfully" + + # Clean up + File.unlink(mp3_path) if File.exist?(mp3_path) + end + + rescue FFMPEG::Error => e + Rails.logger.error "FFMPEG Error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + errors.add(:file, "could not be processed: #{e.message}") + rescue StandardError => e + Rails.logger.error "Standard Error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + errors.add(:file, "encountered an error during processing: #{e.message}") + end + end + end +end diff --git a/app/models/post.rb b/app/models/post.rb index f4d0cc3..6a54d0c 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -16,6 +16,8 @@ class Post < ApplicationRecord scope :dispatches, -> { where(post_type: 'dispatch') } scope :bookmarks, -> { where(post_type: 'bookmark') } + has_one :audio, dependent: :nullify + def self.get_posts_and_bookmarks_with_pagination(page, per_page, filter) posts = case filter when 'posts' diff --git a/app/views/audios/_form.html.erb b/app/views/audios/_form.html.erb new file mode 100644 index 0000000..dab5840 --- /dev/null +++ b/app/views/audios/_form.html.erb @@ -0,0 +1,43 @@ +<%# app/views/audios/_form.html.erb %> +<%= form_with(model: audio, local: true) do |form| %> + <% if audio.errors.any? %> +
+

<%= pluralize(audio.errors.count, "error") %> prohibited this audio from being saved:

+ +
+ <% end %> + +
+ <%= form.label :title %> + <%= form.text_field :title %> +
+ +
+ <%= form.label :description %> + <%= form.text_area :description %> +
+ +
+ <%= form.label :file %> + <%= form.file_field :file, accept: 'audio/mpeg,audio/mp3,audio/ogg,audio/wav,audio/x-m4a,audio/aac' %> + Supports MP3, M4A, OGG, and WAV files. M4A files will be automatically converted to MP3. +
+ +
+ <%= form.label :published_at %> + <%= form.datetime_local_field :published_at %> +
+ +
+ <%= form.label :post_id, "Related Post" %> + <%= form.collection_select :post_id, Post.where(post_type: :dispatch).order(published_at: :desc), :id, :title, { include_blank: "None" } %> +
+ +
+ <%= form.submit %> or <%= link_to "Cancel", audios_path %> +
+<% end %> diff --git a/app/views/audios/edit.html.erb b/app/views/audios/edit.html.erb new file mode 100644 index 0000000..2fe2c37 --- /dev/null +++ b/app/views/audios/edit.html.erb @@ -0,0 +1,11 @@ +
+ <% content_for :title, "edit audio episode" %> +

Edit Audio Episode

+ <%= link_to "Back to audio", audio_path(@audio), class: "button" %> +
+ +
+
+ <%= render "form", audio: @audio %> +
+
diff --git a/app/views/audios/feed.rss.erb b/app/views/audios/feed.rss.erb new file mode 100644 index 0000000..79e4700 --- /dev/null +++ b/app/views/audios/feed.rss.erb @@ -0,0 +1,38 @@ + + + + <%= Rails.application.config.site_name %> Podcast + <%= root_url %> + en-us + © <%= Time.current.year %> <%= Rails.application.config.site_name %> + <%= Rails.application.config.site_author %> + <%= Rails.application.config.podcast_description %> + episodic + + <%= Rails.application.config.site_author %> + <%= Rails.application.config.admin_email %> + + + + + <% @audios.each do |audio| %> + <% if audio.file.attached? %> + + <%= audio.title %> + ]]> + <%= audio.published_at.rfc2822 %> + + <%= audio_url(audio) %> + <%= audio.rss_duration %> + ]]> + <% if audio.post.present? %> + <%= post_url(audio.post) %> + <% end %> + + <% end %> + <% end %> + + diff --git a/app/views/audios/index.html.erb b/app/views/audios/index.html.erb new file mode 100644 index 0000000..9e1e422 --- /dev/null +++ b/app/views/audios/index.html.erb @@ -0,0 +1,45 @@ +
+ <% content_for :title, "audio episodes" %> +

Audio Episodes

+ <%= link_to "New Audio Episode", new_audio_path, class: "button" %> + <%= link_to "Home", root_path, class: "button" %> +
+ +<%= render partial: "layouts/alert" %> + +
+
+ + + + + + + + + + + + <% @audios.each do |audio| %> + + + + + + + + <% end %> + +
TitleDurationPublishedRelated PostActions
<%= audio.title %><%= audio.duration_formatted %><%= audio.published_at.strftime("%Y-%m-%d %H:%M") %> + <% if audio.post %> + <%= link_to audio.post.title, post_path(audio.post) %> + <% else %> + None + <% end %> + + <%= link_to "View", audio_path(audio), class: "button small" %> + <%= link_to "Edit", edit_audio_path(audio), class: "button small" %> + <%= link_to "Delete", audio_path(audio), method: :delete, data: { confirm: "Are you sure you want to delete this audio episode?" }, class: "button small danger" %> +
+
+
diff --git a/app/views/audios/new.html.erb b/app/views/audios/new.html.erb new file mode 100644 index 0000000..494ee65 --- /dev/null +++ b/app/views/audios/new.html.erb @@ -0,0 +1,11 @@ +
+ <% content_for :title, "new audio episode" %> +

New Audio Episode

+ <%= link_to "Back to list", audios_path, class: "button" %> +
+ +
+
+ <%= render "form", audio: @audio %> +
+
diff --git a/app/views/audios/show.html.erb b/app/views/audios/show.html.erb new file mode 100644 index 0000000..611b038 --- /dev/null +++ b/app/views/audios/show.html.erb @@ -0,0 +1,38 @@ +
+ <% content_for :title, @audio.title %> +

<%= @audio.title %>

+ <%= link_to "Edit", edit_audio_path(@audio), class: "button" %> + <%= link_to "Back", audios_path, class: "button" %> +
+ +
+
+
+ <% if @audio.needs_conversion? %> +

Audio file is being converted to MP3 format. Please refresh in a moment.

+ <% else %> + + <% if @audio.duration_in_seconds %> +
Duration: <%= @audio.duration_formatted %>
+ <% end %> + <% end %> +
+ +

Details

+
+
Published At
+
<%= @audio.published_at.strftime("%B %d, %Y at %I:%M %p") %>
+ +
Description
+
<%= @audio.description %>
+ + <% if @audio.post.present? %> +
Related Post
+
<%= link_to @audio.post.title, post_path(@audio.post) %>
+ <% end %> +
+
+
diff --git a/app/views/layouts/_navigation_buttons.html.erb b/app/views/layouts/_navigation_buttons.html.erb index f8b2998..223bce8 100644 --- a/app/views/layouts/_navigation_buttons.html.erb +++ b/app/views/layouts/_navigation_buttons.html.erb @@ -14,6 +14,7 @@ <% if current_user&.admin? %> <%= link_to "Manage posts", posts_path, class: "button" %> + <%= link_to "Manage audios", audios_path, class: "button" %> <%= link_to "Manage pages", pages_path, class: "button" %> <%= link_to "Manage API keys", api_keys_path, class: "button" %> <%= link_to "Manage users", user_manager_index_path, class: "button" %> diff --git a/app/views/pubview/post.html.erb b/app/views/pubview/post.html.erb index e3a3923..223912c 100644 --- a/app/views/pubview/post.html.erb +++ b/app/views/pubview/post.html.erb @@ -38,6 +38,11 @@

<%= @promo_strings.sample %> <%= link_to "Start right now!", join_path, class: "lead-button" %>

<% end %> + + <% if @post.audio.present? && @post.audio.file.attached? %> + <%= render partial: "shared/audio_player", locals: { audio: @post.audio } %> + <% end %> + <%= raw @rendered_content %> <% if @post.bookmark? && @post.summary? %> diff --git a/app/views/shared/_audio_player.html.erb b/app/views/shared/_audio_player.html.erb new file mode 100644 index 0000000..4942108 --- /dev/null +++ b/app/views/shared/_audio_player.html.erb @@ -0,0 +1,12 @@ +<% if audio && audio.file.attached? %> +
+ +

Listen to this post.

+ <% if audio.duration_in_seconds %> +
Duration: <%= audio.duration_formatted %>
+ <% end %> +
+<% end %> diff --git a/config/application.rb b/config/application.rb index 7295245..69d13b3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,5 +37,12 @@ module Arelpe config.time_zone = "Australia/Adelaide" # config.eager_load_paths << Rails.root.join("extras") config.active_support.to_time_preserves_timezone = :zone + + config.site_name = "mind reader" + config.site_author = "Aidan Cornelius-Bell" + config.podcast_description = "Anti-capitalist dispatches for individualistic times." + config.podcast_category = "Society & Culture" + config.podcast_image_url = "https://mndrdr.org/podcast-cover.jpg" + config.admin_email = "aidan@cornelius-bell.com" end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 74d7105..7be03bd 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -17,6 +17,9 @@ Rails.application.configure do # Enable server timing. config.server_timing = true + # Do jobs + config.active_job.queue_adapter = :async + # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. if Rails.root.join("tmp/caching-dev.txt").exist? @@ -42,7 +45,7 @@ Rails.application.configure do config.action_mailer.perform_caching = false config.action_mailer.default_url_options = { host: "localhost", port: 3000 } - + config.action_mailer.delivery_method = :letter_opener config.action_mailer.perform_deliveries = true @@ -75,7 +78,7 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true - + # Turn off captcha stuff for localhost config.hcaptcha_enabled = false diff --git a/config/routes.rb b/config/routes.rb index f4dcd09..b96708f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,10 @@ Rails.application.routes.draw do end end + resources :audios do + get :feed, on: :collection, defaults: { format: 'rss' } + end + get '/feed', to: 'pubview#rss', as: 'rss', defaults: { format: 'rss' } get '/feed/dispatches', to: 'pubview#dispatches_rss', as: 'dispatches_rss', defaults: { format: 'rss' } get '/join', to: "pubview#join" diff --git a/db/migrate/20250116235629_create_audios.rb b/db/migrate/20250116235629_create_audios.rb new file mode 100644 index 0000000..39519d1 --- /dev/null +++ b/db/migrate/20250116235629_create_audios.rb @@ -0,0 +1,14 @@ +class CreateAudios < ActiveRecord::Migration[8.0] + def change + create_table :audios do |t| + t.string :title + t.text :description + t.string :audio_url + t.integer :duration_in_seconds + t.datetime :published_at + t.references :post, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250117001201_create_active_storage_tables.active_storage.rb b/db/migrate/20250117001201_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..6bd8bd0 --- /dev/null +++ b/db/migrate/20250117001201_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/migrate/20250117001421_add_storage_to_audios.rb b/db/migrate/20250117001421_add_storage_to_audios.rb new file mode 100644 index 0000000..8e5d7ef --- /dev/null +++ b/db/migrate/20250117001421_add_storage_to_audios.rb @@ -0,0 +1,16 @@ +class AddStorageToAudios < ActiveRecord::Migration[8.0] + def change + # Remove the old audio_url column + remove_column :audios, :audio_url if column_exists?(:audios, :audio_url) + + # Add description if it doesn't exist + unless column_exists?(:audios, :description) + add_column :audios, :description, :text + end + + # Ensure not null constraints + change_column_null :audios, :title, false + change_column_null :audios, :duration_in_seconds, false + change_column_null :audios, :published_at, false + end +end diff --git a/db/migrate/20250117002947_update_audio_duration_constraint.rb b/db/migrate/20250117002947_update_audio_duration_constraint.rb new file mode 100644 index 0000000..5b914dc --- /dev/null +++ b/db/migrate/20250117002947_update_audio_duration_constraint.rb @@ -0,0 +1,5 @@ +class UpdateAudioDurationConstraint < ActiveRecord::Migration[8.0] + def change + change_column_null :audios, :duration_in_seconds, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 582226d..77e0795 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,35 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_13_020623) do +ActiveRecord::Schema[8.0].define(version: 2025_01_17_002947) do + create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + create_table "api_keys", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "key" t.datetime "created_at", null: false @@ -27,6 +55,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_13_020623) do t.integer "user_id" end + create_table "audios", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "title", null: false + t.text "description" + t.integer "duration_in_seconds" + t.datetime "published_at", null: false + t.bigint "post_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["post_id"], name: "index_audios_on_post_id" + end + create_table "pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "title" t.string "slug" @@ -93,5 +132,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_13_020623) do t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "api_keys", "users" + add_foreign_key "audios", "posts" end