miniradio_server 0.0.3 → 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.
@@ -1,17 +1,22 @@
1
- # -*- coding: utf-8 -*-
2
- require 'rack'
3
- require 'open3' # Used in convert_to_hls
4
- require 'tilt/slim'
5
- require 'mp3info'
6
- require 'json'
1
+ require "rack"
2
+ require "open3" # Used in convert_to_hls
3
+ require "tilt/slim"
4
+ require "mp3info"
5
+ require "json"
6
+ require "uri"
7
+ require "uri/rfc2396_parser"
7
8
 
8
9
  # Required to use the handler from Rack 3+
9
10
  # You might need to run: gem install rackup
10
- require 'rackup/handler/webrick'
11
+ require "rackup/handler/webrick"
11
12
 
12
13
  # Rack application class
13
14
  module MiniradioServer
14
15
  class App
16
+ MAX_ARTWORK_BYTES = 5 * 1024 * 1024
17
+ URL_PARSER = URI::RFC2396_Parser.new
18
+ private_constant :URL_PARSER
19
+
15
20
  def initialize(mp3_dir, cache_dir, ffmpeg_cmd, segment_duration, logger)
16
21
  @mp3_dir = Pathname.new(mp3_dir).realpath
17
22
  @cache_dir = Pathname.new(cache_dir).realpath
@@ -20,16 +25,23 @@ module MiniradioServer
20
25
  @logger = logger
21
26
  # For managing locks during conversion processing (using Mutex per file)
22
27
  @conversion_locks = Hash.new { |h, k| h[k] = Mutex.new } # Mutex is built-in, no require needed
28
+ @locks_mutex = Mutex.new
23
29
  end
24
30
 
25
31
  def call(env)
26
- request_path = env['PATH_INFO']
32
+ request_path = env["PATH_INFO"]
27
33
  @logger.info "Request received: #{request_path}"
28
34
 
29
35
  # Root URL
30
36
  match = request_path.match(%r{^/$|^/index(\.html)$})
31
37
  if match
32
- return response(200, index, 'text/html')
38
+ return response(200, index, "text/html")
39
+ end
40
+
41
+ if (artwork = request_path.match(%r{^/artwork/([^/]+)$}))
42
+ _basename, source, error = mp3_source(artwork[1])
43
+ return error if error
44
+ return serve_artwork(source)
33
45
  end
34
46
 
35
47
  # Path pattern: /stream/{mp3_basename}/{playlist or segment}
@@ -41,37 +53,25 @@ module MiniradioServer
41
53
  return not_found_response("Not Found (Invalid Path Format)")
42
54
  end
43
55
 
44
- mp3_basename = URI.decode_uri_component(match[1]) # e.g., "your_music" (without extension)
56
+ mp3_basename, original_mp3_path, error = mp3_source(match[1])
57
+ return error if error
45
58
  requested_filename = match[2] # e.g., "playlist.m3u8" or "segment001.mp3"
46
59
  extension = match[3].downcase # "m3u8" or "mp3"
47
-
48
- # --- Check if the original MP3 file exists ---
49
- # Security: Check for directory traversal in basename
50
- if mp3_basename.include?('..') || mp3_basename.include?('/')
51
- @logger.warn "Invalid MP3 base name requested: #{mp3_basename}"
52
- return forbidden_response("Invalid filename.")
53
- end
54
- original_mp3_path = @mp3_dir.join("#{mp3_basename}.mp3")
55
-
56
- unless original_mp3_path.exist? && original_mp3_path.file?
57
- @logger.warn "Original MP3 file not found: #{original_mp3_path}"
58
- return not_found_response("Not Found (Original MP3)")
59
- end
60
+ return not_found_response if extension == "m3u8" && requested_filename != "playlist.m3u8"
60
61
 
61
62
  # --- Build cache paths ---
62
63
  cache_subdir = @cache_dir.join(mp3_basename)
