From 396569a74e210085a7f308aa0291efe40c7029e1 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Fri, 26 Jun 2026 08:07:58 +0200 Subject: [PATCH 01/25] WIP: Adding google drive backend for song media --- Gemfile | 4 ++ Gemfile.lock | 52 +++++++++++++++++++ app/controllers/song_media_controller.rb | 2 +- app/helpers/song_media_helper.rb | 10 ++-- app/models/song_media_storage_accessor.rb | 31 +++++++++++ app/models/song_medium.rb | 21 +++++++- app/views/song_media/_form.html.erb | 8 +++ app/views/songs/_download_dropdown.html.erb | 12 +++-- app/views/songs/show.html.erb | 4 +- config/credentials.yml.enc | 2 +- ...72843_add_file_identifier_to_song_media.rb | 7 +++ db/schema.rb | 3 +- 12 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 app/models/song_media_storage_accessor.rb create mode 100644 db/migrate/20260608172843_add_file_identifier_to_song_media.rb diff --git a/Gemfile b/Gemfile index 73069af..3b3d007 100644 --- a/Gemfile +++ b/Gemfile @@ -97,3 +97,7 @@ gem 'browser', '~> 6.2' gem 'mission_control-jobs', '~> 1.1' gem 'ruby-vips', '~> 2.3' + +gem 'google-apis-drive_v3', '~> 0.81.0' + +gem 'multi_json', '~> 1.21' diff --git a/Gemfile.lock b/Gemfile.lock index 5fe0a55..7c6226f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -134,6 +134,7 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + declarative (0.0.20) diff-lcs (1.6.2) docile (1.4.1) dotenv (3.2.0) @@ -148,6 +149,14 @@ GEM tzinfo faker (3.8.0) i18n (>= 1.8.11, < 2) + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-net_http (3.4.4) + net-http (~> 0.5) ffi (1.17.2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-arm-linux-gnu) @@ -160,6 +169,29 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + google-apis-core (1.2.3) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) + representable (~> 3.0) + retriable (>= 3.1, < 5.0) + google-apis-drive_v3 (0.81.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-logging-utils (0.2.0) + googleauth (1.17.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) highline (3.1.2) reline htmlentities (4.4.2) @@ -196,6 +228,8 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.20.0) + jwt (3.2.0) + base64 kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) @@ -278,6 +312,9 @@ GEM stimulus-rails turbo-rails msgpack (1.8.3) + multi_json (1.21.1) + net-http (0.9.1) + uri (>= 0.11.1) net-imap (0.6.3) date net-protocol @@ -306,6 +343,7 @@ GEM racc (~> 1.4) nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) + os (1.1.4) ostruct (0.6.3) parallel (2.1.0) parser (3.3.11.1) @@ -325,6 +363,7 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack + pstore (0.2.1) psych (5.3.1) date stringio @@ -395,6 +434,11 @@ GEM regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (4.2.0) rexml (3.4.4) rodf (1.2.0) builder (>= 3.0) @@ -458,6 +502,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -519,12 +567,14 @@ GEM thruster (0.1.21-x86_64-darwin) thruster (0.1.21-x86_64-linux) timeout (0.6.1) + trailblazer-option (0.1.2) tsort (0.2.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uber (0.1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -561,6 +611,7 @@ DEPENDENCIES debug down (~> 5.6) faker (~> 3.8) + google-apis-drive_v3 (~> 0.81.0) i18n-tasks (~> 1.1.2) icalendar (~> 2.12) image_processing (~> 2.0) @@ -569,6 +620,7 @@ DEPENDENCIES letter_opener litestream (~> 0.14.0) mission_control-jobs (~> 1.1) + multi_json (~> 1.21) passwordless (~> 1.8) positioning (~> 0.4.8) propshaft diff --git a/app/controllers/song_media_controller.rb b/app/controllers/song_media_controller.rb index 698009c..af7f9c3 100644 --- a/app/controllers/song_media_controller.rb +++ b/app/controllers/song_media_controller.rb @@ -32,6 +32,6 @@ def destroy private def song_medium_params - params.expect(song_medium: %i[song_id register kind file]) + params.expect(song_medium: %i[song_id register kind file file_identifier]) end end diff --git a/app/helpers/song_media_helper.rb b/app/helpers/song_media_helper.rb index 5a0cd14..dce7012 100644 --- a/app/helpers/song_media_helper.rb +++ b/app/helpers/song_media_helper.rb @@ -9,13 +9,15 @@ def song_media_register_options Register::Song::REGISTERS.map { |r| [t("activerecord.attributes.song.enums.register.#{r}"), r] } end - def file_icon(attachment) - tabler_icon(if attachment.audio? + def file_icon(song_medium) + tabler_icon(if song_medium.type_audio? :'file-music' - elsif attachment.content_type == 'application/pdf' + elsif song_medium.type_pdf? :'file-type-pdf' - elsif attachment.video? + elsif song_medium.type_video? :video + else + :file end, classes: ['mr-2']) end end diff --git a/app/models/song_media_storage_accessor.rb b/app/models/song_media_storage_accessor.rb new file mode 100644 index 0000000..a92e941 --- /dev/null +++ b/app/models/song_media_storage_accessor.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'singleton' + +class SongMediaStorageAccessor + include Singleton + + def initialize + @drive_service = Google::Apis::DriveV3::DriveService.new + json_key = StringIO.new(Rails.application.credentials.dig(:google_drive, :service_account_json_key)) + @drive_service.authorization = Google::Auth::ServiceAccountCredentials.make_creds( + json_key_io: json_key, + scope: 'https://www.googleapis.com/auth/drive.readonly' + ) + end + + def retrieve_files(next_page_token = nil) + response = @drive_service.list_files( + q: "trashed = false and mimeType != 'application/vnd.google-apps.folder'", + page_size: 1000, + fields: 'files(id, name, mimeType), next_page_token', + include_items_from_all_drives: true, + supports_all_drives: true, + page_token: next_page_token + ) + + files = response.files + files += retrieve_files(response.next_page_token) if response.next_page_token + files + end +end diff --git a/app/models/song_medium.rb b/app/models/song_medium.rb index a3f4dcb..94cdcfa 100644 --- a/app/models/song_medium.rb +++ b/app/models/song_medium.rb @@ -22,11 +22,30 @@ class SongMedium < ApplicationRecord validates :register, absence: true, if: proc { |sm| sm.kind != KIND_RECORDING_REGISTER } validates :kind, presence: true, inclusion: KINDS validates :kind, uniqueness: { scope: %i[song_id register] } - validates :file, presence: true + validates :file, presence: true, if: proc { |sm| sm.file_identifier.blank? } + validates :file_identifier, presence: true, if: proc { |sm| sm.file.blank? } scope :recording, -> { where(kind: [KIND_RECORDING_ALL, KIND_RECORDING_REGISTER]) } def human_kind I18n.t("activerecord.attributes.song_medium.enums.kind.#{kind}") end + + def type_audio? + return false if file.blank? + + file.attachment.audio? + end + + def type_pdf? + return false if file.blank? + + file.attachment.content_type == 'application/pdf' + end + + def type_video? + return false if file.blank? + + file.attachment.video? + end end diff --git a/app/views/song_media/_form.html.erb b/app/views/song_media/_form.html.erb index 56f172a..5357234 100644 --- a/app/views/song_media/_form.html.erb +++ b/app/views/song_media/_form.html.erb @@ -29,6 +29,14 @@

<%= f.file_field :file %>

+
+

+ <%= f.label :file_identifier %> <%# i18n-tasks-use t('activerecord.attributes.song_medium.file_identifier') %> +

+ +

<%= f.text_field :file_identifier %>

+
+

<%= f.submit class: 'btn btn-primary' %> <%# i18n-tasks-use t('helpers.submit.song_medium.create') %>

diff --git a/app/views/songs/_download_dropdown.html.erb b/app/views/songs/_download_dropdown.html.erb index 15b278a..f26ce33 100644 --- a/app/views/songs/_download_dropdown.html.erb +++ b/app/views/songs/_download_dropdown.html.erb @@ -16,12 +16,14 @@ tabindex="-1" > <% song.song_media.each do |song_medium| %> - <%= link_to rails_blob_path(song_medium.file, disposition: 'attachment'), class: 'inline-flex block px-4 py-2 text-sm text-primary', role: 'menuitem', tabindex: '-1' do %> - <%= file_icon(song_medium.file) %> - <%= song_medium.human_kind %> + <% if song_medium.file.present? %> + <%= link_to rails_blob_path(song_medium.file, disposition: 'attachment'), class: 'inline-flex block px-4 py-2 text-sm text-primary', role: 'menuitem', tabindex: '-1' do %> + <%= file_icon(song_medium) %> + <%= song_medium.human_kind %> - <% if song_medium.register.present? %> - <%= human_register(song_medium.register) %> + <% if song_medium.register.present? %> + <%= human_register(song_medium.register) %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/views/songs/show.html.erb b/app/views/songs/show.html.erb index ebbc9a8..9ff7623 100644 --- a/app/views/songs/show.html.erb +++ b/app/views/songs/show.html.erb @@ -65,14 +65,12 @@ <%= t('.song_media.kind') %> <%= t('.song_media.register') %> - - <% @song.song_media.each do |song_medium| %> - <%= file_icon(song_medium.file) %> + <%= file_icon(song_medium) %> <%= song_medium.human_kind %> diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index d70e654..9c656b9 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -S7TX3PrBCOPcAnXZe/dVWMbd3pJvzhzz+Jjg8nOH07UyyZBSjrRP2iLa/bfKK2VTgUhwVoIOzfSeYbRNYfdpxhG55j+28y5cUpjscRlq5bIj8lFta5UnjC0DWuaRaQfv1hQ9qjmGrwnvYhhg4mPjfeun4C/OKco/PpaVr1QWy/rtewRFv71Mn5iRvTuOZaWOCVbL7lsIHlMKdFqmUudiWwFoJr/LOq69TYyfQBDOeV7xKE+KVhA4Q2SLVPfyQ9w+5+DDxYIp7l38nfaiymRshiMHuEx/CWc9WC3/FLDK4J0xEFuFcMtjgpIn8o8kGHGS9tsaZnHQQANX8v/6n3UlltV5yzImzS4lRAU1aUPI0SbdMDPefNbB4H998AzMxeOgSob3cYRKxZqzJFOAi/xsJRsmv6wyeMmk6DQnrVssEW2u9Avx6JMLUZo6zYwcv6oy1vC+LbMQ8cgIIJmB/b5zLDpCEx18fgJMWFGt8kBJSzBOfCXEfnLKC+TezlICVg3LE6BnTBHUiw4ZbHk4yEn0xWiR+Wx0OD4wPExhsXpET3wNDYBULsU55OdPrt7GNjXvb7vprLwARulkc6LRyB0BDjOnL+jEGdOz7F6Kt3jGG9ihIWbzhq1Ep2PxSWyqqPKr0/T2UE/ESTPA8JxdbW2QJfzm1W9f6RHTs4iM4Xpv3uwrcaZfOqCSYPqk9W8KOVC/S3PRfC4Fk831aVN4Pwbx1fQ36PTVKI8SzGeJk6fhQed1SJWjQJzvY1Mb7I0H/H/plzKKQpNfe6f9ypWZDESapEzG6F5SDFr3p8xSF1Dbx0z2jAJzfe2HtJFijIhvrFk+c5f79dJwb4QgWASBenMnUyx527f+eo4pt9r3mebHVeGGV/MtClcvH4FjG/PXZAiOjsB7k41DynGCiIe89KnY65InkqbBQcVH6k78UQg=--U1EZyYjA0fdJ38lw--xXipuna5kn6yf8mKlk7g5w== \ No newline at end of file +Zxm6VEN8VKjaVG8AxlZDlMQfAKdLejMNDrfpq1NFdTeSY7BKVOsUXN5kEiKjkPX2W1Z6TNs0qmONn0v4bQUYv3Hv6tdRVVhEh0OEgT8D2FJ4VGZHPLFm+KrVk64XkPJydzT0NHJZPxMew46CQfWpnfLSyNrLWEYKa+ZzpsHbt170OM2ADgLCNfW/U9ovIt2KFir28rLZCNGapRTWQHL3lsJMy4vdK1BAI7p8p3866uwlHh5RHwWUmtD4ANOWPDwl48bDELaA4RTti4hfzmett/oLDwFWITIWzCvLnag0KEyzOqqQVL4oRp4fl19ZxiU5KlReobPaZvlhFgUHykZUeRxHpTAeZMR2cTUQe5Zm9dwOdPean98g+DPBr5Jo5dpRnnT1nmKKakj7GLKheRSUQsuNbTGcALDEOamZ9V8p0C8NDB4rBtBus53zX9iYW8yV0vfL/MFFi4yCyNAkLc9st6SiQXAHDaac6ETOOyx/mFqDoECw7z3Bi5RCb0R26VfKJkc5Jz5enaMjsBqDctSxpt3+BL9OSM7zbxS59rJ25YMVluiTjUTWp9pWHKsTmAF0SfCm3lwacXLemkVH0T/97XyKMMLsIKLlCwQ1/yNOQw45oLr7EfSOmrdjPX4jb1wcHfnaZauGabIXPr77uuksuT0BtcVaIY09x7h9yyEEm5aW4sceOXV9hSIi1Fb+r2gLAco5B8olEM4jk+21BLhJvC1YfQuFYa96evVJeQ86CAQlw4GKLnagEyBmSq2uu/3jIBQISx78BuwI2l2Z2inH79HNvj+LWl3NFHw6MsRH5yuJ/CfTBj9/njMwLmdmycLNxh28dS9kTVRypEQU5exUPamUaKASbBWTiG08qjjF0ScU9tMxZ+KJnivLEd7p2Bf4sfVbwQlrjdrF9XC4t6My9AtU+DT820O2ncqp1dQfocj3DO7Tq1LokKMI5wQTN0m+qFGVCYOu6aKCz0rruzuK+yXyzf6YDFSlq58jICkABI/BLFn02CKpjk3ICYddMF3jRejXrddjtu1SANh8S4nP+tXUEZybKUKRucO4Mq9LNXwdwDsASOSf1AKrfsvslCM24WXrzF7+AUXggeiaa025NfFsvCAa0kgvcauH/aYD8Bt1YBvo5czO97G5MsjWJxr4dSblPra/UgXEcDiy0Yel/DzKrYeIpwLeggqOpbZiRiBcx12Ut2mO5dkkHgo/MVNejHuCwVG0/RLHE8Q+vgK37OkdZY1QZdqL1EwsM8fecBXV+h/VlprgS18bU4bwCPw05GiLelqBkad7a07GZtlLn3onnUznFLqcfddgazAM9EuXoUMybWerhMKz3IuUrrfEg9wZ/bIuTPO5ixEh1qU/ZRdHNy2zJrAE87uAxIKJvQwN6jRQa3o62a2D1BnmLyBL7cYeRH10wexzjnGxTHM8AVntqIvjJS+MY1L7uw4g1QKGWm6WKXgUzdmWBzoWd0Jp0R6HzkHuN9rCgiAD+9n8MGElpprySD4oniQSPsvwEehPkT3h/HhR8CcNopdG8/A/6BUgyvCQa++OT0TyBdSpsd/kTLd3vyTrMRa4jxTVbd7/75BlT3Z7mhplFIjukjt/mJNhMXvmZUpsi5UkP6gGtIxMGSrj3vklSbeEIhj95iPjME1j43ydOAcZRDeLZi9TtwBg5ki02Z9jq56CAwvnRHAUQj8J6LaiGEvwkYZtXCJoTeiWOtm5sv4EetzECMULZxIngPgmWJSWuCZmcSSuECUPRULXWDoRzyjEzUcpB9LSiRonbjzWEyvAdEed+BKsN5gB0yQhdnl2Q94WeC6YDmqfRl9Qu1oNLUOsC/B0koo2XXgOOUe8tecZk4RvR0FGutpdhw1p/W1CwRGjYP5/U+JXw7nirVAvTgOWh1PrV+ULFslgYgLg7J/ei5jy7srMMgxBQ64WcIKK2Vv4qoyYKjqiJ/2qElJcIVOAi/TQiGozkSxhgM2b/G1Z1ML7oP1Nl/m0tJZxy7yQD7PLcysNkec+7VuDaFMhwED1g99Qt39H7yqe/6voyLAIgJkq9ksn6huenMUS5LWPqKijlukidVfxgtb/244lAHzbVaSD7C0+2xZ6Ax1WsX6j9xeIKbUhCRFuiRE9fBLCCKRYV78Q+fkOE3WOVop/D8MX7udZ6Y5x+L481WmKA2Kt7sHaPgvuBxv7AZc/NfpEj/ez5O3kJHfVWHLMbDInkF69Iekbbf3L73BJzQ1eeB5VP5sx05TQ3jr1eK4zH3u/BOBJzSXW6/etu8OLiXaBzOL1zuZAZFFdh/FRdNXGdtdVXUs73lXzeexJQZVkHtSZ5ITEGxN8giozpgdYLsFEj4KZolJUQvgmnhI6QQY07+hASBYQd2+4K5Cp2PG/8qj62ah2+CgZLjD9QHSAl0oMtkWCF4YoPs7Y9wX656R7hg3weWl1NvPm/KLbWf0fIMO/DTYSpRz9gYkV7ezRU2qePeHVAl/rxnlpWtShcmbtMa4pLWFm41Zg8kOeMi329zaHEKSusTWVK+YEXw4Su4YN0zbIAbmVOd3Sd9IIO4plo8dzUicm2GQtw/fEe4cA79u4iYh+ruvOmt76NHJZJ3m/gZ1CnrjLi6r0GWRB871cquhMrEo2UH9A3sN/79HZDft4gBqeGm+2S+gxyXmZ2ijkhPVq5JXxSfQWVPAghCAebJfrjgLndj+LVzQK12l0pkRoXWT9dTiGfmEPAQnGFyt80dVv3xJAGUwdWsFzjHmcUPmDj95DznFqCGd55R8T3U40BloTXbAh5Z2O66xiavOxTR5+z93wvQKZygBZ4LCf/4usVzL2ymbTDhVYsLKG04aCjTpqEpH7m34PAohnBg42LJzZC+SMRt9rKDMgQL30naW3tfMX0Y6fUdKlBeRPDK605Nuu2olt2KMDZOMLeZ4Hco33RbeIWS6Y3px+gGO51p3IAoySxZsarNSJ7/bbVGvlpecGQ4/fGXnG5Aq2hA3cT+hcVJGzL+4Aw/OMbq6zCJk7xt8dMxsekI1gtPFOUaR3OV7cuc5zhbWGOcTTe2qQB/2fSi+ECFBJ0VxN5Ow2qWWdh3idn5MaMA9S8KPl9quutdgtMLu3UonqfOHBGo6Cd/8NtfWezueuvTy6WPlcH3q3ZcTMSLQ+9uBB5EtD5cjvCnI8we0hl4Ok9SjPCBU0KfIgcCmKY3RSRyk/ggPmMLxrgkNAG7w7icxbPPmNYDB6G0kgeUGad5j8nkpaosidlQbMMoL10gkcpbOUsFUzSDmUSaUbxr7uI7tArEqaRWeaRNpd9O/ac+up61zLNzChYmg7PxL7hugYZlfpJpyYZOwpPk+NLjv5zml1bku0xKlBpNCF62PNi3ox8s+G/gHFTNqyuV6FQcSU+/eLPR2eZr/XcRzuYR4t6mUXSFXULZ0LkgC66UZ8KBDwhuc08m617BI6yCEwrU8FunOZl36pvF1Fz7rCPeF+IddAO/Fuj8tne/iQtwzqZRQ02b0JT9jKBNOBVy/TjU0sAAkE3sT0+eIQtbqlUIcfkwLhFvsXpbHILCPGr1UYqYQFh79WknpEmYlmo+PCK4ELR3yengZwxDi6P0gp10Bz5ksmYgup4Wz61LXIJKRHyswBjP6mF1ixQi836IwIYKmXXuNXstjQe7znBWprwnGLARh4QsODRdlQ5+2Dr/bV2p1HjSUMzissxuGhtLY24ZpU3noM0CGRinpQkxOgxwDnf5ZaFEev6xqYP9CLHi4JqwOKq70WwWr/9YtAB+PAmYP8XFA3HFzUir8OHsDGB6lLyM9rEsxgDKv8wftZrByDe85/bmx3v7hjnPwMgQB0U7KaGRcqBrTDO+AtzwY87LkiSJbdiELeywN47qgi/mpJb0apf6NGsPOlG4x6NZNF8qL6sV0wY/qsjtFebZgLDzp+i11Y1Df0noTnSNpnJpo4rNqJcEbd3ih4teLlzUgJfzRMYETQhLdHsSe9JM5kfj8cY4K3R6IQBV2bbTeQeTbfjNXwQjgN/UQ1CKlIHyqjxItt3dSidspu8IAKNBRtgnNWeNL/4YOC2hjY+HCrYqZJnWOjv1Bte1F0aFY72GVxi3cPJXEkb3zh+Q9OVb6TMVjRZEnGFIlRwtNgG2J2rNSN7L7/VUgPUFDHoYtfWyK7Z6eqYH71h0fhvpNdUgOZIjQPFHcJXuP0Apb+gDKeGg==--wozz0GidTYJZdMpQ--mFZWRv7ChTBs8Z0bj5OgpA== \ No newline at end of file diff --git a/db/migrate/20260608172843_add_file_identifier_to_song_media.rb b/db/migrate/20260608172843_add_file_identifier_to_song_media.rb new file mode 100644 index 0000000..b040d2f --- /dev/null +++ b/db/migrate/20260608172843_add_file_identifier_to_song_media.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddFileIdentifierToSongMedia < ActiveRecord::Migration[8.1] + def change + add_column :song_media, :file_identifier, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index b0acb0d..55fe9a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_14_172005) do +ActiveRecord::Schema[8.1].define(version: 2026_06_08_172843) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -137,6 +137,7 @@ create_table "song_media", force: :cascade do |t| t.datetime "created_at", null: false + t.string "file_identifier" t.string "kind", null: false t.string "register" t.integer "song_id", null: false From 7cc8874222939e0c1fedef6fb74e0b7bb8898302 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Mon, 3 Aug 2026 20:30:28 +0200 Subject: [PATCH 02/25] WIP --- app/models/song_media_storage_accessor.rb | 30 +++++++++++++++++++++-- app/services/occurrence_resolver.rb | 1 + 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/models/song_media_storage_accessor.rb b/app/models/song_media_storage_accessor.rb index a92e941..b708774 100644 --- a/app/models/song_media_storage_accessor.rb +++ b/app/models/song_media_storage_accessor.rb @@ -2,6 +2,26 @@ require 'singleton' +class DriveFiles + def initialize(all_files) + @all_files = all_files.map { |f| DriveFile.new(f) }.group_by(&:parent) + end + + def roots + @all_files[nil] + end +end + +class DriveFile + def initialize(file) + @file = file + end + + def parent + @file.parents.try(:first) + end +end + class SongMediaStorageAccessor include Singleton @@ -14,11 +34,17 @@ def initialize ) end + def files + DriveFiles.new(retrieve_files) + # grouped = files.group_by(&:parents) + # grouped.transform_keys { |k| files.find { |f| f.id == k.first } } + end + def retrieve_files(next_page_token = nil) response = @drive_service.list_files( - q: "trashed = false and mimeType != 'application/vnd.google-apps.folder'", + q: 'trashed = false', page_size: 1000, - fields: 'files(id, name, mimeType), next_page_token', + fields: 'files(id, name, mimeType, parents), next_page_token', include_items_from_all_drives: true, supports_all_drives: true, page_token: next_page_token diff --git a/app/services/occurrence_resolver.rb b/app/services/occurrence_resolver.rb index 4f66598..76690c1 100644 --- a/app/services/occurrence_resolver.rb +++ b/app/services/occurrence_resolver.rb @@ -41,6 +41,7 @@ def self.parse_and_resolve(ics_content) def parse_and_resolve events = Icalendar::Event.parse(ics_content) events.map do |e| + debugger if e.uid == '46q4ltidl8n87sbj7mkuii11u1@google.com' if e.rrule.empty? e else From 9d1e7054537329a78bae122256bcfded29aeef10 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 1 Sep 2026 21:23:15 +0200 Subject: [PATCH 03/25] Selectable drive media --- app/controllers/song_media_controller.rb | 1 + app/helpers/song_media_helper.rb | 6 ++ app/models/song_media_storage_accessor.rb | 67 ++++++++++++++++++++--- app/views/song_media/_form.html.erb | 2 +- app/views/song_media/new.html.erb | 2 +- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/app/controllers/song_media_controller.rb b/app/controllers/song_media_controller.rb index af7f9c3..c671ea5 100644 --- a/app/controllers/song_media_controller.rb +++ b/app/controllers/song_media_controller.rb @@ -3,6 +3,7 @@ class SongMediaController < ApplicationController def new @song_medium = authorize SongMedium.new(song_id: params[:song_id]) + @drive_files = SongMediaStorageAccessor.instance.drive_files end def create diff --git a/app/helpers/song_media_helper.rb b/app/helpers/song_media_helper.rb index dce7012..0167741 100644 --- a/app/helpers/song_media_helper.rb +++ b/app/helpers/song_media_helper.rb @@ -20,4 +20,10 @@ def file_icon(song_medium) :file end, classes: ['mr-2']) end + + def file_identifier_options(drive_files) + drive_files.all_files.select(&:media_file?).map do |drive_file| + [drive_file.ancestor_names.join('/'), drive_file.id] + end.sort_by(&:first) + end end diff --git a/app/models/song_media_storage_accessor.rb b/app/models/song_media_storage_accessor.rb index b708774..ddfa610 100644 --- a/app/models/song_media_storage_accessor.rb +++ b/app/models/song_media_storage_accessor.rb @@ -4,22 +4,77 @@ class DriveFiles def initialize(all_files) - @all_files = all_files.map { |f| DriveFile.new(f) }.group_by(&:parent) + @all_files = all_files.map { |f| DriveFile.new(self, f) } + @files_by_id = @all_files.index_by(&:id) + @files_by_parent = @all_files.group_by(&:parent) end def roots - @all_files[nil] + @files_by_parent[nil] end + + attr_reader :files_by_id, :files_by_parent, :all_files end class DriveFile - def initialize(file) + def initialize(drive_files, file) + @drive_files = drive_files @file = file end def parent - @file.parents.try(:first) + @drive_files.files_by_id[@file.parents.try(:first)] + end + + def children + @drive_files.files_by_parent[self] || [] + end + + def ancestors + ancestors = [] + f = self + until f.nil? + ancestors << f + f = f.parent + end + ancestors + end + + def ancestor_names + ancestors.map(&:name).reverse + end + + def folder? + file.mime_type == 'application/vnd.google-apps.folder' end + + def shortcut? + file.mime_type == 'application/vnd.google-apps.shortcut' + end + + def file? + !folder? && !shortcut? + end + + def audio? + file.mime_type.starts_with?('audio/') || file.mime_type == 'application/ogg' + end + + def video? + file.mime_type.starts_with? 'video/' + end + + def pdf? + file.mime_type == 'application/pdf' + end + + def media_file? + audio? || video? || pdf? + end + + delegate :id, :name, to: :file + + attr_reader :file end class SongMediaStorageAccessor @@ -34,10 +89,8 @@ def initialize ) end - def files + def drive_files DriveFiles.new(retrieve_files) - # grouped = files.group_by(&:parents) - # grouped.transform_keys { |k| files.find { |f| f.id == k.first } } end def retrieve_files(next_page_token = nil) diff --git a/app/views/song_media/_form.html.erb b/app/views/song_media/_form.html.erb index 5357234..4d06733 100644 --- a/app/views/song_media/_form.html.erb +++ b/app/views/song_media/_form.html.erb @@ -34,7 +34,7 @@ <%= f.label :file_identifier %> <%# i18n-tasks-use t('activerecord.attributes.song_medium.file_identifier') %>

-

<%= f.text_field :file_identifier %>

+

<%= f.select :file_identifier, file_identifier_options(drive_files) %>

diff --git a/app/views/song_media/new.html.erb b/app/views/song_media/new.html.erb index 7bcd316..d3a03e9 100644 --- a/app/views/song_media/new.html.erb +++ b/app/views/song_media/new.html.erb @@ -1,3 +1,3 @@ <% content_for :title, t('.title') %> -<%= render 'form', song_medium: @song_medium %> +<%= render 'form', song_medium: @song_medium, drive_files: @drive_files %> From 013346b86de9c20543fbefb30f630c0e3d69147d Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 1 Sep 2026 22:21:43 +0200 Subject: [PATCH 04/25] File download to buffer possible --- app/models/song_media_storage_accessor.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/models/song_media_storage_accessor.rb b/app/models/song_media_storage_accessor.rb index ddfa610..1b7332c 100644 --- a/app/models/song_media_storage_accessor.rb +++ b/app/models/song_media_storage_accessor.rb @@ -72,7 +72,15 @@ def media_file? audio? || video? || pdf? end - delegate :id, :name, to: :file + def download + buffer = StringIO.new + SongMediaStorageAccessor.instance.get_file( + id, download_dest: buffer, supports_all_drives: true + ) + buffer + end + + delegate :id, :name, :size, to: :file attr_reader :file end @@ -94,7 +102,7 @@ def drive_files end def retrieve_files(next_page_token = nil) - response = @drive_service.list_files( + response = drive_service.list_files( q: 'trashed = false', page_size: 1000, fields: 'files(id, name, mimeType, parents), next_page_token', @@ -107,4 +115,10 @@ def retrieve_files(next_page_token = nil) files += retrieve_files(response.next_page_token) if response.next_page_token files end + + delegate :get_file, to: :drive_service + + private + + attr_reader :drive_service end From 93307f0e08e166cb7395aa42a6b5a5f9c9044333 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Sat, 5 Sep 2026 18:48:59 +0200 Subject: [PATCH 05/25] Persist drive entries to database with sync job --- app/controllers/song_media_controller.rb | 2 +- app/helpers/song_media_helper.rb | 6 +- app/jobs/song_media_storage_update_job.rb | 68 ++++++++++++ app/models/drive_file.rb | 38 +++++++ app/models/drive_files.rb | 10 ++ app/models/song_media_storage_accessor.rb | 105 +++--------------- app/models/song_media_storage_entry.rb | 14 +++ app/views/song_media/_form.html.erb | 2 +- app/views/song_media/new.html.erb | 2 +- config/recurring.yml | 3 + ...01421_create_song_media_storage_entries.rb | 18 +++ db/schema.rb | 14 ++- 12 files changed, 183 insertions(+), 99 deletions(-) create mode 100644 app/jobs/song_media_storage_update_job.rb create mode 100644 app/models/drive_file.rb create mode 100644 app/models/drive_files.rb create mode 100644 app/models/song_media_storage_entry.rb create mode 100644 db/migrate/20260903201421_create_song_media_storage_entries.rb diff --git a/app/controllers/song_media_controller.rb b/app/controllers/song_media_controller.rb index c671ea5..2bbc6b7 100644 --- a/app/controllers/song_media_controller.rb +++ b/app/controllers/song_media_controller.rb @@ -3,7 +3,7 @@ class SongMediaController < ApplicationController def new @song_medium = authorize SongMedium.new(song_id: params[:song_id]) - @drive_files = SongMediaStorageAccessor.instance.drive_files + @song_media_storage_entries = SongMediaStorageEntry.media_file end def create diff --git a/app/helpers/song_media_helper.rb b/app/helpers/song_media_helper.rb index 0167741..516ba01 100644 --- a/app/helpers/song_media_helper.rb +++ b/app/helpers/song_media_helper.rb @@ -21,9 +21,9 @@ def file_icon(song_medium) end, classes: ['mr-2']) end - def file_identifier_options(drive_files) - drive_files.all_files.select(&:media_file?).map do |drive_file| - [drive_file.ancestor_names.join('/'), drive_file.id] + def file_identifier_options(song_media_storage_entries) + song_media_storage_entries.map do |entry| + [entry.path, entry.identifier] end.sort_by(&:first) end end diff --git a/app/jobs/song_media_storage_update_job.rb b/app/jobs/song_media_storage_update_job.rb new file mode 100644 index 0000000..eb7ce71 --- /dev/null +++ b/app/jobs/song_media_storage_update_job.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class SongMediaStorageUpdateJob < ApplicationJob + queue_as :default + + def perform + @created_count = @updated_count = @deleted_count = @unchanged_count = 0 + + do_update! + + { created_count: @created_count, updated_count: @updated_count, deleted_count: @deleted_count, + unchanged_count: @unchanged_count } + end + + private + + def do_update! + drive_files = SongMediaStorageAccessor.instance.drive_files + + ActiveRecord::Base.transaction do + list = drive_files.all_files.map do |drive_file| + to_attributes(drive_file) + end + identifiers = insert_or_update(list) + destroy_others(identifiers) + end + end + + def to_attributes(drive_file) + { + identifier: drive_file.file.id, + mime_type: drive_file.file.mime_type, + name: drive_file.file.name, + parent_identifiers: drive_file.file.parents, + parent_identifier: drive_file.file.parents.try(&:first), + path: drive_file.ancestor_names.join('/') + } + end + + def insert_or_update(list) + identifiers = [] + list.each do |attributes| + entry = SongMediaStorageEntry.find_or_initialize_by(identifier: attributes[:identifier]) + entry.attributes = attributes + save!(entry) + identifiers << entry.identifier + end + identifiers + end + + def destroy_others(identifiers) + entries_to_delete = SongMediaStorageEntry.where.not(identifier: identifiers) + @deleted_count = entries_to_delete.count + entries_to_delete.destroy_all + end + + def save!(entry) + if entry.new_record? + @created_count += 1 + elsif entry.changed? + @updated_count += 1 + else + @unchanged_count += 1 + end + + entry.save! + end +end diff --git a/app/models/drive_file.rb b/app/models/drive_file.rb new file mode 100644 index 0000000..4a2a099 --- /dev/null +++ b/app/models/drive_file.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class DriveFile + def initialize(drive_files, file) + @drive_files = drive_files + @file = file + end + + def parent + @drive_files.files_by_id[@file.parents.try(:first)] + end + + def ancestors + ancestors = [] + f = self + until f.nil? + ancestors << f + f = f.parent + end + ancestors + end + + def ancestor_names + ancestors.map(&:name).reverse + end + + def download + buffer = StringIO.new + SongMediaStorageAccessor.instance.get_file( + id, download_dest: buffer, supports_all_drives: true + ) + buffer + end + + delegate :id, :name, :size, to: :file + + attr_reader :file +end diff --git a/app/models/drive_files.rb b/app/models/drive_files.rb new file mode 100644 index 0000000..284e1fa --- /dev/null +++ b/app/models/drive_files.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DriveFiles + def initialize(all_files) + @all_files = all_files.map { |f| DriveFile.new(self, f) } + @files_by_id = @all_files.index_by(&:id) + end + + attr_reader :files_by_id, :all_files +end diff --git a/app/models/song_media_storage_accessor.rb b/app/models/song_media_storage_accessor.rb index 1b7332c..ea82219 100644 --- a/app/models/song_media_storage_accessor.rb +++ b/app/models/song_media_storage_accessor.rb @@ -2,89 +2,6 @@ require 'singleton' -class DriveFiles - def initialize(all_files) - @all_files = all_files.map { |f| DriveFile.new(self, f) } - @files_by_id = @all_files.index_by(&:id) - @files_by_parent = @all_files.group_by(&:parent) - end - - def roots - @files_by_parent[nil] - end - - attr_reader :files_by_id, :files_by_parent, :all_files -end - -class DriveFile - def initialize(drive_files, file) - @drive_files = drive_files - @file = file - end - - def parent - @drive_files.files_by_id[@file.parents.try(:first)] - end - - def children - @drive_files.files_by_parent[self] || [] - end - - def ancestors - ancestors = [] - f = self - until f.nil? - ancestors << f - f = f.parent - end - ancestors - end - - def ancestor_names - ancestors.map(&:name).reverse - end - - def folder? - file.mime_type == 'application/vnd.google-apps.folder' - end - - def shortcut? - file.mime_type == 'application/vnd.google-apps.shortcut' - end - - def file? - !folder? && !shortcut? - end - - def audio? - file.mime_type.starts_with?('audio/') || file.mime_type == 'application/ogg' - end - - def video? - file.mime_type.starts_with? 'video/' - end - - def pdf? - file.mime_type == 'application/pdf' - end - - def media_file? - audio? || video? || pdf? - end - - def download - buffer = StringIO.new - SongMediaStorageAccessor.instance.get_file( - id, download_dest: buffer, supports_all_drives: true - ) - buffer - end - - delegate :id, :name, :size, to: :file - - attr_reader :file -end - class SongMediaStorageAccessor include Singleton @@ -102,7 +19,19 @@ def drive_files end def retrieve_files(next_page_token = nil) - response = drive_service.list_files( + response = request_files(next_page_token) + + return response.files unless response.next_page_token + + response.files + retrieve_files(response.next_page_token) + end + + delegate :get_file, to: :drive_service + + private + + def request_files(next_page_token) + drive_service.list_files( q: 'trashed = false', page_size: 1000, fields: 'files(id, name, mimeType, parents), next_page_token', @@ -110,15 +39,7 @@ def retrieve_files(next_page_token = nil) supports_all_drives: true, page_token: next_page_token ) - - files = response.files - files += retrieve_files(response.next_page_token) if response.next_page_token - files end - delegate :get_file, to: :drive_service - - private - attr_reader :drive_service end diff --git a/app/models/song_media_storage_entry.rb b/app/models/song_media_storage_entry.rb new file mode 100644 index 0000000..65c9bfc --- /dev/null +++ b/app/models/song_media_storage_entry.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class SongMediaStorageEntry < ApplicationRecord + validates :identifier, :name, :mime_type, presence: true + + belongs_to :parent, class_name: 'SongMediaStorageEntry', foreign_key: :parent_identifier, primary_key: :identifier, + inverse_of: :children, optional: true + has_many :children, class_name: 'SongMediaStorageEntry', dependent: nil + + scope :media_file, -> { video.or(audio).or(pdf) } + scope :video, -> { where("mime_type like 'video/%'") } + scope :audio, -> { where("mime_type like 'audio/%' or mime_type = 'application/ogg'") } + scope :pdf, -> { where("mime_type = 'application/pdf'") } +end diff --git a/app/views/song_media/_form.html.erb b/app/views/song_media/_form.html.erb index 4d06733..fa237bd 100644 --- a/app/views/song_media/_form.html.erb +++ b/app/views/song_media/_form.html.erb @@ -34,7 +34,7 @@ <%= f.label :file_identifier %> <%# i18n-tasks-use t('activerecord.attributes.song_medium.file_identifier') %>

-

<%= f.select :file_identifier, file_identifier_options(drive_files) %>

+

<%= f.select :file_identifier, file_identifier_options(song_media_storage_entries) %>

diff --git a/app/views/song_media/new.html.erb b/app/views/song_media/new.html.erb index d3a03e9..05c1c71 100644 --- a/app/views/song_media/new.html.erb +++ b/app/views/song_media/new.html.erb @@ -1,3 +1,3 @@ <% content_for :title, t('.title') %> -<%= render 'form', song_medium: @song_medium, drive_files: @drive_files %> +<%= render 'form', song_medium: @song_medium, song_media_storage_entries: @song_media_storage_entries %> diff --git a/config/recurring.yml b/config/recurring.yml index 172ea07..f26e58e 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -5,3 +5,6 @@ production: bundle_download_cleanup: class: SongMediaBundleDownloadCleanupJob schedule: every day at 2am + calendar_sync: + class: SongMediaStorageUpdateJob + schedule: every day at 3am diff --git a/db/migrate/20260903201421_create_song_media_storage_entries.rb b/db/migrate/20260903201421_create_song_media_storage_entries.rb new file mode 100644 index 0000000..41f646b --- /dev/null +++ b/db/migrate/20260903201421_create_song_media_storage_entries.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateSongMediaStorageEntries < ActiveRecord::Migration[8.1] + def change + create_table :song_media_storage_entries do |t| + t.string :identifier, null: false + t.string :mime_type, null: false + t.string :name, null: false + t.json :parent_identifiers + t.string :parent_identifier + t.string :path + + t.timestamps + + t.index :identifier, unique: true, name: 'index_song_media_storage_entries_on_identifier' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 2ea2665..bd427c9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_29_133956) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_201421) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -160,6 +160,18 @@ t.index ["song_list_id"], name: "index_song_media_bundle_downloads_on_song_list_id" end + create_table "song_media_storage_entries", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "identifier", null: false + t.string "mime_type", null: false + t.string "name", null: false + t.string "parent_identifier" + t.json "parent_identifiers" + t.string "path" + t.datetime "updated_at", null: false + t.index ["identifier"], name: "index_song_media_storage_entries_on_identifier", unique: true + end + create_table "songs", force: :cascade do |t| t.string "arranger" t.datetime "created_at", null: false From 1abfd8809f20aa41f38318765dff1f679c5177c2 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Sat, 5 Sep 2026 19:19:45 +0200 Subject: [PATCH 06/25] Load WIP --- Gemfile | 2 +- Gemfile.lock | 12 ++++-------- app/controllers/song_media_controller.rb | 7 +++++++ app/models/drive_file.rb | 8 -------- app/models/song_media_storage_accessor.rb | 8 +++++++- app/models/song_medium.rb | 4 ++++ app/policies/song_medium_policy.rb | 4 ++++ app/views/songs/_download_dropdown.html.erb | 9 +++++++++ config/locales/de.yml | 1 + config/routes.rb | 4 +++- spec/spec_helper.rb | 19 +++++++++---------- 11 files changed, 49 insertions(+), 29 deletions(-) diff --git a/Gemfile b/Gemfile index 6811231..b1098c3 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,7 @@ group :development, :test do gem 'rubocop-capybara' gem 'rubocop-rails' gem 'rubocop-rspec_rails' + gem 'simplecov-rspec', '~> 1.0' end group :development do @@ -60,7 +61,6 @@ group :test do gem 'capybara' gem 'pundit-matchers', '~> 4.0' gem 'selenium-webdriver' - gem 'simplecov', require: false end gem 'tailwindcss-rails', '= 3.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 917867e..1967424 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,7 +136,6 @@ GEM reline (>= 0.3.8) declarative (0.0.20) diff-lcs (1.6.2) - docile (1.4.1) dotenv (3.2.0) down (5.6.0) addressable (~> 2.8) @@ -509,12 +508,9 @@ GEM addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 4.0) - simplecov (0.22.0) - docile (~> 1.1) - simplecov-html (~> 0.11) - simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.2) - simplecov_json_formatter (0.1.4) + simplecov (1.2.0) + simplecov-rspec (1.1.0) + simplecov (~> 1.0) solid_cable (4.0.0) actioncable (>= 7.2) activejob (>= 7.2) @@ -640,7 +636,7 @@ DEPENDENCIES rubocop-rspec_rails ruby-vips (~> 2.3) selenium-webdriver - simplecov + simplecov-rspec (~> 1.0) solid_cable (~> 4.0) solid_cache (~> 1.0) solid_queue (~> 1.7) diff --git a/app/controllers/song_media_controller.rb b/app/controllers/song_media_controller.rb index 2bbc6b7..9ba2ef7 100644 --- a/app/controllers/song_media_controller.rb +++ b/app/controllers/song_media_controller.rb @@ -30,6 +30,13 @@ def destroy end end + def load + song_medium = authorize SongMedium.find(params.expect(:song_medium_id)) + return render status: :not_found if song_medium.file_identifier.nil? + + send_data(song_medium.buffer) + end + private def song_medium_params diff --git a/app/models/drive_file.rb b/app/models/drive_file.rb index 4a2a099..7573185 100644 --- a/app/models/drive_file.rb +++ b/app/models/drive_file.rb @@ -24,14 +24,6 @@ def ancestor_names ancestors.map(&:name).reverse end - def download - buffer = StringIO.new - SongMediaStorageAccessor.instance.get_file( - id, download_dest: buffer, supports_all_drives: true - ) - buffer - end - delegate :id, :name, :size, to: :file attr_reader :file diff --git a/app/models/song_media_storage_accessor.rb b/app/models/song_media_storage_accessor.rb index ea82219..f16b3db 100644 --- a/app/models/song_media_storage_accessor.rb +++ b/app/models/song_media_storage_accessor.rb @@ -26,7 +26,13 @@ def retrieve_files(next_page_token = nil) response.files + retrieve_files(response.next_page_token) end - delegate :get_file, to: :drive_service + def download(id) + buffer = StringIO.new + drive_service.get_file( + id, download_dest: buffer, supports_all_drives: true + ) + buffer.tap(&:rewind) + end private diff --git a/app/models/song_medium.rb b/app/models/song_medium.rb index 94cdcfa..10e0159 100644 --- a/app/models/song_medium.rb +++ b/app/models/song_medium.rb @@ -48,4 +48,8 @@ def type_video? file.attachment.video? end + + def buffer + SongMediaStorageAccessor.instance.download(file_identifier) + end end diff --git a/app/policies/song_medium_policy.rb b/app/policies/song_medium_policy.rb index 8e665a3..b81f99a 100644 --- a/app/policies/song_medium_policy.rb +++ b/app/policies/song_medium_policy.rb @@ -29,6 +29,10 @@ def destroy? song_management? end + def load? + show? + end + private def song_management? diff --git a/app/views/songs/_download_dropdown.html.erb b/app/views/songs/_download_dropdown.html.erb index f26ce33..c2deb51 100644 --- a/app/views/songs/_download_dropdown.html.erb +++ b/app/views/songs/_download_dropdown.html.erb @@ -21,6 +21,15 @@ <%= file_icon(song_medium) %> <%= song_medium.human_kind %> + <% if song_medium.register.present? %> + <%= human_register(song_medium.register) %> + <% end %> + <% end %> + <% else %> + <%= link_to song_song_medium_load_path(song_id: song_medium.song.id, song_medium_id: song_medium.id), class: 'inline-flex block px-4 py-2 text-sm text-primary', role: 'menuitem', tabindex: '-1' do %> + <%= file_icon(song_medium) %> + <%= song_medium.human_kind %> + <% if song_medium.register.present? %> <%= human_register(song_medium.register) %> <% end %> diff --git a/config/locales/de.yml b/config/locales/de.yml index 5af9acf..6b62bf4 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -96,6 +96,7 @@ de: recording_register: Audioaufnahme sheet_music: Notenblatt file: Datei + file_identifier: Google Drive-Datei kind: Art register: Stimme user: diff --git a/config/routes.rb b/config/routes.rb index c375657..fc299f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,7 +48,9 @@ end resources :songs do - resources :song_media, only: %i[new create destroy] + resources :song_media, only: %i[new create destroy] do + get :load + end resource :score, only: %i[new create edit update destroy] end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4ebbb77..e26ddf8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'pundit/matchers' +require 'simplecov-rspec' # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. @@ -95,15 +96,13 @@ # Kernel.srand config.seed end -require 'simplecov' +SimpleCov::RSpec.start do + skip '/spec/' + skip '/config/' -SimpleCov.start do - SimpleCov.skip '/spec/' - SimpleCov.skip '/config/' - - SimpleCov.group 'Controllers', 'app/controllers' - SimpleCov.group 'Models', 'app/models' - SimpleCov.group 'Helpers', 'app/helpers' - SimpleCov.group 'Mailers', 'app/mailers' - SimpleCov.group 'Policies', 'app/policies' + group 'Controllers', 'app/controllers' + group 'Models', 'app/models' + group 'Helpers', 'app/helpers' + group 'Mailers', 'app/mailers' + group 'Policies', 'app/policies' end From 58f0dfbadae10226acd8a08e86ed4005b4794007 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 15:28:50 +0200 Subject: [PATCH 07/25] Drive files downloadable and playable --- app/controllers/song_media_controller.rb | 12 ++++++++---- app/models/song_media_storage_entry.rb | 4 ++++ app/models/song_medium.rb | 5 +---- app/views/songs/_score.html.erb | 6 +++++- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/controllers/song_media_controller.rb b/app/controllers/song_media_controller.rb index 9ba2ef7..6d94797 100644 --- a/app/controllers/song_media_controller.rb +++ b/app/controllers/song_media_controller.rb @@ -31,10 +31,14 @@ def destroy end def load - song_medium = authorize SongMedium.find(params.expect(:song_medium_id)) - return render status: :not_found if song_medium.file_identifier.nil? - - send_data(song_medium.buffer) + storage_entry = authorize(SongMedium.find(params.expect(:song_medium_id))).try(:song_media_storage_entry) + return render status: :not_found if storage_entry.nil? + + send_data( + storage_entry.buffer.string.force_encoding('binary'), + filename: storage_entry.name, + type: storage_entry.mime_type + ) end private diff --git a/app/models/song_media_storage_entry.rb b/app/models/song_media_storage_entry.rb index 65c9bfc..8f65559 100644 --- a/app/models/song_media_storage_entry.rb +++ b/app/models/song_media_storage_entry.rb @@ -11,4 +11,8 @@ class SongMediaStorageEntry < ApplicationRecord scope :video, -> { where("mime_type like 'video/%'") } scope :audio, -> { where("mime_type like 'audio/%' or mime_type = 'application/ogg'") } scope :pdf, -> { where("mime_type = 'application/pdf'") } + + def buffer + SongMediaStorageAccessor.instance.download(identifier) + end end diff --git a/app/models/song_medium.rb b/app/models/song_medium.rb index 10e0159..d68ed21 100644 --- a/app/models/song_medium.rb +++ b/app/models/song_medium.rb @@ -14,6 +14,7 @@ class SongMedium < ApplicationRecord ].freeze belongs_to :song, touch: true # when a SongMedium changes, its Song changes too + belongs_to :song_media_storage_entry, foreign_key: :file_identifier, primary_key: :identifier, inverse_of: false has_one_attached :file validates :register, presence: true, inclusion: Register::Song::REGISTERS, if: proc { |sm| @@ -48,8 +49,4 @@ def type_video? file.attachment.video? end - - def buffer - SongMediaStorageAccessor.instance.download(file_identifier) - end end diff --git a/app/views/songs/_score.html.erb b/app/views/songs/_score.html.erb index 262f2a2..d7e019b 100644 --- a/app/views/songs/_score.html.erb +++ b/app/views/songs/_score.html.erb @@ -12,7 +12,11 @@ <% end %> - <%= audio_tag url_for(recording.file), class: 'w-5/6', controls: true, preload: 'none', data: {'score-target': 'audioTag', 'action': 'seeked->score#update'} %> + <% if recording.file.present? %> + <%= audio_tag url_for(recording.file), class: 'w-5/6', controls: true, preload: 'none', data: {'score-target': 'audioTag', 'action': 'seeked->score#update'} %> + <% else %> + <%= audio_tag song_song_medium_load_path(song_id: recording.song.id, song_medium_id: recording.id), class: 'w-5/6', controls: true, preload: 'none', data: {'score-target': 'audioTag', 'action': 'seeked->score#update'} %> + <% end %> <% end %> From bfad7359c4bb220c8164ef295085389ccc2f7d8f Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 15:39:51 +0200 Subject: [PATCH 08/25] Make song media bundles work with drive files --- app/controllers/song_media_controller.rb | 2 +- app/jobs/song_media_bundle_job.rb | 6 +++++- app/models/song_media_storage_entry.rb | 6 ++++++ app/views/songs/_score.html.erb | 2 ++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/controllers/song_media_controller.rb b/app/controllers/song_media_controller.rb index 6d94797..56dddbf 100644 --- a/app/controllers/song_media_controller.rb +++ b/app/controllers/song_media_controller.rb @@ -35,7 +35,7 @@ def load return render status: :not_found if storage_entry.nil? send_data( - storage_entry.buffer.string.force_encoding('binary'), + storage_entry.data, filename: storage_entry.name, type: storage_entry.mime_type ) diff --git a/app/jobs/song_media_bundle_job.rb b/app/jobs/song_media_bundle_job.rb index e1fed3c..6d38b6b 100644 --- a/app/jobs/song_media_bundle_job.rb +++ b/app/jobs/song_media_bundle_job.rb @@ -50,7 +50,11 @@ def download_media(song_media, register) recording = recording_for_register(song_media, register) return nil if recording.nil? - [recording.file.filename, recording.file.attachment.download] + if recording.file.present? + [recording.file.filename, recording.file.attachment.download] + else + [recording.song_media_storage_entry.name, recording.song_media_storage_entry.data] + end end def recording_for_register(song_media, register) diff --git a/app/models/song_media_storage_entry.rb b/app/models/song_media_storage_entry.rb index 8f65559..f17a110 100644 --- a/app/models/song_media_storage_entry.rb +++ b/app/models/song_media_storage_entry.rb @@ -12,6 +12,12 @@ class SongMediaStorageEntry < ApplicationRecord scope :audio, -> { where("mime_type like 'audio/%' or mime_type = 'application/ogg'") } scope :pdf, -> { where("mime_type = 'application/pdf'") } + def data + buffer.string.force_encoding('binary') + end + + private + def buffer SongMediaStorageAccessor.instance.download(identifier) end diff --git a/app/views/songs/_score.html.erb b/app/views/songs/_score.html.erb index d7e019b..ea8c1c3 100644 --- a/app/views/songs/_score.html.erb +++ b/app/views/songs/_score.html.erb @@ -13,8 +13,10 @@ <% if recording.file.present? %> + <%= tabler_icon :file %> <%= audio_tag url_for(recording.file), class: 'w-5/6', controls: true, preload: 'none', data: {'score-target': 'audioTag', 'action': 'seeked->score#update'} %> <% else %> + <%= tabler_icon :'brand-google-drive' %> <%= audio_tag song_song_medium_load_path(song_id: recording.song.id, song_medium_id: recording.id), class: 'w-5/6', controls: true, preload: 'none', data: {'score-target': 'audioTag', 'action': 'seeked->score#update'} %> <% end %> From 4cafa1000a58607445c7a30a55c24caf1c42edbe Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 15:55:27 +0200 Subject: [PATCH 09/25] Display info about where song media is stored --- app/models/drive_file.rb | 18 ++++++++++-------- app/models/song_media_storage_entry.rb | 12 ++++++++++++ app/models/song_medium.rb | 10 +++++++--- app/views/songs/_score.html.erb | 2 -- app/views/songs/show.html.erb | 5 ++++- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/app/models/drive_file.rb b/app/models/drive_file.rb index 7573185..f87968c 100644 --- a/app/models/drive_file.rb +++ b/app/models/drive_file.rb @@ -10,6 +10,16 @@ def parent @drive_files.files_by_id[@file.parents.try(:first)] end + def ancestor_names + ancestors.map(&:name).reverse + end + + delegate :id, :name, :size, to: :file + + attr_reader :file + + private + def ancestors ancestors = [] f = self @@ -19,12 +29,4 @@ def ancestors end ancestors end - - def ancestor_names - ancestors.map(&:name).reverse - end - - delegate :id, :name, :size, to: :file - - attr_reader :file end diff --git a/app/models/song_media_storage_entry.rb b/app/models/song_media_storage_entry.rb index f17a110..37d5b8c 100644 --- a/app/models/song_media_storage_entry.rb +++ b/app/models/song_media_storage_entry.rb @@ -16,6 +16,18 @@ def data buffer.string.force_encoding('binary') end + def type_audio? + mime_type.starts_with?('audio/') || mime_type == 'application/ogg' + end + + def type_pdf? + mime_type == 'application/pdf' + end + + def type_video? + mime_type.starts_with?('video/') + end + private def buffer diff --git a/app/models/song_medium.rb b/app/models/song_medium.rb index d68ed21..9b18d25 100644 --- a/app/models/song_medium.rb +++ b/app/models/song_medium.rb @@ -32,20 +32,24 @@ def human_kind I18n.t("activerecord.attributes.song_medium.enums.kind.#{kind}") end + def drive_file? + file_identifier.present? + end + def type_audio? - return false if file.blank? + return song_media_storage_entry.type_audio? if file.blank? file.attachment.audio? end def type_pdf? - return false if file.blank? + return song_media_storage_entry.type_pdf? if file.blank? file.attachment.content_type == 'application/pdf' end def type_video? - return false if file.blank? + return song_media_storage_entry.type_video? if file.blank? file.attachment.video? end diff --git a/app/views/songs/_score.html.erb b/app/views/songs/_score.html.erb index ea8c1c3..d7e019b 100644 --- a/app/views/songs/_score.html.erb +++ b/app/views/songs/_score.html.erb @@ -13,10 +13,8 @@ <% if recording.file.present? %> - <%= tabler_icon :file %> <%= audio_tag url_for(recording.file), class: 'w-5/6', controls: true, preload: 'none', data: {'score-target': 'audioTag', 'action': 'seeked->score#update'} %> <% else %> - <%= tabler_icon :'brand-google-drive' %> <%= audio_tag song_song_medium_load_path(song_id: recording.song.id, song_medium_id: recording.id), class: 'w-5/6', controls: true, preload: 'none', data: {'score-target': 'audioTag', 'action': 'seeked->score#update'} %> <% end %> diff --git a/app/views/songs/show.html.erb b/app/views/songs/show.html.erb index 9ff7623..aaa8654 100644 --- a/app/views/songs/show.html.erb +++ b/app/views/songs/show.html.erb @@ -70,7 +70,10 @@ <% @song.song_media.each do |song_medium| %> - <%= file_icon(song_medium) %> + + <%= file_icon(song_medium) %> + <%= tabler_icon :'brand-google-drive' if song_medium.drive_file? %> + <%= song_medium.human_kind %> From b604bbea8f3d43ac95c247998f474da6c6349de7 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 23:07:20 +0200 Subject: [PATCH 10/25] Fix some tests --- app/controllers/song_media_controller.rb | 2 +- app/models/song_medium.rb | 3 ++- app/views/song_media/_form.html.erb | 8 -------- config/locales/de.yml | 1 - 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/app/controllers/song_media_controller.rb b/app/controllers/song_media_controller.rb index 56dddbf..9bc4372 100644 --- a/app/controllers/song_media_controller.rb +++ b/app/controllers/song_media_controller.rb @@ -44,6 +44,6 @@ def load private def song_medium_params - params.expect(song_medium: %i[song_id register kind file file_identifier]) + params.expect(song_medium: %i[song_id register kind file_identifier]) end end diff --git a/app/models/song_medium.rb b/app/models/song_medium.rb index 9b18d25..8f4619d 100644 --- a/app/models/song_medium.rb +++ b/app/models/song_medium.rb @@ -14,7 +14,8 @@ class SongMedium < ApplicationRecord ].freeze belongs_to :song, touch: true # when a SongMedium changes, its Song changes too - belongs_to :song_media_storage_entry, foreign_key: :file_identifier, primary_key: :identifier, inverse_of: false + belongs_to :song_media_storage_entry, foreign_key: :file_identifier, primary_key: :identifier, inverse_of: false, + optional: true has_one_attached :file validates :register, presence: true, inclusion: Register::Song::REGISTERS, if: proc { |sm| diff --git a/app/views/song_media/_form.html.erb b/app/views/song_media/_form.html.erb index fa237bd..0a1bbcb 100644 --- a/app/views/song_media/_form.html.erb +++ b/app/views/song_media/_form.html.erb @@ -21,14 +21,6 @@

-
-

- <%= f.label :file %> <%# i18n-tasks-use t('activerecord.attributes.song_medium.file') %> -

- -

<%= f.file_field :file %>

-
-

<%= f.label :file_identifier %> <%# i18n-tasks-use t('activerecord.attributes.song_medium.file_identifier') %> diff --git a/config/locales/de.yml b/config/locales/de.yml index 7a92c31..32513e6 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -95,7 +95,6 @@ de: recording_all: Audioaufnahme (alle Stimmen) recording_register: Audioaufnahme sheet_music: Notenblatt - file: Datei file_identifier: Google Drive-Datei kind: Art register: Stimme From 2d6a90d68676fd70941d4b4c5eaf9ffe049ac152 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 23:12:57 +0200 Subject: [PATCH 11/25] Fix test --- spec/features/songs/create_song_media_spec.rb | 2 +- spec/fixtures/song_media_storage_entries.yml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/song_media_storage_entries.yml diff --git a/spec/features/songs/create_song_media_spec.rb b/spec/features/songs/create_song_media_spec.rb index 976c8ad..6d86fee 100644 --- a/spec/features/songs/create_song_media_spec.rb +++ b/spec/features/songs/create_song_media_spec.rb @@ -15,7 +15,7 @@ click_on 'Neue Mediendatei' select 'Notenblatt', from: 'Art' - attach_file 'Datei', Rails.root.join('spec/fixtures/files/Härlig_är_jorden.pdf') + select 'path/to/the/file.mp3', from: 'Google Drive-Datei' click_on 'Mediendatei erstellen' expect(page).to have_text('Mediendatei erfolgreich erstellt.') end diff --git a/spec/fixtures/song_media_storage_entries.yml b/spec/fixtures/song_media_storage_entries.yml new file mode 100644 index 0000000..8a96478 --- /dev/null +++ b/spec/fixtures/song_media_storage_entries.yml @@ -0,0 +1,7 @@ +storage_entry: + identifier: identifier + mime_type: audio/mpeg + name: file.mp3 + parent_identifier: parent-identifier + parent_identifiers: ['parent-identifier'] + path: path/to/the/file.mp3 From aa7f7fcd2766ec25e99543d762b2bffb537422ad Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 23:16:48 +0200 Subject: [PATCH 12/25] Update bundle --- Gemfile.lock | 295 ++++++++++++++++++++++++++------------------------- 1 file changed, 148 insertions(+), 147 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3e0397e..f7bcd30 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,31 +1,31 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.17) + action_text-trix (2.1.19) railties - actioncable (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + actioncable (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionmailbox (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) mail (>= 2.8.0) - actionmailer (8.1.3) - actionpack (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activesupport (= 8.1.3) + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.3) - actionview (= 8.1.3) - activesupport (= 8.1.3) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,36 +33,36 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.3) + actiontext (8.1.3.1) action_text-trix (~> 2.1.15) - actionpack (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionpack (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.3) - activesupport (= 8.1.3) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.3) - activesupport (= 8.1.3) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) globalid (>= 0.3.6) - activemodel (8.1.3) - activesupport (= 8.1.3) - activerecord (8.1.3) - activemodel (= 8.1.3) - activesupport (= 8.1.3) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) timeout (>= 0.4.0) - activestorage (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activesupport (= 8.1.3) + activestorage (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activesupport (= 8.1.3.1) marcel (~> 1.0) - activesupport (8.1.3) + activesupport (8.1.3.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -79,7 +79,7 @@ GEM public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) aws-eventstream (1.4.0) - aws-partitions (1.1262.0) + aws-partitions (1.1288.0) aws-sdk-core (3.257.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -89,8 +89,8 @@ GEM jmespath (~> 1, >= 1.6.1) logger rexml (~> 3.4, >= 3.4.2) - aws-sdk-kms (1.129.0) - aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-kms (1.132.0) + aws-sdk-core (~> 3, >= 3.256.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.232.1) aws-sdk-core (~> 3, >= 3.256.0) @@ -99,13 +99,13 @@ GEM aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.20) + bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) - bigdecimal (4.1.2) + bigdecimal (4.1.3) bindex (0.8.1) - bootsnap (1.24.6) - msgpack (~> 1.2) - brakeman (8.0.5) + bootsnap (1.26.0) + msgpack (~> 1.5) + brakeman (8.0.6) racc browser (6.2.0) builder (3.3.0) @@ -118,7 +118,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - caxlsx (4.4.2) + caxlsx (4.5.0) htmlentities (~> 4.3, >= 4.3.4) marcel (~> 1.0) nokogiri (~> 1.10, >= 1.10.4) @@ -127,10 +127,10 @@ GEM childprocess (5.1.0) logger (~> 1.5) choice (0.2.0) - concurrent-ruby (1.3.7) + concurrent-ruby (1.3.8) connection_pool (3.0.2) - crass (1.0.6) - csv (3.3.5) + crass (1.0.7) + csv (3.3.6) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -143,13 +143,13 @@ GEM base64 (~> 0.3) drb (2.2.3) ed25519 (1.4.0) - erb (6.0.2) + erb (6.0.7) erubi (1.13.1) - et-orbi (1.4.0) + et-orbi (1.4.2) tzinfo faker (3.8.0) i18n (>= 1.8.11, < 2) - faraday (2.14.3) + faraday (2.14.4) faraday-net_http (>= 2.0, < 3.5) json logger @@ -157,19 +157,18 @@ GEM faraday (>= 1, < 3) faraday-net_http (3.4.4) net-http (~> 0.5) - ffi (1.17.2) - ffi (1.17.2-aarch64-linux-gnu) - ffi (1.17.2-arm-linux-gnu) - ffi (1.17.2-arm64-darwin) - ffi (1.17.2-x86-linux-gnu) - ffi (1.17.2-x86_64-darwin) - ffi (1.17.2-x86_64-linux-gnu) - fugit (1.12.1) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + fugit (1.14.0) et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.3.0) + globalid (1.4.0) activesupport (>= 6.1) - google-apis-core (1.2.3) + google-apis-core (1.2.5) addressable (~> 2.9) faraday (~> 2.13) faraday-follow_redirects (~> 0.3) @@ -180,11 +179,11 @@ GEM retriable (>= 3.1, < 5.0) google-apis-drive_v3 (0.81.0) google-apis-core (>= 0.15.0, < 2.a) - google-cloud-env (2.3.1) + google-cloud-env (2.4.0) base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-logging-utils (0.2.0) - googleauth (1.17.1) + googleauth (1.17.4) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -209,7 +208,7 @@ GEM rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) - icalendar (2.12.3) + icalendar (2.12.4) base64 ice_cube (~> 0.16) logger @@ -224,15 +223,15 @@ GEM actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - io-console (0.8.2) - irb (1.17.0) + io-console (0.9.4) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.20.0) - jwt (3.2.0) + json (3.0.2) + jwt (3.3.0) base64 kamal (2.12.0) activesupport (>= 7.0) @@ -245,7 +244,7 @@ GEM sshkit (>= 1.23.0, < 2.0) thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) - language_server-protocol (3.17.0.5) + language_server-protocol (3.17.0.6) launchy (3.1.1) addressable (~> 2.8) childprocess (~> 5.0) @@ -289,16 +288,16 @@ GEM railties (>= 7.0) sqlite3 logger (1.7.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.9.0) + mail (2.9.1) logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) matrix (0.4.3) mini_mime (1.1.5) mini_portile2 (2.8.9) @@ -315,16 +314,16 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - msgpack (1.8.3) - multi_json (1.21.1) + msgpack (1.8.5) + multi_json (1.21.2) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.3) + net-imap (0.6.7) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.2) + net-protocol (0.4.0) timeout net-scp (4.1.0) net-ssh (>= 2.6.5, < 8.0.0) @@ -332,25 +331,25 @@ GEM net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.1) net-protocol - net-ssh (7.3.2) + net-ssh (7.3.3) nio4r (2.7.5) - nokogiri (1.19.2) + nokogiri (1.19.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.2-aarch64-linux-gnu) + nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-gnu) + nokogiri (1.19.4-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) ostruct (0.6.3) - parallel (2.1.0) - parser (3.3.11.1) + parallel (2.2.0) + parser (3.3.12.0) ast (~> 2.4.1) racc passwordless (1.8.1) @@ -359,7 +358,7 @@ GEM positioning (0.4.8) activerecord (>= 6.1) activesupport (>= 6.1) - pp (0.6.3) + pp (0.6.4) prettyprint prettyprint (0.2.0) prism (1.9.0) @@ -368,9 +367,6 @@ GEM activesupport (>= 7.0.0) rack pstore (0.2.1) - psych (5.3.1) - date - stringio public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) @@ -381,9 +377,9 @@ GEM rspec-expectations (~> 3.12) rspec-mocks (~> 3.12) rspec-support (~> 3.12) - raabro (1.4.0) + raabro (1.5.0) racc (1.8.1) - rack (3.2.6) + rack (3.2.7) rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -391,20 +387,20 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (8.1.3) - actioncable (= 8.1.3) - actionmailbox (= 8.1.3) - actionmailer (= 8.1.3) - actionpack (= 8.1.3) - actiontext (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activemodel (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) bundler (>= 1.15.0) - railties (= 8.1.3) + railties (= 8.1.3.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -413,15 +409,15 @@ GEM activerecord (>= 7.0) activesupport (>= 7.0) choice (~> 0.2.0) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) - railties (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + railties (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -429,13 +425,18 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) - rdoc (7.2.0) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) erb - psych (>= 4.0.0) + prism (>= 1.6.0) + rbs (>= 4.0.0) tsort - regexp_parser (2.12.0) - reline (0.6.3) + regexp_parser (2.13.0) + reline (0.7.0) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -443,9 +444,9 @@ GEM uber (< 0.2.0) retriable (4.2.0) rexml (3.4.4) - rodf (1.2.0) + rodf (1.3.0) builder (>= 3.0) - rubyzip (>= 1.0) + rubyzip (>= 3.2) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -463,8 +464,8 @@ GEM rspec-mocks (>= 3.13.0, < 5.0.0) rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) - rubocop (1.88.0) - json (~> 2.3) + rubocop (1.91.0) + json (>= 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) parallel (>= 1.10) @@ -474,21 +475,22 @@ GEM rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.1) + rubocop-ast (1.50.0) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-capybara (3.0.0) lint_roller (~> 1.1) rubocop (~> 1.81) - rubocop-rails (2.35.5) + rubocop-rails (2.38.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.75.0, < 2.0) + rubocop (>= 1.89.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rspec (3.8.0) + rubocop-rspec (3.10.2) lint_roller (~> 1.1) - rubocop (~> 1.81) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) rubocop-rspec_rails (2.32.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) @@ -497,9 +499,9 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (3.4.0) + rubyzip (3.7.0) securerandom (0.4.1) - selenium-webdriver (4.45.0) + selenium-webdriver (4.49.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -509,10 +511,10 @@ GEM addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 4.0) - simplecov (1.2.0) + simplecov (1.3.0) simplecov-rspec (1.1.0) simplecov (~> 1.0) - solid_cable (4.0.0) + solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) activerecord (>= 7.2) @@ -532,13 +534,13 @@ GEM caxlsx (>= 4.0) csv rodf - sqlite3 (2.9.5-aarch64-linux-gnu) - sqlite3 (2.9.5-arm-linux-gnu) - sqlite3 (2.9.5-arm64-darwin) - sqlite3 (2.9.5-x86-linux-gnu) - sqlite3 (2.9.5-x86_64-darwin) - sqlite3 (2.9.5-x86_64-linux-gnu) - sshkit (1.25.0) + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm64-darwin) + sqlite3 (2.9.6-x86-linux-gnu) + sqlite3 (2.9.6-x86_64-darwin) + sqlite3 (2.9.6-x86_64-linux-gnu) + sshkit (1.25.1) base64 logger net-scp (>= 1.1.2) @@ -547,7 +549,6 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.2.0) tabler_icons (0.1.1) tailwindcss-rails (3.1.0) railties (>= 7.0.0) @@ -561,11 +562,11 @@ GEM terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.5.0) - thruster (0.1.21) - thruster (0.1.21-aarch64-linux) - thruster (0.1.21-arm64-darwin) - thruster (0.1.21-x86_64-darwin) - thruster (0.1.21-x86_64-linux) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) + thruster (0.1.26-arm64-darwin) + thruster (0.1.26-x86_64-darwin) + thruster (0.1.26-x86_64-linux) timeout (0.6.1) trailblazer-option (0.1.2) tsort (0.2.0) @@ -575,9 +576,9 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (3.2.0) - unicode-emoji (~> 4.1) - unicode-emoji (4.2.0) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) uri (1.1.1) useragent (0.16.11) web-console (4.3.0) @@ -585,13 +586,13 @@ GEM bindex (>= 0.4.0) railties (>= 8.0.0) websocket (1.2.11) - websocket-driver (0.8.0) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.8.2) + zeitwerk (2.8.3) PLATFORMS aarch64-linux From 7ade73bfc9b9c85a0f65009a2e9654f2b996e734 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 23:20:16 +0200 Subject: [PATCH 13/25] Fix rubocop --- app/models/song_media_bundle_download.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/models/song_media_bundle_download.rb b/app/models/song_media_bundle_download.rb index 74832bf..755865d 100644 --- a/app/models/song_media_bundle_download.rb +++ b/app/models/song_media_bundle_download.rb @@ -5,7 +5,7 @@ class SongMediaBundleDownload < ApplicationRecord has_one_attached :file - # rubocop:disable Naming/VariableNumber + # rubocop:disable-next Naming/VariableNumber enum :register, { soprano_1: 'soprano_1', @@ -17,7 +17,6 @@ class SongMediaBundleDownload < ApplicationRecord bass_1: 'bass_1', bass_2: 'bass_2' } - # rubocop:enable Naming/VariableNumber enum :status, { processing: 'processing', ready: 'ready', errored: 'errored' }, default: :processing From 4945b2b10cfbe8ad4a29f4c87396393f693edf26 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Tue, 22 Sep 2026 23:45:46 +0200 Subject: [PATCH 14/25] Downgrade json from 3 to 2 --- .ruby-version | 2 +- Gemfile | 4 ++++ Gemfile.lock | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.ruby-version b/.ruby-version index d13e837..43beb40 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.6 +4.0.7 diff --git a/Gemfile b/Gemfile index c65ac67..6245978 100644 --- a/Gemfile +++ b/Gemfile @@ -103,3 +103,7 @@ gem 'google-apis-drive_v3', '~> 0.81.0' gem 'icalendar-recurrence', '~> 1.2' gem 'multi_json', '~> 1.21' + +# FIXME: There seems to be an issue with activesupport being incompatible +# with json > 2 (https://github.com/rails/rails/issues/58685) so we downgrade it from 3 to 2 +gem 'json', '~> 2' diff --git a/Gemfile.lock b/Gemfile.lock index f7bcd30..1dd0180 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -230,7 +230,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (3.0.2) + json (2.21.2) jwt (3.3.0) base64 kamal (2.12.0) @@ -618,6 +618,7 @@ DEPENDENCIES icalendar-recurrence (~> 1.2) image_processing (~> 2.1) importmap-rails + json (~> 2) kamal (~> 2.12) letter_opener litestream (~> 0.14.0) From 70b9c7be3e0c6a423c4a7cbc77d5193569d9f641 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Wed, 23 Sep 2026 07:57:39 +0200 Subject: [PATCH 15/25] Add spec for SongMediaStorageUpdateJob --- app/jobs/song_media_storage_update_job.rb | 10 +-- app/models/drive_file.rb | 2 +- spec/features/songs/create_song_media_spec.rb | 2 +- spec/fixtures/song_media_storage_entries.yml | 21 +++++- .../song_media_storage_update_job_spec.rb | 64 +++++++++++++++++++ 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 spec/jobs/song_media_storage_update_job_spec.rb diff --git a/app/jobs/song_media_storage_update_job.rb b/app/jobs/song_media_storage_update_job.rb index eb7ce71..81985b9 100644 --- a/app/jobs/song_media_storage_update_job.rb +++ b/app/jobs/song_media_storage_update_job.rb @@ -28,11 +28,11 @@ def do_update! def to_attributes(drive_file) { - identifier: drive_file.file.id, - mime_type: drive_file.file.mime_type, - name: drive_file.file.name, - parent_identifiers: drive_file.file.parents, - parent_identifier: drive_file.file.parents.try(&:first), + identifier: drive_file.id, + mime_type: drive_file.mime_type, + name: drive_file.name, + parent_identifiers: drive_file.parents, + parent_identifier: drive_file.parents.try(&:first), path: drive_file.ancestor_names.join('/') } end diff --git a/app/models/drive_file.rb b/app/models/drive_file.rb index f87968c..bd9327f 100644 --- a/app/models/drive_file.rb +++ b/app/models/drive_file.rb @@ -14,7 +14,7 @@ def ancestor_names ancestors.map(&:name).reverse end - delegate :id, :name, :size, to: :file + delegate :id, :mime_type, :name, :parents, to: :file attr_reader :file diff --git a/spec/features/songs/create_song_media_spec.rb b/spec/features/songs/create_song_media_spec.rb index 6d86fee..83ae1fe 100644 --- a/spec/features/songs/create_song_media_spec.rb +++ b/spec/features/songs/create_song_media_spec.rb @@ -15,7 +15,7 @@ click_on 'Neue Mediendatei' select 'Notenblatt', from: 'Art' - select 'path/to/the/file.mp3', from: 'Google Drive-Datei' + select 'Folder/file.mp3', from: 'Google Drive-Datei' click_on 'Mediendatei erstellen' expect(page).to have_text('Mediendatei erfolgreich erstellt.') end diff --git a/spec/fixtures/song_media_storage_entries.yml b/spec/fixtures/song_media_storage_entries.yml index 8a96478..519c32c 100644 --- a/spec/fixtures/song_media_storage_entries.yml +++ b/spec/fixtures/song_media_storage_entries.yml @@ -1,7 +1,22 @@ -storage_entry: - identifier: identifier +parent: + identifier: parent-identifier + mime_type: application/vnd.google-apps.folder + name: Folder + parent_identifiers: [] + path: Folder + +storage_entry_1: + identifier: identifier-1 mime_type: audio/mpeg name: file.mp3 parent_identifier: parent-identifier parent_identifiers: ['parent-identifier'] - path: path/to/the/file.mp3 + path: Folder/file.mp3 + +storage_entry_2: + identifier: identifier-2 + mime_type: application/pdf + name: file.pdf + parent_identifier: parent-identifier + parent_identifiers: ['parent-identifier'] + path: Folder/file.pdf diff --git a/spec/jobs/song_media_storage_update_job_spec.rb b/spec/jobs/song_media_storage_update_job_spec.rb new file mode 100644 index 0000000..78a439e --- /dev/null +++ b/spec/jobs/song_media_storage_update_job_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +MockDriveFile = Struct.new('DriveFile', :id, :mime_type, :name, :parents) + +RSpec.describe SongMediaStorageUpdateJob do + fixtures :all + + it 'synchronises the Google Drive files to the database' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + allow(SongMediaStorageAccessor.instance).to receive(:drive_files).and_return( + DriveFiles.new( + [ + MockDriveFile.new('parent-identifier', 'application/vnd.google-apps.folder', 'Folder', []), # unchanged + MockDriveFile.new('identifier-1', 'audio/mpeg', 'myfile.mp3', ['parent-identifier']), # rename file -> updated + MockDriveFile.new('identifier-3', 'video/mp4', 'myfile.mp4', ['parent-identifier']) # created + ] + ) + ) + + counts = described_class.perform_now + + expect(counts).to eq( + { + created_count: 1, + deleted_count: 1, + unchanged_count: 1, + updated_count: 1 + } + ) + + expect(SongMediaStorageEntry.find_by(identifier: 'parent-identifier').attributes).to include( + { + 'identifier' => 'parent-identifier', + 'mime_type' => 'application/vnd.google-apps.folder', + 'name' => 'Folder', + 'parent_identifier' => nil, + 'parent_identifiers' => [], + 'path' => 'Folder' + } + ) + expect(SongMediaStorageEntry.find_by(identifier: 'identifier-1').attributes).to include( + { + 'identifier' => 'identifier-1', + 'mime_type' => 'audio/mpeg', + 'name' => 'myfile.mp3', + 'parent_identifier' => 'parent-identifier', + 'parent_identifiers' => ['parent-identifier'], + 'path' => 'Folder/myfile.mp3' + } + ) + expect(SongMediaStorageEntry.find_by(identifier: 'identifier-2')).to be_nil + expect(SongMediaStorageEntry.find_by(identifier: 'identifier-3').attributes).to include( + { + 'identifier' => 'identifier-3', + 'mime_type' => 'video/mp4', + 'name' => 'myfile.mp4', + 'parent_identifier' => 'parent-identifier', + 'parent_identifiers' => ['parent-identifier'], + 'path' => 'Folder/myfile.mp4' + } + ) + end +end From e739f3eda1d53100bbc96dd8524b57d4231df089 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Wed, 23 Sep 2026 08:07:14 +0200 Subject: [PATCH 16/25] Fix test on GitHub --- spec/jobs/song_media_storage_update_job_spec.rb | 1 + spec/spec_helper.rb | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/spec/jobs/song_media_storage_update_job_spec.rb b/spec/jobs/song_media_storage_update_job_spec.rb index 78a439e..a230626 100644 --- a/spec/jobs/song_media_storage_update_job_spec.rb +++ b/spec/jobs/song_media_storage_update_job_spec.rb @@ -8,6 +8,7 @@ fixtures :all it 'synchronises the Google Drive files to the database' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + allow(SongMediaStorageAccessor).to receive(:new) allow(SongMediaStorageAccessor.instance).to receive(:drive_files).and_return( DriveFiles.new( [ diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e26ddf8..c2b38da 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -101,8 +101,18 @@ skip '/config/' group 'Controllers', 'app/controllers' - group 'Models', 'app/models' group 'Helpers', 'app/helpers' + group 'Jobs', 'app/jobs' group 'Mailers', 'app/mailers' + group 'Models', 'app/models' group 'Policies', 'app/policies' + group 'Services', 'app/services' + group 'Validators', 'app/validators' + + enable_coverage :branch # track branches as well as lines + + coverage :line do + minimum 90 # fail the suite below 90% line coverage + maximum_drop 1 # ...or when coverage drops more than 1% + end end From 569c808cc638ed1ad791e87e41fee9b425d13412 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Wed, 23 Sep 2026 16:57:48 +0200 Subject: [PATCH 17/25] Regenerate ERD --- .erdconfig | 1 + doc/erd.mmd | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 .erdconfig create mode 100644 doc/erd.mmd diff --git a/.erdconfig b/.erdconfig new file mode 100644 index 0000000..435975f --- /dev/null +++ b/.erdconfig @@ -0,0 +1 @@ +filename: doc/erd diff --git a/doc/erd.mmd b/doc/erd.mmd new file mode 100644 index 0000000..1012df9 --- /dev/null +++ b/doc/erd.mmd @@ -0,0 +1,147 @@ +erDiagram + direction TB + "ActiveStorage::Attachment" { + string name + string record_type + } + "ActiveStorage::Blob" { + integer byte_size + string checksum + string content_type + string filename + string key + text metadata + string service_name + } + "ActiveStorage::VariantRecord" { + string variation_digest + } + Attendance { + text remarks + string status + } + CalendarEvent { + boolean deleted + string description + datetime ends_at + datetime event_created_at + boolean is_recurring + string location + datetime starts_at + string summary + string uid + } + Info { + boolean active + text description + string kind + string title + } + NameGuess { + boolean correct + } + "Passwordless::Session" { + string authenticatable_type + datetime claimed_at + datetime expires_at + string identifier + datetime timeout_at + string token_digest + } + Program { + } + Score { + json metadata + } + Song { + string arranger + json genres + string key_signature + string language + text lyrics + string music_composer + json registers + boolean repertoire + string texter + string time_signature + string title + } + SongList { + string name + string status + } + SongListItem { + string name + text notes + integer position + } + SongMediaBundleDownload { + datetime last_downloaded_at + text log + string register + datetime song_list_updated_at + string status + } + SongMediaStorageEntry { + string identifier + string mime_type + string name + json parent_identifiers + string path + } + SongMedium { + string kind + string register + } + User { + date birth_date + string canonical_register + string city + string email + string first_name + string last_name + string member_since + string nick_name + string phone_number + string register + text remarks + json roles + string salutation + string street + string zip_code + } + UserStatus { + date from_date + string note + string status + date to_date + } + "ActiveStorage::Blob" ||--|o "ActiveStorage::Attachment" : "" + "ActiveStorage::VariantRecord" ||--|o "ActiveStorage::Attachment" : "" + Score ||--|o "ActiveStorage::Attachment" : "" + SongMediaBundleDownload ||--|o "ActiveStorage::Attachment" : "" + SongMedium ||--|o "ActiveStorage::Attachment" : "" + User ||--|o "ActiveStorage::Attachment" : "" + "ActiveStorage::Blob" ||--}o "ActiveStorage::Attachment" : "" + "ActiveStorage::Blob" ||--}o "ActiveStorage::VariantRecord" : "" + "ActiveStorage::Blob" o|..|o "ActiveStorage::Blob" : "" + CalendarEvent ||--}o Attendance : "" + User ||--}o Attendance : "" + CalendarEvent ||--}o Program : "" + CalendarEvent o|..}o SongList : "" + User ||--}o NameGuess : "" + User ||--}o "Passwordless::Session" : "" + SongList ||--}o Program : "" + Song ||--|o Score : "" + Score o|..|o "ActiveStorage::Blob" : "" + Song ||--}o SongMedium : "" + Song o|--}o SongListItem : "" + SongList ||--}o SongListItem : "" + SongList ||--}o SongMediaBundleDownload : "" + SongList o|..}o Song : "" + SongMediaBundleDownload o|..|o "ActiveStorage::Blob" : "" + SongMediaStorageEntry o|--}o SongMediaStorageEntry : "" + SongMediaStorageEntry o|--}o SongMedium : "" + SongMedium o|..|o "ActiveStorage::Blob" : "" + User o|..|o "ActiveStorage::Blob" : "" + User ||--}o UserStatus : "" \ No newline at end of file From b4e007165e71924904ae35b2c7a8d65388871913 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Thu, 24 Sep 2026 16:51:26 +0200 Subject: [PATCH 18/25] WIP: Improve test coverage --- .rubocop.yml | 1 + app/controllers/song_lists_controller.rb | 2 +- .../song_media_storage_update_job_spec.rb | 2 + spec/requests/song_lists_controller_spec.rb | 100 ++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 spec/requests/song_lists_controller_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index dc42d38..de4f9a2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,6 +13,7 @@ Style/Documentation: RSpec/MultipleExpectations: Exclude: - spec/features/**/* + - spec/requests/**/* RSpec/ExampleLength: Exclude: diff --git a/app/controllers/song_lists_controller.rb b/app/controllers/song_lists_controller.rb index c3aaf3a..e3878fb 100644 --- a/app/controllers/song_lists_controller.rb +++ b/app/controllers/song_lists_controller.rb @@ -57,6 +57,6 @@ def destroy private def song_list_params - params.expect(song_list: %i[name status calendar_event_id]) + params.expect(song_list: %i[name status]) end end diff --git a/spec/jobs/song_media_storage_update_job_spec.rb b/spec/jobs/song_media_storage_update_job_spec.rb index a230626..4679b4b 100644 --- a/spec/jobs/song_media_storage_update_job_spec.rb +++ b/spec/jobs/song_media_storage_update_job_spec.rb @@ -8,6 +8,7 @@ fixtures :all it 'synchronises the Google Drive files to the database' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + RSpec::Mocks.configuration.allow_message_expectations_on_nil = true allow(SongMediaStorageAccessor).to receive(:new) allow(SongMediaStorageAccessor.instance).to receive(:drive_files).and_return( DriveFiles.new( @@ -20,6 +21,7 @@ ) counts = described_class.perform_now + RSpec::Mocks.configuration.allow_message_expectations_on_nil = false expect(counts).to eq( { diff --git a/spec/requests/song_lists_controller_spec.rb b/spec/requests/song_lists_controller_spec.rb new file mode 100644 index 0000000..4b43982 --- /dev/null +++ b/spec/requests/song_lists_controller_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'rails_helper' + +require_relative '../features/log_in_helpers' + +RSpec.describe SongListsController do + let(:phips) { users(:phips) } + let(:concert1) { song_lists(:concert1) } + + before do + passwordless_sign_in(phips) + end + + describe '#index' do + it 'renders the index template successfully' do + get '/song_lists' + expect(response).to have_http_status(:ok) + expect(response.body).to include('Concert 1') + end + end + + describe '#show' do + it 'renders the show template successfully' do + get "/song_lists/#{concert1.id}" + expect(response).to have_http_status(:ok) + expect(response.body).to include('Concert 1') + expect(response.body).to include('Härlig är jorden') + end + end + + describe '#new' do + it 'renders the new template successfully' do + get '/song_lists/new' + expect(response).to have_http_status(:ok) + expect(response.body).to include('Neue Liederliste') + end + end + + describe '#edit' do + it 'renders the edit template successfully' do + get "/song_lists/#{concert1.id}/edit" + expect(response).to have_http_status(:ok) + expect(response.body).to include('Liederliste bearbeiten') + end + end + + describe '#create' do + describe 'with correct parameters' do + let(:params) { { song_list: { name: 'Foo', status: 'in_preparation' } } } + + it 'creates a record and redirects successfully' do + expect do + post '/song_lists', params: params + expect(response).to redirect_to(song_lists_path) + end.to change(SongList, :count).by(1) + end + end + + describe 'with missing name' do + let(:params) { { song_list: { name: '', status: 'in_preparation' } } } + + it 'creates a record and redirects successfully' do + expect do + post '/song_lists', params: params + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Name muss ausgefüllt werden') + end.not_to change(SongList, :count) + end + end + end + + describe '#update' do + describe 'with correct parameters' do + let(:params) { { song_list: { name: 'Foo', status: 'in_preparation' } } } + + it 'updates a record and redirects successfully' do + put "/song_lists/#{concert1.id}", params: params + expect(response).to redirect_to(song_lists_path) + end + end + + describe 'with missing name' do + let(:params) { { song_list: { name: '', status: 'in_preparation' } } } + + it 'updates a record and redirects successfully' do + put "/song_lists/#{concert1.id}", params: params + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Name muss ausgefüllt werden') + end + end + end + + describe '#destroy' do + it 'destroys an existing record' do + delete "/song_lists/#{concert1.id}" + expect(response).to redirect_to(song_lists_path) + end + end +end From 10ef10bd3789846db82762aab5c0a7e27bfb0eab Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Thu, 24 Sep 2026 16:59:53 +0200 Subject: [PATCH 19/25] WIP: More specs --- .rubocop.yml | 7 ++----- .../quick_actions_controller_spec.rb | 8 ++++---- .../song_media_bundle_downloads_helper_spec.rb | 18 ++++++++++++++++++ spec/jobs/song_media_bundle_job_spec.rb | 2 +- .../jobs/song_media_storage_update_job_spec.rb | 2 +- spec/models/song_list_spec.rb | 2 +- .../calendar_sync_database_service_spec.rb | 2 +- 7 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 spec/helpers/song_media_bundle_downloads_helper_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index de4f9a2..92f440c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,13 +11,10 @@ Style/Documentation: Enabled: false RSpec/MultipleExpectations: - Exclude: - - spec/features/**/* - - spec/requests/**/* + Enabled: false RSpec/ExampleLength: - Exclude: - - spec/features/**/* + Enabled: false Metrics/AbcSize: Exclude: diff --git a/spec/controllers/attendances/quick_actions_controller_spec.rb b/spec/controllers/attendances/quick_actions_controller_spec.rb index 524decb..505ff3f 100644 --- a/spec/controllers/attendances/quick_actions_controller_spec.rb +++ b/spec/controllers/attendances/quick_actions_controller_spec.rb @@ -19,7 +19,7 @@ { attendance: { user_id: user.id, calendar_event_id: calendar_event.id, status: 'attended' } } end - it 'creates a new attendance' do # rubocop:disable RSpec/MultipleExpectations,RSpec/ExampleLength + it 'creates a new attendance' do expect do post :create, params: params, xhr: true expect(response).to be_successful @@ -29,7 +29,7 @@ end.to change(Attendance, :count).by(1) end - it 'renders error response when creating attendance fails' do # rubocop:disable RSpec/MultipleExpectations + it 'renders error response when creating attendance fails' do post :create, params: params.deep_merge({ attendance: { status: 'invalid' } }), xhr: true expect(response).to have_http_status(:bad_request) expect(response.body).to match( @@ -53,7 +53,7 @@ { attendance_id: attendance.id, attendance: { status: 'excused' } } end - it 'updates an existing attendance' do # rubocop:disable RSpec/MultipleExpectations + it 'updates an existing attendance' do patch :update, params: params, xhr: true expect(response).to be_successful expect(response.body).to match( @@ -61,7 +61,7 @@ ) end - it 'renders error response when updating attendance fails' do # rubocop:disable RSpec/MultipleExpectations + it 'renders error response when updating attendance fails' do patch :update, params: params.deep_merge({ attendance: { status: 'invalid' } }), xhr: true expect(response).to have_http_status(:bad_request) expect(response.body).to match( diff --git a/spec/helpers/song_media_bundle_downloads_helper_spec.rb b/spec/helpers/song_media_bundle_downloads_helper_spec.rb new file mode 100644 index 0000000..93bb524 --- /dev/null +++ b/spec/helpers/song_media_bundle_downloads_helper_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SongMediaBundleDownloadsHelper do + describe '#icon_for_song_media_bundle_download_status' do + it 'returns an icon for a status' do + processing_icon = helper.icon_for_song_media_bundle_download_status('processing') + expect(processing_icon).to include('class="animate-spin"') + + ready_icon = helper.icon_for_song_media_bundle_download_status('ready') + expect(ready_icon).to include('class="icon tabler-icon"') + + errored_icon = helper.icon_for_song_media_bundle_download_status('errored') + expect(errored_icon).to include('class="icon tabler-icon"') + end + end +end diff --git a/spec/jobs/song_media_bundle_job_spec.rb b/spec/jobs/song_media_bundle_job_spec.rb index 52034fe..60385f4 100644 --- a/spec/jobs/song_media_bundle_job_spec.rb +++ b/spec/jobs/song_media_bundle_job_spec.rb @@ -5,7 +5,7 @@ RSpec.describe SongMediaBundleJob do fixtures :all - it 'generates the zip file and sets the required attributes on the download' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + it 'generates the zip file and sets the required attributes on the download' do song_media_bundle_download = SongMediaBundleDownload.create( song_list: song_lists(:concert1), register: 'bass_2' diff --git a/spec/jobs/song_media_storage_update_job_spec.rb b/spec/jobs/song_media_storage_update_job_spec.rb index 4679b4b..52603e6 100644 --- a/spec/jobs/song_media_storage_update_job_spec.rb +++ b/spec/jobs/song_media_storage_update_job_spec.rb @@ -7,7 +7,7 @@ RSpec.describe SongMediaStorageUpdateJob do fixtures :all - it 'synchronises the Google Drive files to the database' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + it 'synchronises the Google Drive files to the database' do RSpec::Mocks.configuration.allow_message_expectations_on_nil = true allow(SongMediaStorageAccessor).to receive(:new) allow(SongMediaStorageAccessor.instance).to receive(:drive_files).and_return( diff --git a/spec/models/song_list_spec.rb b/spec/models/song_list_spec.rb index cecc793..3898f5b 100644 --- a/spec/models/song_list_spec.rb +++ b/spec/models/song_list_spec.rb @@ -50,7 +50,7 @@ end.to change(song_list, :last_updated_at) end - it 'changes the return value when a song in the list has a new song medium' do # rubocop:disable RSpec/ExampleLength + it 'changes the return value when a song in the list has a new song medium' do song_medium = SongMedium.new( kind: 'choreography_video' ) diff --git a/spec/services/calendar_sync_database_service_spec.rb b/spec/services/calendar_sync_database_service_spec.rb index 65d065d..2790699 100644 --- a/spec/services/calendar_sync_database_service_spec.rb +++ b/spec/services/calendar_sync_database_service_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength, RSpec/NestedGroups +# rubocop:disable RSpec/NestedGroups require 'rails_helper' From 2aa13aa440391f88b1c14a12016c161a6f0e782c Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Thu, 24 Sep 2026 20:25:28 +0200 Subject: [PATCH 20/25] WIP: more specs --- app/models/song_media_storage_accessor.rb | 16 ++++---- app/models/song_media_storage_entry.rb | 6 --- .../song_media_storage_accessor_spec.rb | 39 +++++++++++++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 spec/models/song_media_storage_accessor_spec.rb diff --git a/app/models/song_media_storage_accessor.rb b/app/models/song_media_storage_accessor.rb index f16b3db..0f7d087 100644 --- a/app/models/song_media_storage_accessor.rb +++ b/app/models/song_media_storage_accessor.rb @@ -18,14 +18,6 @@ def drive_files DriveFiles.new(retrieve_files) end - def retrieve_files(next_page_token = nil) - response = request_files(next_page_token) - - return response.files unless response.next_page_token - - response.files + retrieve_files(response.next_page_token) - end - def download(id) buffer = StringIO.new drive_service.get_file( @@ -36,6 +28,14 @@ def download(id) private + def retrieve_files(next_page_token = nil) + response = request_files(next_page_token) + + return response.files unless response.next_page_token + + response.files + retrieve_files(response.next_page_token) + end + def request_files(next_page_token) drive_service.list_files( q: 'trashed = false', diff --git a/app/models/song_media_storage_entry.rb b/app/models/song_media_storage_entry.rb index 37d5b8c..f94e602 100644 --- a/app/models/song_media_storage_entry.rb +++ b/app/models/song_media_storage_entry.rb @@ -27,10 +27,4 @@ def type_pdf? def type_video? mime_type.starts_with?('video/') end - - private - - def buffer - SongMediaStorageAccessor.instance.download(identifier) - end end diff --git a/spec/models/song_media_storage_accessor_spec.rb b/spec/models/song_media_storage_accessor_spec.rb new file mode 100644 index 0000000..aa2cd90 --- /dev/null +++ b/spec/models/song_media_storage_accessor_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +class FakeDriveService + def list_files(**options); end + def get_file(id, **options); end +end + +Response = Struct.new('Response', :next_page_token, :files) +GoogleDriveFile = Struct.new('GoogleDriveFile', :id, :mime_type, :name, :parents) + +RSpec.describe SongMediaStorageAccessor do + describe '#drive_files' do + it 'requests files from google drive and builds the hierarchy' do + accessor = described_class.instance.tap do |s| + fake_drive_service = FakeDriveService.new + allow(fake_drive_service).to receive(:list_files).and_return( + Response.new( + 'next-page-token', + [GoogleDriveFile.new('id-1', 'mimetype-1', 'name-1', [])] + ), + Response.new( + nil, + [GoogleDriveFile.new('id-2', 'mimetype-2', 'name-2', ['id-1'])] + ) + ) + + allow(s).to receive(:drive_service).and_return(fake_drive_service) + end + + drive_files = accessor.drive_files + expect(drive_files.all_files.length).to eq(2) + expect(drive_files.files_by_id.length).to eq(2) + + expect(drive_files.all_files.second.parent).to eq(drive_files.all_files.first) + end + end +end From 35c91ca2ffbb2e42feaa0dc8f5e8e923a47242b8 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Sun, 27 Sep 2026 15:06:15 +0200 Subject: [PATCH 21/25] Fix failing spec --- .gitignore | 4 ++++ .../{credentials.yml.enc => credentials/production.yml.enc} | 0 config/credentials/test.yml.enc | 1 + spec/models/song_media_storage_accessor_spec.rb | 6 +++++- 4 files changed, 10 insertions(+), 1 deletion(-) rename config/{credentials.yml.enc => credentials/production.yml.enc} (100%) create mode 100644 config/credentials/test.yml.enc diff --git a/.gitignore b/.gitignore index e56c5a1..119beee 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ config/registry-token .DS_Store coverage erd.pdf + +# Ignore key files for decrypting credentials and more. +/config/credentials/*.key + diff --git a/config/credentials.yml.enc b/config/credentials/production.yml.enc similarity index 100% rename from config/credentials.yml.enc rename to config/credentials/production.yml.enc diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc new file mode 100644 index 0000000..8a1fb30 --- /dev/null +++ b/config/credentials/test.yml.enc @@ -0,0 +1 @@ +uIzQzIbU1oY9qsXMXF9JETmph/AIENbPdSzbBAWTaz5oMXU0iJoarvvqBhcJQ962SiO5Hx34VNNi0mL3SUe+3Trb5lr3bl+AVnL+YWfLR4HZrNlfKH4kW5IFdi5qoMonfA75juOGrqhZZfBmko4o7JBNFh0mkYHSwcRryy51jAlE3KiacA2RxQlE/INVOWB+Z1Ctw9/C97gYE1yKHIyz3COe9AA529oKvjqNch4ZbamMVGadZCuOGaS0wcgsu9VCOpFwbDKKKFa1xRkzRMSql9a/Si/vMt6Ds0NttsVOdh1AaKOlHSX1uz/nQ3hvJpO/uIN/WaIS8LD93X+HgSUQII+NxF01LOpkMgOX3x8sYt1iuJ6M6uVIbmm79Mqax+oPQgf1XZJxouwGTJE+r9ozA+QtI92uUubT3BNZlRU7bPcP3Qi8FpBJbOfUolvsMjMI6u96sK5kPEWAgDXtPoNZoHssuSUqRa/PoIntfNzEpkqkmu+UKzoET7Qs4d84R1wzpaB32FZb4i4pCMHOaHbboKFp1S/TI7xxPPIutK06BMr5gPk/uDcAn3gptA0Xz6ETi/Ntaqp1jQHxRvh43dNVJMOD5a8EDxdDho7YBqzFJmRpWOtVEQnETxB3iqCFYf4AWgJTj4LGbDOnTr/6pqhF22MuAG1g29elFreJasxJpPc4r/KHPdGruyeVnKqVvoTgVHKlMgLAWoL0N6LCqdzfBYcEATE6VexTZ4OQrskEtQFD93h3gfDp1YHV5BlycqPXpYPCYjeXoonwlVznduj+wktO9/93wYlxWT1pdFG7c+tGCmMkMXlta4hCzfQa0mN2xn3BdwPdamjZjCiD1LV4yAwjSxYK4V2oGoLZAcB+MuU4T+fYO4qPpWFSyR54JX31vc8n3d6EbkHfeeA1zVj0yp5KGDpFkfgB3GBe1jTOcobQEdMf+tPqAUMkDgEUoOcg3hzRFRTJbPwSSQa5lR7TiNF6D1T80CvS6jExp6eEJECsF2UZIKx/4DjcUcKboxPjR86Ccd36WAQyw7ErPqyRTrKPqhYky7YpNrF76auv6S7CgGJpOg0uwButUbxAOjJYHszqv0iC+ThGy/vea7U0JhXcjhsHHtSkqXDLhnqC/tD2tH7HEX9Oucf/Jf7VmB+FROkXoy/Uu1HhqKohaAYW6rPcg6ExmMF9RxIwCyPvCA3J9MOpaTGuR3IV1wHsUIw6gfKkXYt2MbzyqM9ZNZNkYtrEXJMV3++gowNUXw0Fb+FRm0xqwg3OWmPQBovCMZOESAJlGut+M5IA9Yqmv/RLBMKFi3vAQ+82S32RZdo1UGcXwwLYa5dwoOjDEAXYlGTFG9teE91mH284UuX7eK1ENCzXYBWeJQDqRv9GBbL33xYnzg+zhmo4LPxvm/U/+WjZv2jnJoTMofSudEvhUdah6CpXvdM/KMDnL5N2RPL07lX+GfX20R5NBQvVo8TabL5S2V5HyL9lTvAyra6+/boW2dXKru9VP2xA5GNRLg0OuFkObH9eebiBohHHZf9xApMQ95LPN09sT/JEAvEqtNk1NDzUBBf5Fx2qHbYhFyJtZrtAMgoWYOswx6RlMwJex+e9fJbPFjHcgMNnB69zm0Y+hZfEilr+rbdBeBU=--Jj+0WVAi4SBuU9EN--sv2vUGoWdM4Dg2Aht+QDmg== \ No newline at end of file diff --git a/spec/models/song_media_storage_accessor_spec.rb b/spec/models/song_media_storage_accessor_spec.rb index aa2cd90..1bfed88 100644 --- a/spec/models/song_media_storage_accessor_spec.rb +++ b/spec/models/song_media_storage_accessor_spec.rb @@ -7,10 +7,14 @@ def list_files(**options); end def get_file(id, **options); end end +class SongMediaStorageAccessorWithoutSetup < SongMediaStorageAccessor + def initialize; end # rubocop:disable Lint/MissingSuper +end + Response = Struct.new('Response', :next_page_token, :files) GoogleDriveFile = Struct.new('GoogleDriveFile', :id, :mime_type, :name, :parents) -RSpec.describe SongMediaStorageAccessor do +RSpec.describe SongMediaStorageAccessorWithoutSetup do # rubocop:disable RSpec/SpecFilePathFormat describe '#drive_files' do it 'requests files from google drive and builds the hierarchy' do accessor = described_class.instance.tap do |s| From 5e63d0768d1aae1976c2576beb8968b88538ba3e Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Sun, 27 Sep 2026 15:34:07 +0200 Subject: [PATCH 22/25] Add more specs --- .../requests/user_statuses_controller_spec.rb | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 spec/requests/user_statuses_controller_spec.rb diff --git a/spec/requests/user_statuses_controller_spec.rb b/spec/requests/user_statuses_controller_spec.rb new file mode 100644 index 0000000..0731527 --- /dev/null +++ b/spec/requests/user_statuses_controller_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +require_relative '../features/log_in_helpers' + +RSpec.describe UserStatusesController do + let(:phips) { users(:phips) } + let(:user) { users(:fabienne) } + let(:user_status) { user_statuses(:fabienne) } + + before do + passwordless_sign_in(phips) + end + + describe '#new' do + it 'renders the new template successfully' do + get "/users/#{user.id}/user_statuses/new" + expect(response).to have_http_status(:ok) + expect(response.body).to include('Neuer Benutzerstatus') + end + end + + describe '#edit' do + it 'renders the edit template successfully' do + get "/users/#{user.id}/user_statuses/#{user_status.id}/edit" + expect(response).to have_http_status(:ok) + expect(response.body).to include('Benutzerstatus bearbeiten') + end + end + + describe '#create' do + describe 'with correct parameters' do + let(:params) do + { user_status: { user_id: user.id, status: 'active', from_date: '2024-01-01', to_date: '2024-12-31' } } + end + + it 'creates a record and redirects successfully' do + expect do + post "/users/#{user.id}/user_statuses", params: params + expect(response).to redirect_to(edit_user_path(user)) + end.to change(UserStatus, :count).by(1) + end + end + + describe 'with incorrect date order' do + let(:params) do + { user_status: { user_id: user.id, status: 'active', from_date: '2024-12-31', to_date: '2024-01-01' } } + end + + it 'creates a record and redirects successfully' do + expect do + post "/users/#{user.id}/user_statuses", params: params + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Enddatum muss größer als 2024-12-31 sein') + end.not_to change(UserStatus, :count) + end + end + end + + describe '#update' do + describe 'with correct parameters' do + let(:params) { { user_status: { status: 'paused' } } } + + it 'updates a record and redirects successfully' do + put "/users/#{user.id}/user_statuses/#{user_status.id}", params: params + expect(response).to redirect_to(edit_user_path(user)) + end + end + + describe 'with missing status' do + let(:params) { { user_status: { status: '' } } } + + it 'updates a record and redirects successfully' do + put "/users/#{user.id}/user_statuses/#{user_status.id}", params: params + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Status muss ausgefüllt werden') + end + end + end + + describe '#destroy' do + it 'destroys an existing record' do + delete "/users/#{user.id}/user_statuses/#{user_status.id}" + expect(response).to redirect_to(edit_user_path(user)) + end + end +end From bc6e28288352d8c3774064533d3d608370b1dfc2 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Sun, 27 Sep 2026 16:22:23 +0200 Subject: [PATCH 23/25] More specs, some fixes --- app/controllers/song_list_items_controller.rb | 8 -- app/models/song_list_item.rb | 4 + config/routes.rb | 2 +- .../song_list_items_controller_spec.rb | 94 +++++++++++++++++++ spec/requests/song_lists_controller_spec.rb | 2 +- 5 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 spec/requests/song_list_items_controller_spec.rb diff --git a/app/controllers/song_list_items_controller.rb b/app/controllers/song_list_items_controller.rb index 26c39cd..253a4fc 100644 --- a/app/controllers/song_list_items_controller.rb +++ b/app/controllers/song_list_items_controller.rb @@ -1,14 +1,6 @@ # frozen_string_literal: true class SongListItemsController < ApplicationController - def index - @song_list_items = policy_scope(SongListItem) - end - - def show - @song_list_item = policy_scope(SongListItem).find(params.expect(:id)) - end - def new @song_list_item = authorize SongListItem.new(song_list_id: params[:song_list_id]) end diff --git a/app/models/song_list_item.rb b/app/models/song_list_item.rb index 7e03eeb..749a4be 100644 --- a/app/models/song_list_item.rb +++ b/app/models/song_list_item.rb @@ -8,6 +8,10 @@ class SongListItem < ApplicationRecord scope :has_song, -> { where.not(song: nil) } + validates :notes, presence: { if: [-> { song.blank? }, -> { name.blank? }] } + validates :name, presence: { if: [-> { song.blank? }, -> { notes.blank? }] } + validates :song, presence: { if: [-> { name.blank? }, -> { notes.blank? }] } + def display_name return name if name.present? diff --git a/config/routes.rb b/config/routes.rb index fc299f7..e28ee13 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,7 +39,7 @@ end resources :song_lists do - resources :song_list_items + resources :song_list_items, except: %i[index show] resources :song_media_bundle_downloads, only: %i[create show] resources :programs, only: %i[create destroy] diff --git a/spec/requests/song_list_items_controller_spec.rb b/spec/requests/song_list_items_controller_spec.rb new file mode 100644 index 0000000..841b280 --- /dev/null +++ b/spec/requests/song_list_items_controller_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +require_relative '../features/log_in_helpers' + +RSpec.describe SongListItemsController do + let(:phips) { users(:phips) } + let(:concert1) { song_lists(:concert1) } + let(:haerlig) { song_list_items(:concert1_härlig_är_jorden) } + let(:joyful) { songs(:joyful_joyful) } + + before do + passwordless_sign_in(phips) + end + + describe '#new' do + it 'renders the new template successfully' do + get "/song_lists/#{concert1.id}/song_list_items/new" + expect(response).to have_http_status(:ok) + expect(response.body).to include('Listeneintrag erstellen') + end + end + + describe '#edit' do + it 'renders the edit template successfully' do + get "/song_lists/#{concert1.id}/song_list_items/#{haerlig.id}/edit" + expect(response).to have_http_status(:ok) + expect(response.body).to include('Listeneintrag bearbeiten') + end + end + + describe '#create' do + describe 'with correct parameters' do + let(:params) do + { song_list_item: { name: '', song_list_id: concert1.id, song_id: joyful.id, position: 2 } } + end + + it 'creates a record and redirects successfully' do + expect do + post "/song_lists/#{concert1.id}/song_list_items", params: params + expect(response).to redirect_to(song_list_path(concert1)) + end.to change(SongListItem, :count).by(1) + end + end + + describe 'with missing notes' do + let(:params) do + { song_list_item: { name: '', song_list_id: concert1.id, song_id: '', position: 2 } } + end + + it 'renders an error' do + expect do + post "/song_lists/#{concert1.id}/song_list_items", params: params + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Notizen muss ausgefüllt werden') + end.not_to change(SongList, :count) + end + end + end + + describe '#update' do + describe 'with correct parameters' do + let(:params) do + { song_list_item: { name: 'Another name', song_list_id: concert1.id, song_id: haerlig.id, + notes: 'Here be notes.' } } + end + + it 'updates a record and redirects successfully' do + put "/song_lists/#{concert1.id}/song_list_items/#{haerlig.id}", params: params + expect(response).to redirect_to(song_list_path(concert1.id)) + end + end + + describe 'with missing notes' do + let(:params) do + { song_list_item: { name: '', song_list_id: concert1.id, song_id: '', position: 2 } } + end + + it 'updates a record and redirects successfully' do + put "/song_lists/#{concert1.id}/song_list_items/#{haerlig.id}", params: params + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Notizen muss ausgefüllt werden') + end + end + end + + describe '#destroy' do + it 'destroys an existing record' do + delete "/song_lists/#{concert1.id}/song_list_items/#{haerlig.id}" + expect(response).to redirect_to(song_list_path(concert1.id)) + end + end +end diff --git a/spec/requests/song_lists_controller_spec.rb b/spec/requests/song_lists_controller_spec.rb index 4b43982..22664fd 100644 --- a/spec/requests/song_lists_controller_spec.rb +++ b/spec/requests/song_lists_controller_spec.rb @@ -60,7 +60,7 @@ describe 'with missing name' do let(:params) { { song_list: { name: '', status: 'in_preparation' } } } - it 'creates a record and redirects successfully' do + it 'renders an error' do expect do post '/song_lists', params: params expect(response).to have_http_status(:unprocessable_content) From 47e72e43a94f184fcc458de91ec89c94e7776a7d Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Mon, 28 Sep 2026 19:06:35 +0200 Subject: [PATCH 24/25] Add spec --- spec/fixtures/calendar_events.yml | 14 +++++++++ spec/fixtures/programs.yml | 3 ++ spec/fixtures/song_lists.yml | 4 +++ spec/requests/programs_controller_spec.rb | 38 +++++++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 spec/fixtures/programs.yml create mode 100644 spec/requests/programs_controller_spec.rb diff --git a/spec/fixtures/calendar_events.yml b/spec/fixtures/calendar_events.yml index 680e028..893d2b4 100644 --- a/spec/fixtures/calendar_events.yml +++ b/spec/fixtures/calendar_events.yml @@ -4,3 +4,17 @@ choir_practice: starts_at: <%= 1.day.from_now.change(hour: 20) %> ends_at: <%= 1.day.from_now.change(hour: 21, minute: 50) %> summary: Reguläre Chorprobe + +choir_concert: + uid: random2 + event_created_at: <%= Time.zone.now %> + starts_at: <%= 1.week.from_now.change(hour: 16) %> + ends_at: <%= 1.week.from_now.change(hour: 18) %> + summary: Konzert + +christmas_concert: + uid: random3 + event_created_at: <%= Time.zone.now %> + starts_at: <%= Date.parse('2026-12-13 20:00:00') %> + ends_at: <%= Date.parse('2026-12-13 22:00:00') %> + summary: Weihnachtskonzert diff --git a/spec/fixtures/programs.yml b/spec/fixtures/programs.yml new file mode 100644 index 0000000..8af1206 --- /dev/null +++ b/spec/fixtures/programs.yml @@ -0,0 +1,3 @@ +program1: + calendar_event: choir_concert + song_list: concert1 diff --git a/spec/fixtures/song_lists.yml b/spec/fixtures/song_lists.yml index 1e15e0a..fa00b94 100644 --- a/spec/fixtures/song_lists.yml +++ b/spec/fixtures/song_lists.yml @@ -1,3 +1,7 @@ concert1: name: Concert 1 status: active + +christmas_concert: + name: Weihnachtskonzert + status: in_preparation diff --git a/spec/requests/programs_controller_spec.rb b/spec/requests/programs_controller_spec.rb new file mode 100644 index 0000000..5e2b228 --- /dev/null +++ b/spec/requests/programs_controller_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +require_relative '../features/log_in_helpers' + +RSpec.describe ProgramsController do + let(:concert1) { song_lists(:concert1) } + let(:christmas_concert) { song_lists(:christmas_concert) } + let(:program1) { programs(:program1) } + let(:christmas_concert_event) { calendar_events(:christmas_concert) } + + before do + passwordless_sign_in(users(:phips)) + end + + describe '#create' do + describe 'with correct parameters' do + let(:params) do + { program: { calendar_event_id: christmas_concert_event.id, song_list_id: christmas_concert.id } } + end + + it 'creates a record and redirects successfully' do + expect do + post "/song_lists/#{christmas_concert.id}/programs", params: params + expect(response).to redirect_to(edit_song_list_path(christmas_concert.id)) + end.to change(Program, :count).by(1) + end + end + end + + describe '#destroy' do + it 'destroys an existing record' do + delete "/song_lists/#{concert1.id}/programs/#{program1.id}" + expect(response).to redirect_to(edit_song_list_path(program1.song_list.id)) + end + end +end From 3d475c57f4bbae6201391df1c3ec747f0e9857d1 Mon Sep 17 00:00:00 2001 From: Ferdinand Niedermann Date: Mon, 28 Sep 2026 19:22:38 +0200 Subject: [PATCH 25/25] Fix specs --- spec/features/attendances/make_excuse_spec.rb | 14 ++++++++------ spec/fixtures/calendar_events.yml | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/spec/features/attendances/make_excuse_spec.rb b/spec/features/attendances/make_excuse_spec.rb index 7dbc7a0..0b95b52 100644 --- a/spec/features/attendances/make_excuse_spec.rb +++ b/spec/features/attendances/make_excuse_spec.rb @@ -17,11 +17,13 @@ scenario do log_in_with_magic_link(users(:uwe)) expect(page).to have_text('my upcoming event') - click_on 'Entschuldigung erfassen' - expect(page).to have_text('Entschuldigung erfassen') - fill_in 'Entschuldigung', with: "Ich werde leider keine Lust haben.\nGruss Uwe" - click_on 'Entschuldigung speichern' - expect(page).to have_text('Entschuldigung erfolgreich erfasst.') - expect(page).to have_no_text('Entschuldigung erfassen') + expect do + create_excuse_links = page.all('a', text: 'Entschuldigung erfassen') + create_excuse_links.first.click + expect(page).to have_text('Entschuldigung erfassen') + fill_in 'Entschuldigung', with: "Ich werde leider keine Lust haben.\nGruss Uwe" + click_on 'Entschuldigung speichern' + expect(page).to have_text('Entschuldigung erfolgreich erfasst.') + end.to change { page.all('a', text: 'Entschuldigung erfassen').size }.by(-1) end end diff --git a/spec/fixtures/calendar_events.yml b/spec/fixtures/calendar_events.yml index 893d2b4..38ce0c2 100644 --- a/spec/fixtures/calendar_events.yml +++ b/spec/fixtures/calendar_events.yml @@ -6,14 +6,14 @@ choir_practice: summary: Reguläre Chorprobe choir_concert: - uid: random2 + uid: random3 event_created_at: <%= Time.zone.now %> starts_at: <%= 1.week.from_now.change(hour: 16) %> ends_at: <%= 1.week.from_now.change(hour: 18) %> summary: Konzert christmas_concert: - uid: random3 + uid: random4 event_created_at: <%= Time.zone.now %> starts_at: <%= Date.parse('2026-12-13 20:00:00') %> ends_at: <%= Date.parse('2026-12-13 22:00:00') %>