* Copyright (C) 2026 Xiaomi Corporation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "tools/tool_media.h"
#include "core/message_bus.h"
#include "infra/vela_tls.h"
#include "agent_compat.h"
#include <math.h>
#include <semaphore.h>
#include <stdarg.h>
#include <string.h>
#include <syslog.h>
#include "cJSON.h"
#include <media_player.h>
#include <media_policy.h>
* Pre-processor Definitions
****************************************************************************/
#define TAG "AGENT_MUSIC"
#define MEDIA_URL_MAX 512
#define MEDIA_OPTIONS_MAX 256
#define MEDIA_VOL_MIN_IDX 0
#define MEDIA_VOL_MAX_IDX 15
#define MEDIA_VOL_USER_MIN 0
#define MEDIA_VOL_USER_MAX 100
#define TIMEOUT_PREPARE_MS 10000
#define TIMEOUT_START_MS 3000
#define TIMEOUT_PAUSE_MS 3000
#define TIMEOUT_STOP_MS 3000
#define TIMEOUT_SEEK_MS 3000
* Private Types
****************************************************************************/
typedef enum {
MEDIA_STATE_IDLE = 0,
MEDIA_STATE_PREPARED,
MEDIA_STATE_PLAYING,
MEDIA_STATE_PAUSED,
MEDIA_STATE_ERROR,
} media_state_e;
typedef struct
{
pthread_mutex_t lock;
media_state_e state;
void* handle;
uint32_t session_id;
char url[MEDIA_URL_MAX];
int volume;
bool exiting;
bool is_live_stream;
sem_t sem;
int last_event;
int last_result;
} media_ctx_t;
* Private Data
****************************************************************************/
static media_ctx_t g_media;
static bool g_media_initialized = false;
* Private Functions
****************************************************************************/
static void media_ctx_init_once(void)
{
if (g_media_initialized) {
return;
}
memset(&g_media, 0, sizeof(g_media));
pthread_mutex_init(&g_media.lock, NULL);
sem_init(&g_media.sem, 0, 0);
g_media.state = MEDIA_STATE_IDLE;
g_media.handle = NULL;
g_media.session_id = 0;
g_media.volume = 50;
g_media.exiting = false;
g_media.last_event = 0;
g_media.last_result = 0;
g_media_initialized = true;
}
static const char* state_str(media_state_e s)
{
switch (s) {
case MEDIA_STATE_IDLE:
return "IDLE";
case MEDIA_STATE_PREPARED:
return "PREPARED";
case MEDIA_STATE_PLAYING:
return "PLAYING";
case MEDIA_STATE_PAUSED:
return "PAUSED";
case MEDIA_STATE_ERROR:
return "ERROR";
default:
return "UNKNOWN";
}
}
* Sanitize URL for logging — strip query string to avoid token leaks.
*/
static void log_url(const char* url, char* buf, size_t bufsz)
{
const char* q = strchr(url, '?');
if (q) {
size_t len = (size_t)(q - url);
if (len >= bufsz) {
len = bufsz - 1;
}
memcpy(buf, url, len);
buf[len] = '\0';
} else {
snprintf(buf, bufsz, "%s", url);
}
}
* Force-close current session (stop + close). Caller must hold lock.
* Does NOT wait for events — used for error recovery and cleanup.
*/
static void force_close_locked(void)
{
if (g_media.handle) {
syslog(LOG_INFO, "[%s] Force close session %" PRIu32 "\n",
TAG, g_media.session_id);
media_player_stop(g_media.handle);
* before close — prevents "play/resume failed: End of file"
* log spam when the player tries to read after EOF. */
pthread_mutex_unlock(&g_media.lock);
usleep(50 * 1000);
pthread_mutex_lock(&g_media.lock);
if (g_media.handle) {
media_player_close(g_media.handle, 0);
g_media.handle = NULL;
}
}
g_media.state = MEDIA_STATE_IDLE;
g_media.url[0] = '\0';
}
* Map user volume (0..100) to hardware index.
*/
static int volume_to_index(int vol)
{
if (vol <= MEDIA_VOL_USER_MIN) {
return MEDIA_VOL_MIN_IDX;
}
if (vol >= MEDIA_VOL_USER_MAX) {
return MEDIA_VOL_MAX_IDX;
}
int idx = MEDIA_VOL_MIN_IDX + (int)round((double)vol * (MEDIA_VOL_MAX_IDX - MEDIA_VOL_MIN_IDX) / 100.0);
if (idx < MEDIA_VOL_MIN_IDX) {
idx = MEDIA_VOL_MIN_IDX;
}
if (idx > MEDIA_VOL_MAX_IDX) {
idx = MEDIA_VOL_MAX_IDX;
}
return idx;
}
* Push a notification to the agent loop so the LLM can inform the user
* about asynchronous playback events (EOF, errors).
* Must be called WITHOUT g_media.lock held (push may block briefly).
*/
static void notify_agent(const char* fmt, ...)
{
char buf[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
agent_msg_t msg;
memset(&msg, 0, sizeof(msg));
strncpy(msg.channel, "system", sizeof(msg.channel) - 1);
strncpy(msg.chat_id, "media", sizeof(msg.chat_id) - 1);
msg.content = strdup(buf);
if (msg.content) {
if (message_bus_push_inbound(&msg) != OK) {
syslog(LOG_WARNING, "[%s] Failed to push media notification\n", TAG);
free(msg.content);
}
}
}
* Media event callback — called from media framework thread.
* Records event + result, then posts semaphore for waiting execute.
* Also handles autonomous events (COMPLETED / async errors).
*/
static void media_event_cb(void* cookie, int event, int result,
const char* extra)
{
uint32_t cb_session = (uint32_t)(uintptr_t)cookie;
pthread_mutex_lock(&g_media.lock);
if (cb_session != g_media.session_id) {
syslog(LOG_DEBUG,
"[%s] Stale event %d for session %" PRIu32
" (current %" PRIu32 "), discarded\n",
TAG, event, cb_session, g_media.session_id);
pthread_mutex_unlock(&g_media.lock);
return;
}
if (g_media.exiting) {
pthread_mutex_unlock(&g_media.lock);
return;
}
if (g_media.state == MEDIA_STATE_IDLE && g_media.handle == NULL) {
syslog(LOG_DEBUG,
"[%s] Event %d after cleanup, discarded\n",
TAG, event);
pthread_mutex_unlock(&g_media.lock);
return;
}
syslog(LOG_INFO, "[%s] Event %d result %d extra=%s\n",
TAG, event, result, extra ? extra : "");
* during playback. If we let them through they poison
* last_event and wake sem, breaking wait_event() for real
* commands (stop / pause / seek / switch). */
if (event == MEDIA_EVENT_NOP) {
pthread_mutex_unlock(&g_media.lock);
return;
}
if (event == MEDIA_EVENT_COMPLETED) {
char url_copy[MEDIA_URL_MAX];
snprintf(url_copy, sizeof(url_copy), "%s", g_media.url);
uint32_t sid = g_media.session_id;
if (result >= 0) {
syslog(LOG_INFO, "[%s] EOF — auto stop session %" PRIu32 "\n",
TAG, sid);
g_media.session_id++;
force_close_locked();
pthread_mutex_unlock(&g_media.lock);
notify_agent("[music] Playback finished (session %" PRIu32 ")."
" The track has ended normally.",
sid);
} else {
syslog(LOG_ERR,
"[%s] COMPLETED with error result=%d session %" PRIu32 "\n",
TAG, result, sid);
g_media.session_id++;
force_close_locked();
pthread_mutex_unlock(&g_media.lock);
notify_agent("[music] Playback failed (session %" PRIu32
", error=%d). Please inform the user that"
" the music could not be played successfully.",
sid, result);
}
return;
}
g_media.last_event = event;
g_media.last_result = result;
if (result >= 0) {
switch (event) {
case MEDIA_EVENT_PREPARED:
g_media.state = MEDIA_STATE_PREPARED;
break;
case MEDIA_EVENT_STARTED:
g_media.state = MEDIA_STATE_PLAYING;
break;
case MEDIA_EVENT_PAUSED:
g_media.state = MEDIA_STATE_PAUSED;
break;
case MEDIA_EVENT_STOPPED:
break;
default:
break;
}
} else {
syslog(LOG_ERR,
"[%s] Async error event=%d result=%d extra=%s\n",
TAG, event, result, extra ? extra : "");
g_media.state = MEDIA_STATE_ERROR;
}
pthread_mutex_unlock(&g_media.lock);
sem_post(&g_media.sem);
}
* Wait for a specific media event with timeout.
* Caller must hold g_media.lock. Lock is released during wait
* to allow the callback to run, then re-acquired.
*
* Returns 0 on success (expected event with result >= 0).
* Returns -1 on timeout, wrong event, or error result.
*/
static int wait_event(int expected_event, int timeout_ms)
{
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += timeout_ms / 1000;
ts.tv_nsec += (timeout_ms % 1000) * 1000000L;
if (ts.tv_nsec >= 1000000000L) {
ts.tv_sec += 1;
ts.tv_nsec -= 1000000000L;
}
g_media.last_event = -1;
g_media.last_result = 0;
pthread_mutex_unlock(&g_media.lock);
int ret = sem_timedwait(&g_media.sem, &ts);
pthread_mutex_lock(&g_media.lock);
if (ret != 0) {
syslog(LOG_ERR, "[%s] Timeout waiting for event %d (%d ms)\n",
TAG, expected_event, timeout_ms);
return -1;
}
if (g_media.last_result < 0) {
syslog(LOG_ERR,
"[%s] Event %d failed with result %d (expected %d)\n",
TAG, g_media.last_event, g_media.last_result,
expected_event);
return -1;
}
if (g_media.last_event != expected_event) {
syslog(LOG_WARNING,
"[%s] Got event %d but expected %d (result=%d)\n",
TAG, g_media.last_event, expected_event,
g_media.last_result);
* in slightly different order */
return 0;
}
syslog(LOG_INFO, "[%s] Event %d confirmed OK\n", TAG, expected_event);
return 0;
}
* JSON error response helper.
*/
static int respond_error(char* output, size_t output_size,
const char* code, const char* message)
{
snprintf(output, output_size,
"{\"ok\":false,\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
code, message);
return ERROR;
}
* JSON ok response with state.
*/
static int respond_ok_state(char* output, size_t output_size,
const char* extra)
{
if (extra) {
snprintf(output, output_size,
"{\"ok\":true,\"state\":\"%s\",%s}",
state_str(g_media.state), extra);
} else {
snprintf(output, output_size,
"{\"ok\":true,\"state\":\"%s\"}",
state_str(g_media.state));
}
return OK;
}
* Public Functions
****************************************************************************/
* music_play — open + prepare (wait PREPARED) + (seek) + start (wait STARTED).
*/
int tool_music_play_execute(const char* input_json,
char* output, size_t output_size)
{
media_ctx_init_once();
cJSON* root = cJSON_Parse(input_json);
if (!root) {
return respond_error(output, output_size,
"EINVAL_ARG", "Invalid JSON input");
}
cJSON* j_url = cJSON_GetObjectItem(root, "url");
if (!j_url || !cJSON_IsString(j_url) || !j_url->valuestring[0]) {
cJSON_Delete(root);
return respond_error(output, output_size,
"EINVAL_ARG", "Missing or empty 'url'");
}
* For raw PCM files, format info is required because there is no
* container header for avformat to auto-detect. */
char options_buf[MEDIA_OPTIONS_MAX] = { 0 };
const char* options = NULL;
cJSON* j_opts = cJSON_GetObjectItem(root, "options");
if (j_opts && cJSON_IsString(j_opts) && j_opts->valuestring[0]) {
snprintf(options_buf, sizeof(options_buf), "%s",
j_opts->valuestring);
options = options_buf;
} else {
cJSON* j_fmt = cJSON_GetObjectItem(root, "format");
cJSON* j_rate = cJSON_GetObjectItem(root, "sample_rate");
cJSON* j_ch = cJSON_GetObjectItem(root, "channels");
* that bogus LLM args like format="" sample_rate=0 channels=0
* fall through to auto-detection instead of producing an
* invalid options string. */
bool has_fmt = j_fmt && cJSON_IsString(j_fmt)
&& j_fmt->valuestring[0];
bool has_rate = j_rate && cJSON_IsNumber(j_rate)
&& (int)j_rate->valuedouble > 0;
bool has_ch = j_ch && cJSON_IsNumber(j_ch)
&& (int)j_ch->valuedouble > 0;
if (has_fmt || has_rate || has_ch) {
const char* fmt = has_fmt ? j_fmt->valuestring : "s16le";
int rate = has_rate ? (int)j_rate->valuedouble : 16000;
int ch = has_ch ? (int)j_ch->valuedouble : 1;
const char* layout = (ch == 2) ? "stereo" : "mono";
snprintf(options_buf, sizeof(options_buf),
"format=%s:sample_rate=%d:ch_layout=%s",
fmt, rate, layout);
options = options_buf;
}
}
* provide a sensible default (s16le, 16kHz, mono). */
if (!options) {
const char* ext = strrchr(j_url->valuestring, '.');
if (ext && strcasecmp(ext, ".pcm") == 0) {
snprintf(options_buf, sizeof(options_buf),
"format=s16le:sample_rate=16000:ch_layout=mono");
options = options_buf;
syslog(LOG_INFO,
"[%s] Auto-detected .pcm, using default options: %s\n",
TAG, options);
}
}
bool autostart = true;
cJSON* j_auto = cJSON_GetObjectItem(root, "autostart");
if (j_auto && cJSON_IsBool(j_auto)) {
autostart = cJSON_IsTrue(j_auto);
}
int start_pos = 0;
cJSON* j_pos = cJSON_GetObjectItem(root, "start_position_ms");
if (j_pos && cJSON_IsNumber(j_pos)) {
start_pos = (int)j_pos->valuedouble;
if (start_pos < 0) {
start_pos = 0;
}
}
const char* url = j_url->valuestring;
pthread_mutex_lock(&g_media.lock);
if (g_media.exiting) {
pthread_mutex_unlock(&g_media.lock);
cJSON_Delete(root);
return respond_error(output, output_size,
"EINTERNAL", "Module is shutting down");
}
if (g_media.state != MEDIA_STATE_IDLE) {
syslog(LOG_INFO, "[%s] Cut-song: closing old session %" PRIu32 "\n",
TAG, g_media.session_id);
force_close_locked();
}
while (sem_trywait(&g_media.sem) == 0)
;
g_media.session_id++;
uint32_t sid = g_media.session_id;
char safe_url[128];
log_url(url, safe_url, sizeof(safe_url));
syslog(LOG_INFO, "[%s] Play session %" PRIu32 " url=%s options=%s\n",
TAG, sid, safe_url, options ? options : "(auto)");
g_media.handle = media_player_open(MEDIA_STREAM_MUSIC);
if (!g_media.handle) {
syslog(LOG_ERR, "[%s] media_player_open failed\n", TAG);
pthread_mutex_unlock(&g_media.lock);
cJSON_Delete(root);
return respond_error(output, output_size,
"EIO_MEDIA", "media_player_open failed");
}
int cb_ret = media_player_set_event_callback(g_media.handle,
(void*)(uintptr_t)sid,
media_event_cb);
if (cb_ret < 0) {
syslog(LOG_ERR, "[%s] set_event_callback failed: %d\n", TAG, cb_ret);
media_player_close(g_media.handle, 0);
g_media.handle = NULL;
pthread_mutex_unlock(&g_media.lock);
cJSON_Delete(root);
return respond_error(output, output_size,
"EIO_MEDIA", "set_event_callback failed");
}
int ret = media_player_prepare(g_media.handle, url, options);
if (ret < 0) {
syslog(LOG_ERR, "[%s] media_player_prepare send failed: %d\n",
TAG, ret);
media_player_close(g_media.handle, 0);
g_media.handle = NULL;
pthread_mutex_unlock(&g_media.lock);
cJSON_Delete(root);
return respond_error(output, output_size,
"EIO_MEDIA", "media_player_prepare failed");
}
if (wait_event(MEDIA_EVENT_PREPARED, TIMEOUT_PREPARE_MS) != 0) {
if (g_media.state == MEDIA_STATE_ERROR) {
syslog(LOG_ERR,
"[%s] Prepare failed (stream error)\n", TAG);
force_close_locked();
pthread_mutex_unlock(&g_media.lock);
cJSON_Delete(root);
return respond_error(output, output_size,
"EIO_MEDIA",
"Track unavailable (prepare failed). "
"Try another song from the search results.");
}
syslog(LOG_WARNING,
"[%s] PREPARED event not confirmed within %d ms, "
"proceeding anyway (prepare ret was 0)\n",
TAG, TIMEOUT_PREPARE_MS);
g_media.state = MEDIA_STATE_PREPARED;
}
snprintf(g_media.url, sizeof(g_media.url), "%s", url);
g_media.is_live_stream = (strncasecmp(url, "rtsp://", 7) == 0 || strncasecmp(url, "rtmp://", 7) == 0);
if (start_pos > 0) {
ret = media_player_seek(g_media.handle, (unsigned int)start_pos);
if (ret >= 0) {
if (wait_event(MEDIA_EVENT_SEEKED, TIMEOUT_SEEK_MS) != 0) {
syslog(LOG_WARNING,
"[%s] Pre-seek wait failed, continuing\n", TAG);
} else {
syslog(LOG_INFO, "[%s] Pre-seek to %d ms OK\n",
TAG, start_pos);
}
} else {
syslog(LOG_WARNING,
"[%s] Pre-seek send failed (%d), will try after start\n",
TAG, ret);
}
}
if (autostart) {
ret = media_player_start(g_media.handle);
if (ret < 0) {
syslog(LOG_ERR, "[%s] media_player_start send failed: %d\n",
TAG, ret);
force_close_locked();
pthread_mutex_unlock(&g_media.lock);
cJSON_Delete(root);
return respond_error(output, output_size,
"EIO_MEDIA", "media_player_start failed");
}
if (wait_event(MEDIA_EVENT_STARTED, TIMEOUT_START_MS) != 0) {
* never fires. If start() returned 0, assume it worked. */
syslog(LOG_WARNING,
"[%s] STARTED event not confirmed within %d ms, "
"proceeding anyway (start ret was 0)\n",
TAG, TIMEOUT_START_MS);
g_media.state = MEDIA_STATE_PLAYING;
}
if (start_pos > 0) {
ret = media_player_seek(g_media.handle,
(unsigned int)start_pos);
if (ret >= 0) {
wait_event(MEDIA_EVENT_SEEKED, TIMEOUT_SEEK_MS);
}
}
}
char extra[256];
snprintf(extra, sizeof(extra),
"\"session_id\":%" PRIu32 ",\"url\":\"%s\","
"\"start_position_ms\":%d",
sid, url, start_pos);
int rc = respond_ok_state(output, output_size, extra);
pthread_mutex_unlock(&g_media.lock);
cJSON_Delete(root);
return rc;
}
* music_pause — pause + wait MEDIA_EVENT_PAUSED.
*/
int tool_music_pause_execute(const char* input_json,
char* output, size_t output_size)
{
(void)input_json;
media_ctx_init_once();
pthread_mutex_lock(&g_media.lock);
if (g_media.state == MEDIA_STATE_PAUSED) {
int rc = respond_ok_state(output, output_size, "\"noop\":true");
pthread_mutex_unlock(&g_media.lock);
return rc;
}
if (g_media.state != MEDIA_STATE_PLAYING) {
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EINVAL_STATE",
"Cannot pause: not playing");
}
int ret = media_player_pause(g_media.handle);
if (ret < 0) {
syslog(LOG_ERR, "[%s] media_player_pause send failed: %d\n",
TAG, ret);
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EIO_MEDIA", "media_player_pause failed");
}
if (wait_event(MEDIA_EVENT_PAUSED, TIMEOUT_PAUSE_MS) != 0) {
syslog(LOG_ERR, "[%s] Pause not confirmed\n", TAG);
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EIO_MEDIA",
"media_player_pause timed out");
}
syslog(LOG_INFO, "[%s] Paused session %" PRIu32 "\n",
TAG, g_media.session_id);
int rc = respond_ok_state(output, output_size, NULL);
pthread_mutex_unlock(&g_media.lock);
return rc;
}
* music_resume — start + wait MEDIA_EVENT_STARTED.
*/
int tool_music_resume_execute(const char* input_json,
char* output, size_t output_size)
{
(void)input_json;
media_ctx_init_once();
pthread_mutex_lock(&g_media.lock);
if (g_media.state == MEDIA_STATE_PLAYING) {
int rc = respond_ok_state(output, output_size, "\"noop\":true");
pthread_mutex_unlock(&g_media.lock);
return rc;
}
if (g_media.state != MEDIA_STATE_PAUSED && g_media.state != MEDIA_STATE_PREPARED) {
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EINVAL_STATE",
"Cannot resume: not paused or prepared");
}
int ret = media_player_start(g_media.handle);
if (ret < 0) {
syslog(LOG_ERR, "[%s] media_player_start (resume) send failed: %d\n",
TAG, ret);
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EIO_MEDIA", "media_player_start failed");
}
if (wait_event(MEDIA_EVENT_STARTED, TIMEOUT_START_MS) != 0) {
syslog(LOG_ERR, "[%s] Resume/start not confirmed\n", TAG);
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EIO_MEDIA",
"media_player_start timed out");
}
syslog(LOG_INFO, "[%s] Resumed session %" PRIu32 "\n",
TAG, g_media.session_id);
int rc = respond_ok_state(output, output_size, NULL);
pthread_mutex_unlock(&g_media.lock);
return rc;
}
* music_stop — stop + wait MEDIA_EVENT_STOPPED, then close.
*/
int tool_music_stop_execute(const char* input_json,
char* output, size_t output_size)
{
(void)input_json;
media_ctx_init_once();
pthread_mutex_lock(&g_media.lock);
if (g_media.state == MEDIA_STATE_IDLE) {
int rc = respond_ok_state(output, output_size, "\"noop\":true");
pthread_mutex_unlock(&g_media.lock);
return rc;
}
syslog(LOG_INFO, "[%s] Stop session %" PRIu32 "\n",
TAG, g_media.session_id);
if (g_media.handle && g_media.state != MEDIA_STATE_ERROR) {
int ret = media_player_stop(g_media.handle);
if (ret >= 0) {
wait_event(MEDIA_EVENT_STOPPED, TIMEOUT_STOP_MS);
}
}
force_close_locked();
int rc = respond_ok_state(output, output_size, NULL);
pthread_mutex_unlock(&g_media.lock);
return rc;
}
* music_seek — seek + wait MEDIA_EVENT_SEEKED.
*/
int tool_music_seek_execute(const char* input_json,
char* output, size_t output_size)
{
media_ctx_init_once();
cJSON* root = cJSON_Parse(input_json);
if (!root) {
return respond_error(output, output_size,
"EINVAL_ARG", "Invalid JSON input");
}
cJSON* j_pos = cJSON_GetObjectItem(root, "position_ms");
if (!j_pos || !cJSON_IsNumber(j_pos)) {
cJSON_Delete(root);
return respond_error(output, output_size,
"EINVAL_ARG", "Missing 'position_ms'");
}
int pos = (int)j_pos->valuedouble;
if (pos < 0) {
pos = 0;
}
cJSON_Delete(root);
pthread_mutex_lock(&g_media.lock);
if (g_media.state != MEDIA_STATE_PREPARED && g_media.state != MEDIA_STATE_PLAYING && g_media.state != MEDIA_STATE_PAUSED) {
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EINVAL_STATE",
"Cannot seek in current state");
}
int ret = media_player_seek(g_media.handle, (unsigned int)pos);
if (ret < 0) {
syslog(LOG_ERR, "[%s] media_player_seek send failed: %d\n",
TAG, ret);
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EIO_MEDIA", "media_player_seek failed");
}
if (wait_event(MEDIA_EVENT_SEEKED, TIMEOUT_SEEK_MS) != 0) {
syslog(LOG_ERR, "[%s] Seek not confirmed\n", TAG);
pthread_mutex_unlock(&g_media.lock);
return respond_error(output, output_size,
"EIO_MEDIA",
"media_player_seek timed out");
}
syslog(LOG_INFO, "[%s] Seek to %d ms, state=%s\n",
TAG, pos, state_str(g_media.state));
char extra[64];
snprintf(extra, sizeof(extra), "\"position_ms\":%d", pos);
int rc = respond_ok_state(output, output_size, extra);
pthread_mutex_unlock(&g_media.lock);
return rc;
}
* music_set_volume — maps 0..100 to hardware index via policy API.
* Policy API is truly synchronous, no event wait needed.
*/
int tool_music_set_volume_execute(const char* input_json,
char* output, size_t output_size)
{
media_ctx_init_once();
cJSON* root = cJSON_Parse(input_json);
if (!root) {
return respond_error(output, output_size,
"EINVAL_ARG", "Invalid JSON input");
}
cJSON* j_vol = cJSON_GetObjectItem(root, "volume");
if (!j_vol || !cJSON_IsNumber(j_vol)) {
cJSON_Delete(root);
return respond_error(output, output_size,
"EINVAL_ARG", "Missing 'volume'");
}
int vol = (int)j_vol->valuedouble;
cJSON_Delete(root);
if (vol < MEDIA_VOL_USER_MIN) {
syslog(LOG_WARNING, "[%s] Volume %d clamped to %d\n",
TAG, vol, MEDIA_VOL_USER_MIN);
vol = MEDIA_VOL_USER_MIN;
}
if (vol > MEDIA_VOL_USER_MAX) {
syslog(LOG_WARNING, "[%s] Volume %d clamped to %d\n",
TAG, vol, MEDIA_VOL_USER_MAX);
vol = MEDIA_VOL_USER_MAX;
}
int idx = volume_to_index(vol);
int ret = media_policy_set_stream_volume(MEDIA_STREAM_MUSIC, idx);
if (ret < 0) {
syslog(LOG_ERR, "[%s] media_policy_set_stream_volume failed: %d\n",
TAG, ret);
return respond_error(output, output_size,
"EIO_MEDIA",
"media_policy_set_stream_volume failed");
}
pthread_mutex_lock(&g_media.lock);
g_media.volume = vol;
pthread_mutex_unlock(&g_media.lock);
syslog(LOG_INFO, "[%s] Volume set to %d (index %d)\n", TAG, vol, idx);
char extra[64];
snprintf(extra, sizeof(extra),
"\"volume\":%d,\"mapped_index\":%d", vol, idx);
snprintf(output, output_size, "{\"ok\":true,%s}", extra);
return OK;
}
* music_status — read-only query, no event wait needed.
*/
int tool_music_status_execute(const char* input_json,
char* output, size_t output_size)
{
(void)input_json;
media_ctx_init_once();
pthread_mutex_lock(&g_media.lock);
media_state_e st = g_media.state;
uint32_t sid = g_media.session_id;
int vol = g_media.volume;
unsigned int position_ms = 0;
unsigned int duration_ms = 0;
bool duration_supported = !g_media.is_live_stream;
bool duration_ok = false;
bool position_ok = false;
if (g_media.handle && st != MEDIA_STATE_IDLE && st != MEDIA_STATE_ERROR) {
if (media_player_get_position(g_media.handle, &position_ms) == 0) {
position_ok = true;
}
if (media_player_get_duration(g_media.handle, &duration_ms) == 0) {
duration_ok = true;
}
}
cJSON* resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "ok", true);
cJSON_AddStringToObject(resp, "state", state_str(st));
if (st != MEDIA_STATE_IDLE) {
cJSON_AddStringToObject(resp, "url", g_media.url);
cJSON_AddNumberToObject(resp, "session_id", sid);
}
if (position_ok) {
cJSON_AddNumberToObject(resp, "position_ms", position_ms);
}
if (duration_ok) {
cJSON_AddNumberToObject(resp, "duration_ms", duration_ms);
} else {
cJSON_AddNumberToObject(resp, "duration_ms", -1);
cJSON_AddBoolToObject(resp, "duration_supported", duration_supported);
if (st != MEDIA_STATE_IDLE) {
cJSON_AddStringToObject(resp, "duration_error", "query_failed");
}
}
cJSON_AddNumberToObject(resp, "volume", vol);
pthread_mutex_unlock(&g_media.lock);
char* json = cJSON_PrintUnformatted(resp);
if (json) {
snprintf(output, output_size, "%s", json);
free(json);
} else {
snprintf(output, output_size, "{\"ok\":true,\"state\":\"%s\"}",
state_str(st));
}
cJSON_Delete(resp);
return OK;
}
* Module cleanup — called on program exit.
*/
void tool_media_cleanup(void)
{
if (!g_media_initialized) {
return;
}
syslog(LOG_INFO, "[%s] Cleanup\n", TAG);
pthread_mutex_lock(&g_media.lock);
g_media.exiting = true;
force_close_locked();
pthread_mutex_unlock(&g_media.lock);
sem_destroy(&g_media.sem);
pthread_mutex_destroy(&g_media.lock);
g_media_initialized = false;
}
* Music Search (Netease Cloud Music API)
****************************************************************************/
#define SEARCH_RESP_SIZE 8192
#define SEARCH_MAX_RESULTS 5
static void url_encode(const char* src, char* dst, size_t dstsz)
{
size_t pos = 0;
while (*src && pos + 3 < dstsz) {
unsigned char c = (unsigned char)*src;
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
dst[pos++] = c;
} else {
pos += snprintf(dst + pos, dstsz - pos, "%%%02X", c);
}
src++;
}
dst[pos] = '\0';
}
int tool_music_search_execute(const char* input_json,
char* output, size_t output_size)
{
cJSON* root = cJSON_Parse(input_json);
if (!root) {
snprintf(output, output_size, "Error: invalid JSON");
return -1;
}
const char* keyword = cJSON_GetStringValue(cJSON_GetObjectItem(root, "keyword"));
if (!keyword || !keyword[0]) {
keyword = cJSON_GetStringValue(cJSON_GetObjectItem(root, "query"));
}
if (!keyword || !keyword[0]) {
cJSON_Delete(root);
snprintf(output, output_size, "Error: missing 'keyword'");
return -1;
}
char encoded[256];
url_encode(keyword, encoded, sizeof(encoded));
char path[512];
snprintf(path, sizeof(path),
"/api/search/get/web?s=%s&type=1&offset=0&total=true&limit=%d",
encoded, SEARCH_MAX_RESULTS);
char* resp = malloc(SEARCH_RESP_SIZE);
if (!resp) {
cJSON_Delete(root);
snprintf(output, output_size, "Error: OOM");
return -1;
}
int status = vela_https_get("music.163.com", "443", path,
resp, SEARCH_RESP_SIZE);
cJSON_Delete(root);
if (status != 200) {
snprintf(output, output_size, "Error: HTTP %d", status);
free(resp);
return -1;
}
cJSON* j = cJSON_Parse(resp);
free(resp);
if (!j) {
snprintf(output, output_size, "Error: bad JSON from server");
return -1;
}
cJSON* songs = cJSON_GetObjectItem(
cJSON_GetObjectItem(j, "result"), "songs");
if (!songs || !cJSON_IsArray(songs) || cJSON_GetArraySize(songs) == 0) {
cJSON_Delete(j);
snprintf(output, output_size, "No results for this keyword");
return 0;
}
int count = cJSON_GetArraySize(songs);
if (count > SEARCH_MAX_RESULTS) {
count = SEARCH_MAX_RESULTS;
}
cJSON* result = cJSON_CreateObject();
cJSON* songs_arr = cJSON_CreateArray();
if (!result || !songs_arr) {
cJSON_Delete(result);
cJSON_Delete(songs_arr);
cJSON_Delete(j);
snprintf(output, output_size, "Error: OOM");
return -ENOMEM;
}
cJSON_AddItemToObject(result, "songs", songs_arr);
for (int i = 0; i < count; i++) {
cJSON* song = cJSON_GetArrayItem(songs, i);
const char* name = cJSON_GetStringValue(cJSON_GetObjectItem(song, "name"));
int id = (int)cJSON_GetNumberValue(cJSON_GetObjectItem(song, "id"));
cJSON* artists = cJSON_GetObjectItem(song, "artists");
const char* artist = "Unknown";
if (artists && cJSON_GetArraySize(artists) > 0) {
const char* a = cJSON_GetStringValue(
cJSON_GetObjectItem(cJSON_GetArrayItem(artists, 0), "name"));
if (a) {
artist = a;
}
}
cJSON* song_obj = cJSON_CreateObject();
if (!song_obj) {
cJSON_Delete(result);
cJSON_Delete(j);
snprintf(output, output_size, "Error: OOM");
return -ENOMEM;
}
char url[128];
snprintf(url, sizeof(url),
"https://music.163.com/song/media/outer/url?id=%d.mp3", id);
if (!cJSON_AddStringToObject(song_obj, "name", name ? name : "") ||
!cJSON_AddStringToObject(song_obj, "artist", artist) ||
!cJSON_AddStringToObject(song_obj, "url", url)) {
cJSON_Delete(song_obj);
cJSON_Delete(result);
cJSON_Delete(j);
snprintf(output, output_size, "Error: OOM");
return -ENOMEM;
}
cJSON_AddItemToArray(songs_arr, song_obj);
}
cJSON_AddStringToObject(result, "action",
"call music_play with one of the URLs above");
char* result_str = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
cJSON_Delete(j);
if (!result_str) {
snprintf(output, output_size, "Error: OOM building result");
return -ENOMEM;
}
strlcpy(output, result_str, output_size);
free(result_str);
syslog(LOG_INFO, "[%s] Found %d songs for search\n", TAG, count);
return 0;
}