63
64
  hls_playlist_path = cache_subdir.join("playlist.m3u8")
64
65
  requested_cache_file_path = cache_subdir.join(requested_filename)
65
66
 
66
- # Security: Check if the requested cache file path is within the cache subdirectory
67
- # Use string comparison as realpath fails if the file doesn't exist yet
68
- unless requested_cache_file_path.to_s.start_with?(cache_subdir.to_s + File::SEPARATOR) || requested_cache_file_path == hls_playlist_path
67
+ # Check lexical paths and resolved ancestors, including symlinks.
68
+ unless within_directory?(cache_subdir, @cache_dir) && within_directory?(requested_cache_file_path, cache_subdir)
69
69
  @logger.warn "Attempted access outside cache directory: #{requested_cache_file_path}"
70
70
  return forbidden_response("Access denied.")
71
71
  end
72
72
 
73
73
  # --- Process based on request type ---
74
- if extension == 'm3u8'
74
+ if extension == "m3u8"
75
75
  # M3U8 request: Check if conversion is needed, convert if necessary, and serve
76
76
  ensure_hls_converted(original_mp3_path, cache_subdir, hls_playlist_path) do |status, message|
77
77
  case status
@@ -84,60 +84,115 @@ module MiniradioServer
84
84
  return internal_server_error_response(message || "HLS conversion failed.")
85
85
  end
86
86
  end
87
- elsif extension == 'mp3'
87
+ elsif extension == "mp3"
88
88
  # MP3 segment request: Serve from cache (404 if not found)
89
89
  # Normally, the m3u8 is requested first, so the cache should exist
90
90
  if requested_cache_file_path.exist? && requested_cache_file_path.file?
91
- return serve_file(requested_cache_file_path)
91
+ serve_file(requested_cache_file_path)
92
92
  else
93
93
  # Segment request might come before m3u8, or an invalid request after conversion failure
94
94
  @logger.warn "Segment file not found (cache not generated or invalid request?): #{requested_cache_file_path}"
95
95
  # For simplicity, return 404. A more robust check might verify parent conversion status.
96
- return not_found_response("Not Found (Segment)")
96
+ not_found_response("Not Found (Segment)")
97
97
  end
98
98
  else
99
99
  # Should not reach here
100
100
  @logger.error "Unexpected file extension: #{extension}"
101
- return internal_server_error_response
101
+ internal_server_error_response
102
102
  end
103
-
104
103
  rescue SystemCallError => e # File access related errors (ENOENT, EACCES, etc.)
105
104
  @logger.error "File access error: #{e.message}"
106
105
  # Return 404 or 500 depending on the context
107
- return not_found_response("Resource not found or access denied")
106
+ not_found_response("Resource not found or access denied")
108
107
  rescue => e
109
108
  @logger.error "Unexpected error occurred: #{e.message}"
110
109
  @logger.error e.backtrace.join("\n")
111
- return internal_server_error_response
110
+ internal_server_error_response
112
111
  end
113
112
 
114
113
  def get_mp3_list
115
114
  r = []
116
115
  @mp3_dir.glob("*.mp3").each do |file|
117
- mp3 = {}
118
- Mp3Info.open(file) do |mp3info|
119
- mp3[:title] = mp3info.tag.title
120
- mp3[:artist] = mp3info.tag.artist
121
- mp3[:album] = mp3info.tag.album
122
- mp3[:file] = file.basename(".mp3")
116
+ next unless file.file? && within_directory?(file, @mp3_dir)
117
+ name = file.basename(".mp3").to_s
118
+ encoded_file = URL_PARSER.escape(name, /[^a-zA-Z0-9\-._~]/)
119
+ mp3 = {file: name, title: nil, artist: nil, album: nil,
120
+ url: "/stream/#{encoded_file}/playlist.m3u8", artwork_url: "/artwork/#{encoded_file}"}
121
+ begin
122
+ Mp3Info.open(file) do |mp3info|
123
+ mp3[:title] = mp3info.tag.title
124
+ mp3[:artist] = mp3info.tag.artist
125
+ mp3[:album] = mp3info.tag.album
126
+ end
127
+ rescue => e
128
+ @logger.warn "Unable to read metadata for #{name}: #{e.message}"
123
129
  end
