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
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)
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)
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)
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)
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)
rubocop-rails-omakase
selenium-webdriver
sprockets-rails
+ streamio-ffmpeg
stripe
stripe-ruby-mock (~> 3.1.0)
tzinfo-data
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;
--- /dev/null
+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
--- /dev/null
+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
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'
--- /dev/null
+<%# app/views/audios/_form.html.erb %>
+<%= form_with(model: audio, local: true) do |form| %>
+ <% if audio.errors.any? %>
+ <div class="error-messages">
+ <h2><%= pluralize(audio.errors.count, "error") %> prohibited this audio from being saved:</h2>
+ <ul>
+ <% audio.errors.full_messages.each do |message| %>
+ <li><%= message %></li>
+ <% end %>
+ </ul>
+ </div>
+ <% end %>
+
+ <div class="field">
+ <%= form.label :title %>
+ <%= form.text_field :title %>
+ </div>
+
+ <div class="field">
+ <%= form.label :description %>
+ <%= form.text_area :description %>
+ </div>
+
+ <div class="field">
+ <%= form.label :file %>
+ <%= form.file_field :file, accept: 'audio/mpeg,audio/mp3,audio/ogg,audio/wav,audio/x-m4a,audio/aac' %>
+ <small>Supports MP3, M4A, OGG, and WAV files. M4A files will be automatically converted to MP3.</small>
+ </div>
+
+ <div class="field">
+ <%= form.label :published_at %>
+ <%= form.datetime_local_field :published_at %>
+ </div>
+
+ <div class="field">
+ <%= 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" } %>
+ </div>
+
+ <div class="actions">
+ <%= form.submit %> or <%= link_to "Cancel", audios_path %>
+ </div>
+<% end %>
--- /dev/null
+<div class="container">
+ <% content_for :title, "edit audio episode" %>
+ <h1>Edit Audio Episode</h1>
+ <%= link_to "Back to audio", audio_path(@audio), class: "button" %>
+</div>
+
+<div class="post">
+ <div class="container">
+ <%= render "form", audio: @audio %>
+ </div>
+</div>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/">
+ <channel>
+ <title><%= Rails.application.config.site_name %> Podcast</title>
+ <link><%= root_url %></link>
+ <language>en-us</language>
+ <copyright>© <%= Time.current.year %> <%= Rails.application.config.site_name %></copyright>
+ <itunes:author><%= Rails.application.config.site_author %></itunes:author>
+ <description><%= Rails.application.config.podcast_description %></description>
+ <itunes:type>episodic</itunes:type>
+ <itunes:owner>
+ <itunes:name><%= Rails.application.config.site_author %></itunes:name>
+ <itunes:email><%= Rails.application.config.admin_email %></itunes:email>
+ </itunes:owner>
+ <itunes:image href="<%= Rails.application.config.podcast_image_url %>"/>
+ <itunes:category text="<%= Rails.application.config.podcast_category %>"/>
+
+ <% @audios.each do |audio| %>
+ <% if audio.file.attached? %>
+ <item>
+ <title><%= audio.title %></title>
+ <description><![CDATA[<%= audio.description %>]]></description>
+ <pubDate><%= audio.published_at.rfc2822 %></pubDate>
+ <enclosure
+ url="<%= rails_blob_url(audio.file) %>"
+ length="<%= audio.file.byte_size %>"
+ type="audio/mpeg"/>
+ <guid isPermaLink="false"><%= audio_url(audio) %></guid>
+ <itunes:duration><%= audio.rss_duration %></itunes:duration>
+ <itunes:summary><![CDATA[<%= audio.description %>]]></itunes:summary>
+ <% if audio.post.present? %>
+ <link><%= post_url(audio.post) %></link>
+ <% end %>
+ </item>
+ <% end %>
+ <% end %>
+ </channel>
+</rss>
--- /dev/null
+<div class="container">
+ <% content_for :title, "audio episodes" %>
+ <h1>Audio Episodes</h1>
+ <%= link_to "New Audio Episode", new_audio_path, class: "button" %>
+ <%= link_to "Home", root_path, class: "button" %>
+</div>
+
+<%= render partial: "layouts/alert" %>
+
+<div class="post">
+ <div class="container">
+ <table>
+ <thead>
+ <tr>
+ <th>Title</th>
+ <th>Duration</th>
+ <th>Published</th>
+ <th>Related Post</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% @audios.each do |audio| %>
+ <tr>
+ <td><%= audio.title %></td>
+ <td><%= audio.duration_formatted %></td>
+ <td><%= audio.published_at.strftime("%Y-%m-%d %H:%M") %></td>
+ <td>
+ <% if audio.post %>
+ <%= link_to audio.post.title, post_path(audio.post) %>
+ <% else %>
+ <span class="quiet">None</span>
+ <% end %>
+ </td>
+ <td>
+ <%= 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" %>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+</div>
--- /dev/null
+<div class="container">
+ <% content_for :title, "new audio episode" %>
+ <h1>New Audio Episode</h1>
+ <%= link_to "Back to list", audios_path, class: "button" %>
+</div>
+
+<div class="post">
+ <div class="container">
+ <%= render "form", audio: @audio %>
+ </div>
+</div>
--- /dev/null
+<div class="container">
+ <% content_for :title, @audio.title %>
+ <h1><%= @audio.title %></h1>
+ <%= link_to "Edit", edit_audio_path(@audio), class: "button" %>
+ <%= link_to "Back", audios_path, class: "button" %>
+</div>
+
+<div class="post">
+ <div class="container">
+ <div class="audio-player">
+ <% if @audio.needs_conversion? %>
+ <p class="processing-notice">Audio file is being converted to MP3 format. Please refresh in a moment.</p>
+ <% else %>
+ <audio controls preload="metadata">
+ <source src="<%= rails_blob_path(@audio.file) %>" type="audio/mpeg">
+ Your browser does not support the audio element.
+ </audio>
+ <% if @audio.duration_in_seconds %>
+ <div class="duration">Duration: <%= @audio.duration_formatted %></div>
+ <% end %>
+ <% end %>
+ </div>
+
+ <h2>Details</h2>
+ <dl>
+ <dt>Published At</dt>
+ <dd><%= @audio.published_at.strftime("%B %d, %Y at %I:%M %p") %></dd>
+
+ <dt>Description</dt>
+ <dd><%= @audio.description %></dd>
+
+ <% if @audio.post.present? %>
+ <dt>Related Post</dt>
+ <dd><%= link_to @audio.post.title, post_path(@audio.post) %></dd>
+ <% end %>
+ </dl>
+ </div>
+</div>
<% 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" %>
<p class="lead"><%= @promo_strings.sample %> <%= link_to "Start right now!", join_path, class: "lead-button" %></p>
</div>
<% 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? %>
--- /dev/null
+<% if audio && audio.file.attached? %>
+ <div class="audio-player">
+ <audio controls preload="metadata">
+ <source src="<%= rails_blob_path(audio.file) %>" type="<%= audio.file.content_type %>">
+ We tried to show you an audio version of this post but your browser does not support the audio element.
+ </audio>
+ <p class="use-description">Listen to this post.</p>
+ <% if audio.duration_in_seconds %>
+ <div class="duration">Duration: <%= audio.duration_formatted %></div>
+ <% end %>
+ </div>
+<% end %>
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"
end
end
# 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?
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
# 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
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"
--- /dev/null
+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
--- /dev/null
+# 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
--- /dev/null
+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
--- /dev/null
+class UpdateAudioDurationConstraint < ActiveRecord::Migration[8.0]
+ def change
+ change_column_null :audios, :duration_in_seconds, true
+ end
+end
#
# 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
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"
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