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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +113 -0
- data/Rakefile +8 -0
- data/hls_cache/.gitkeep +0 -0
- data/lib/miniradio_server/app.rb +296 -0
- data/lib/miniradio_server/version.rb +5 -0
- data/lib/miniradio_server.rb +106 -0
- data/mp3_files/.gitkeep +0 -0
- data/sig/miniradio_server.rbs +4 -0
- metadata +151 -0
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
data/hls_cache/.gitkeep
ADDED
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,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
|
data/mp3_files/.gitkeep
ADDED
File without changes
|
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: []
|