124
130
  r << mp3
125
131
  end
126
132
  r
127
133
  end
128
-
134
+
129
135
  def index
130
- template = Tilt::SlimTemplate.new("#{__dir__}/templ/index.html.slim")
131
- template.render(self, :mp3_list => get_mp3_list)
136
+ template = Tilt::SlimTemplate.new("#{__dir__}/templ/index.html.slim")
137
+ # JSON is data, but HTML still recognizes closing script tags inside it.
138
+ tracks_json = JSON.generate(get_mp3_list).gsub("<", '\\u003c').gsub(">", '\\u003e').gsub("&", '\\u0026')
139
+ template.render(self, tracks_json: tracks_json)
132
140
  end
133
141
 
134
142
  private
135
143
 
144
+ # The stream and artwork routes share the same source validation.
145
+ def mp3_source(encoded_name)
146
+ name = URL_PARSER.unescape(encoded_name).force_encoding(Encoding::UTF_8)
147
+ if !name.valid_encoding? || name.include?("\0") || name.include?("..") || name.include?("/") || name.include?("\\")
148
+ return [nil, nil, forbidden_response("Invalid filename.")]
149
+ end
150
+ path = @mp3_dir.join("#{name}.mp3")
151
+ return [nil, nil, forbidden_response("Access denied.")] unless within_directory?(path, @mp3_dir)
152
+ return [nil, nil, not_found_response("Not Found (Original MP3)")] unless path.file?
153
+
154
+ [name, path, nil]
155
+ end
156
+
157
+ def serve_artwork(source)
158
+ Mp3Info.open(source) do |mp3|
159
+ mp3.tag2.pictures.each do |_name, data|
160
+ next unless data.is_a?(String) && data.bytesize <= MAX_ARTWORK_BYTES
161
+ data = data.b
162
+ mime = if data.start_with?("\x89PNG\r\n\x1a\n".b)
163
+ "image/png"
164
+ elsif data.start_with?("\xff\xd8\xff".b)
165
+ "image/jpeg"
166
+ end
167
+ next unless mime
168
+
169
+ return [200, {"content-type" => mime, "content-length" => data.bytesize.to_s,
170
+ "cache-control" => "no-store", "x-content-type-options" => "nosniff"}, [data]]
171
+ end
172
+ end
173
+ not_found_response("No supported artwork")
174
+ rescue => e
175
+ @logger.warn "Unable to read artwork: #{e.message}"
176
+ not_found_response("No supported artwork")
177
+ end
178
+
179
+ # Resolve existing ancestors as well as files, so symlinks cannot escape a
180
+ # configured directory before conversion or when serving cached segments.
181
+ def within_directory?(path, directory)
182
+ return false unless path.to_s.start_with?(directory.to_s + File::SEPARATOR) || path == directory
183
+
184
+ ancestor = path
185
+ ancestor = ancestor.parent until ancestor.exist? || ancestor.symlink?
186
+ resolved = ancestor.realpath.join(path.relative_path_from(ancestor))
187
+ root = directory.exist? ? directory.realpath : directory
188
+ resolved == root || resolved.to_s.start_with?(root.to_s + File::SEPARATOR)
189
+ end
190
+
136
191
  # Check if HLS conversion is needed and execute if necessary (with lock)
137
192
  # Yields the status (:ok, :already_exists, :converting, :error) and an optional message to the block
138
193
  def ensure_hls_converted(input_mp3_path, output_dir, playlist_path)
139
- mp3_basename = input_mp3_path.basename('.mp3').to_s
140
- lock = @conversion_locks[mp3_basename] # Get the Mutex specific to this file
194
+ mp3_basename = input_mp3_path.basename(".mp3").to_s
195
+ lock = @locks_mutex.synchronize { @conversion_locks[mp3_basename] }
141
196
 
