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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +215 -101
- data/exe/miniradio_server +5 -0
- data/lib/miniradio_server/app.rb +129 -78
- data/lib/miniradio_server/cli.rb +55 -0
- data/lib/miniradio_server/templ/index.html.slim +53 -42
- data/lib/miniradio_server/version.rb +1 -1
- data/lib/miniradio_server.rb +6 -72
- data/lib/public/player.js +312 -0
- data/lib/public/style/main.css +55 -21
- metadata +76 -11
- data/Rakefile +0 -8
- data/hls_cache/.gitkeep +0 -0
- data/mp3_files/.gitkeep +0 -0
- data/sig/miniradio_server.rbs +0 -4
data/lib/miniradio_server/app.rb
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
require
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
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
|
|
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[
|
|
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,
|
|
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 =
|
|
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
|
-
#
|
|
67
|
-
|
|
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 ==
|
|
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 ==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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(
|
|
140
|
-
lock = @conversion_locks[mp3_basename]
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
when
|
|
248
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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(
|
|
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 =
|
|
350
|
+
def response(status, message, content_type = "text/plain", extra_headers = {})
|
|
299
351
|
headers = {
|
|
300
|
-
|
|
301
|
-
|
|
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,
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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}
|
data/lib/miniradio_server.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|