miniradio_server 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: aaaf57cfdaba5f66a068447ec813636651ddc440000f09815658f9a42c539606
4
+ data.tar.gz: 05d5e7eb56654389f97e779c50376f778af460c93c1ba195f9c3998f6ca50f56
5
+ SHA512:
6
+ metadata.gz: 6ce3d244968874b6a9810e13f0f2ffbeeb7d1a7cc76531ffefa71a3cbdbe6e03828861456386cf1a7e68e4e50321c527e20bd646dbfeb7d1082c03e5354609d2
7
+ data.tar.gz: ceed1b93aeded65a534581dc185d5413bbdf8e90869d1abbe56419d76fa4cba6406071a127a8370bdff9f17c0441efe2fc1460b2da3462b88a102c7f0a5b7b6a
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Koichiro Ohba
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Miniradio Server is Simple Ruby HLS Server for MP3s
2
+ This is a basic HTTP Live Streaming (HLS) server written in Ruby using the Rack interface. It serves MP3 audio files by converting them on-the-fly into HLS format (M3U8 playlist and MP3 segment files) using `ffmpeg`. Converted files are cached for subsequent requests.
3
+ This server is designed for simplicity and primarily targets Video on Demand (VOD) scenarios where you want to stream existing MP3 files via HLS without pre-converting them.
4
+
5
+ ## Prerequisites
6
+ Before running the server, ensure you have the following installed:
7
+ 1. **Ruby:** Version 3.1 or later (tested on 3.4).
8
+ 2. **Dependency Gems:** Install using `bundle`.
9
+ 3. **FFmpeg:** A recent version of `ffmpeg` must be installed and accessible in your system's PATH. You can download it from [ffmpeg.org](https://ffmpeg.org/) or install it via your system's package manager (e.g., `apt install ffmpeg`, `brew install ffmpeg`).
10
+ ## Setup
11
+ 1. **Install Gems:**
12
+ ```bash
13
+ gem install miniradio_server
14
+ ```
15
+ 2. **Create MP3 Directory:** Create a directory named `mp3_files` in the same location as the script.
16
+ ```bash
17
+ mkdir mp3_files
18
+ ```
19
+ *(Alternatively, change the `MP3_SRC_DIR` constant in the script).*
20
+ 3. **Add MP3 Files:** Place the MP3 files you want to stream into the `mp3_files` directory.
21
+ * **Important:** Use simple, URL-safe filenames for your MP3s (e.g., letters, numbers, underscores, hyphens). Spaces or special characters might cause issues. Example: `my_cool_song.mp3`, `podcast_episode_1.mp3`.
22
+ 4. **Cache Directory:** The script will automatically create a `hls_cache` directory (or the directory specified by `HLS_CACHE_DIR`) when the first conversion occurs. Ensure the script has write permissions in its parent directory.
23
+
24
+ ## Running the Server
25
+ Navigate to the directory containing the script in your terminal and run:
26
+
27
+ ```bash
28
+ bin/miniradio_server
29
+ Info: ffmpeg found.
30
+ Starting HLS conversion and streaming server on port 9292...
31
+ MP3 Source Directory: /path/to/your/project/mp3_files
32
+ HLS Cache Directory: /path/to/your/project/hls_cache
33
+ Default External Encoding: UTF-8
34
+ Using Handler: Rackup::Handler::WEBrick
35
+ Example Streaming URL: http://localhost:9292/stream/{mp3_filename_without_extension}/playlist.m3u8
36
+ e.g., If mp3_files/ contains my_music.mp3 -> http://localhost:9292/stream/my_music/playlist.m3u8
37
+ Press Ctrl+C to stop.
38
+ ```
39
+
40
+ The server will run in the foreground. Press Ctrl+C to stop it.
41
+
42
+ ## Usage: Accessing Streams
43
+
44
+ Once the server is running, you can access the HLS streams using an HLS-compatible player (like VLC, QuickTime Player on macOS/iOS, Safari, or web players using hls.js).
45
+
46
+ The URL format is:
47
+
48
+ http\://localhost:{SERVER\_PORT}/stream/{mp3\_filename\_without\_extension}/playlist.m3u8
49
+
50
+ **Example:**
51
+
52
+ If you have an MP3 file named mp3\_files/awesome\_track.mp3 and the server is running on the default port 9292, the streaming URL would be:
53
+
54
+ http\://localhost:9292/stream/awesome\_track/playlist.m3u8
55
+
56
+ **Note:** The first time you request a specific stream, the server will run ffmpeg to convert the MP3. This might take a few seconds depending on the file size. Subsequent requests for the same stream will be served instantly from the cache.
57
+
58
+ ## Configuration
59
+
60
+ You can modify the following constants at the top of the script (miniradio\_server.rb):
61
+
62
+ - MP3\_SRC\_DIR: Path to the directory containing your original MP3 files.
63
+ - HLS\_CACHE\_DIR: Path to the directory where HLS segments and playlists will be cached.
64
+ - SERVER\_PORT: The network port the server listens on.
65
+ - FFMPEG\_COMMAND: The command used to execute ffmpeg (change if it's not in your PATH).
66
+ - HLS\_SEGMENT\_DURATION: The target duration (in seconds) for each HLS segment.
67
+
68
+ ## How it Works
69
+
70
+ 1. A client requests an M3U8 playlist URL (e.g., /stream/my\_song/playlist.m3u8).
71
+ 2. The server checks if the corresponding HLS files (hls\_cache/my\_song/playlist.m3u8 and segments) exist in the cache directory.
72
+ 3. If the cache does not exist:
73
+ - It verifies the original mp3\_files/my\_song.mp3 exists.
74
+ - It acquires a lock specific to my\_song to prevent simultaneous conversions.
75
+ - It runs ffmpeg to convert my\_song.mp3 into hls\_cache/my\_song/playlist.m3u8 and hls\_cache/my\_song/segmentXXX.mp3.
76
+ - The lock is released.
77
+ 4. If the cache does exist (or after successful conversion), the server serves the requested playlist.m3u8 file.
78
+ 5. The client parses the M3U8 playlist and requests the individual MP3 segment files listed within it (e.g., /stream/my\_song/segment000.mp3, /stream/my\_song/segment001.mp3, etc.).
79
+ 6. The server serves these segment files directly from the cache directory.
80
+
81
+ ## Limitations
82
+
83
+ - **VOD Only:** This server is designed for Video on Demand (pre-existing files) and does not support live streaming.
84
+ - **Basic Caching:** Cache is persistent but simple. There's no automatic cache invalidation if the source MP3 changes. You would need to manually clear the corresponding subdirectory in hls\_cache.
85
+ - **Security:** Basic checks against directory traversal are included, but it's not hardened for production use against malicious requests. No authentication/authorization is implemented.
86
+ - **Performance:** Relies on ffmpeg execution per file (first request only). Uses Ruby's WEBrick via rackup, which is single-threaded by default and not ideal for high-concurrency production loads.
87
+ - **Error Handling:** Basic error handling is implemented, but complex ffmpeg issues or edge cases might not be handled gracefully.
88
+ - **Resource Usage:** Conversion can be CPU-intensive (though -c:a copy helps significantly) and disk I/O intensive during the first request for a file.
89
+
90
+
91
+ ## Development
92
+
93
+ Install from git repository:
94
+
95
+ ```bash
96
+ git clone https://github.com/koichiro/miniradio_server.git
97
+ cd miniradio_server
98
+ bundle
99
+ bin/miniradio_server
100
+ ```
101
+
102
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
103
+
104
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
105
+
106
+ ## Contributing
107
+
108
+ Bug reports and pull requests are welcome on GitHub at https://github.com/koichiro/miniradio_server.
109
+
110
+ ## License
111
+
112
+ This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the LICENSE file for details (or assume MIT if no LICENSE file is present).
113
+
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
File without changes
@@ -0,0 +1,296 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'rack'
3
+ require 'open3' # Used in convert_to_hls
4
+
5
+ # Required to use the handler from Rack 3+
6
+ # You might need to run: gem install rackup
7
+ require 'rackup/handler/webrick'
8
+
9
+ # Rack application class
10
+ module MiniradioServer
11
+ class App
12
+ def initialize(mp3_dir, cache_dir, ffmpeg_cmd, segment_duration, logger)
13
+ @mp3_dir = Pathname.new(mp3_dir).realpath
14
+ @cache_dir = Pathname.new(cache_dir).realpath
15
+ @ffmpeg_cmd = ffmpeg_cmd
16
+ @segment_duration = segment_duration
17
+ @logger = logger
18
+ # For managing locks during conversion processing (using Mutex per file)
19
+ @conversion_locks = Hash.new { |h, k| h[k] = Mutex.new } # Mutex is built-in, no require needed
20
+ end
21
+
22
+ def call(env)
23
+ request_path = env['PATH_INFO']
24
+ @logger.info "Request received: #{request_path}"
25
+
26
+ # Path pattern: /stream/{mp3_basename}/{playlist or segment}
27
+ # mp3_basename is the filename without the extension
28
+ match = request_path.match(%r{^/stream/([^/]+)/(.+\.(m3u8|mp3))$})
29
+
30
+ unless match
31
+ @logger.warn "Invalid request path format: #{request_path}"
32
+ return not_found_response("Not Found (Invalid Path Format)")
33
+ end
34
+
35
+ mp3_basename = match[1] # e.g., "your_music" (without extension)
36
+ requested_filename = match[2] # e.g., "playlist.m3u8" or "segment001.mp3"
37
+ extension = match[3].downcase # "m3u8" or "mp3"
38
+
39
+ # --- Check if the original MP3 file exists ---
40
+ # Security: Check for directory traversal in basename
41
+ if mp3_basename.include?('..') || mp3_basename.include?('/')
42
+ @logger.warn "Invalid MP3 base name requested: #{mp3_basename}"
43
+ return forbidden_response("Invalid filename.")
44
+ end
45
+ original_mp3_path = @mp3_dir.join("#{mp3_basename}.mp3")
46
+
47
+ unless original_mp3_path.exist? && original_mp3_path.file?
48
+ @logger.warn "Original MP3 file not found: #{original_mp3_path}"
49
+ return not_found_response("Not Found (Original MP3)")
50
+ end
51
+
52
+ # --- Build cache paths ---
53
+ cache_subdir = @cache_dir.join(mp3_basename)
54
+ hls_playlist_path = cache_subdir.join("playlist.m3u8")
55
+ requested_cache_file_path = cache_subdir.join(requested_filename)
56
+
57
+ # Security: Check if the requested cache file path is within the cache subdirectory
58
+ # Use string comparison as realpath fails if the file doesn't exist yet
59
+ unless requested_cache_file_path.to_s.start_with?(cache_subdir.to_s + File::SEPARATOR) || requested_cache_file_path == hls_playlist_path
60
+ @logger.warn "Attempted access outside cache directory: #{requested_cache_file_path}"
61
+ return forbidden_response("Access denied.")
62
+ end
63
+
64
+ # --- Process based on request type ---
65
+ if extension == 'm3u8'
66
+ # M3U8 request: Check if conversion is needed, convert if necessary, and serve
67
+ ensure_hls_converted(original_mp3_path, cache_subdir, hls_playlist_path) do |status, message|
68
+ case status
69
+ when :ok, :already_exists
70
+ return serve_file(hls_playlist_path)
71
+ when :converting
72
+ # Another process/thread is converting
73
+ return service_unavailable_response("Conversion in progress. Please try again shortly.")
74
+ when :error
75
+ return internal_server_error_response(message || "HLS conversion failed.")
76
+ end
77
+ end
78
+ elsif extension == 'mp3'
79
+ # MP3 segment request: Serve from cache (404 if not found)
80
+ # Normally, the m3u8 is requested first, so the cache should exist
81
+ if requested_cache_file_path.exist? && requested_cache_file_path.file?
82
+ return serve_file(requested_cache_file_path)
83
+ else
84
+ # Segment request might come before m3u8, or an invalid request after conversion failure
85
+ @logger.warn "Segment file not found (cache not generated or invalid request?): #{requested_cache_file_path}"
86
+ # For simplicity, return 404. A more robust check might verify parent conversion status.
87
+ return not_found_response("Not Found (Segment)")
88
+ end
89
+ else
90
+ # Should not reach here
91
+ @logger.error "Unexpected file extension: #{extension}"
92
+ return internal_server_error_response
93
+ end
94
+
95
+ rescue SystemCallError => e # File access related errors (ENOENT, EACCES, etc.)
96
+ @logger.error "File access error: #{e.message}"
97
+ # Return 404 or 500 depending on the context
98
+ return not_found_response("Resource not found or access denied")
99
+ rescue => e
100
+ @logger.error "Unexpected error occurred: #{e.message}"
101
+ @logger.error e.backtrace.join("\n")
102
+ return internal_server_error_response
103
+ end
104
+
105
+ private
106
+
107
+ # Check if HLS conversion is needed and execute if necessary (with lock)
108
+ # Yields the status (:ok, :already_exists, :converting, :error) and an optional message to the block
109
+ def ensure_hls_converted(input_mp3_path, output_dir, playlist_path)
110
+ mp3_basename = input_mp3_path.basename('.mp3').to_s
111
+ lock = @conversion_locks[mp3_basename] # Get the Mutex specific to this file
112
+
113
+ # Check if the converted file already exists (check outside lock for speed)
114
+ if playlist_path.exist?
115
+ yield(:already_exists, nil)
116
+ return
117
+ end
118
+
119
+ # Use Mutex for exclusive control of conversion processing
120
+ if lock.try_lock # If the lock is acquired, execute the conversion process
121
+ begin
122
+ # After acquiring the lock, check file existence again (another thread might have just finished)
123
+ if playlist_path.exist?
124
+ yield(:already_exists, nil)
125
+ return
126
+ end
127
+
128
+ @logger.info "[#{mp3_basename}] Starting HLS conversion..."
129
+ success, error_msg = convert_to_hls(input_mp3_path, output_dir)
130
+
131
+ if success
132
+ @logger.info "[#{mp3_basename}] HLS conversion completed."
133
+ yield(:ok, nil)
134
+ else
135
+ @logger.error "[#{mp3_basename}] HLS conversion failed. Error: #{error_msg}"
136
+ yield(:error, error_msg)
137
+ end
138
+ ensure
139
+ lock.unlock # Always release the lock
140
+ end
141
+ else
142
+ # Failed to acquire lock = another thread is converting
143
+ @logger.info "[#{mp3_basename}] is currently being converted by another request."
144
+ yield(:converting, nil)
145
+ end
146
+ end
147
+
148
+
149
+ # Convert MP3 file to HLS format (execute ffmpeg)
150
+ # Returns: [Boolean (success/failure), String (error message or nil)]
151
+ def convert_to_hls(input_mp3_path, output_dir)
152
+ # Create the output directory
153
+ FileUtils.mkdir_p(output_dir) unless output_dir.exist?
154
+
155
+ playlist_path = output_dir.join("playlist.m3u8")
156
+ segment_path_template = output_dir.join("segment%03d.mp3") # %03d is replaced by ffmpeg with sequence number
157
+
158
+ # Build the ffmpeg command (using an array is safer for paths with spaces)
159
+ cmd = [
160
+ @ffmpeg_cmd,
161
+ '-y', # Overwrite existing files (just in case)
162
+ '-i', input_mp3_path.to_s,
163
+ '-c:a', 'copy', # Copy audio codec (no re-encoding)
164
+ '-f', 'hls',
165
+ '-hls_time', @segment_duration.to_s,
166
+ '-hls_list_size', '0', # VOD (include all segments in the list)
167
+ '-hls_playlist_type', 'vod', # Specify VOD playlist type
168
+ '-hls_segment_filename', segment_path_template.to_s,
169
+ playlist_path.to_s
170
+ ]
171
+
172
+ @logger.info "Executing command: #{cmd.join(' ')}"
173
+
174
+ # Execute command (capture standard output, standard error, and status)
175
+ stdout, stderr, status = Open3.capture3(*cmd)
176
+
177
+ unless status.success?
178
+ error_message = "ffmpeg exited with status #{status.exitstatus}. Stderr: #{stderr.strip}"
179
+ @logger.error "ffmpeg command execution failed. #{error_message}"
180
+ # If failed, attempt to delete potentially incomplete cache directory
181
+ begin
182
+ FileUtils.rm_rf(output_dir.to_s) if output_dir.exist?
183
+ rescue => e
184
+ @logger.error "Error occurred while deleting cache directory: #{output_dir}, Error: #{e.message}"
185
+ end
186
+ return [false, error_message]
187
+ end
188
+
189
+ # Log warnings from stderr even on success (if necessary), ignoring common deprecation warnings
190
+ unless stderr.empty? || stderr.strip.downcase.include?('deprecated')
191
+ @logger.warn "ffmpeg stderr (on success): #{stderr.strip}"
192
+ end
193
+
194
+ return [true, nil]
195
+
196
+ rescue Errno::ENOENT => e # Command not found, etc.
197
+ error_message = "Error occurred during ffmpeg command preparation: #{e.message}"
198
+ @logger.error error_message
199
+ return [false, error_message]
200
+ rescue => e # Catch other exceptions around ffmpeg execution
201
+ error_message = "Unexpected error occurred during ffmpeg execution: #{e.message}"
202
+ @logger.error error_message
203
+ # Attempt to clean up cache dir on unexpected error too
204
+ begin
205
+ FileUtils.rm_rf(output_dir.to_s) if output_dir.exist?
206
+ rescue => e_rm
207
+ @logger.error "Error occurred while deleting cache directory: #{output_dir}, Error: #{e_rm.message}"
208
+ end
209
+ return [false, error_message]
210
+ end
211
+
212
+ # Serve the file
213
+ def serve_file(file_path)
214
+ extension = file_path.extname.downcase
215
+ content_type = case extension
216
+ when '.m3u8'
217
+ 'application/vnd.apple.mpegurl' # Or 'audio/mpegurl'
218
+ when '.mp3'
219
+ 'audio/mpeg'
220
+ else
221
+ @logger.warn "Serving attempt: Unsupported file type: #{file_path}"
222
+ return forbidden_response("Unsupported file type.")
223
+ end
224
+
225
+ # Re-check file existence and type (just before serving)
226
+ unless file_path.exist? && file_path.file?
227
+ @logger.warn "File to serve not found (serve_file): #{file_path}"
228
+ return not_found_response("Not Found (Serving File)")
229
+ end
230
+
231
+ # Get file size (with error handling)
232
+ begin
233
+ file_size = file_path.size
234
+ rescue Errno::ENOENT
235
+ @logger.error "Failed to get file size (file disappeared?): #{file_path}"
236
+ return not_found_response("Not Found (File disappeared)")
237
+ rescue SystemCallError => e # Other file access errors
238
+ @logger.error "Failed to get file size: #{file_path}, Error: #{e.message}"
239
+ return internal_server_error_response("Failed to get file size")
240
+ end
241
+
242
+
243
+ headers = {
244
+ 'Content-Type' => content_type,
245
+ 'Content-Length' => file_size.to_s,
246
+ 'Access-Control-Allow-Origin' => '*', # CORS header
247
+ # For HLS, it's often safer not to cache (especially for live streams)
248
+ # For VOD, caching might be okay, but we'll disable it here for simplicity
249
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
250
+ 'Pragma' => 'no-cache',
251
+ 'Expires' => '0'
252
+ }
253
+
254
+ @logger.info "Serving: #{file_path} (#{content_type}, #{file_size} bytes)"
255
+
256
+ # Return the File object as the response body (Rack handles streaming efficiently)
257
+ begin
258
+ # Open in binary mode
259
+ file_body = file_path.open('rb')
260
+ [200, headers, file_body]
261
+ rescue SystemCallError => e # Error during file opening
262
+ @logger.error "Failed to open file: #{file_path}, Error: #{e.message}"
263
+ # Rack should handle closing the file even if opened, so no explicit close needed here
264
+ internal_server_error_response("Failed to open file")
265
+ end
266
+ end
267
+
268
+ # --- HTTP Status Code Response Methods ---
269
+ def response(status, message, content_type = 'text/plain', extra_headers = {})
270
+ headers = {
271
+ 'Content-Type' => content_type,
272
+ 'Access-Control-Allow-Origin' => '*'
273
+ }.merge(extra_headers)
274
+ # Returning the body as an array is the Rack specification
275
+ [status, headers, [message + "\n"]]
276
+ end
277
+
278
+ def not_found_response(message = "Not Found")
279
+ response(404, message)
280
+ end
281
+
282
+ def forbidden_response(message = "Forbidden")
283
+ response(403, message)
284
+ end
285
+
286
+ def internal_server_error_response(message = "Internal Server Error")
287
+ response(500, message)
288
+ end
289
+
290
+ def service_unavailable_response(message = "Service Unavailable")
291
+ # Add Retry-After header suggesting a retry after 5 seconds
292
+ response(503, message, 'text/plain', { 'Retry-After' => '5' })
293
+ end
294
+ end
295
+ end
296
+
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MiniradioServer
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'pathname'
5
+ require 'fileutils'
6
+
7
+ require_relative "miniradio_server/version"
8
+
9
+ module MiniradioServer
10
+ class Error < StandardError; end
11
+
12
+ # --- Configuration ---
13
+ # Directory containing the original MP3 files
14
+ MP3_SRC_DIR = File.expand_path('./mp3_files')
15
+ # Directory to cache the HLS converted content
16
+ HLS_CACHE_DIR = File.expand_path('./hls_cache')
17
+ # Port the server will listen on
18
+ SERVER_PORT = 9292
19
+ # Path to the ffmpeg command (usually just 'ffmpeg' if it's in the system PATH)
20
+ FFMPEG_COMMAND = 'ffmpeg'
21
+ # HLS segment duration in seconds
22
+ HLS_SEGMENT_DURATION = 10
23
+ # ---
24
+
25
+ # --- Helper Methods ---
26
+
27
+ # Ensures that the necessary directories exist, creating them if they don't.
28
+ # @param dirs [Array<String>] An array of directory paths to check and create.
29
+ # @param logger [Logger] Logger instance for outputting information.
30
+ def self.ensure_directories_exist(dirs, logger)
31
+ dirs.each do |dir|
32
+ unless Dir.exist?(dir)
33
+ logger.info("Creating directory: #{dir}")
34
+ FileUtils.mkdir_p(dir)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
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 "Using Handler: Rackup::Handler::WEBrick" # For confirmation log
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
+ app,
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
File without changes
@@ -0,0 +1,4 @@
1
+ module MiniradioServer
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,151 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: miniradio_server
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Koichiro Ohba
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: irb
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.16'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.16'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rackup
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: webrick
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: open3
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: logger
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ description: |2
111
+ This is a basic HTTP Live Streaming (HLS) server written in Ruby using the Rack interface. It serves MP3 audio files by converting them on-the-fly into HLS format (M3U8 playlist and MP3 segment files) using `ffmpeg`. Converted files are cached for subsequent requests.
112
+ This server is designed for simplicity and primarily targets Video on Demand (VOD) scenarios where you want to stream existing MP3 files via HLS without pre-converting them.
113
+ email:
114
+ - koichiro.ohba@gmail.com
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - LICENSE.txt
120
+ - README.md
121
+ - Rakefile
122
+ - hls_cache/.gitkeep
123
+ - lib/miniradio_server.rb
124
+ - lib/miniradio_server/app.rb
125
+ - lib/miniradio_server/version.rb
126
+ - mp3_files/.gitkeep
127
+ - sig/miniradio_server.rbs
128
+ homepage: https://github.com/koichiro/miniradio_server
129
+ licenses:
130
+ - MIT
131
+ metadata:
132
+ homepage_uri: https://github.com/koichiro/miniradio_server
133
+ source_code_uri: https://github.com/koichiro/miniradio_server.git
134
+ rdoc_options: []
135
+ require_paths:
136
+ - lib
137
+ required_ruby_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: 3.1.0
142
+ required_rubygems_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ requirements: []
148
+ rubygems_version: 3.6.7
149
+ specification_version: 4
150
+ summary: Miniradio Server is Simple Ruby HLS Server for MP3s.
151
+ test_files: []