142
197
  # Check if the converted file already exists (check outside lock for speed)
143
198
  if playlist_path.exist?
@@ -174,7 +229,6 @@ module MiniradioServer
174
229
  end
175
230
  end
176
231
 
177
-
178
232
  # Convert MP3 file to HLS format (execute ffmpeg)
179
233
  # Returns: [Boolean (success/failure), String (error message or nil)]
180
234
  def convert_to_hls(input_mp3_path, output_dir)
@@ -187,21 +241,21 @@ module MiniradioServer
187
241
  # Build the ffmpeg command (using an array is safer for paths with spaces)
188
242
  cmd = [
189
243
  @ffmpeg_cmd,
190
- '-y', # Overwrite existing files (just in case)
191
- '-i', input_mp3_path.to_s,
192
- '-c:a', 'copy', # Copy audio codec (no re-encoding)
193
- '-f', 'hls',
194
- '-hls_time', @segment_duration.to_s,
195
- '-hls_list_size', '0', # VOD (include all segments in the list)
196
- '-hls_playlist_type', 'vod', # Specify VOD playlist type
197
- '-hls_segment_filename', segment_path_template.to_s,
244
+ "-y", # Overwrite existing files (just in case)
245
+ "-i", input_mp3_path.to_s,
246
+ "-c:a", "copy", # Copy audio codec (no re-encoding)
247
+ "-f", "hls",
248
+ "-hls_time", @segment_duration.to_s,
249
+ "-hls_list_size", "0", # VOD (include all segments in the list)
250
+ "-hls_playlist_type", "vod", # Specify VOD playlist type
251
+ "-hls_segment_filename", segment_path_template.to_s,
198
252
  playlist_path.to_s
199
253
  ]
200
254
 
201
- @logger.info "Executing command: #{cmd.join(' ')}"
255
+ @logger.info "Executing command: #{cmd.join(" ")}"
202
256
 
203
257
  # Execute command (capture standard output, standard error, and status)
204
- stdout, stderr, status = Open3.capture3(*cmd)
258
+ _stdout, stderr, status = Open3.capture3(*cmd)
205
259
 
206
260
  unless status.success?
207
261
  error_message = "ffmpeg exited with status #{status.exitstatus}. Stderr: #{stderr.strip}"
@@ -216,16 +270,15 @@ module MiniradioServer
216
270
  end
217
271
 
218
272
  # Log warnings from stderr even on success (if necessary), ignoring common deprecation warnings
219
- unless stderr.empty? || stderr.strip.downcase.include?('deprecated')
273
+ unless stderr.empty? || stderr.strip.downcase.include?("deprecated")
220
274
  @logger.warn "ffmpeg stderr (on success): #{stderr.strip}"
221
275
  end
222
276
 
223
- return [true, nil]
224
-
277
+ [true, nil]
225
278
  rescue Errno::ENOENT => e # Command not found, etc.
226
279
  error_message = "Error occurred during ffmpeg command preparation: #{e.message}"
227
280
  @logger.error error_message
228
- return [false, error_message]
281
+ [false, error_message]
229
282
  rescue => e # Catch other exceptions around ffmpeg execution
230
283
  error_message = "Unexpected error occurred during ffmpeg execution: #{e.message}"
231
284
  @logger.error error_message
@@ -235,17 +288,17 @@ module MiniradioServer
235
288
  rescue => e_rm
236
289
  @logger.error "Error occurred while deleting cache directory: #{output_dir}, Error: #{e_rm.message}"
237
290
  end
238
- return [false, error_message]
291
+ [false, error_message]
239
292
  end
240
293
 
241
294
  # Serve the file
242
295
  def serve_file(file_path)
243
296
  extension = file_path.extname.downcase
244
297
  content_type = case extension
