diff --git a/Dockerfile b/Dockerfile
index 24364a4a..d2cf6e65 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,7 +5,7 @@ FROM base AS builder
ENV RAILS_ENV production
ENV NODE_ENV production
-# build for musl-libc, not glibc (see https://github.com/sparklemotion/nokogiri/issues/2075, https://github.com/rubygems/rubygems/issues/3174)
+# Build for musl-libc, not glibc (see https://github.com/sparklemotion/nokogiri/issues/2075, https://github.com/rubygems/rubygems/issues/3174)
ENV BUNDLE_FORCE_RUBY_PLATFORM 1
COPY --from=node /usr/local/bin/node /usr/local/bin/node
@@ -56,6 +56,9 @@ RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app
COPY --from=builder --chown=app:app /usr/local/bundle/ /usr/local/bundle/
COPY --from=builder --chown=app:app /app/ /app/
+# Forwards media listener logs to stdout so they can be captured in docker logs.
+RUN ln -sf /dev/stdout /app/log/media_listener_production.log
+
USER app
ENTRYPOINT ["docker/entrypoint.sh"]
diff --git a/Gemfile b/Gemfile
index 3e27e1cf..8e1b755d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,7 +20,7 @@ gem "cssbundling-rails", "~> 1.2.0"
gem "jsbundling-rails", "~> 1.1.2"
# Use Puma as the app server
-gem "puma", "~> 6.3.0"
+gem "puma", "~> 6.4.0"
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem "jbuilder", "~> 2.11.5"
@@ -52,6 +52,9 @@ gem "bcrypt", "~> 3.1.11"
# For sync on library changes
gem "listen", "~> 3.8.0"
+# For daemonize library sync process
+gem "daemons", "~> 1.4.0"
+
# Default stack for database, cache, background job and pub/sub
gem "litestack", "~> 0.4.2"
diff --git a/Gemfile.lock b/Gemfile.lock
index 90b38036..c372e332 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -124,6 +124,7 @@ GEM
cuprite (0.14.3)
capybara (~> 3.0)
ferrum (~> 0.13.0)
+ daemons (1.4.1)
date (3.3.3)
debug (1.8.0)
irb (>= 1.5.0)
@@ -231,8 +232,8 @@ GEM
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- oj (3.16.2)
- bigdecimal (~> 3.1)
+ oj (3.16.3)
+ bigdecimal (>= 3.0)
pagy (6.0.4)
parallel (1.23.0)
parser (3.2.2.4)
@@ -247,7 +248,7 @@ GEM
psych (5.1.1.1)
stringio
public_suffix (5.0.3)
- puma (6.3.1)
+ puma (6.4.0)
nio4r (~> 2.0)
racc (1.7.1)
rack (3.0.8)
@@ -401,6 +402,7 @@ DEPENDENCIES
carrierwave (~> 3.0.0)
cssbundling-rails (~> 1.2.0)
cuprite (~> 0.14.3)
+ daemons (~> 1.4.0)
debug
erb_lint (~> 0.4.0)
httparty (~> 0.21.0)
@@ -413,7 +415,7 @@ DEPENDENCIES
pagy (~> 6.0.0)
pg (~> 1.3.2)
propshaft (~> 0.7.0)
- puma (~> 6.3.0)
+ puma (~> 6.4.0)
rails (~> 7.1.0)
ransack (~> 4.1.0)
redis (~> 4.0)
diff --git a/README.md b/README.md
index a18e79b8..4455d3e5 100644
--- a/README.md
+++ b/README.md
@@ -143,24 +143,6 @@ services:
But you can also use embedded mode of Sidekiq if you don't want another separate Sidekiq process. This can help your deployment become easier.
All you need to do is to set `EMBEDDED_SIDEKIQ` environment variable to true.
-### Listener For Media Library
-
-Listener for media library can automatically sync for media library changes. You need another process to run the listener.
-
-```yaml
-version: '3'
-services:
- app: &app_base
- image: ghcr.io/blackcandy-org/blackcandy:edge
- volumes:
- - ./storage_data:/app/storage
- - ./uploads_data:/app/public/uploads
- - /media_data:/media_data
- listener:
- <<: *app_base
- command: bundle exec rails listen_media_changes
-```
-
### Logging
Black Candy logs to `STDOUT` by default. So if you want to control the log, Docker already supports a lot of options to handle the log in the container. see: https://docs.docker.com/config/containers/logging/configure/.
diff --git a/app/assets/stylesheets/components/_button.scss b/app/assets/stylesheets/components/_button.scss
index 77dde39c..acffff3d 100644
--- a/app/assets/stylesheets/components/_button.scss
+++ b/app/assets/stylesheets/components/_button.scss
@@ -8,6 +8,7 @@
padding: spacing("tiny") spacing("narrow");
color: var(--btn-color);
cursor: pointer;
+ font-size: font-size("medium");
}
.c-button--primary {
diff --git a/app/assets/stylesheets/settings/_theme.scss b/app/assets/stylesheets/settings/_theme.scss
index 677fb864..d16b560a 100644
--- a/app/assets/stylesheets/settings/_theme.scss
+++ b/app/assets/stylesheets/settings/_theme.scss
@@ -30,6 +30,7 @@ $green: rgb(35, 166, 80);
/* Text */
--text-primary-color: #{$grey-900};
--text-secondary-color: #{$grey-600};
+ --text-success-color: #{$green};
/* Sidebar */
--sidebar-bg-color: #{$grey-100};
@@ -136,6 +137,7 @@ $green: rgb(35, 166, 80);
/* Text */
--text-primary-color: #{$white};
--text-secondary-color: #{$grey-300};
+ --text-success-color: #{$green};
/* Sidebar */
--sidebar-bg-color: #{$grey-900};
diff --git a/app/assets/stylesheets/utilities/_text.scss b/app/assets/stylesheets/utilities/_text.scss
index ad2ed963..0ed36750 100644
--- a/app/assets/stylesheets/utilities/_text.scss
+++ b/app/assets/stylesheets/utilities/_text.scss
@@ -22,6 +22,10 @@
color: var(--text-secondary-color) !important;
}
+.u-text-color-success {
+ color: var(--text-success-color) !important;
+}
+
.u-text-monospace {
font-family: variables.$monospace-fonts;
}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index d1842151..bb2d03ca 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -48,7 +48,7 @@ def need_transcode?(song)
return true if browser.safari? && !song_format.in?(Stream::SAFARI_SUPPORTED_FORMATS)
return true if ios_app? && !song_format.in?(Stream::IOS_SUPPORTED_FORMATS)
- Setting.allow_transcode_lossless ? song.lossless? : false
+ Setting.allow_transcode_lossless? ? song.lossless? : false
end
def flash_errors_message(object, now: false)
diff --git a/app/controllers/media_syncing_controller.rb b/app/controllers/media_syncing_controller.rb
new file mode 100644
index 00000000..37042638
--- /dev/null
+++ b/app/controllers/media_syncing_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class MediaSyncingController < ApplicationController
+ before_action :require_admin
+
+ def create
+ if Media.syncing?
+ flash[:error] = t("error.syncing_in_progress")
+ redirect_to setting_path
+ else
+ MediaSyncJob.perform_later
+ end
+ end
+end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index 5240880d..aaf1df3b 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -10,7 +10,6 @@ def update
setting = Setting.instance
if setting.update(setting_params)
- MediaSyncJob.perform_later if setting_params[:media_path]
flash.now[:success] = t("success.update")
else
flash_errors_message(setting, now: true)
diff --git a/app/jobs/media_sync_job.rb b/app/jobs/media_sync_job.rb
index e2681a6c..fee46863 100644
--- a/app/jobs/media_sync_job.rb
+++ b/app/jobs/media_sync_job.rb
@@ -2,6 +2,10 @@ class MediaSyncJob < ApplicationJob
queue_as :default
def perform(type = :all, file_paths = [])
- Media.sync(type, file_paths)
+ if type == :all
+ Media.sync_all
+ else
+ Media.sync(type, file_paths)
+ end
end
end
diff --git a/app/models/concerns/global_setting_concern.rb b/app/models/concerns/global_setting_concern.rb
index 7f372b1c..bbfb08d3 100644
--- a/app/models/concerns/global_setting_concern.rb
+++ b/app/models/concerns/global_setting_concern.rb
@@ -36,6 +36,11 @@ def has_setting(setting, type: :string, default: nil)
setting_value || default_value
end
+
+ if type == :boolean
+ alias_method "#{setting}?", setting
+ singleton_class.send(:alias_method, "#{setting}?", setting)
+ end
end
end
end
diff --git a/app/models/media.rb b/app/models/media.rb
index 834b2784..e690faee 100644
--- a/app/models/media.rb
+++ b/app/models/media.rb
@@ -3,15 +3,17 @@
class Media
include Singleton
include Turbo::Broadcastable
-
extend ActiveModel::Naming
- @@syncing = false
-
class << self
- def sync(type = :all, file_paths = [])
+ def sync_all(dir = Setting.media_path)
+ sync(:all, MediaFile.file_paths(dir))
+ ensure
+ instance.broadcast_render_to "media_sync", partial: "media_syncing/syncing", locals: {syncing: false}
+ end
+
+ def sync(type, file_paths = [])
self.syncing = true
- file_paths = MediaFile.file_paths if type == :all
return if file_paths.blank?
@@ -32,12 +34,12 @@ def sync(type = :all, file_paths = [])
end
def syncing?
- @@syncing
+ Rails.cache.fetch("media_syncing") { false }
end
def syncing=(is_syncing)
- @@syncing = is_syncing
- instance.broadcast_render_to "media_sync", partial: "settings/media_sync"
+ return if is_syncing == syncing?
+ Rails.cache.write("media_syncing", is_syncing, expires_in: 1.hour)
end
private
diff --git a/app/models/media_file.rb b/app/models/media_file.rb
index c9f9eaf4..9ad77ce0 100644
--- a/app/models/media_file.rb
+++ b/app/models/media_file.rb
@@ -4,8 +4,10 @@ class MediaFile
SUPPORTED_FORMATS = WahWah.support_formats.freeze
class << self
- def file_paths
- media_path = File.expand_path(Setting.media_path)
+ def file_paths(media_path)
+ return [] if media_path.blank?
+
+ absolute_media_path = File.expand_path(media_path)
# Because Ruby ignores the FNM_CASEFOLD flag in Dir.glob, case sensitivity depends on your system.
# So we need another way to make Dir.glob case-insensitive.
@@ -14,7 +16,7 @@ def file_paths
format.chars.map { |char| (char.downcase != char.upcase) ? "[#{char.downcase}#{char.upcase}]" : char }.join
end
- Dir.glob("#{media_path}/**/*.{#{case_insensitive_supported_formats.join(",")}}")
+ Dir.glob("#{absolute_media_path}/**/*.{#{case_insensitive_supported_formats.join(",")}}")
end
def format(file_path)
diff --git a/app/models/media_listener.rb b/app/models/media_listener.rb
new file mode 100644
index 00000000..c052387a
--- /dev/null
+++ b/app/models/media_listener.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class MediaListener
+ include Singleton
+
+ SERVICE_PATH = File.join(Rails.root, "lib", "daemons", "media_listener_service")
+ class Config
+ DEFAULTS = {
+ service_name: "media_listener_service",
+ pid_dir: File.join(Rails.root, "tmp", "pids")
+ }
+
+ attr_accessor :service_name, :pid_dir
+
+ def initialize
+ DEFAULTS.each do |key, value|
+ send("#{key}=", value)
+ end
+ end
+ end
+
+ class << self
+ delegate :start, :stop, :running?, to: :instance
+
+ def config
+ yield instance.config
+ end
+ end
+
+ attr_reader :config
+
+ def initialize
+ @config = Config.new
+ end
+
+ def start
+ run("start")
+ end
+
+ def stop
+ run("stop")
+ end
+
+ def running?
+ pid_file_path = Daemons::PidFile.find_files(@config.pid_dir, @config.service_name).first
+ return false unless pid_file_path.present?
+
+ pid_file = Daemons::PidFile.existing(pid_file_path)
+ Daemons::Pid.running?(pid_file.pid)
+ end
+
+ private
+
+ def run(command)
+ system "bundle exec #{SERVICE_PATH} #{command} -d #{@config.pid_dir} -n #{@config.service_name}"
+ end
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index ad392466..1e731fee 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -9,18 +9,34 @@ class Setting < ApplicationRecord
has_setting :discogs_token
has_setting :transcode_bitrate, type: :integer, default: 128
has_setting :allow_transcode_lossless, type: :boolean, default: false
+ has_setting :enable_media_listener, type: :boolean, default: false
validates :transcode_bitrate, inclusion: {in: AVAILABLE_BITRATE_OPTIONS}, allow_nil: true
validate :media_path_exist
+ after_update :toggle_media_listener, if: :saved_change_to_enable_media_listener?
+ after_update_commit :sync_media, if: :saved_change_to_media_path?
+
private
def media_path_exist
- return if media_path.blank?
+ errors.add(:media_path, :blank) and return if media_path.blank?
path = File.expand_path(media_path)
errors.add(:media_path, :not_exist) unless File.exist?(path)
errors.add(:media_path, :unreadable) unless File.readable?(path)
end
+
+ def sync_media
+ MediaSyncJob.perform_later
+ end
+
+ def toggle_media_listener
+ if enable_media_listener?
+ MediaListener.start
+ else
+ MediaListener.stop
+ end
+ end
end
diff --git a/app/views/media_syncing/_button.html.erb b/app/views/media_syncing/_button.html.erb
new file mode 100644
index 00000000..c0f4ecbe
--- /dev/null
+++ b/app/views/media_syncing/_button.html.erb
@@ -0,0 +1,21 @@
+
+ <% if syncing %>
+ <%= button_to(
+ t("label.syncing"),
+ false,
+ class: "c-button c-button--secondary",
+ data: {test_id: "media_sync_button"},
+ disabled: true
+ ) %>
+ <% else %>
+ <%= button_to(
+ t("label.sync"),
+ media_syncing_path,
+ class: "c-button c-button--secondary",
+ data: {
+ test_id: "media_sync_button",
+ turbo_submits_with: t("label.syncing")
+ }
+ ) %>
+ <% end %>
+
diff --git a/app/views/media_syncing/_syncing.turbo_stream.erb b/app/views/media_syncing/_syncing.turbo_stream.erb
new file mode 100644
index 00000000..512c1c2b
--- /dev/null
+++ b/app/views/media_syncing/_syncing.turbo_stream.erb
@@ -0,0 +1,5 @@
+<%= turbo_stream.replace "turbo-media-syncing-button", partial: "media_syncing/button", locals: {syncing: syncing} %>
+
+<% unless syncing %>
+ <%= render_flash(message: t("text.sync_completed")) %>
+<% end %>
diff --git a/app/views/media_syncing/create.turbo_stream.erb b/app/views/media_syncing/create.turbo_stream.erb
new file mode 100644
index 00000000..dd94429f
--- /dev/null
+++ b/app/views/media_syncing/create.turbo_stream.erb
@@ -0,0 +1 @@
+<%= render partial: "media_syncing/syncing", locals: { syncing: true } %>
\ No newline at end of file
diff --git a/app/views/settings/_form.html.erb b/app/views/settings/_form.html.erb
index 1f875561..3e4d76a3 100644
--- a/app/views/settings/_form.html.erb
+++ b/app/views/settings/_form.html.erb
@@ -1,6 +1,33 @@
-
<%= t("label.media_path") %>
- <%= render "settings/media_path_form" %>
+
+ <%= t("label.library") %>
+ <%= render partial: "media_syncing/button", locals: {syncing: Media.syncing?} %>
+
+
+ <%= form_with model: Setting, url: setting_path, method: :put, class: "c-form" do |form| %>
+
+ <%= form.label :media_path %>
+ <%= form.text_field :media_path, value: Setting.media_path, class: "c-input" %>
+
+
+
+
+ <%= form.label :enable_media_listener %>
+ <%= form.check_box :enable_media_listener %>
+
+ <% if MediaListener.running? %>
+
+ <%= icon_tag("check", size: "small") %>
+ <%= t("text.media_listener_running") %>
+
+ <% end %>
+
+
<%= t("text.enable_media_listener") %>
+
+
+ <%= form.submit t("label.save"), class: "c-button c-button--primary c-button--full-width" %>
+
+ <% end %>
diff --git a/app/views/settings/_media_path_form.html.erb b/app/views/settings/_media_path_form.html.erb
deleted file mode 100644
index 42dae381..00000000
--- a/app/views/settings/_media_path_form.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<%= form_with model: Setting, url: setting_path, method: :put, class: "c-form o-flex o-flex--justify-between", id: "turbo-settings-media-path" do |form| %>
- <%= form.text_field :media_path, value: Setting.media_path, class: "c-input u-mr-narrow" %>
- <% if Media.syncing? %>
- <%= form.submit t("label.syncing"), class: "c-button c-button--primary", data: {test_id: "media_sync_button"}, disabled: true %>
- <% else %>
- <%= form.submit t("label.sync"), class: "c-button c-button--primary", data: {test_id: "media_sync_button"} %>
- <% end %>
-<% end %>
diff --git a/app/views/settings/_media_sync.turbo_stream.erb b/app/views/settings/_media_sync.turbo_stream.erb
deleted file mode 100644
index e602e6a2..00000000
--- a/app/views/settings/_media_sync.turbo_stream.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<%= turbo_stream.replace 'turbo-settings-media-path', partial: 'settings/media_path_form' %>
-
-<% unless Media.syncing? %>
- <%= render_flash(message: t("text.sync_completed")) %>
-<% end %>
diff --git a/config/environments/test.rb b/config/environments/test.rb
index b90fc9a0..f686d532 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -26,7 +26,7 @@
# Show full error reports and disable caching.
config.consider_all_requests_local = true
config.action_controller.perform_caching = false
- config.cache_store = :null_store
+ config.cache_store = :memory_store
# Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = :rescuable
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index 3860f659..dc74527d 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -14,3 +14,7 @@
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym "RESTful"
# end
+
+ActiveSupport::Inflector.inflections(:en) do |inflect|
+ inflect.uncountable %w[media_syncing]
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e5352774..85419d6a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -116,6 +116,7 @@ en:
unprocessable_entity: 'The change you wanted was rejected'
internal_server_error: "We're sorry, but something went wrong"
already_in_playlist: "Add to playlist failed, song is already in playlist"
+ syncing_in_progress: "Syncing in progress, please wait"
success:
delete: 'Deleted successfully'
create: 'Created successfully'
@@ -128,6 +129,8 @@ en:
transcoding: "Black candy will transcode formats that are not supported by the browser by default."
allow_transcode_lossless: "Will transcode lossless format to mp3 to reduce bandwidth."
sync_completed: "Media sync completed"
+ enable_media_listener: "Media listener can automatically sync with library changes."
+ media_listener_running: "Listener is running"
activerecord:
errors:
diff --git a/config/puma.rb b/config/puma.rb
index 2067b380..4dc10fcd 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -23,28 +23,37 @@
# Specifies the `pidfile` that Puma will use.
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
-# Specifies that the worker count should equal the number of processors in production.
-if ENV["RAILS_ENV"] == "production"
- require "concurrent-ruby"
- worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
- workers worker_count if worker_count > 1
-end
-
# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
# process behavior so workers use less memory.
-#
-# preload_app!
+preload_app!
# Allow puma to be restarted by `rails restart` command.
plugin :tmp_restart
+on_booted do
+ MediaListener.start if Setting.enable_media_listener? && !MediaListener.running?
+end
+
+if ENV["RAILS_ENV"] == "production"
+ # Specifies that the worker count should equal the number of processors in production.
+ require "concurrent-ruby"
+ worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
+ workers worker_count if worker_count > 1
+
+ on_worker_shutdown do
+ MediaListener.stop if MediaListener.running?
+ end
+end
+
+if ENV["RAILS_ENV"] == "development"
+ on_thread_exit do
+ MediaListener.stop if MediaListener.running?
+ end
+end
+
if BlackCandy::Config.embedded_sidekiq?
- # Preloading the application is necessary to ensure
- # the configuration in your initializer runs before
- # the boot callback below.
- preload_app!
sidekiq = nil
on_worker_boot do
diff --git a/config/routes.rb b/config/routes.rb
index 7a11870b..845cacab 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -71,6 +71,8 @@
resources :stream, only: [:new]
resources :transcoded_stream, only: [:new]
+ resource :media_syncing, only: [:create]
+
get "/403", to: "errors#forbidden", as: :forbidden
get "/404", to: "errors#not_found", as: :not_found
get "/422", to: "errors#unprocessable_entity", as: :unprocessable_entity
diff --git a/lib/daemons/media_listener_service b/lib/daemons/media_listener_service
new file mode 100755
index 00000000..d8b2084f
--- /dev/null
+++ b/lib/daemons/media_listener_service
@@ -0,0 +1,53 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "daemons"
+require "optparse"
+
+ROOT_DIR = File.expand_path(__dir__ + "/../../")
+
+options = {
+ name: "black_candy_media_listener_service",
+ dir: File.join(ROOT_DIR, "tmp", "pids")
+}
+
+OptionParser.new do |parser|
+ parser.on("-n", "--name=NAME", "Service Name") do |name|
+ options[:name] = name
+ end
+
+ parser.on("-d", "--dir=DIR", "PID Directory") do |dir|
+ options[:dir] = dir
+ end
+end.parse!
+
+daemon_options = {
+ dir_mode: :normal,
+ dir: options[:dir],
+ multiple: false,
+ shush: ENV["RAILS_ENV"] == "test",
+ log_output: true,
+ log_dir: File.join(ROOT_DIR, "log"),
+ output_logfilename: "media_listener_#{ENV["RAILS_ENV"]}.log"
+}
+
+Daemons.run_proc(options[:name], daemon_options) do
+ Dir.chdir ROOT_DIR
+
+ require "./config/environment"
+
+ listener_log_level = (ENV["RAILS_ENV"] == "production") ? :info : :debug
+ Listen.logger = ::Logger.new($stdout, level: ENV.fetch("RAILS_LOG_LEVEL", listener_log_level))
+
+ supported_formates = MediaFile::SUPPORTED_FORMATS.map { |formate| %r{\.#{formate}$} }
+
+ listener = Listen.to(File.expand_path(Setting.media_path), only: supported_formates, latency: 5, wait_for_delay: 10) do |modified, added, removed|
+ MediaSyncJob.perform_later(:modified, modified) if modified.present?
+ MediaSyncJob.perform_later(:added, added) if added.present?
+ MediaSyncJob.perform_later(:removed, removed) if removed.present?
+ end
+
+ listener.start
+
+ sleep
+end
diff --git a/lib/tasks/listen_media_change.rake b/lib/tasks/listen_media_change.rake
deleted file mode 100644
index 16643581..00000000
--- a/lib/tasks/listen_media_change.rake
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-task listen_media_changes: :environment do
- supported_formates = MediaFile::SUPPORTED_FORMATS.map { |formate| %r{\.#{formate}$} }
-
- listener = Listen.to(File.expand_path(Setting.media_path), only: supported_formates) do |modified, added, removed|
- MediaSyncJob.perform_later(:modified, modified) if modified.present?
- MediaSyncJob.perform_later(:added, added) if added.present?
- MediaSyncJob.perform_later(:removed, removed) if removed.present?
- end
-
- listener.start
-
- sleep
-end
diff --git a/test/controllers/api/v1/stream_controller_test.rb b/test/controllers/api/v1/stream_controller_test.rb
index aa698f73..a56ef15e 100644
--- a/test/controllers/api/v1/stream_controller_test.rb
+++ b/test/controllers/api/v1/stream_controller_test.rb
@@ -5,7 +5,6 @@
class Api::V1::StreamControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:visitor1)
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
end
test "should get new stream" do
diff --git a/test/controllers/api/v1/transcoded_stream_controller_test.rb b/test/controllers/api/v1/transcoded_stream_controller_test.rb
index 3eabd263..bf06c211 100644
--- a/test/controllers/api/v1/transcoded_stream_controller_test.rb
+++ b/test/controllers/api/v1/transcoded_stream_controller_test.rb
@@ -5,7 +5,6 @@
class Api::V1::TranscodedStreamControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:visitor1)
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
cache_directory = "#{Stream::TRANSCODE_CACHE_DIRECTORY}/#{songs(:flac_sample).id}"
if Dir.exist?(cache_directory)
diff --git a/test/controllers/media_syncing_controller_test.rb b/test/controllers/media_syncing_controller_test.rb
new file mode 100644
index 00000000..6f32c625
--- /dev/null
+++ b/test/controllers/media_syncing_controller_test.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class MediaSyncingControllerTest < ActionDispatch::IntegrationTest
+ include ActiveJob::TestHelper
+
+ test "should sync media" do
+ login users(:admin)
+
+ Media.stub(:syncing?, false) do
+ assert_enqueued_with(job: MediaSyncJob) do
+ post media_syncing_url
+ end
+ end
+ end
+
+ test "should only admin can sync media" do
+ login
+
+ post media_syncing_url
+ assert_response :forbidden
+ end
+
+ test "should not sync media when the media is syncing" do
+ login users(:admin)
+
+ Media.stub(:syncing?, true) do
+ assert_no_enqueued_jobs do
+ post media_syncing_url
+ end
+ end
+ end
+end
diff --git a/test/controllers/settings_controller_test.rb b/test/controllers/settings_controller_test.rb
index 9c1b2e4c..fe0698f0 100644
--- a/test/controllers/settings_controller_test.rb
+++ b/test/controllers/settings_controller_test.rb
@@ -29,8 +29,6 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
end
test "should sync media when media_path setting updated" do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
-
login users(:admin)
assert_enqueued_with(job: MediaSyncJob) do
diff --git a/test/controllers/stream_controller_test.rb b/test/controllers/stream_controller_test.rb
index 5b6440cc..6b20f553 100644
--- a/test/controllers/stream_controller_test.rb
+++ b/test/controllers/stream_controller_test.rb
@@ -5,8 +5,6 @@
class StreamControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:visitor1)
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
-
login(@user)
end
diff --git a/test/controllers/transcoded_stream_controller_test.rb b/test/controllers/transcoded_stream_controller_test.rb
index df72b3f7..5989a5c6 100644
--- a/test/controllers/transcoded_stream_controller_test.rb
+++ b/test/controllers/transcoded_stream_controller_test.rb
@@ -5,7 +5,6 @@
class TranscodedStreamControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:visitor1)
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
cache_directory = "#{Stream::TRANSCODE_CACHE_DIRECTORY}/#{songs(:flac_sample).id}"
if Dir.exist?(cache_directory)
diff --git a/test/fixtures/settings.yml b/test/fixtures/settings.yml
new file mode 100644
index 00000000..31bdcc6e
--- /dev/null
+++ b/test/fixtures/settings.yml
@@ -0,0 +1,5 @@
+global_setting:
+ id: 1
+ singleton_guard: 0
+ values:
+ media_path: <%= Rails.root.join("test/fixtures/files").to_s %>
\ No newline at end of file
diff --git a/test/jobs/media_sync_job_test.rb b/test/jobs/media_sync_job_test.rb
index 773fcfff..62f394a4 100644
--- a/test/jobs/media_sync_job_test.rb
+++ b/test/jobs/media_sync_job_test.rb
@@ -3,14 +3,54 @@
require "test_helper"
class MediaSyncJobTest < ActiveJob::TestCase
- test "sync media" do
+ test "sync all media" do
mock = Minitest::Mock.new
- mock.expect(:call, true, [:all, []])
-
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
+ mock.expect(:call, true, [])
MediaSyncJob.perform_later
+ Media.stub(:sync_all, mock) do
+ perform_enqueued_jobs
+ mock.verify
+ end
+ end
+
+ test "sync added media" do
+ file_paths = [fixtures_file_path("artist1_album1.flac")]
+ mock = Minitest::Mock.new
+
+ mock.expect(:call, true, [:added, file_paths])
+
+ MediaSyncJob.perform_later(:added, file_paths)
+
+ Media.stub(:sync, mock) do
+ perform_enqueued_jobs
+ mock.verify
+ end
+ end
+
+ test "sync removed media" do
+ file_paths = [fixtures_file_path("artist1_album1.flac")]
+ mock = Minitest::Mock.new
+
+ mock.expect(:call, true, [:removed, file_paths])
+
+ MediaSyncJob.perform_later(:removed, file_paths)
+
+ Media.stub(:sync, mock) do
+ perform_enqueued_jobs
+ mock.verify
+ end
+ end
+
+ test "sync modified media" do
+ file_paths = [fixtures_file_path("artist1_album1.flac")]
+ mock = Minitest::Mock.new
+
+ mock.expect(:call, true, [:modified, file_paths])
+
+ MediaSyncJob.perform_later(:modified, file_paths)
+
Media.stub(:sync, mock) do
perform_enqueued_jobs
mock.verify
diff --git a/test/models/media_file_test.rb b/test/models/media_file_test.rb
index 74db24d0..5d1bf949 100644
--- a/test/models/media_file_test.rb
+++ b/test/models/media_file_test.rb
@@ -16,9 +16,7 @@ class MediaFileTest < ActiveSupport::TestCase
fixtures_file_path("various_artists.mp3")
]
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
-
- MediaFile.file_paths.each do |file_path|
+ MediaFile.file_paths(Rails.root.join("test/fixtures/files")).each do |file_path|
assert_includes expect_file_paths, file_path
end
end
@@ -32,17 +30,13 @@ class MediaFileTest < ActiveSupport::TestCase
Rails.root.join("test/fixtures/case_insensitive_files", "test.OpUS")
].map(&:to_s)
- Setting.update(media_path: Rails.root.join("test/fixtures/case_insensitive_files"))
-
- MediaFile.file_paths.each do |file_path|
+ MediaFile.file_paths(Rails.root.join("test/fixtures/case_insensitive_files")).each do |file_path|
assert_includes expect_file_paths, file_path
end
end
test "should ignore not supported files under media_path" do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
-
- assert_not_includes MediaFile.file_paths, fixtures_file_path("not_supported_file.txt")
+ assert_not_includes MediaFile.file_paths(Rails.root.join("test/fixtures/files")), fixtures_file_path("not_supported_file.txt")
end
test "should get correct format" do
diff --git a/test/models/media_listener_test.rb b/test/models/media_listener_test.rb
new file mode 100644
index 00000000..b569978c
--- /dev/null
+++ b/test/models/media_listener_test.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class MediaListenerTest < ActiveSupport::TestCase
+ teardown do
+ MediaListener.stop
+ end
+
+ test "start listening" do
+ MediaListener.start
+
+ assert MediaListener.running?
+ end
+
+ test "stop listening" do
+ MediaListener.start
+ MediaListener.stop
+
+ assert_not MediaListener.running?
+ end
+end
diff --git a/test/models/media_test.rb b/test/models/media_test.rb
index d4f25602..571d164c 100644
--- a/test/models/media_test.rb
+++ b/test/models/media_test.rb
@@ -7,9 +7,7 @@ class MediaTest < ActiveSupport::TestCase
setup do
clear_media_data
-
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
- Media.sync
+ Media.sync_all
end
test "should create all records in database when synced" do
@@ -47,7 +45,7 @@ class MediaTest < ActiveSupport::TestCase
test "should change associations when modify album info on file" do
MediaFile.stub(:file_info, media_file_info_stub(file_fixture("artist1_album2.mp3"), album_name: "album1")) do
- Media.sync
+ Media.sync_all
album1_songs_ids = Song.where(name: %w[flac_sample m4a_sample mp3_sample]).ids.sort
@@ -61,7 +59,7 @@ class MediaTest < ActiveSupport::TestCase
:file_info,
media_file_info_stub(file_fixture("artist1_album2.mp3"), artist_name: "artist2", albumartist_name: "artist2")
) do
- Media.sync
+ Media.sync_all
artist2_songs_ids = Song.where(
name: %w[mp3_sample ogg_sample wav_sample opus_sample oga_sample wma_sample]
@@ -75,22 +73,20 @@ class MediaTest < ActiveSupport::TestCase
test "should change song attribute when modify song info on file" do
MediaFile.stub(:file_info, media_file_info_stub(file_fixture("artist1_album2.mp3"), tracknum: 2)) do
assert_changes -> { Song.find_by(name: "mp3_sample").tracknum }, from: 1, to: 2 do
- Media.sync
+ Media.sync_all
end
end
end
test "should clear records on database when delete file" do
create_tmp_dir(from: Setting.media_path) do |tmp_dir|
- Setting.update(media_path: tmp_dir)
-
File.delete File.join(tmp_dir, "artist2_album3.ogg")
File.delete File.join(tmp_dir, "artist2_album3.wav")
File.delete File.join(tmp_dir, "artist2_album3.opus")
File.delete File.join(tmp_dir, "artist2_album3.oga")
File.delete File.join(tmp_dir, "artist2_album3.wma")
- Media.sync
+ Media.sync_all(tmp_dir)
assert_nil Song.find_by(name: "ogg_sample")
assert_nil Song.find_by(name: "wav_sample")
@@ -115,19 +111,17 @@ class MediaTest < ActiveSupport::TestCase
assert_equal Media.instance.object_id, Media.instance.object_id
end
- test "should broadcast media sync stream when set syncing status" do
+ test "should broadcast media sync stream when sync all completed" do
assert_broadcasts("media_sync", 1) do
- Media.syncing = true
+ Media.sync_all
end
end
test "should not attach record when file path is invalide" do
clear_media_data
- MediaFile.stub(:file_paths, ["/fake.mp3", file_fixture("artist1_album2.mp3").to_s]) do
- Media.sync
- assert_equal 1, Album.count
- end
+ Media.sync(:all, ["/fake.mp3", file_fixture("artist1_album2.mp3").to_s])
+ assert_equal 1, Album.count
end
test "should not attach record when file info is invalide" do
@@ -141,7 +135,7 @@ class MediaTest < ActiveSupport::TestCase
}
MediaFile.stub(:file_info, file_info) do
- Media.sync
+ Media.sync_all
assert_equal 0, Album.count
end
end
diff --git a/test/models/setting_test.rb b/test/models/setting_test.rb
index d584f336..ea5b758a 100644
--- a/test/models/setting_test.rb
+++ b/test/models/setting_test.rb
@@ -3,14 +3,17 @@
require "test_helper"
class SettingTest < ActiveSupport::TestCase
+ include ActiveJob::TestHelper
+
test "should have AVAILABLE_SETTINGS constant" do
- assert_equal [:media_path, :discogs_token, :transcode_bitrate, :allow_transcode_lossless], Setting::AVAILABLE_SETTINGS
+ assert_equal [:media_path, :discogs_token, :transcode_bitrate, :allow_transcode_lossless, :enable_media_listener], Setting::AVAILABLE_SETTINGS
end
test "should get env default value when setting value did not set" do
- with_env("MEDIA_PATH" => "/test_media_path") do
- assert_nil Setting.instance.values&.[]("media_path")
- assert_equal "/test_media_path", Setting.media_path
+ Setting.instance.stub(:values, {"media_path" => nil}) do
+ with_env("MEDIA_PATH" => "/test_media_path") do
+ assert_equal "/test_media_path", Setting.media_path
+ end
end
end
@@ -67,4 +70,18 @@ class SettingTest < ActiveSupport::TestCase
assert_not setting.valid?
end
+
+ test "should sync media when media_path changed" do
+ assert_enqueued_with(job: MediaSyncJob) do
+ Setting.update(media_path: Rails.root.join("test/fixtures"))
+ end
+ end
+
+ test "should toggle media listener when enable_media_listener changed" do
+ Setting.update(enable_media_listener: true)
+ assert MediaListener.running?
+
+ Setting.update(enable_media_listener: false)
+ assert_not MediaListener.running?
+ end
end
diff --git a/test/system/album_test.rb b/test/system/album_test.rb
index 7698a230..485765af 100644
--- a/test/system/album_test.rb
+++ b/test/system/album_test.rb
@@ -4,7 +4,6 @@
class AlbumSystemTest < ApplicationSystemTestCase
setup do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
@album = albums(:album1)
end
diff --git a/test/system/current_playlist_test.rb b/test/system/current_playlist_test.rb
index ae5bd22e..fbec9da6 100644
--- a/test/system/current_playlist_test.rb
+++ b/test/system/current_playlist_test.rb
@@ -4,7 +4,6 @@
class CurrentPlaylistSystemTest < ApplicationSystemTestCase
setup do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
users(:visitor1).current_playlist.replace(Song.ids.sort)
login_as users(:visitor1)
end
diff --git a/test/system/favorite_playlist_test.rb b/test/system/favorite_playlist_test.rb
index 37ce2da0..b937b75c 100644
--- a/test/system/favorite_playlist_test.rb
+++ b/test/system/favorite_playlist_test.rb
@@ -4,7 +4,6 @@
class FavoritePlaylistSystemTest < ApplicationSystemTestCase
setup do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
users(:visitor1).favorite_playlist.replace(Song.ids.sort)
login_as users(:visitor1)
diff --git a/test/system/player_test.rb b/test/system/player_test.rb
index 869c5149..cc7ca42c 100644
--- a/test/system/player_test.rb
+++ b/test/system/player_test.rb
@@ -4,7 +4,6 @@
class PlayerSystemTest < ApplicationSystemTestCase
setup do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
users(:visitor1).current_playlist.replace(Song.ids.sort)
login_as users(:visitor1)
end
diff --git a/test/system/playlist_test.rb b/test/system/playlist_test.rb
index 5986d3bd..7ad2961e 100644
--- a/test/system/playlist_test.rb
+++ b/test/system/playlist_test.rb
@@ -4,7 +4,6 @@
class PlaylistSystemTest < ApplicationSystemTestCase
setup do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
login_as users(:admin)
visit playlist_songs_url(playlists(:playlist1))
diff --git a/test/system/songs_test.rb b/test/system/songs_test.rb
index 842bcd1c..aae9d9f8 100644
--- a/test/system/songs_test.rb
+++ b/test/system/songs_test.rb
@@ -4,8 +4,6 @@
class SongsSystemTest < ApplicationSystemTestCase
setup do
- Setting.update(media_path: Rails.root.join("test/fixtures/files"))
-
# create many record to test infinite scroll
(Pagy::DEFAULT[:items] * 2).times do |n|
Song.create(
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 05155cd8..8fef91b3 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -33,6 +33,10 @@
WebMock.disable_net_connect!(allow_localhost: true, net_http_connect_on_start: true, allow: allowed_sites_for_webmock)
+MediaListener.config do |config|
+ config.service_name = "media_listener_service_test"
+end
+
class ActiveSupport::TestCase
include Turbo::Broadcastable::TestHelper
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.