Add MSP media protocol support

This commit is contained in:
2026-09-17 17:53:08 +02:00
parent 3fd28c2e0b
commit 573f1b7acb
14 changed files with 357 additions and 51 deletions
+48 -20
View File
@@ -9,7 +9,7 @@ module MeowClient
PlayRequest = Struct.new(
:name, :url, :kind, :tag, :volume, :fade_in, :fade_out, :start_at,
:finish_at, :loops, :priority, :continue_existing, :key, :caption,
keyword_init: true
:allow_insecure_http, keyword_init: true
)
StopRequest = Struct.new(
:name, :kind, :tag, :priority, :key, :fade_away, :fade_out,
@@ -20,21 +20,23 @@ module MeowClient
Track = Struct.new(:request, :handle, :url, :remaining_loops, :started_at, keyword_init: true)
class URLPolicy
AUDIO_EXTENSIONS = %w[.mp3 .ogg .opus .wav .flac .m4a .aac].freeze
AUDIO_EXTENSIONS = %w[.mp3 .ogg .opus .wav .flac .m4a .aac .mid .midi].freeze
def resolve(base_url, name, explicit_url = nil)
def resolve(base_url, name, explicit_url = nil, allow_insecure_http: false)
clean_name = validate_name(name)
base = explicit_url.to_s.strip == "" ? base_url : explicit_url
raise ArgumentError, "No media base URL was supplied" if base.to_s.strip == ""
base_uri = validate_uri(URI.parse(base.to_s))
base_uri = validate_uri(URI.parse(base.to_s), :allow_insecure_http => allow_insecure_http)
value = clean_name == "" ? base_uri : URI.join(ensure_directory(base_uri.to_s), clean_name)
validate_uri(value)
validate_uri(value, :allow_insecure_http => allow_insecure_http)
rescue URI::InvalidURIError => error
raise ArgumentError, "Invalid media URL: #{error.message}"
end
def validate_uri(uri)
raise ArgumentError, "Only HTTPS media URLs are allowed" unless uri.scheme.to_s.downcase == "https"
def validate_uri(uri, allow_insecure_http: false)
scheme = uri.scheme.to_s.downcase
allowed = scheme == "https" || (allow_insecure_http && scheme == "http")
raise ArgumentError, allow_insecure_http ? "Only HTTP or HTTPS media URLs are allowed" : "Only HTTPS media URLs are allowed" unless allowed
raise ArgumentError, "Media URL must include a host" if uri.host.to_s == ""
raise ArgumentError, "Media URL credentials are not allowed" if uri.userinfo != nil
raise ArgumentError, "Media URL contains control characters" if uri.to_s.match?(/[\x00-\x1f\x7f]/)
@@ -90,7 +92,7 @@ module MeowClient
@worker.report_on_exception = false
end
def enqueue(id, uri)
def enqueue(id, uri, allow_insecure_http: false)
return false if @closed
cached = cache_path(uri)
if File.file?(cached)
@@ -98,7 +100,7 @@ module MeowClient
@results << DownloadResult.new(:id => id, :path => cached, :url => uri.to_s)
return true
end
@queue.push([id, uri], true)
@queue.push([id, uri, allow_insecure_http], true)
true
rescue ThreadError
false
@@ -119,9 +121,9 @@ module MeowClient
loop do
job = @queue.pop
break if job == nil || @closed
id, uri = job
id, uri, allow_insecure_http = job
begin
path, final_uri = download(uri)
path, final_uri = download(uri, 0, :allow_insecure_http => allow_insecure_http)
@results << DownloadResult.new(:id => id, :path => path, :url => final_uri.to_s)
rescue Exception => error
@results << DownloadResult.new(:id => id, :url => uri.to_s, :error => error.message)
@@ -129,14 +131,14 @@ module MeowClient
end
end
def download(uri, redirects = 0)
def download(uri, redirects = 0, allow_insecure_http: false)
raise "Too many media redirects" if redirects > MAX_REDIRECTS
current = @policy.validate_uri(uri)
current = @policy.validate_uri(uri, :allow_insecure_http => allow_insecure_http)
response = nil
Net::HTTP.start(
current.host,
current.port,
:use_ssl => true,
:use_ssl => current.scheme.to_s.downcase == "https",
:open_timeout => OPEN_TIMEOUT,
:read_timeout => READ_TIMEOUT
) do |http|
@@ -146,8 +148,11 @@ module MeowClient
if incoming.is_a?(Net::HTTPRedirection)
location = incoming["location"]
raise "Media redirect did not include a location" if location.to_s == ""
redirected = @policy.validate_uri(URI.join(current.to_s, location))
return download(redirected, redirects + 1)
redirected = @policy.validate_uri(
URI.join(current.to_s, location),
:allow_insecure_http => allow_insecure_http
)
return download(redirected, redirects + 1, :allow_insecure_http => allow_insecure_http)
end
raise "Media download failed with HTTP #{incoming.code}" unless incoming.is_a?(Net::HTTPSuccess)
length = Integer(incoming["content-length"], :exception => false)
@@ -274,11 +279,22 @@ module MeowClient
end
def default_url=(value)
@default_url = @policy.validate_uri(URI.parse(value.to_s)).to_s
@default_url = normalize_base_url(value)
rescue ArgumentError, URI::InvalidURIError => error
add_error(error.message)
end
def normalize_base_url(value, allow_insecure_http: false)
@policy.validate_uri(
URI.parse(value.to_s),
:allow_insecure_http => allow_insecure_http
).to_s
end
def report_error(value)
add_error(value)
end
def preload(request)
queue(request, false)
end
@@ -353,15 +369,27 @@ module MeowClient
def queue(request, play_after)
loaded = @loaded_urls[request.name.to_s] if request.url.to_s == ""
if loaded != nil
uri = @policy.validate_uri(URI.parse(loaded))
uri = @policy.validate_uri(
URI.parse(loaded),
:allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true
)
else
base = request.url.to_s == "" ? @default_url : request.url
uri = @policy.resolve(base, request.name, request.url)
uri = @policy.resolve(
base,
request.name,
request.url,
:allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true
)
end
raise "Unsupported audio file type" unless @policy.supported_audio?(uri)
@sequence += 1
@pending[@sequence] = [request, play_after]
unless @downloader.enqueue(@sequence, uri)
unless @downloader.enqueue(
@sequence,
uri,
:allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true
)
@pending.delete(@sequence)
raise "Media download queue is full"
end
+123
View File
@@ -0,0 +1,123 @@
module MeowClient
module MSP
MAX_TRIGGER_BYTES = 8_192
Action = Struct.new(:type, :request, :url, :error, keyword_init: true)
class Adapter
attr_reader :default_url
def initialize
@default_url = nil
end
def default_url=(value)
@default_url = value.to_s
end
def parse(text, out_of_band: false)
value = text.to_s
raise ArgumentError, "MSP trigger exceeds #{MAX_TRIGGER_BYTES} bytes" if value.bytesize > MAX_TRIGGER_BYTES
value = "!!#{value}" if out_of_band && value.match?(/\A(?:SOUND|MUSIC)\(/i)
match = value.match(/\A!!(SOUND|MUSIC)\((.*)\)\z/i)
return nil if match == nil
kind = match[1].downcase
tokens = tokenize(match[2])
raise ArgumentError, "MSP trigger does not include a file name" if tokens.empty?
name = tokens.shift
parameters = parse_parameters(tokens)
url = parameters["U"]
if name.casecmp?("off")
return Action.new(:type => :default_url, :url => url) if url != nil
return Action.new(:type => :stop, :request => Media::StopRequest.new(:kind => kind))
end
raise ArgumentError, "MSP wildcard media names require a local sound pack" if name.include?("*") || name.include?("?")
raise ArgumentError, "MSP media name must use forward slashes" if name.include?("\\")
name += kind == "sound" ? ".wav" : ".mid" if File.extname(name) == ""
volume = integer_parameter(parameters, "V", 100, 0..100)
loops = integer_parameter(parameters, "L", 1, nil)
raise ArgumentError, "MSP L must be -1 or greater than zero" unless loops == -1 || loops > 0
priority = integer_parameter(parameters, "P", 50, 0..100)
continuing = integer_parameter(parameters, "C", 1, 0..1) == 1
tag = parameters["T"]
if tag.to_s == ""
directory = File.dirname(name)
tag = directory unless directory == "."
end
request = Media::PlayRequest.new(
:name => name,
:url => url || @default_url,
:kind => kind,
:tag => tag,
:volume => volume,
:loops => loops,
:priority => priority,
:continue_existing => kind == "music" ? continuing : false,
:key => kind == "music" ? "msp:music" : nil,
:allow_insecure_http => true
)
Action.new(:type => :play, :request => request)
rescue ArgumentError => error
Action.new(:type => :error, :error => error.message)
end
def parse_bytes(bytes, encoding)
value = bytes.to_s.b.force_encoding(encoding.to_s).encode(
Encoding::UTF_8,
:invalid => :replace,
:undef => :replace,
:replace => "\uFFFD"
)
parse(value, :out_of_band => true)
rescue Encoding::ConverterNotFoundError => error
Action.new(:type => :error, :error => "Could not decode MSP trigger: #{error.message}")
end
private
def tokenize(body)
tokens = []
token = +""
quoted = false
body.to_s.each_char do |character|
if character == '"'
quoted = !quoted
token << character
elsif character.match?(/\s/) && !quoted
unless token.empty?
tokens << token
token = +""
end
else
token << character
end
end
raise ArgumentError, "MSP trigger contains an unterminated quote" if quoted
tokens << token unless token.empty?
tokens
end
def parse_parameters(tokens)
tokens.each_with_object({}) do |token, parameters|
name, value = token.split("=", 2)
next if value == nil || !name.match?(/\A[A-Za-z]\z/)
value = value[1...-1] if value.start_with?('"') && value.end_with?('"')
parameters[name.upcase] = value
end
end
def integer_parameter(parameters, name, default, range)
return default unless parameters.key?(name)
value = Integer(parameters[name], :exception => false)
raise ArgumentError, "MSP #{name} must be an integer" if value == nil
raise ArgumentError, "MSP #{name} is outside its supported range" if range != nil && !range.cover?(value)
value
end
end
end
end
+39 -4
View File
@@ -14,7 +14,7 @@ module MeowClient
@program = program
@profile = profile
@transport = transport
@pipeline = TextPipeline.new(profile.encoding)
@pipeline = TextPipeline.new(profile.encoding, :enable_msp => profile.remote_media_enabled)
@lines = []
@line_bytes = 0
@history = []
@@ -26,7 +26,14 @@ module MeowClient
@gmcp_enabled = false
@gmcp_state = GMCP::State.new
@gmcp_log = GMCP::MessageLog.new
@media = nil
@msp = profile.remote_media_enabled ? MSP::Adapter.new : nil
@media = if profile.remote_media_enabled
Media::Manager.new(
@program.cache_path("mud-media"),
:sound_volume => profile.sound_volume.to_f / 100.0,
:music_volume => profile.music_volume.to_f / 100.0
)
end
@closed = false
build_form
end
@@ -55,9 +62,10 @@ module MeowClient
@speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading"))
@stop_speech = Button.new(_("Stop current speech"))
@gmcp_button = Button.new(_("GMCP information"))
@media_button = Button.new(_("Media"))
@disconnect = Button.new(_("Disconnect"))
@form = Form.new(
[@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @disconnect],
[@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @media_button, @disconnect],
:quiet => true
)
@form.accept_button = @send
@@ -68,6 +76,7 @@ module MeowClient
@speech_button.on(:press) { toggle_speech }
@stop_speech.on(:press) { speech_stop }
@gmcp_button.on(:press) { open_gmcp_hub }
@media_button.on(:press) { open_media_view }
@disconnect.on(:press) { request_disconnect }
@output.add_tip(_("Use the context menu to jump to the latest output."))
@@ -113,7 +122,7 @@ module MeowClient
result = @pipeline.feed(event.payload)
result.replies.each { |reply| @transport.send_bytes(reply) }
result.events.each { |protocol_event| handle_protocol_event(protocol_event) }
result.records.each { |record| receive_record(record) }
result.records.each { |record| receive_protocol_record(record) }
when :disconnected, :error
@pipeline.finish.each { |record| receive_record(record) }
add_system_line(event.payload.to_s)
@@ -123,6 +132,32 @@ module MeowClient
rescue ThreadError
end
def receive_protocol_record(record)
if @msp != nil
action = @msp.parse(record)
return handle_msp_action(action) if action != nil
end
receive_record(record)
end
def handle_msp_action(action)
case action.type
when :play
@media.play(action.request)
when :stop
@media.stop(action.request)
when :default_url
normalized = @media.normalize_base_url(action.url, :allow_insecure_http => true)
@msp.default_url = normalized
when :error
@media.report_error(action.error)
end
true
rescue ArgumentError, URI::InvalidURIError => error
@media.report_error(error.message)
true
end
def receive_record(record)
text = record.to_s
append_line(text)
+11 -9
View File
@@ -11,6 +11,17 @@ module MeowClient
receive_gmcp(payload)
when :gmcp_error
@gmcp_log.add_error(payload)
when :msp_enabled
add_system_line(payload ? _("MSP negotiated.") : _("MSP disabled by the server."))
when :msp
action = @msp.parse_bytes(payload, @profile.encoding) if @msp != nil
if action == nil
@media.report_error("Invalid MSP subnegotiation") if @media != nil
else
handle_msp_action(action)
end
when :msp_error
@media.report_error(payload) if @media != nil
end
end
@@ -22,13 +33,6 @@ module MeowClient
packages = ["Char 1", "Char.Items 1", "Char.Skills 1", "Room 1", "Group 1", "Comm 1"]
packages << "Client.Media 1" if @profile.remote_media_enabled
send_gmcp("Core.Supports.Set", packages)
if @profile.remote_media_enabled
@media = Media::Manager.new(
@program.cache_path("mud-media"),
:sound_volume => @profile.sound_volume.to_f / 100.0,
:music_volume => @profile.music_volume.to_f / 100.0
)
end
add_system_line(_("GMCP negotiated."))
end
@@ -37,8 +41,6 @@ module MeowClient
@gmcp_enabled = false
@gmcp_button.label = _("GMCP information") if @gmcp_button != nil
@gmcp_state.reset
@media.close if @media != nil
@media = nil
add_system_line(_("GMCP disabled by the server."))
end
+10 -2
View File
@@ -15,6 +15,7 @@ module MeowClient
SUPPRESS_GO_AHEAD = 3
TERMINAL_TYPE = 24
COMPRESS2 = 86
MSP = 90
GMCP = 201
TTYPE_IS = 0
TTYPE_SEND = 1
@@ -40,7 +41,7 @@ module MeowClient
class Parser
attr_reader :remote_echo
def initialize
def initialize(enable_msp: false)
@state = :data
@negotiation_command = nil
@subnegotiation_option = nil
@@ -51,6 +52,7 @@ module MeowClient
@local_options = {}
@inflater = nil
@compression_finished = false
@enable_msp = enable_msp == true
end
def feed(bytes)
@@ -167,7 +169,7 @@ module MeowClient
def negotiate(command, option)
case command
when WILL
enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option)
enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option) || (option == MSP && @enable_msp)
changed = @remote_options[option] != enabled
@remote_options[option] = enabled
return [nil, nil] unless changed
@@ -176,6 +178,7 @@ module MeowClient
return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]]
end
return [packet(DO, option), [:gmcp_enabled, true]] if option == GMCP && enabled
return [packet(DO, option), [:msp_enabled, true]] if option == MSP && enabled
[packet(enabled ? DO : DONT, option), nil]
when WONT
changed = @remote_options[option] != false
@@ -185,6 +188,7 @@ module MeowClient
return [packet(DONT, option), [:remote_echo, false]] if changed
end
return [changed ? packet(DONT, option) : nil, [:gmcp_enabled, false]] if option == GMCP && changed
return [changed ? packet(DONT, option) : nil, [:msp_enabled, false]] if option == MSP && changed && @enable_msp
[changed ? packet(DONT, option) : nil, nil]
when DO
enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option)
@@ -203,6 +207,7 @@ module MeowClient
def finish_subnegotiation
if @subnegotiation_overflow
return [nil, [:gmcp_error, "Telnet subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == GMCP
return [nil, [:msp_error, "MSP subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == MSP
return [nil, [:subnegotiation, @subnegotiation_option]]
end
if @subnegotiation_option == GMCP && @remote_options[GMCP] == true
@@ -212,6 +217,9 @@ module MeowClient
return [nil, [:gmcp_error, error.message]]
end
end
if @subnegotiation_option == MSP && @remote_options[MSP] == true
return [nil, [:msp, @subnegotiation.dup]]
end
if @subnegotiation_option == COMPRESS2 &&
@remote_options[COMPRESS2] == true &&
@subnegotiation.empty?
+2 -2
View File
@@ -41,8 +41,8 @@ module MeowClient
class TextPipeline
Result = Struct.new(:records, :replies, :events, keyword_init: true)
def initialize(encoding)
@telnet = Telnet::Parser.new
def initialize(encoding, enable_msp: false)
@telnet = Telnet::Parser.new(:enable_msp => enable_msp)
@ansi = AnsiFilter.new
@encoding = encoding.to_s
@decode_pending = +"".b
+1 -1
View File
@@ -81,7 +81,7 @@ module MeowClient
port = EditBox.new(_("Port"), :type => EditBox::Flags::Numbers, :text => profile.port.to_s)
tls = CheckBox.new(_("Use TLS"), :checked => profile.tls)
auto_read = CheckBox.new(_("Read incoming text automatically"), :checked => profile.auto_read)
remote_media = CheckBox.new(_("Allow this server to download and play HTTPS media"), :checked => profile.remote_media_enabled)
remote_media = CheckBox.new(_("Allow remote media; legacy MSP servers may use insecure HTTP"), :checked => profile.remote_media_enabled)
speak_captions = CheckBox.new(_("Read media captions automatically"), :checked => profile.speak_media_captions)
sound_volume = EditBox.new(_("Sound volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => profile.sound_volume.to_i.to_s)
music_volume = EditBox.new(_("Music volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => profile.music_volume.to_i.to_s)
+1 -1
View File
@@ -1,3 +1,3 @@
module MeowClient
VERSION = "0.2.0"
VERSION = "0.3.0"
end