245
- when '.m3u8'
246
- 'application/vnd.apple.mpegurl' # Or 'audio/mpegurl'
247
- when '.mp3'
248
- 'audio/mpeg'
298
+ when ".m3u8"
299
+ "application/vnd.apple.mpegurl" # Or 'audio/mpegurl'
300
+ when ".mp3"
301
+ "audio/mpeg"
249
302
  else
250
303
  @logger.warn "Serving attempt: Unsupported file type: #{file_path}"
251
304
  return forbidden_response("Unsupported file type.")
@@ -268,16 +321,15 @@ module MiniradioServer
268
321
  return internal_server_error_response("Failed to get file size")
269
322
  end
270
323
 
271
-
272
324
  headers = {
273
- 'Content-Type' => content_type,
274
- 'Content-Length' => file_size.to_s,
275
- 'Access-Control-Allow-Origin' => '*', # CORS header
325
+ "content-type" => content_type,
326
+ "content-length" => file_size.to_s,
327
+ "access-control-allow-origin" => "*", # CORS header
276
328
  # For HLS, it's often safer not to cache (especially for live streams)
277
329
  # For VOD, caching might be okay, but we'll disable it here for simplicity
278
- 'Cache-Control' => 'no-cache, no-store, must-revalidate',
279
- 'Pragma' => 'no-cache',
280
- 'Expires' => '0'
330
+ "cache-control" => "no-cache, no-store, must-revalidate",
331
+ "pragma" => "no-cache",
332
+ "expires" => "0"
281
333
  }
282
334
 
283
335
  @logger.info "Serving: #{file_path} (#{content_type}, #{file_size} bytes)"
@@ -285,7 +337,7 @@ module MiniradioServer
285
337
  # Return the File object as the response body (Rack handles streaming efficiently)
286
338
  begin
287
339
  # Open in binary mode
288
- file_body = file_path.open('rb')
340
+ file_body = file_path.open("rb")
289
341
  [200, headers, file_body]
290
342
  rescue SystemCallError => e # Error during file opening
291
343
  @logger.error "Failed to open file: #{file_path}, Error: #{e.message}"
@@ -295,10 +347,10 @@ module MiniradioServer
295
347
  end
296
348
 
297
349
  # --- HTTP Status Code Response Methods ---
298
- def response(status, message, content_type = 'text/plain', extra_headers = {})
350
+ def response(status, message, content_type = "text/plain", extra_headers = {})
299
351
  headers = {
300
- 'Content-Type' => content_type,
301
- 'Access-Control-Allow-Origin' => '*'
352
+ "content-type" => content_type,
353
+ "access-control-allow-origin" => "*"
302
354
  }.merge(extra_headers)
303
355
  # Returning the body as an array is the Rack specification
304
356
  [status, headers, [message + "\n"]]
@@ -318,8 +370,7 @@ module MiniradioServer
318
370
 
319
371
  def service_unavailable_response(message = "Service Unavailable")
320
372
  # Add Retry-After header suggesting a retry after 5 seconds
321
- response(503, message, 'text/plain', { 'Retry-After' => '5' })
373
+ response(503, message, "text/plain", {"retry-after" => "5"})
322
374
  end
323
375
  end
324
376
  end
