relay.app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE +17 -0
  4. data/README.md +132 -0
  5. data/app/concerns/attachment.rb +12 -0
  6. data/app/concerns/context.rb +147 -0
  7. data/app/concerns/roda.rb +50 -0
  8. data/app/concerns/view.rb +90 -0
  9. data/app/forms/mcp/forgejo.rb +55 -0
  10. data/app/forms/mcp/github.rb +47 -0
  11. data/app/forms/mcp.rb +89 -0
  12. data/app/hooks/require_user.rb +10 -0
  13. data/app/init/database.rb +36 -0
  14. data/app/init/env.rb +21 -0
  15. data/app/init/router.rb +164 -0
  16. data/app/models/context.rb +82 -0
  17. data/app/models/mcp/preset.rb +60 -0
  18. data/app/models/mcp.rb +165 -0
  19. data/app/models/model_record.rb +70 -0
  20. data/app/models/song.rb +11 -0
  21. data/app/models/user.rb +31 -0
  22. data/app/pages/base.rb +25 -0
  23. data/app/pages/chat.rb +18 -0
  24. data/app/pages/mcp.rb +12 -0
  25. data/app/pages/sign_in.rb +14 -0
  26. data/app/prompts/system.md +129 -0
  27. data/app/resources/jukebox.yml +90 -0
  28. data/app/routes/base.rb +36 -0
  29. data/app/routes/clear_attachment.rb +13 -0
  30. data/app/routes/list_chat.rb +11 -0
  31. data/app/routes/list_contexts.rb +17 -0
  32. data/app/routes/list_controls.rb +11 -0
  33. data/app/routes/list_mcp.rb +16 -0
  34. data/app/routes/list_models.rb +14 -0
  35. data/app/routes/list_providers.rb +11 -0
  36. data/app/routes/list_tools.rb +13 -0
  37. data/app/routes/mcp/base.rb +16 -0
  38. data/app/routes/mcp/create.rb +19 -0
  39. data/app/routes/mcp/delete.rb +17 -0
  40. data/app/routes/mcp/form.rb +11 -0
  41. data/app/routes/mcp/new.rb +16 -0
  42. data/app/routes/mcp/show.rb +17 -0
  43. data/app/routes/mcp/toggle.rb +17 -0
  44. data/app/routes/mcp/update.rb +20 -0
  45. data/app/routes/settings/new_context.rb +23 -0
  46. data/app/routes/settings/set_context.rb +26 -0
  47. data/app/routes/settings/set_model.rb +23 -0
  48. data/app/routes/settings/set_provider.rb +38 -0
  49. data/app/routes/sign_in.rb +39 -0
  50. data/app/routes/upload_attachment.rb +35 -0
  51. data/app/routes/websocket/connection.rb +247 -0
  52. data/app/routes/websocket/interrupt.rb +25 -0
  53. data/app/routes/websocket/stream.rb +46 -0
  54. data/app/routes/websocket.rb +62 -0
  55. data/app/tools/add_song.rb +27 -0
  56. data/app/tools/juke_box.rb +41 -0
  57. data/app/tools/relay_knowledge.rb +59 -0
  58. data/app/tools/remove_song.rb +53 -0
  59. data/app/validators/mcp.rb +42 -0
  60. data/app/views/fragments/_append_message.erb +1 -0
  61. data/app/views/fragments/_chat.erb +15 -0
  62. data/app/views/fragments/_contexts.erb +7 -0
  63. data/app/views/fragments/_contexts_body.erb +35 -0
  64. data/app/views/fragments/_controls.erb +15 -0
  65. data/app/views/fragments/_iframe.erb +8 -0
  66. data/app/views/fragments/_input.erb +67 -0
  67. data/app/views/fragments/_mcp_settings.erb +52 -0
  68. data/app/views/fragments/_message.erb +31 -0
  69. data/app/views/fragments/_models.erb +25 -0
  70. data/app/views/fragments/_providers.erb +26 -0
  71. data/app/views/fragments/_remove_empty_state.erb +1 -0
  72. data/app/views/fragments/_replace_last_message.erb +1 -0
  73. data/app/views/fragments/_sidebar_menu.erb +11 -0
  74. data/app/views/fragments/_sidebar_status.erb +21 -0
  75. data/app/views/fragments/_status.erb +40 -0
  76. data/app/views/fragments/_stream.erb +26 -0
  77. data/app/views/fragments/_tools.erb +34 -0
  78. data/app/views/fragments/_tools_panel.erb +4 -0
  79. data/app/views/fragments/mcp/_editor.erb +54 -0
  80. data/app/views/fragments/mcp/_fields_forgejo.erb +16 -0
  81. data/app/views/fragments/mcp/_fields_github.erb +12 -0
  82. data/app/views/fragments/mcp/_list.erb +55 -0
  83. data/app/views/fragments/mcp/_workspace.erb +14 -0
  84. data/app/views/fragments/models/_loading.erb +4 -0
  85. data/app/views/fragments/settings/_chat.erb +1 -0
  86. data/app/views/fragments/settings/_input.erb +1 -0
  87. data/app/views/fragments/settings/_replace_contexts.erb +1 -0
  88. data/app/views/fragments/settings/_workspace.erb +4 -0
  89. data/app/views/layout.erb +19 -0
  90. data/app/views/pages/chat.erb +13 -0
  91. data/app/views/pages/mcps.erb +10 -0
  92. data/app/views/pages/sign_in.erb +45 -0
  93. data/app/views/partials/_sidebar.erb +24 -0
  94. data/bin/relay +38 -0
  95. data/config.ru +21 -0
  96. data/db/migrate/20260319131927_create_users.rb +12 -0
  97. data/db/migrate/20260327000000_create_contexts.rb +20 -0
  98. data/db/migrate/20260426130000_create_mcps.rb +19 -0
  99. data/db/migrate/20260426170000_create_model_infos.rb +20 -0
  100. data/db/migrate/20260503120000_create_songs.rb +17 -0
  101. data/db/migrate/20260503153000_drop_chat_from_model_infos.rb +8 -0
  102. data/db/migrate/20260503160000_rename_model_infos_to_model_records.rb +5 -0
  103. data/db/seeds.rb +13 -0
  104. data/lib/relay/attachment/session.rb +154 -0
  105. data/lib/relay/attachment.rb +55 -0
  106. data/lib/relay/cache/in_memory_cache.rb +60 -0
  107. data/lib/relay/cache.rb +5 -0
  108. data/lib/relay/jukebox.rb +96 -0
  109. data/lib/relay/markdown.rb +45 -0
  110. data/lib/relay/model.rb +12 -0
  111. data/lib/relay/reloader.rb +29 -0
  112. data/lib/relay/task.rb +66 -0
  113. data/lib/relay/task_monitor.rb +80 -0
  114. data/lib/relay/test.rb +11 -0
  115. data/lib/relay/theme.rb +5 -0
  116. data/lib/relay/tool.rb +12 -0
  117. data/lib/relay/version.rb +5 -0
  118. data/lib/relay.rb +183 -0
  119. data/libexec/relay/bootstrap +10 -0
  120. data/libexec/relay/configure +100 -0
  121. data/libexec/relay/migrate +7 -0
  122. data/libexec/relay/setup +10 -0
  123. data/libexec/relay/start +31 -0
  124. data/public/.gitkeep +0 -0
  125. data/public/images/relay.png +0 -0
  126. data/public/js/relay.js +68669 -0
  127. data/public/js/relay.js.map +1 -0
  128. data/public/stylesheets/application.css +2292 -0
  129. data/public/stylesheets/application.css.map +1 -0
  130. metadata +465 -0
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= title %></title>
7
+ <link rel="icon" href="/images/favicon.ico" sizes="any">
8
+ <link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
9
+ <link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
10
+ <link rel="icon" type="image/png" sizes="48x48" href="/images/favicon-48x48.png">
11
+ <link rel="icon" type="image/png" sizes="64x64" href="/images/favicon-64x64.png">
12
+ <link rel="apple-pptouch-icon" href="/images/apple-touch-icon.png">
13
+ <link rel="stylesheet" href="/stylesheets/application.css">
14
+ <script src="/js/relay.js"></script>
15
+ </head>
16
+ <body data-theme="<%= Relay::THEME %>" class="font-sans">
17
+ <%== yield %>
18
+ </body>
19
+ </html>
@@ -0,0 +1,13 @@
1
+ <main class="app-shell h-dvh overflow-hidden font-sans">
2
+ <div id="htmx-activity" class="activity-indicator" aria-live="polite" aria-busy="false">
3
+ <span class="activity-indicator__dot"></span>
4
+ <span>Loading…</span>
5
+ </div>
6
+ <div id="htmx-activity-bar" class="activity-bar" aria-hidden="true"></div>
7
+ <div class="workspace-frame mx-auto flex h-full min-h-0 w-full max-w-none flex-col gap-4 px-4 py-5 lg:flex-row lg:gap-6 sm:px-5">
8
+ <div id="workspace-main" class="flex min-h-0 min-w-0 flex-1 gap-4 lg:gap-5">
9
+ <%== partial("partials/sidebar", locals: {show_controls: true}) %>
10
+ <%== partial("fragments/chat", locals: {messages:, swap_oob: false}) %>
11
+ </div>
12
+ </div>
13
+ </main>
@@ -0,0 +1,10 @@
1
+ <main class="app-shell h-dvh overflow-hidden font-sans">
2
+ <div class="workspace-frame mx-auto flex h-full min-h-0 w-full max-w-none flex-col gap-4 px-4 py-5 lg:flex-row lg:gap-6 sm:px-5">
3
+ <div class="flex min-h-0 min-w-0 flex-1 gap-4 lg:gap-5">
4
+ <%== partial("partials/sidebar", locals: {show_controls: true}) %>
5
+ <section class="workspace-chat">
6
+ <%== partial("fragments/mcp/workspace", locals: {form:, selected_id:, mcps: mcps}) %>
7
+ </section>
8
+ </div>
9
+ </div>
10
+ </main>
@@ -0,0 +1,45 @@
1
+ <main class="auth-shell app-shell min-h-screen px-4 py-6 sm:px-6">
2
+ <div class="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-md items-center justify-center">
3
+ <section class="auth-panel w-full px-6 py-8 sm:px-8">
4
+ <div class="w-full space-y-8">
5
+ <a
6
+ href="https://www.youtube.com/watch?v=CyzdOtyYnng"
7
+ target="_blank"
8
+ rel="noreferrer noopener"
9
+ aria-label="Relay home"
10
+ class="block border-b pb-6 text-center"
11
+ style="border-color: var(--theme-panel-border);"
12
+ >
13
+ <img
14
+ class="mx-auto max-h-14 w-auto max-w-[10rem] rounded-2xl shadow-sm"
15
+ src="/images/relay.png"
16
+ alt="Relay"
17
+ >
18
+ </a>
19
+
20
+ <div class="space-y-2 text-center">
21
+ <p class="label">Relay</p>
22
+ <p class="mx-auto max-w-xs text-sm leading-6 text-[color:var(--theme-muted)]">
23
+ Welcome to relay
24
+ </p>
25
+ </div>
26
+
27
+ <form class="space-y-5" method="post" action="/sign-in">
28
+ <div class="space-y-2.5">
29
+ <label class="label" for="email">Email</label>
30
+ <input class="field auth-field" id="email" name="email" type="email" placeholder="you@example.com" autocomplete="email">
31
+ </div>
32
+
33
+ <div class="space-y-2.5">
34
+ <label class="label" for="password">Password</label>
35
+ <input class="field auth-field" id="password" name="password" type="password" placeholder="Enter your password" autocomplete="current-password">
36
+ </div>
37
+
38
+ <button class="auth-button" type="submit">
39
+ Sign in
40
+ </button>
41
+ </form>
42
+ </div>
43
+ </section>
44
+ </div>
45
+ </main>
@@ -0,0 +1,24 @@
1
+ <aside class="hidden shrink-0 self-start lg:sticky lg:top-5 lg:flex lg:w-[17rem] lg:flex-col">
2
+ <div class="workspace-rail flex h-[calc(100vh-2.5rem)] min-h-0 flex-col px-4 py-4">
3
+ <div class="sidebar-header flex w-full justify-center">
4
+ <a
5
+ href="https://www.youtube.com/watch?v=CyzdOtyYnng"
6
+ target="_blank"
7
+ rel="noreferrer noopener"
8
+ aria-label="Relay home"
9
+ class="flex items-center justify-center"
10
+ >
11
+ <img
12
+ class="h-10 w-10 rounded-2xl shadow-sm"
13
+ src="/images/relay.png"
14
+ alt="Relay"
15
+ >
16
+ </a>
17
+ </div>
18
+ <% if show_controls %>
19
+ <div class="sidebar-body mt-5 min-h-0 flex-1">
20
+ <%== partial("fragments/controls") %>
21
+ </div>
22
+ <% end %>
23
+ </div>
24
+ </aside>
data/bin/relay ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ def wait
5
+ Process.wait
6
+ rescue Interrupt
7
+ retry
8
+ end
9
+
10
+ def libexec
11
+ File.realpath File.join(__dir__, "..", "libexec", "relay")
12
+ end
13
+
14
+ def main(argv)
15
+ case argv[0]
16
+ when "setup"
17
+ Process.spawn File.join(libexec, "setup"), *argv[1..]
18
+ Process.wait
19
+ when "migrate"
20
+ Process.spawn File.join(libexec, "migrate"), *argv[1..]
21
+ Process.wait
22
+ when "start"
23
+ Process.spawn File.join(libexec, "start"), *argv[1..]
24
+ Process.wait
25
+ else
26
+ require "relay"
27
+ warn Relay.banner
28
+ warn "Usage: relay [COMMAND] [OPTIONS]\n\n" \
29
+ "Commands:\n" \
30
+ " setup Setup Relay for the first time\n" \
31
+ " start Start Relay\n" \
32
+ " migrate Run database migrations\n"
33
+ end
34
+ rescue Interrupt
35
+ wait
36
+ end
37
+
38
+ main(ARGV)
data/config.ru ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT"] ||= (64 * 1024 * 1024).to_s
4
+
5
+ require_relative "app/init"
6
+
7
+ use Rack::Static, urls: ["/g"], root: Relay.home
8
+ use Rack::Static, urls: ["/images", "/stylesheets", "/js"], root: Relay.public_dir
9
+ case Relay.environment
10
+ when "development"
11
+ use Rack::Reloader
12
+ use Rack::Lint
13
+ use Rack::TempfileReaper
14
+ use Rack::ContentLength
15
+ use Rack::ETag
16
+ use Rack::ConditionalGet
17
+ use Rack::Head
18
+ use Relay::Reloader
19
+ end
20
+
21
+ run Relay::Router
@@ -0,0 +1,12 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:users) do
4
+ primary_key :id
5
+ String :name, null: false
6
+ String :email, null: false
7
+ String :password_digest, null: false
8
+
9
+ index :email, unique: true
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:contexts) do
4
+ primary_key :id
5
+ foreign_key :user_id, :users, null: false, on_delete: :cascade
6
+ String :model, null: false
7
+ String :provider, null: false
8
+ Integer :input_tokens, default: 0
9
+ Integer :output_tokens, default: 0
10
+ Integer :total_tokens, default: 0
11
+ column :data, :json, null: false, default: Sequel.lit("'{}'")
12
+
13
+ DateTime :created_at, null: false
14
+ DateTime :updated_at, null: false
15
+
16
+ index :user_id
17
+ index :created_at
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:mcps) do
4
+ primary_key :id
5
+ foreign_key :user_id, :users, null: false, on_delete: :cascade
6
+ String :name, null: false
7
+ String :description, text: true
8
+ String :transport, null: false, default: "stdio"
9
+ TrueClass :enabled, null: false, default: true
10
+ column :data, :json, null: false, default: Sequel.lit("'{}'")
11
+
12
+ DateTime :created_at, null: false
13
+ DateTime :updated_at, null: false
14
+
15
+ index :user_id
16
+ index [:user_id, :enabled]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:model_infos) do
4
+ primary_key :id
5
+ String :provider, null: false
6
+ String :model_id, null: false
7
+ String :name, null: false
8
+ TrueClass :chat, null: false, default: false
9
+ column :data, :json, null: false, default: Sequel.lit("'{}'")
10
+
11
+ DateTime :synced_at, null: false
12
+ DateTime :created_at, null: false
13
+ DateTime :updated_at, null: false
14
+
15
+ index :provider
16
+ index [:provider, :chat]
17
+ index [:provider, :model_id], unique: true
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:songs) do
4
+ primary_key :id
5
+ String :name, null: false
6
+ String :title, null: false
7
+ String :track, null: false
8
+
9
+ DateTime :created_at, null: false
10
+ DateTime :updated_at, null: false
11
+
12
+ index :name
13
+ index :title
14
+ index :track, unique: true
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ Sequel.migration do
2
+ change do
3
+ alter_table(:model_infos) do
4
+ drop_index [:provider, :chat]
5
+ drop_column :chat
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ Sequel.migration do
2
+ change do
3
+ rename_table :model_infos, :model_records
4
+ end
5
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../app/init"
3
+ require "yaml"
4
+
5
+ if Relay::Models::Song.count.zero?
6
+ YAML.safe_load_file(File.join(Relay.resources_dir, "jukebox.yml"), permitted_classes: [], aliases: false).each do |entry|
7
+ Relay::Models::Song.create(
8
+ name: entry["name"],
9
+ title: entry["title"],
10
+ track: entry["track"]
11
+ )
12
+ end
13
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Relay::Attachment
4
+ class Session
5
+ require "fileutils"
6
+ require "securerandom"
7
+ require "stringio"
8
+
9
+ IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .tiff .tif .heic .heif].freeze
10
+
11
+ ##
12
+ # @param [Hash] session
13
+ # @param [String] root
14
+ # @param [Object, nil] user
15
+ # @param [String, nil] provider
16
+ def initialize(session:, root:, user: nil, provider: nil)
17
+ @session = session
18
+ @root = root
19
+ @user = user
20
+ @provider = provider
21
+ end
22
+
23
+ ##
24
+ # @return [Relay::Attachment]
25
+ def file
26
+ @attachment ||= begin
27
+ value = @session["attachment"]
28
+ attachment = Relay::Attachment.new(
29
+ name: value&.[]("name"),
30
+ path: value&.[]("path"),
31
+ type: value&.[]("type")
32
+ )
33
+ clear unless attachment.file?
34
+ attachment
35
+ end
36
+ end
37
+
38
+ ##
39
+ # @return [String, nil]
40
+ def error
41
+ @session["attachment_error"]
42
+ end
43
+
44
+ ##
45
+ # @param [String] message
46
+ # @return [String]
47
+ def error=(message)
48
+ @session["attachment_error"] = message.to_s
49
+ end
50
+
51
+ ##
52
+ # @return [void]
53
+ def clear_error
54
+ @session.delete("attachment_error")
55
+ end
56
+
57
+ ##
58
+ # @return [String]
59
+ def accept
60
+ case provider
61
+ when "anthropic" then "image/*,application/pdf,.pdf"
62
+ when "openai", "google" then "*/*"
63
+ else ""
64
+ end
65
+ end
66
+
67
+ ##
68
+ # @return [Boolean]
69
+ def supported?
70
+ %w[openai anthropic google].include?(provider)
71
+ end
72
+
73
+ ##
74
+ # @param [String] filename
75
+ # @param [String, nil] type
76
+ # @return [Boolean]
77
+ def type_supported?(filename:, type:)
78
+ return false unless supported?
79
+ return image_upload?(filename, type) || pdf_upload?(filename, type) if provider == "anthropic"
80
+ true
81
+ end
82
+
83
+ ##
84
+ # @return [String]
85
+ def unsupported_message
86
+ return "#{provider} does not support attachments in Relay yet" unless supported?
87
+ "#{provider} attachments must be images or PDFs"
88
+ end
89
+
90
+ ##
91
+ # @param [#read] io
92
+ # @param [String] filename
93
+ # @param [String, nil] type
94
+ # @return [Relay::Attachment]
95
+ def attach(io:, filename:, type:)
96
+ raise ArgumentError, "a file is required" unless io && filename
97
+ FileUtils.mkdir_p(attachments_dir)
98
+ file = Relay::Attachment.new(
99
+ name: sanitize_filename(filename),
100
+ path: File.join(attachments_dir, "#{SecureRandom.hex(8)}-#{sanitize_filename(filename)}"),
101
+ type: type.to_s
102
+ )
103
+ io.rewind if io.respond_to?(:rewind)
104
+ IO.copy_stream(io, file.path)
105
+ @session["attachment"] = file.to_h
106
+ clear_error
107
+ @attachment = file
108
+ end
109
+
110
+ ##
111
+ # @return [Relay::Attachment, nil]
112
+ def consume
113
+ value = @session.delete("attachment")
114
+ return unless Hash === value
115
+ @attachment = Relay::Attachment.new(
116
+ name: value["name"],
117
+ path: value["path"],
118
+ type: value["type"]
119
+ )
120
+ @attachment.file? ? @attachment : nil
121
+ end
122
+
123
+ ##
124
+ # @return [void]
125
+ def clear
126
+ @session.delete("attachment")
127
+ clear_error
128
+ @attachment = Relay::Attachment.new
129
+ end
130
+
131
+ private
132
+
133
+ def attachments_dir
134
+ user_id = @user&.id || "anonymous"
135
+ File.join(@root, "tmp", "uploads", user_id.to_s)
136
+ end
137
+
138
+ def provider
139
+ @provider || "deepseek"
140
+ end
141
+
142
+ def pdf_upload?(filename, type)
143
+ File.extname(filename.to_s).downcase == ".pdf" || type.to_s == "application/pdf"
144
+ end
145
+
146
+ def image_upload?(filename, type)
147
+ type.to_s.start_with?("image/") || IMAGE_EXTENSIONS.include?(File.extname(filename.to_s).downcase)
148
+ end
149
+
150
+ def sanitize_filename(filename)
151
+ File.basename(filename.to_s).gsub(/[^A-Za-z0-9._-]/, "_")
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Relay::Attachment
4
+ require_relative "attachment/session"
5
+
6
+ ##
7
+ # @param [Hash] session
8
+ # @param [String] root
9
+ # @param [Object, nil] user
10
+ # @param [String, nil] provider
11
+ # @return [Relay::Attachment::Session]
12
+ def self.session(session:, root:, user: nil, provider: nil)
13
+ Session.new(session:, root:, user:, provider:)
14
+ end
15
+
16
+ ##
17
+ # @param [String, nil] name
18
+ # @param [String, nil] path
19
+ # @param [String, nil] type
20
+ def initialize(name: nil, path: nil, type: nil)
21
+ @name = name.to_s
22
+ @path = path.to_s
23
+ @type = type.to_s
24
+ end
25
+
26
+ ##
27
+ # @return [String]
28
+ attr_reader :name
29
+
30
+ ##
31
+ # @return [String]
32
+ attr_reader :path
33
+
34
+ ##
35
+ # @return [String]
36
+ attr_reader :type
37
+
38
+ ##
39
+ # @return [Boolean]
40
+ def file?
41
+ !path.empty? && File.file?(path)
42
+ end
43
+
44
+ ##
45
+ # @return [Boolean]
46
+ def attached?
47
+ file?
48
+ end
49
+
50
+ ##
51
+ # @return [Hash<String, String>]
52
+ def to_h
53
+ {"name" => name, "path" => path, "type" => type}
54
+ end
55
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Cache
4
+ ##
5
+ # A small in-process cache that supports dynamic attribute-style access.
6
+ #
7
+ # Missing keys are initialized as nested InMemoryCache instances, which
8
+ # makes it convenient for lightweight grouped state such as cached model
9
+ # lists or provider-specific values.
10
+ class InMemoryCache
11
+ ##
12
+ # @return [Relay::Cache::InMemoryCache]
13
+ def initialize
14
+ @cache = {}
15
+ end
16
+
17
+ ##
18
+ # Handles dynamic cache reads and writes.
19
+ #
20
+ # Setter calls like `cache.models = value` store the value under the
21
+ # corresponding string key. Getter calls return the stored value when
22
+ # present, or initialize a nested InMemoryCache when missing.
23
+ #
24
+ # @param [Symbol] m
25
+ # @param [Array] args
26
+ # @return [Object]
27
+ def method_missing(m, *args, &block)
28
+ if m.to_s.end_with?("=")
29
+ self[m.to_s[0..-2]] = args[0]
30
+ elsif @cache.key?(m.to_s)
31
+ @cache[m.to_s]
32
+ else
33
+ @cache[m.to_s] = InMemoryCache.new
34
+ end
35
+ end
36
+
37
+ ##
38
+ # Respond to missing methods that are handled by method_missing.
39
+ def respond_to_missing?(m, include_private = false)
40
+ true
41
+ end
42
+
43
+ ##
44
+ # Stores a value by key.
45
+ # @param [String,Symbol] k
46
+ # @param [Object] v
47
+ # @return [Object]
48
+ def []=(k, v)
49
+ @cache[k.to_s] = v
50
+ end
51
+
52
+ ##
53
+ # Fetches a value by key.
54
+ # @param [String,Symbol] k
55
+ # @return [Object,nil]
56
+ def [](k)
57
+ @cache[k.to_s]
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Cache
4
+ require_relative "cache/in_memory_cache"
5
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "uri"
5
+
6
+ module Relay
7
+ class Jukebox
8
+ def load
9
+ Relay::Models::Song.order(:id).map { entry(_1) }
10
+ end
11
+
12
+ def normalize_track(url)
13
+ uri = URI.parse(url.to_s.strip)
14
+ host = uri.host.to_s.downcase
15
+ video_id =
16
+ case host
17
+ when "youtu.be"
18
+ uri.path.split("/").reject(&:empty?).first
19
+ when "youtube.com", "www.youtube.com", "m.youtube.com",
20
+ "youtube-nocookie.com", "www.youtube-nocookie.com"
21
+ extract_youtube_id(uri)
22
+ end
23
+ raise ArgumentError, "unsupported YouTube URL" if video_id.to_s.empty?
24
+ "https://www.youtube-nocookie.com/embed/#{video_id}"
25
+ rescue URI::InvalidURIError
26
+ raise ArgumentError, "invalid YouTube URL"
27
+ end
28
+
29
+ def remove(name: nil, title: nil, track: nil)
30
+ normalized_name = name && normalize_text(name)
31
+ normalized_title = title && normalize_text(title)
32
+ normalized_track = track && normalize_track(track)
33
+ raise ArgumentError, "name, title, or track is required" unless [normalized_name, normalized_title, normalized_track].any?
34
+ removed = []
35
+ songs.each do |song|
36
+ entry = entry(song)
37
+ matched = true
38
+ matched &&= normalize_text(entry["name"]) == normalized_name if normalized_name
39
+ matched &&= normalize_text(entry["title"]) == normalized_title if normalized_title
40
+ matched &&= normalize_track(entry["track"]) == normalized_track if normalized_track
41
+ next unless matched
42
+ removed << entry
43
+ song.delete
44
+ end
45
+ {removed: removed.length, entries: removed}
46
+ end
47
+
48
+ def add(name:, title:, track:)
49
+ normalized_track = normalize_track(track)
50
+ entry = {"name" => scrub_text(name), "title" => scrub_text(title), "track" => normalized_track}
51
+ raise ArgumentError, "name is required" if entry["name"].empty?
52
+ raise ArgumentError, "title is required" if entry["title"].empty?
53
+ songs.each do |song|
54
+ existing = entry(song)
55
+ song.delete if same_track?(existing, entry) || same_song?(existing, entry)
56
+ end
57
+ Relay::Models::Song.create(**entry.transform_keys(&:to_sym))
58
+ entry
59
+ end
60
+
61
+ private
62
+
63
+ def extract_youtube_id(uri)
64
+ path = uri.path.to_s
65
+ return path.split("/").reject(&:empty?).last if path.start_with?("/embed/", "/shorts/")
66
+ CGI.parse(uri.query.to_s).fetch("v", []).first
67
+ end
68
+
69
+ def songs
70
+ Relay::Models::Song.order(:id).all
71
+ end
72
+
73
+ def same_track?(left, right)
74
+ normalize_track(left["track"]) == normalize_track(right["track"])
75
+ rescue ArgumentError
76
+ false
77
+ end
78
+
79
+ def same_song?(left, right)
80
+ normalize_text(left["name"]) == normalize_text(right["name"]) &&
81
+ normalize_text(left["title"]) == normalize_text(right["title"])
82
+ end
83
+
84
+ def scrub_text(value)
85
+ value.to_s.strip.gsub(/\s+/, " ")
86
+ end
87
+
88
+ def normalize_text(value)
89
+ scrub_text(value).downcase
90
+ end
91
+
92
+ def entry(song)
93
+ {"name" => song.name, "title" => song.title, "track" => normalize_track(song.track)}
94
+ end
95
+ end
96
+ end