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.