325
-
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../miniradio_server"
5
+
6
+ module MiniradioServer
7
+ module CLI
8
+ def self.run(argv = ARGV, out: $stdout, err: $stderr)
9
+ options = {
10
+ mp3_dir: MP3_SRC_DIR, cache_dir: HLS_CACHE_DIR,
11
+ port: SERVER_PORT, ffmpeg: FFMPEG_COMMAND,
12
+ segment_duration: HLS_SEGMENT_DURATION
13
+ }
14
+ parser = OptionParser.new do |opts|
15
+ opts.banner = "Usage: miniradio_server [options]"
16
+ opts.on("--mp3-dir PATH", "Directory containing MP3 files") { |value| options[:mp3_dir] = value }
17
+ opts.on("--cache-dir PATH", "HLS cache directory") { |value| options[:cache_dir] = value }
18
+ opts.on("--port PORT", Integer, "HTTP port (default: 9292)") { |value| options[:port] = value }
19
+ opts.on("--ffmpeg PATH", "FFmpeg executable") { |value| options[:ffmpeg] = value }
20
+ opts.on("--version", "Print the version") {
21
+ out.puts(VERSION)
22
+ return 0
23
+ }
24
+ opts.on("-h", "--help", "Print this help") {
25
+ out.puts(opts)
26
+ return 0
27
+ }
28
+ end
29
+ remaining = parser.parse(argv)
30
+ raise OptionParser::InvalidArgument, remaining.join(" ") unless remaining.empty?
31
+ raise OptionParser::InvalidArgument, "port must be between 1 and 65535" unless (1..65535).cover?(options[:port])
32
+
33
+ logger = Logger.new(out)
34
+ logger.level = Logger::INFO
35
+ MiniradioServer.ensure_directories_exist([options[:mp3_dir], options[:cache_dir]], logger)
36
+ app = App.new(options[:mp3_dir], options[:cache_dir], options[:ffmpeg], options[:segment_duration], logger)
37
+ out.puts "Starting Miniradio Server #{VERSION} on port #{options[:port]}..."
38
+ out.puts "MP3 Source Directory: #{File.expand_path(options[:mp3_dir])}"
39
+ out.puts "HLS Cache Directory: #{File.expand_path(options[:cache_dir])}"
40
+ out.puts "Server URL: http://localhost:#{options[:port]}"
41
+ out.puts "Press Ctrl+C to stop."
42
+ Rackup::Handler::WEBrick.run(
43
+ Rack::Static.new(app, urls: ["/style", "/player.js"], root: File.expand_path("../public", __dir__)),
44
+ Port: options[:port], Logger: logger, AccessLog: []
45
+ )
46
+ 0
47
+ rescue Interrupt
48
+ out.puts "\nShutting down server."
49
+ 0
50
+ rescue OptionParser::ParseError, SystemCallError => e
51
+ err.puts "Error: #{e.message}"
52
+ 1
53
+ end
54
+ end
55
+ end
@@ -1,50 +1,61 @@
1
1
  doctype html
2
- html
2
+ html lang="en"
3
3
  head
4
4
  meta charset="UTF-8"
5
5
  meta name="viewport" content="width=device-width, initial-scale=1.0"
6
6
  title Miniradio Server
7
7
  link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" type="text/css"
8
- link rel="stylesheet" href="style/main.css" type="text/css"
9
- script src="https://cdn.jsdelivr.net/npm/hls.js@1"
8
+ link rel="stylesheet" href="/style/main.css" type="text/css"
9
+ script src="https://cdn.jsdelivr.net/npm/hls.js@1" defer=true
10
+ script src="/player.js" defer=true
10
11
  body
11
- h1 Straming List
12
- table.compact.striped
13
- thead
14
- tr
15
- th scope="col" Play
16
- th scope="col" Title
17
- th scope="col" Artist
18
- th scope="col" Album
19
- tbody
20
- - mp3_list.each_with_index do |mp3, i|
12
+ header.container
13
+ h1 Miniradio
14
+ main.container
15
+ section.now-playing aria-label="Current track"
16
+ div.track-details
17
+ div.artwork
18
+ img id="currentArtwork" alt="" hidden=true
19
+ div id="artworkPlaceholder" aria-hidden="true"
20
+ span ♪
21
+ small No artwork
22
+ div.track-metadata aria-live="polite"
23
+ p id="playbackState" No track selected
24
+ h2 id="currentTrack" Select a track
25
+ p id="currentArtist"
26
+ p id="currentAlbum"
27
+ div.custom-controls id="customControls" hidden=true
28
+ div.time-labels
29
+ span id="elapsedTime" 0:00
30
+ span id="durationTime" --:--
31
+ input id="seekControl" type="range" min="0" max="0" step="0.1" value="0" disabled=true aria-label="Playback position"
32
+ div.transport role="group" aria-label="Playback controls"
33
+ button id="prevButton" disabled=true aria-label="Previous track" ⏮ Previous
34
+ button id="playPauseButton" disabled=true Play
35
+ button id="nextButton" disabled=true aria-label="Next track" Next ⏭
36
+ div.volume id="volumeControls"
37
+ button id="muteButton" aria-pressed="false" Mute
38
+ label for="volumeControl" Volume
39
+ input id="volumeControl" type="range" min="0" max="1" step="0.01" value="1"
40
+ small id="volumeHint" hidden=true Use your device’s volume controls.
41
+ div.playback-options role="group" aria-label="Playback options"
42
+ button id="startButton" Play all
43
+ button id="loopButton" aria-pressed="false" Repeat track: OFF
44
+ button id="shuffleButton" aria-pressed="false" Shuffle: OFF
45
+ audio id="player" controls=true preload="none"
46
+ p id="playerStatus" role="status"
47
+ h2.playlist-heading Tracks
48
+ table.compact.striped id="playlistTable"
49
+ thead
21
50
  tr
22
- td
23
- audio controls=true id='audio-#{i+1}'
24
- td=mp3[:title] || mp3[:file]
25
- td=mp3[:artist]
26
- td=mp3[:album]
27
- javascript:
28
- function setupHLS(audioElementId, streamUrl) {
29
- const audio = document.getElementById(audioElementId);
30
-
31
- if (Hls.isSupported()) {
32
- const hls = new Hls();
33
- hls.loadSource(streamUrl);
34
- hls.attachMedia(audio);
35
- hls.on(Hls.Events.MANIFEST_PARSED, function () {
36
- console.log(`${audioElementId} loaded`);
37
- });
38
- } else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
39
- // HLS native support browser. ex.Safari
40
- audio.src = streamUrl;
41
- } else {
42
- console.error('This browser cannot play HLS.');
43
- }
44
- }
45
- var mp3s = #{{JSON.generate(mp3_list)}};
46
- for (let i = 0; i < mp3s.length; i++) {
47
- setupHLS(`audio-${i+1}`, `/stream/${mp3s[i].file}/playlist.m3u8`);
48
- }
49
- hr
50
- p Miniradio ver #{MiniradioServer::VERSION}
51
+ th scope="col" Play
52
+ th scope="col" Title
53
+ th scope="col" Artist
54
+ th scope="col" Album
55
+ th scope="col" Status
56
+ tbody
57
+ script id="tracks" type="application/json"
58
+ == tracks_json
59
+ footer.container
60
+ hr
61
+ p Miniradio ver #{MiniradioServer::VERSION}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MiniradioServer
4
- VERSION = "0.0.3"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
4
- require 'pathname'
5
- require 'fileutils'
3
+ require "logger"
4
+ require "pathname"
5
+ require "fileutils"
6
6
 
7
7
  require_relative "miniradio_server/version"
8
8
 
@@ -11,13 +11,13 @@ module MiniradioServer
11
11
 
12
12
  # --- Configuration ---
13
13
  # Directory containing the original MP3 files
14
- MP3_SRC_DIR = File.expand_path('./mp3_files')
14
+ MP3_SRC_DIR = File.expand_path("./mp3_files")
15
15
  # Directory to cache the HLS converted content
16
- HLS_CACHE_DIR = File.expand_path('./hls_cache')
16
+ HLS_CACHE_DIR = File.expand_path("./hls_cache")
17
17
  # Port the server will listen on
18
18
  SERVER_PORT = 9292
19
19
  # Path to the ffmpeg command (usually just 'ffmpeg' if it's in the system PATH)
20
- FFMPEG_COMMAND = 'ffmpeg'
20
+ FFMPEG_COMMAND = "ffmpeg"
21
21
  # HLS segment duration in seconds
22
22
  HLS_SEGMENT_DURATION = 10
23
23
  # ---
@@ -38,69 +38,3 @@ module MiniradioServer
38
38
  end
39
39
 
40
40
  require_relative "miniradio_server/app"
41
-
42
- # --- Server Startup ---
43
-
44
- # Only run server startup logic if the script is executed directly
45
- if __FILE__ == $PROGRAM_NAME || 'bin/miniradio_server' == $PROGRAM_NAME || 'miniradio_server' == $PROGRAM_NAME
46
- logger = Logger.new(STDOUT)
47
- # Ensure directories exist before starting the app
48
- # Use constants defined within the module
49
- MiniradioServer.ensure_directories_exist(
50
- [MiniradioServer::MP3_SRC_DIR, MiniradioServer::HLS_CACHE_DIR],
51
- logger
52
- )
53
-
54
- logger.level = Logger::INFO
55
-
56
- # Check encoding settings, especially for non-ASCII filenames
57
- Encoding.default_external = Encoding::UTF_8 if Encoding.default_external != Encoding::UTF_8
58
- # Encoding.default_internal = Encoding::UTF_8 # Set internal encoding if needed
59
-
60
- # Use constants defined within the module
61
- app = MiniradioServer::App.new(
62
- MiniradioServer::MP3_SRC_DIR,
63
- MiniradioServer::HLS_CACHE_DIR,
64
- MiniradioServer::FFMPEG_COMMAND,
65
- MiniradioServer::HLS_SEGMENT_DURATION,
66
- logger
67
- )
68
-
69
- puts "Starting HLS conversion and streaming server on port #{MiniradioServer::SERVER_PORT}..."
70
- puts "MP3 Source Directory: #{MiniradioServer::MP3_SRC_DIR}"
71
- puts "HLS Cache Directory: #{MiniradioServer::HLS_CACHE_DIR}"
72
- puts "Default External Encoding: #{Encoding.default_external}" # For confirmation log
73
- puts "Server URL: http://localhost:9292"
74
- puts "Example Streaming URL: http://localhost:#{MiniradioServer::SERVER_PORT}/stream/{mp3_filename_without_extension}/playlist.m3u8"
75
- puts "e.g., If mp3_files/ contains my_music.mp3 -> http://localhost:#{MiniradioServer::SERVER_PORT}/stream/my_music/playlist.m3u8"
76
- puts "Press Ctrl+C to stop."
77
-
78
- begin
79
- # Use Rackup::Handler::WEBrick, recommended for Rack 3+
80
- Rackup::Handler::WEBrick.run(
81
- Rack::Static.new(app, urls: ["/style", "/images"], root: "#{__dir__}/public"),
82
- Port: MiniradioServer::SERVER_PORT,
83
- Logger: logger, # Share logger with WEBrick
84
- AccessLog: [] # Disable WEBrick's own access log (logging handled by Rack app)
85
- # , :DoNotReverseLookup => true # Disable DNS reverse lookup for faster responses (optional)
86
- )
87
- rescue Interrupt # When stopped with Ctrl+C
88
- puts "\nShutting down server."
89
- rescue Errno::EADDRINUSE # Port already in use
90
- puts "Error: Port #{MiniradioServer::SERVER_PORT} is already in use."
91
- exit(1)
92
- rescue LoadError => e
93
- # Error handling if rackup/handler/webrick is not found
94
- if e.message.include?('rackup/handler/webrick')
95
- puts "Error: Rackup WEBrick handler not found."
96
- puts "Please install the rackup gem by running: `gem install rackup`"
97
- else
98
- puts "Library load error: #{e.message}"
99
- end
100
- exit(1)
101
- rescue => e
102
- puts "Server startup error: #{e.message}"
103
- puts e.backtrace.join("\n")
104
- exit(1)
105
- end
106
- end