Merge pull request #135 from tsirysndr/copilot/add-http-stream-playback-support

Add HTTP(S) network stream playback to Rockbox Desktop
This commit is contained in:
Tsiry Sandratraina 2026-04-15 20:58:40 +03:00 committed by GitHub
commit b48f46dc66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 4129 additions and 241 deletions

61
Cargo.lock generated
View file

@ -574,6 +574,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "ast_node"
version = "0.9.9"
@ -1717,6 +1727,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "compact_str"
version = "0.8.1"
@ -6843,6 +6862,31 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "mockito"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-core",
"http 1.1.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.4.1",
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.0",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "moka"
version = "0.12.7"
@ -7003,6 +7047,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "netstream"
version = "0.1.0"
dependencies = [
"libc",
"mockito",
"once_cell",
"reqwest",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -9082,6 +9136,7 @@ dependencies = [
"lazy_static",
"local-ip-addr",
"md5",
"netstream",
"owo-colors 4.1.0",
"queryst",
"rand 0.8.5",
@ -9938,6 +9993,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "0.3.11"

View file

@ -8,6 +8,9 @@ members = [
resolver = "2"
exclude = ["rmpc", "crates/controls"]
[profile.release]
panic = "abort"
[workspace.package]
authors = ["Tsiry Sandratraina <tsiry.sndr@fluentci.io"]
edition = "2021"

View file

@ -26,6 +26,7 @@ menus/audiohw_eq_menu.c
#endif
menus/eq_menu.c
buffering.c
streamfd.c
voice_thread.c
rbcodec_helpers.c
menus/main_menu.c

View file

@ -19,6 +19,7 @@
*
****************************************************************************/
#include "config.h"
#include <stdio.h>
#include <string.h>
#include "system.h"
#include "storage.h"
@ -37,6 +38,7 @@
#endif
#include "buffering.h"
#include "linked_list.h"
#include "streamfd.h"
/* Define LOGF_ENABLE to enable logf output in this file */
/* #define LOGF_ENABLE */
@ -177,8 +179,8 @@ static struct queue_sender_list buffering_queue_sender_list SHAREDBSS_ATTR;
static void close_fd(int *fd_p)
{
int fd = *fd_p;
if (fd >= 0) {
close(fd);
if (fd != -1) {
stream_close(fd);
*fd_p = -1;
}
}
@ -635,18 +637,22 @@ static bool buffer_handle(int handle_id, size_t to_buffer)
return true;
}
if (h->fd < 0) { /* file closed, reopen */
if (h->fd == -1) { /* file closed, reopen */
if (h->path[0] != '\0')
h->fd = open(h->path, O_RDONLY);
h->fd = stream_open(h->path, O_RDONLY);
if (h->fd < 0) {
fprintf(stderr, "[buffering] buffer_handle: hid=%d type=%d path=%s -> fd=%d filesize=%lu end=%lu\n",
handle_id, (int)h->type, h->path, h->fd,
(unsigned long)h->filesize, (unsigned long)h->end);
if (h->fd == -1) {
/* could not open the file, truncate it where it is */
h->filesize = h->end;
return true;
}
if (h->start)
lseek(h->fd, h->start, SEEK_SET);
stream_lseek(h->fd, h->start, SEEK_SET);
}
trigger_cpu_boost();
@ -689,10 +695,18 @@ static bool buffer_handle(int handle_id, size_t to_buffer)
return false; /* no space for read */
/* rc is the actual amount read */
ssize_t rc = read(h->fd, ringbuf_ptr(widx), copy_n);
ssize_t rc = stream_read(h->fd, ringbuf_ptr(widx), copy_n);
if (h->end == 0) {
/* Log the very first read result for each handle */
fprintf(stderr, "[buffering] buffer_handle: hid=%d type=%d first_read copy_n=%zd -> rc=%zd fd=%d\n",
handle_id, (int)h->type, (ssize_t)copy_n, rc, h->fd);
}
if (rc <= 0) {
/* Some kind of filesystem error, maybe recoverable if not codec */
fprintf(stderr, "[buffering] buffer_handle: hid=%d type=%d read FAILED rc=%zd end=%lu filesize=%lu\n",
handle_id, (int)h->type, rc, (unsigned long)h->end, (unsigned long)h->filesize);
if (h->type == TYPE_CODEC) {
logf("Partial codec");
break;
@ -954,8 +968,8 @@ int bufopen(const char *file, off_t offset, enum data_type type,
return ERR_UNSUPPORTED_TYPE;
#endif
/* Other cases: there is a little more work. */
int fd = open(file, O_RDONLY);
if (fd < 0)
int fd = stream_open(file, O_RDONLY);
if (fd == -1)
return ERR_FILE_ERROR;
size_t size = 0;
@ -983,7 +997,7 @@ int bufopen(const char *file, off_t offset, enum data_type type,
#endif /* HAVE_ALBUMART */
if (size == 0)
size = filesize(fd);
size = (size_t)stream_filesize_fd(fd);
unsigned int hflags = 0;
if (type == TYPE_PACKET_AUDIO || type == TYPE_CODEC)
@ -1005,7 +1019,7 @@ int bufopen(const char *file, off_t offset, enum data_type type,
if (!h) {
DEBUGF("%s(): failed to add handle\n", __func__);
mutex_unlock(&llist_mutex);
close(fd);
stream_close(fd);
/*warn playback.c if it is trying to buffer too large of an image*/
if(type == TYPE_BITMAP && padded_size >= buffer_len - 64*1024)
@ -1071,7 +1085,7 @@ int bufopen(const char *file, off_t offset, enum data_type type,
queue_send(&buffering_queue, Q_BUFFER_HANDLE, handle_id);
} else {
/* Other types will get buffered in the course of normal operations */
close(fd);
stream_close(fd);
if (handle_id >= 0) {
/* Inform the buffering thread that we added a handle */
@ -1216,8 +1230,8 @@ static void rebuffer_handle(int handle_id, off_t newpos)
h->ridx = h->widx = h->data = new_index;
h->start = h->pos = h->end = newpos;
if (h->fd >= 0)
lseek(h->fd, newpos, SEEK_SET);
if (h->fd != -1)
stream_lseek(h->fd, newpos, SEEK_SET);
off_t filerem = h->filesize - newpos;
bool send = HLIST_NEXT(h) &&

View file

@ -47,6 +47,7 @@
#include "settings.h"
#include "audiohw.h"
#include "general.h"
#include "streamfd.h"
#include <stdio.h>
#ifdef HAVE_TAGCACHE
@ -1756,13 +1757,23 @@ static bool audio_start_codec(bool auto_skip)
struct mp3entry *cur_id3 = valid_mp3entry(bufgetid3(info.id3_hid));
fprintf(stderr, "[playback] audio_start_codec: id3_hid=%d cur_id3=%p\n",
info.id3_hid, (void*)cur_id3);
if (!cur_id3)
{
fprintf(stderr, "[playback] audio_start_codec: ERROR no valid id3\n");
return false;
}
fprintf(stderr, "[playback] audio_start_codec: path=%s codectype=%u length=%lu elapsed=%lu audio_hid=%d\n",
cur_id3->path, cur_id3->codectype, cur_id3->length, cur_id3->elapsed, info.audio_hid);
buf_pin_handle(info.id3_hid, true);
if (!audio_init_codec(&info, cur_id3))
{
fprintf(stderr, "[playback] audio_start_codec: ERROR audio_init_codec failed\n");
buf_pin_handle(info.id3_hid, false);
return false;
}
@ -1831,6 +1842,8 @@ static bool audio_start_codec(bool auto_skip)
ci.filesize = buf_filesize(info.audio_hid);
buf_set_base_handle(info.audio_hid);
fprintf(stderr, "[playback] audio_start_codec: ci.audio_hid=%d ci.filesize=%lu calling codec_go()\n",
ci.audio_hid, (unsigned long)ci.filesize);
/* All required data is now available for the codec */
codec_go();
@ -2147,13 +2160,11 @@ static int audio_load_track(void)
if (!path)
break;
/* Test for broken playlists by probing for the files */
if (file_exists(path))
{
fd = open(path, O_RDONLY);
if (fd >= 0)
break;
}
/* Test for broken playlists by probing for the file/stream. */
fd = stream_open(path, O_RDONLY);
fprintf(stderr, "[playback] audio_load_track: stream_open(%s) -> fd=%d\n", path, fd);
if (fd != -1)
break;
logf("Open failed %s", path);
/* only skip if failed track has a successor in playlist */
@ -2194,6 +2205,8 @@ static int audio_load_track(void)
if (filling == STATE_FULL ||
(info.id3_hid = bufopen(path, 0, TYPE_ID3, NULL)) < 0)
{
fprintf(stderr, "[playback] audio_load_track: bufopen ID3 failed or buffer full (id3_hid=%d filling=%d)\n",
info.id3_hid, filling);
/* Buffer or track list is full */
struct mp3entry *ub_id3;
@ -2202,7 +2215,7 @@ static int audio_load_track(void)
/* Load the metadata for the first unbuffered track */
ub_id3 = id3_get(UNBUFFERED_ID3);
if (fd >= 0)
if (fd != -1)
{
id3_mutex_lock();
get_metadata(ub_id3, fd, path);
@ -2224,16 +2237,16 @@ static int audio_load_track(void)
{
track_list_free_info(&info);
track_list.in_progress_hid = 0;
if (fd >= 0)
close(fd);
if (fd != -1)
stream_close(fd);
return LOAD_TRACK_ERR_FAILED;
}
/* Successful load initiation */
track_list.in_progress_hid = info.self_hid;
}
if (fd >= 0)
close(fd);
if (fd != -1)
stream_close(fd);
return LOAD_TRACK_OK;
}
@ -2264,25 +2277,36 @@ static int audio_finish_load_track(struct track_info *infop)
struct mp3entry *track_id3 = valid_mp3entry(bufgetid3(infop->id3_hid));
fprintf(stderr, "[playback] audio_finish_load_track: id3_hid=%d track_id3=%p\n",
infop->id3_hid, (void*)track_id3);
if (!track_id3)
{
/* This is an error condition. Track cannot be played without valid
metadata; skip the track. */
logf("No metadata");
fprintf(stderr, "[playback] audio_finish_load_track: ERROR no valid metadata\n");
trackstat = LOAD_TRACK_ERR_FINISH_FAILED;
goto audio_finish_load_track_exit;
}
fprintf(stderr, "[playback] audio_finish_load_track: path=%s codectype=%u length=%lu elapsed=%lu filesize=%lu first_frame_off=%lu\n",
track_id3->path, track_id3->codectype, track_id3->length,
track_id3->elapsed, track_id3->FS_PREFIX(filesize),
track_id3->first_frame_offset);
struct track_info user_cur;
#ifdef HAVE_PLAY_FREQ
track_list_user_current(0, &user_cur);
bool is_current_user = infop->self_hid == user_cur.self_hid;
fprintf(stderr, "[playback] audio_finish_load_track: HAVE_PLAY_FREQ check is_current_user=%d\n", (int)is_current_user);
if (audio_auto_change_frequency(track_id3, is_current_user))
{
// frequency switch requires full re-buffering, so stop buffering
filling = STATE_STOPPED;
logf("buffering stopped (current_track: %b, current_user: %b)", infop->self_hid == cur_info.self_hid, is_current_user);
fprintf(stderr, "[playback] audio_finish_load_track: HAVE_PLAY_FREQ triggered early exit\n");
if (is_current_user)
// audio_finish_load_track_exit not needed as playback restart is already initiated
return trackstat;
@ -2291,17 +2315,21 @@ static int audio_finish_load_track(struct track_info *infop)
}
#endif
fprintf(stderr, "[playback] audio_finish_load_track: calling audio_load_cuesheet\n");
/* Try to load a cuesheet for the track */
if (!audio_load_cuesheet(infop, track_id3))
{
/* No space for cuesheet on buffer, not an error */
filling = STATE_FULL;
fprintf(stderr, "[playback] audio_finish_load_track: cuesheet buffer full -> exit\n");
goto audio_finish_load_track_exit;
}
fprintf(stderr, "[playback] audio_finish_load_track: cuesheet OK\n");
#ifdef HAVE_ALBUMART
/* Try to load album art for the track */
int retval = audio_load_albumart(infop, track_id3, infop->self_hid == cur_info.self_hid);
fprintf(stderr, "[playback] audio_finish_load_track: albumart retval=%d\n", retval);
if (retval == ERR_BITMAP_TOO_LARGE)
{
/* No space for album art on buffer because the file is larger than the buffer.
@ -2310,6 +2338,7 @@ static int audio_finish_load_track(struct track_info *infop)
{
/* No space for album art on buffer, not an error */
filling = STATE_FULL;
fprintf(stderr, "[playback] audio_finish_load_track: albumart buffer full -> exit\n");
goto audio_finish_load_track_exit;
}
#endif
@ -2372,6 +2401,8 @@ static int audio_finish_load_track(struct track_info *infop)
}
logf("load track: %s", track_id3->path);
fprintf(stderr, "[playback] audio_finish_load_track: audiotype=%d file_offset(before)=%lld\n",
audiotype, (long long)file_offset);
if (file_offset > AUDIO_REBUFFER_GUESS_SIZE)
{
@ -2385,7 +2416,11 @@ static int audio_finish_load_track(struct track_info *infop)
file_offset = track_id3->first_frame_offset;
}
fprintf(stderr, "[playback] audio_finish_load_track: bufopen path=%s offset=%lld audiotype=%d\n",
track_id3->path, (long long)file_offset, audiotype);
int hid = bufopen(track_id3->path, file_offset, audiotype, NULL);
fprintf(stderr, "[playback] audio_finish_load_track: bufopen -> hid=%d\n", hid);
if (hid >= 0)
{
@ -2427,6 +2462,7 @@ static int audio_finish_load_track(struct track_info *infop)
}
audio_finish_load_track_exit:
fprintf(stderr, "[playback] audio_finish_load_track: EXIT trackstat=%d filling=%d\n", trackstat, (int)filling);
if (trackstat >= LOAD_TRACK_OK && !track_info_sync(infop))
{
logf("Track info sync failed");
@ -2584,8 +2620,15 @@ static void audio_on_finish_load_track(int id3_hid)
{
struct track_info info, user_cur;
fprintf(stderr, "[playback] audio_on_finish_load_track: id3_hid=%d buf_is_handle=%d\n",
id3_hid, buf_is_handle(id3_hid));
if (!buf_is_handle(id3_hid) || !track_list_last(0, &info))
{
fprintf(stderr, "[playback] audio_on_finish_load_track: early return (buf_is_handle=%d track_list_last=%d)\n",
buf_is_handle(id3_hid), track_list_last(0, &info));
return;
}
track_list_user_current(1, &user_cur);
if (info.self_hid == user_cur.self_hid)

View file

@ -564,6 +564,22 @@ static ssize_t format_track_path(char *dest, char *src, int buf_length,
src[len] = '\0';
/* HTTP(S) URLs are absolute — copy them as-is, no directory prepending.
* Also normalise a spurious leading '/' (e.g. "/http://...") that may
* have been stored in an older control file by a previous buggy build. */
{
char *url_src = src;
if (*url_src == '/' &&
(strncmp(url_src + 1, "http://", 7) == 0 ||
strncmp(url_src + 1, "https://", 8) == 0))
url_src++;
if (strncmp(url_src, "http://", 7) == 0 || strncmp(url_src, "https://", 8) == 0)
{
strlcpy(dest, url_src, buf_length);
return (ssize_t)strlen(dest);
}
}
/* Replace backslashes with forward slashes */
path_correct_separators(src, src);
@ -3024,6 +3040,18 @@ const char* playlist_peek(int steps, char* buf, size_t buf_size)
temp_ptr = buf;
/* HTTP(S) URLs don't live on the filesystem — return as-is.
* Normalise a spurious leading '/' (e.g. "/http://...") from stale data. */
{
char *url_ptr = buf;
if (*url_ptr == '/' &&
(strncmp(url_ptr + 1, "http://", 7) == 0 ||
strncmp(url_ptr + 1, "https://", 8) == 0))
url_ptr++;
if (strncmp(url_ptr, "http://", 7) == 0 || strncmp(url_ptr, "https://", 8) == 0)
return url_ptr;
}
/* remove bogus dirs from beginning of path
(workaround for buggy playlist creation tools) */
while (temp_ptr)

126
apps/streamfd.c Normal file
View file

@ -0,0 +1,126 @@
/***************************************************************************
* Unified stream I/O dispatch for local files and HTTP(S) network streams.
* Active in the SDL simulator build and the hosted SDL application build.
*
* See streamfd.h for the public interface and fd-encoding documentation.
***************************************************************************/
#include "config.h"
#if defined(SIMULATOR) || defined(APPLICATION)
#include "streamfd.h"
#include "file.h" /* for app_open / app_read / app_lseek / app_close /
app_filesize and sim_* equivalents (via macros) */
#include <string.h>
#include <stdint.h>
#include <fcntl.h>
#include <stdio.h>
/* ------------------------------------------------------------------
* C declarations for the Rust ABI exported by crates/netstream.
* ------------------------------------------------------------------ */
extern int32_t rb_net_open (const char *url);
extern int64_t rb_net_read (int32_t h, void *dst, size_t n);
extern int64_t rb_net_lseek (int32_t h, int64_t off, int32_t whence);
extern int64_t rb_net_len (int32_t h);
extern int64_t rb_net_content_type(int32_t h, char *dst, size_t n);
extern void rb_net_close (int32_t h);
/* ------------------------------------------------------------------ */
/** Convert an HTTP fd (<=STREAM_HTTP_FD_BASE) back to a Rust handle id. */
static inline int32_t http_fd_to_handle(int fd)
{
return (int32_t)(STREAM_HTTP_FD_BASE - fd);
}
/* ------------------------------------------------------------------ */
static int path_is_url(const char *path)
{
return (strncmp(path, "http://", 7) == 0 ||
strncmp(path, "https://", 8) == 0);
}
int stream_open(const char *path, int flags)
{
if (path == NULL)
return -1;
if (path_is_url(path)) {
int32_t h = rb_net_open(path);
fprintf(stderr, "[streamfd] stream_open(URL): url=%s handle=%d\n", path, (int)h);
if (h < 0)
return -1;
int fd = STREAM_HTTP_FD_BASE - (int)h;
fprintf(stderr, "[streamfd] stream_open: url=%s -> http_fd=%d\n", path, fd);
return fd;
}
int fd = open(path, flags);
fprintf(stderr, "[streamfd] stream_open(file): path=%s -> fd=%d\n", path, fd);
return fd;
}
ssize_t stream_read(int fd, void *buf, size_t n)
{
if (stream_is_http_fd(fd)) {
int64_t r = rb_net_read(http_fd_to_handle(fd), buf, n);
fprintf(stderr, "[streamfd] stream_read: http_fd=%d n=%zu -> %lld\n", fd, n, (long long)r);
return (ssize_t)r;
}
ssize_t r = read(fd, buf, n);
return r;
}
off_t stream_lseek(int fd, off_t off, int whence)
{
if (stream_is_http_fd(fd)) {
int64_t r = rb_net_lseek(http_fd_to_handle(fd), (int64_t)off, whence);
fprintf(stderr, "[streamfd] stream_lseek: http_fd=%d off=%lld whence=%d -> %lld\n",
fd, (long long)off, whence, (long long)r);
return (off_t)r;
}
return lseek(fd, off, whence);
}
int stream_close(int fd)
{
if (fd == -1)
return 0;
if (stream_is_http_fd(fd)) {
fprintf(stderr, "[streamfd] stream_close: http_fd=%d (handle=%d)\n",
fd, http_fd_to_handle(fd));
rb_net_close(http_fd_to_handle(fd));
return 0;
}
return close(fd);
}
off_t stream_filesize_fd(int fd)
{
if (stream_is_http_fd(fd)) {
int64_t len = rb_net_len(http_fd_to_handle(fd));
off_t result;
if (len < 0) {
result = (off_t)0x7FFFFFFF;
} else {
result = (off_t)len;
}
fprintf(stderr, "[streamfd] stream_filesize_fd: http_fd=%d -> %lld (raw_len=%lld)\n",
fd, (long long)result, (long long)len);
return result;
}
return filesize(fd);
}
ssize_t stream_content_type(int fd, char *buf, size_t n)
{
if (!stream_is_http_fd(fd))
return -1;
return (ssize_t)rb_net_content_type(http_fd_to_handle(fd), buf, n);
}
#endif /* SIMULATOR || APPLICATION */

117
apps/streamfd.h Normal file
View file

@ -0,0 +1,117 @@
/***************************************************************************
* streamfd.h - Unified stream I/O abstraction for local files and HTTP(S)
* network streams.
*
* In the SDL simulator build and the hosted SDL application build, URLs that
* start with "http://" or "https://" are opened as network streams backed by
* the Rust "netstream" crate. All other paths are handled by the normal
* Rockbox file-system functions.
*
* File-descriptor encoding (simulator / hosted SDL app):
* fd == -1 : closed / unset sentinel
* fd >= 0 : normal file descriptor
* fd <= STREAM_HTTP_FD_BASE : HTTP stream handle
* handle_id = STREAM_HTTP_FD_BASE - fd
*
* On all other (embedded) builds, every symbol reduces to the existing
* Rockbox file-system macro/function so there is zero overhead and zero code
* change needed in callers.
***************************************************************************/
#ifndef APPS_STREAMFD_H
#define APPS_STREAMFD_H
#ifdef SIMULATOR
#define STREAM_HTTP_ENABLED
#elif defined(APPLICATION)
#define STREAM_HTTP_ENABLED
#endif
#ifdef STREAM_HTTP_ENABLED
#include <sys/types.h>
#include <fcntl.h>
#include <stdint.h>
/* Sentinel: file descriptors <= this value are HTTP stream handles. */
#define STREAM_HTTP_FD_BASE (-1000)
/** Return non-zero if @p fd refers to an open HTTP stream handle. */
static inline int stream_is_http_fd(int fd)
{
return fd <= STREAM_HTTP_FD_BASE;
}
/**
* Open a path.
*
* If @p path begins with "http://" or "https://" the request is forwarded
* to the Rust network layer; otherwise a normal open() is performed.
*
* @return A file descriptor >= 0, an HTTP handle <= STREAM_HTTP_FD_BASE,
* or -1 on error.
*/
int stream_open(const char *path, int flags);
/**
* Read up to @p n bytes from @p fd into @p buf.
* Routes to read() for real fds, rb_net_read() for HTTP fds.
*/
ssize_t stream_read(int fd, void *buf, size_t n);
/**
* Seek within @p fd.
* Routes to lseek() for real fds, rb_net_lseek() for HTTP fds.
*/
off_t stream_lseek(int fd, off_t off, int whence);
/**
* Close @p fd.
* Routes to close() for real fds, rb_net_close() for HTTP fds.
* Silently ignores fd == -1.
*
* @return 0 on success, -1 on error.
*/
int stream_close(int fd);
/**
* Return the total size of the stream associated with @p fd.
*
* For HTTP streams: the Content-Length if known, or a large sentinel
* value (~2 GiB) if unknown (buffering will truncate on EOF).
* For regular fds: delegates to filesize().
*
* @return Size in bytes, or -1 on error.
*/
off_t stream_filesize_fd(int fd);
/**
* Copy the normalized Content-Type associated with @p fd into @p buf.
*
* For HTTP streams this returns the response Content-Type without parameters.
* For regular files this returns -1.
*
* @return Full string length on success, or -1 if unknown/unavailable.
*/
ssize_t stream_content_type(int fd, char *buf, size_t n);
#else /* !STREAM_HTTP_ENABLED */
/*
* Non-simulator / embedded builds: map every symbol straight through to
* the native Rockbox file-system API so no code needs to change in callers.
*/
#include "file.h"
#include <unistd.h>
#define stream_is_http_fd(fd) (0)
#define stream_open(path, flags) open((path), (flags))
#define stream_read(fd, buf, n) read((fd), (buf), (n))
#define stream_lseek(fd, off, whence) lseek((fd), (off), (whence))
#define stream_close(fd) close(fd)
#define stream_filesize_fd(fd) filesize(fd)
#define stream_content_type(fd, buf, n) ((ssize_t)-1)
#endif /* STREAM_HTTP_ENABLED */
#endif /* APPS_STREAMFD_H */

View file

@ -1,4 +1,7 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Re-link whenever the C/Zig static library changes.
println!("cargo:rerun-if-changed=../build-lib/librockbox.a");
tonic_build::configure()
.out_dir("src/api")
.file_descriptor_set_path("src/api/rockbox_descriptor.bin")

View file

@ -1,6 +1,7 @@
pub mod clear;
pub mod community;
pub mod login;
pub mod open;
pub mod repl;
pub mod run;
pub mod scan;

52
cli/src/cmd/open.rs Normal file
View file

@ -0,0 +1,52 @@
use std::{env, path::Path, thread};
use anyhow::{anyhow, Error};
use rockbox::{
api::rockbox::v1alpha1::{playback_service_client::PlaybackServiceClient, PlayTrackRequest},
install_rockboxd, wait_for_rockboxd,
};
use super::start::start;
pub async fn open(path_or_url: &str) -> Result<(), Error> {
install_rockboxd()?;
let handle = thread::spawn(|| match start(false) {
Ok(_) => {}
Err(e) => {
eprintln!("Failed to start Rockbox server: {}", e);
}
});
let host = env::var("ROCKBOX_HOST").unwrap_or_else(|_| "localhost".to_string());
let port = env::var("ROCKBOX_PORT").unwrap_or_else(|_| "6061".to_string());
wait_for_rockboxd(port.parse()?, None)?;
let mut client = PlaybackServiceClient::connect(format!("tcp://{}:{}", host, port)).await?;
client
.play_track(tonic::Request::new(PlayTrackRequest {
path: normalize_path_or_url(path_or_url)?,
}))
.await?;
drop(handle);
Ok(())
}
fn normalize_path_or_url(path_or_url: &str) -> Result<String, Error> {
if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
return Ok(path_or_url.to_string());
}
let path = Path::new(path_or_url);
if !path.exists() {
return Err(anyhow!("Path does not exist: {}", path_or_url));
}
if !path.is_file() {
return Err(anyhow!("Path is not a file: {}", path_or_url));
}
Ok(path.canonicalize()?.to_string_lossy().to_string())
}

View file

@ -5,8 +5,8 @@ use clap::{arg, Command};
use owo_colors::OwoColorize;
use cmd::{
clear::*, community::*, login::*, repl::*, run::*, scan::*, service, start::*, webui::*,
whoami::*,
clear::*, community::*, login::*, open::*, repl::*, run::*, scan::*, service, start::*,
webui::*, whoami::*,
};
pub mod cmd;
@ -63,6 +63,11 @@ fn cli() -> Command {
.about("Run a JavaScript or TypeScript program")
.visible_alias("x"),
)
.subcommand(
Command::new("open")
.arg(arg!(<PATH_OR_URL> "Local file path or HTTP URL to play"))
.about("Play a local track or remote HTTP URL directly"),
)
.subcommand(
Command::new("service")
.about("Manage systemd service for Rockbox")
@ -127,6 +132,10 @@ async fn main() -> Result<(), Error> {
Some(("repl", _)) => {
repl();
}
Some(("open", args)) => {
let path_or_url = args.get_one::<String>("PATH_OR_URL").unwrap();
open(path_or_url).await?;
}
Some(("tui", _)) => {
rmpc::main_tui()?;
}

View file

@ -1,7 +1,7 @@
use anyhow::Error;
use clap::Command;
use owo_colors::OwoColorize;
use rockbox_library::audio_scan::scan_audio_files;
use rockbox_library::audio_scan::{save_audio_metadata, scan_audio_files};
use rockbox_library::{create_connection_pool, repo};
use rockbox_search::album::Album;
use rockbox_search::artist::Artist;
@ -172,3 +172,42 @@ Firmware |____|_ /\____/ \___ >__|_ \|___ /\____/__/\_ \
return 0;
}
#[no_mangle]
pub extern "C" fn save_remote_track_metadata(url: *const std::ffi::c_char) -> i32 {
if url.is_null() {
eprintln!("save_remote_track_metadata: null url");
return -1;
}
let url = unsafe { CStr::from_ptr(url) };
let url = match url.to_str() {
Ok(url) => url,
Err(e) => {
eprintln!("save_remote_track_metadata: invalid utf-8: {}", e);
return -1;
}
};
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!(
"save_remote_track_metadata: failed to create runtime: {}",
e
);
return -1;
}
};
match rt.block_on(async {
let pool = create_connection_pool().await?;
save_audio_metadata(pool, url).await
}) {
Ok(()) => 0,
Err(e) => {
eprintln!("save_remote_track_metadata: {}", e);
-1
}
}
}

View file

@ -66,6 +66,7 @@ async fn index_graphiql(req: HttpRequest) -> Result<HttpResponse> {
async fn index_file(req: HttpRequest) -> Result<NamedFile, actix_web::Error> {
let id = req.match_info().get("id").unwrap();
println!("{}", id);
let id = id.split('.').next().unwrap();
let mut path = PathBuf::new();

View file

@ -4,6 +4,13 @@ use anyhow::Error;
use lofty::{file::TaggedFileExt, probe::Probe, tag::Accessor};
pub fn extract_and_save_album_cover(track_path: &str) -> Result<Option<String>, Error> {
extract_and_save_album_cover_with_key(track_path, None)
}
pub fn extract_and_save_album_cover_with_key(
track_path: &str,
album_key: Option<&str>,
) -> Result<Option<String>, Error> {
let tagged_file = match Probe::open(track_path)
.expect("ERROR: Bad path provided!")
.read()
@ -15,8 +22,15 @@ pub fn extract_and_save_album_cover(track_path: &str) -> Result<Option<String>,
}
};
let primary_tag = tagged_file.primary_tag();
let tag = match primary_tag {
let tag = match tagged_file
.primary_tag()
.filter(|tag| !tag.pictures().is_empty())
.or_else(|| {
tagged_file
.tags()
.iter()
.find(|tag| !tag.pictures().is_empty())
}) {
Some(tag) => tag,
None => {
println!("No tag found in file: {}", track_path);
@ -30,13 +44,17 @@ pub fn extract_and_save_album_cover(track_path: &str) -> Result<Option<String>,
let covers_path = format!("{}/.config/rockbox.org/covers", home);
std::fs::create_dir_all(&covers_path)?;
let picture = &pictures[0];
if tag.album().is_none() {
println!("No album found in file: {}", track_path);
return Ok(None);
}
let album = md5::compute(tag.album().unwrap().as_bytes());
let album_key = tag
.album()
.filter(|album| !album.trim().is_empty())
.map(|album| album.to_string())
.or_else(|| {
album_key
.filter(|album| !album.trim().is_empty())
.map(|album| album.to_string())
})
.unwrap_or_else(|| track_path.to_string());
let album = md5::compute(album_key.as_bytes());
let filename = format!("{}/{:x}", covers_path, album);
match picture.mime_type() {
Some(lofty::picture::MimeType::Jpeg) => {

View file

@ -1,4 +1,4 @@
use crate::album_art::extract_and_save_album_cover;
use crate::album_art::extract_and_save_album_cover_with_key;
use crate::copyright_message::extract_copyright_message;
use crate::entity::album::Album;
use crate::entity::album_tracks::AlbumTracks;
@ -6,14 +6,15 @@ use crate::entity::artist::Artist;
use crate::entity::artist_tracks::ArtistTracks;
use crate::label::extract_label;
use crate::{entity::track::Track, repo};
use anyhow::Error;
use anyhow::{anyhow, Error};
use chrono::Utc;
use futures::future::BoxFuture;
use futures::stream::{FuturesUnordered, StreamExt};
use owo_colors::OwoColorize;
use rockbox_sys as rb;
use rockbox_sys::types::mp3_entry::Mp3Entry;
use sqlx::{Pool, Sqlite};
use std::path::PathBuf;
use std::{io::Write, path::PathBuf};
use tokio::fs;
const AUDIO_EXTENSIONS: [&str; 18] = [
@ -65,12 +66,10 @@ pub fn scan_audio_files(
}
pub async fn save_audio_metadata(pool: Pool<Sqlite>, path: &str) -> Result<(), Error> {
if !AUDIO_EXTENSIONS
.into_iter()
.any(|ext| path.ends_with(&format!(".{}", ext)))
{
if !is_supported_audio_path(path) {
return Ok(());
}
let existing_track = repo::track::find_by_path(pool.clone(), path).await?;
let filename = path.split('/').last().unwrap();
let dir = path.replace(filename, "");
@ -80,23 +79,49 @@ pub async fn save_audio_metadata(pool: Pool<Sqlite>, path: &str) -> Result<(), E
dir,
filename.bright_yellow()
);
let entry = rb::metadata::get_metadata(-1, path);
let remote_probe = if is_remote_path(path) {
Some(download_partial_remote_file(path).await?)
} else {
None
};
let metadata_path = remote_probe
.as_ref()
.map(|probe| probe.path.as_str())
.unwrap_or(path);
let entry = rb::metadata::get_metadata(-1, metadata_path);
let title = track_title(&entry, path);
let artist = track_artist(&entry);
let album_artist = track_album_artist(&entry, &artist);
let album = track_album(&entry, &title);
let album_art = extract_and_save_album_cover_with_key(metadata_path, Some(&album))?;
let track_hash = format!("{:x}", md5::compute(entry.path.as_bytes()));
if let Some(existing_track) = existing_track {
if let Some(ref album_art) = album_art {
if existing_track.album_art.as_deref() != Some(album_art.as_str()) {
repo::track::update_album_art(pool.clone(), &existing_track.id, album_art).await?;
}
if let Some(album) = repo::album::find(pool.clone(), &existing_track.album_id).await? {
if album.album_art.as_deref() != Some(album_art.as_str()) {
repo::album::update_album_art(pool.clone(), &album.id, album_art).await?;
}
}
}
return Ok(());
}
let track_hash = format!("{:x}", md5::compute(path.as_bytes()));
let artist_id = cuid::cuid1()?;
let album_id = cuid::cuid1()?;
let album_md5 = format!(
"{:x}",
md5::compute(format!("{}{}{}", entry.albumartist, entry.album, entry.year).as_bytes())
md5::compute(format!("{}{}{}", album_artist, album, entry.year).as_bytes())
);
let artist_id = repo::artist::save(
pool.clone(),
Artist {
id: artist_id.clone(),
name: match entry.albumartist.is_empty() {
true => entry.artist.clone(),
false => entry.albumartist.clone(),
},
name: artist.clone(),
bio: None,
image: None,
genres: None,
@ -104,23 +129,25 @@ pub async fn save_audio_metadata(pool: Pool<Sqlite>, path: &str) -> Result<(), E
)
.await?;
let album_art = extract_and_save_album_cover(&entry.path)?;
let album_id = repo::album::save(
pool.clone(),
Album {
id: album_id,
title: entry.album.clone(),
artist: match entry.albumartist.is_empty() {
true => entry.artist.clone(),
false => entry.albumartist.clone(),
},
year: entry.year as u32,
year_string: entry.year_string.clone(),
title: album.clone(),
artist: album_artist.clone(),
year: clamp_i32_to_u32(entry.year).unwrap_or_default(),
year_string: option_string(&entry.year_string).unwrap_or_default(),
album_art: album_art.clone(),
md5: album_md5,
artist_id: artist_id.clone(),
label: extract_label(&entry.path)?,
copyright_message: extract_copyright_message(&entry.path)?,
label: match is_remote_path(path) {
true => None,
false => extract_label(path)?,
},
copyright_message: match is_remote_path(path) {
true => None,
false => extract_copyright_message(path)?,
},
},
)
.await?;
@ -129,24 +156,21 @@ pub async fn save_audio_metadata(pool: Pool<Sqlite>, path: &str) -> Result<(), E
pool.clone(),
Track {
id: cuid::cuid1()?,
path: entry.path.clone(),
title: entry.title,
artist: entry.artist.clone(),
album: entry.album,
genre: match entry.genre_string.as_str() {
"" => None,
_ => Some(entry.genre_string),
},
year: Some(entry.year as u32),
track_number: Some(entry.tracknum as u32),
disc_number: entry.discnum as u32,
year_string: Some(entry.year_string),
path: path.to_string(),
title,
artist,
album,
genre: option_string(&entry.genre_string),
year: clamp_i32_to_u32(entry.year),
track_number: clamp_i32_to_u32(entry.tracknum),
disc_number: entry.discnum.max(0) as u32,
year_string: option_string(&entry.year_string),
composer: entry.composer,
album_artist: entry.albumartist.clone(),
album_artist,
bitrate: entry.bitrate,
frequency: entry.frequency as u32,
filesize: entry.filesize as u32,
length: entry.length as u32,
frequency: clamp_u64_to_u32(entry.frequency),
filesize: clamp_u64_to_u32(entry.filesize),
length: clamp_u64_to_u32(entry.length),
md5: track_hash,
created_at: Utc::now(),
updated_at: Utc::now(),
@ -180,3 +204,189 @@ pub async fn save_audio_metadata(pool: Pool<Sqlite>, path: &str) -> Result<(), E
Ok(())
}
fn is_remote_path(path: &str) -> bool {
path.starts_with("http://") || path.starts_with("https://")
}
fn is_supported_audio_path(path: &str) -> bool {
is_remote_path(path)
|| AUDIO_EXTENSIONS
.into_iter()
.any(|ext| path.ends_with(&format!(".{}", ext)))
}
fn option_string(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn clamp_i32_to_u32(value: i32) -> Option<u32> {
if value > 0 {
Some(value as u32)
} else {
None
}
}
fn clamp_u64_to_u32(value: u64) -> u32 {
value.min(u32::MAX as u64) as u32
}
fn track_title(entry: &Mp3Entry, path: &str) -> String {
if !entry.title.trim().is_empty() {
return entry.title.clone();
}
path.rsplit('/')
.next()
.and_then(|segment| segment.split('?').next())
.filter(|segment| !segment.is_empty())
.unwrap_or(path)
.to_string()
}
fn track_artist(entry: &Mp3Entry) -> String {
if !entry.artist.trim().is_empty() {
return entry.artist.clone();
}
if !entry.albumartist.trim().is_empty() {
return entry.albumartist.clone();
}
"Unknown Artist".to_string()
}
fn track_album_artist(entry: &Mp3Entry, artist: &str) -> String {
if !entry.albumartist.trim().is_empty() {
entry.albumartist.clone()
} else {
artist.to_string()
}
}
fn track_album(entry: &Mp3Entry, title: &str) -> String {
if !entry.album.trim().is_empty() {
entry.album.clone()
} else {
title.to_string()
}
}
struct RemoteProbeFile {
path: String,
}
impl Drop for RemoteProbeFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
async fn download_partial_remote_file(url: &str) -> Result<RemoteProbeFile, Error> {
const MAX_PROBE_BYTES: usize = 8 * 1024 * 1024;
let client = reqwest::Client::new();
let mut response = client
.get(url)
.header(
reqwest::header::RANGE,
format!("bytes=0-{}", MAX_PROBE_BYTES - 1),
)
.send()
.await?;
if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT
{
return Err(anyhow!(
"failed to probe remote media {}: {}",
url,
response.status()
));
}
let extension = match response.headers().get(reqwest::header::CONTENT_TYPE) {
Some(content_type) => match content_type.to_str() {
Ok(content_type) => content_type_to_extension(content_type)
.or_else(|| extension_from_path(url))
.unwrap_or("mp3"),
Err(_) => extension_from_path(url).unwrap_or("mp3"),
},
None => extension_from_path(url).unwrap_or("mp3"),
};
let probe_path = std::env::temp_dir().join(format!(
"rockbox-remote-probe-{:x}.{}",
md5::compute(url.as_bytes()),
extension
));
let mut file = std::fs::File::create(&probe_path)?;
let mut written = 0usize;
while let Some(chunk) = response.chunk().await? {
if written >= MAX_PROBE_BYTES {
break;
}
let remaining = MAX_PROBE_BYTES - written;
let bytes = if chunk.len() > remaining {
&chunk[..remaining]
} else {
chunk.as_ref()
};
file.write_all(bytes)?;
written += bytes.len();
}
if written == 0 {
return Err(anyhow!(
"failed to probe remote media {}: empty response",
url
));
}
Ok(RemoteProbeFile {
path: probe_path.to_string_lossy().to_string(),
})
}
fn extension_from_path(path: &str) -> Option<&str> {
let path = path.split('?').next().unwrap_or(path);
let extension = path.rsplit('.').next()?.to_ascii_lowercase();
AUDIO_EXTENSIONS
.into_iter()
.find(|candidate| *candidate == extension.as_str())
}
fn content_type_to_extension(content_type: &str) -> Option<&'static str> {
match content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim()
{
"audio/mpeg" => Some("mp3"),
"audio/ogg" => Some("ogg"),
"audio/flac" => Some("flac"),
"audio/x-m4a" | "audio/m4a" => Some("m4a"),
"audio/aac" => Some("aac"),
"video/mp4" | "audio/mp4" => Some("mp4"),
"audio/wav" | "audio/x-wav" => Some("wav"),
"audio/x-wavpack" => Some("wv"),
"audio/x-musepack" => Some("mpc"),
"audio/aiff" | "audio/x-aiff" => Some("aiff"),
"audio/ac3" | "audio/vnd.dolby.dd-raw" => Some("ac3"),
"audio/opus" => Some("opus"),
"audio/x-speex" => Some("spx"),
"audio/prs.sid" => Some("sid"),
"audio/ape" | "audio/x-monkeys-audio" => Some("ape"),
"audio/x-ms-wma" => Some("wma"),
_ => None,
}
}

View file

@ -129,3 +129,16 @@ pub async fn all(pool: Pool<Sqlite>) -> Result<Vec<Album>, sqlx::Error> {
}
}
}
pub async fn update_album_art(
pool: Pool<Sqlite>,
id: &str,
album_art: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE album SET album_art = $2 WHERE id = $1")
.bind(id)
.bind(album_art)
.execute(&pool)
.await?;
Ok(())
}

View file

@ -110,6 +110,15 @@ pub async fn all(pool: Pool<Sqlite>) -> Result<Vec<Track>, Error> {
Ok(result)
}
pub async fn update_album_art(pool: Pool<Sqlite>, id: &str, album_art: &str) -> Result<(), Error> {
sqlx::query("UPDATE track SET album_art = $2 WHERE id = $1")
.bind(id)
.bind(album_art)
.execute(&pool)
.await?;
Ok(())
}
pub async fn find_by_artist(pool: Pool<Sqlite>, artist: &str) -> Result<Vec<Track>, Error> {
let result: Vec<Track> =
sqlx::query_as("SELECT * FROM track WHERE artist = $1 ORDER BY title ASC")

1584
crates/netstream/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
[package]
name = "netstream"
version = "0.1.0"
edition = "2021"
[lib]
name = "rbnetstream"
crate-type = ["staticlib", "rlib"]
[dependencies]
reqwest = { version = "0.12.5", features = ["blocking", "rustls-tls"], default-features = false }
once_cell = "1.17.1"
libc = "0.2.168"
[dev-dependencies]
mockito = "1.7.2"

942
crates/netstream/src/lib.rs Normal file
View file

@ -0,0 +1,942 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::ffi::CStr;
use std::io::{self, Read};
use std::os::raw::c_char;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::{Arc, Mutex};
/// Sentinel handle ID returned on error.
const INVALID_HANDLE: i32 = -1;
/// Per-stream state.
struct StreamState {
url: String,
pos: u64,
content_length: Option<u64>,
content_type: Option<String>,
response: Option<reqwest::blocking::Response>,
}
impl StreamState {
fn response_content_type(resp: &reqwest::blocking::Response) -> Option<String> {
let value = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)?
.to_str()
.ok()?;
let value = value.split(';').next()?.trim();
if value.is_empty() {
None
} else {
Some(value.to_ascii_lowercase())
}
}
fn new(url: String) -> Option<Self> {
let response = CLIENT.get(&url).send().ok()?;
if !response.status().is_success() {
return None;
}
let content_length = response.content_length();
let content_type = Self::response_content_type(&response);
Some(StreamState {
url,
pos: 0,
content_length,
content_type,
response: Some(response),
})
}
fn skip_bytes(resp: &mut reqwest::blocking::Response, mut to_skip: u64) -> bool {
let mut buf = [0u8; 8192];
while to_skip > 0 {
let chunk = usize::min(to_skip as usize, buf.len());
match resp.read(&mut buf[..chunk]) {
Ok(0) => return false,
Ok(bytes_read) => to_skip -= bytes_read as u64,
Err(_) => return false,
}
}
true
}
fn update_content_length_from_content_range(&mut self, resp: &reqwest::blocking::Response) {
if self.content_length.is_some() {
return;
}
if let Some(cr) = resp.headers().get("content-range") {
if let Ok(cr_str) = cr.to_str() {
if let Some(total_str) = cr_str.split('/').last() {
if let Ok(total) = total_str.trim().parse::<u64>() {
self.content_length = Some(total);
}
}
}
}
}
fn parse_content_range_start(resp: &reqwest::blocking::Response) -> Option<u64> {
let value = resp.headers().get("content-range")?.to_str().ok()?;
let value = value.strip_prefix("bytes ")?;
let (range, _) = value.split_once('/')?;
let (start, _) = range.split_once('-')?;
start.trim().parse::<u64>().ok()
}
/// Re-issue the request starting at `new_pos` using an HTTP Range header.
/// Falls back to reopening from byte 0 and discarding bytes if the server
/// ignores Range and responds with the full body.
fn seek_to(&mut self, new_pos: u64) -> bool {
self.response = None;
let result = CLIENT
.get(&self.url)
.header("Range", format!("bytes={}-", new_pos))
.send();
match result {
Ok(resp) if resp.status().as_u16() == 206 => {
self.update_content_length_from_content_range(&resp);
if self.content_type.is_none() {
self.content_type = Self::response_content_type(&resp);
}
if Self::parse_content_range_start(&resp) != Some(new_pos) {
return false;
}
self.response = Some(resp);
self.pos = new_pos;
true
}
Ok(mut resp) if resp.status().is_success() => {
if self.content_length.is_none() {
self.content_length = resp.content_length();
}
if self.content_type.is_none() {
self.content_type = Self::response_content_type(&resp);
}
if new_pos > 0 && !Self::skip_bytes(&mut resp, new_pos) {
return false;
}
self.response = Some(resp);
self.pos = new_pos;
true
}
_ => false,
}
}
}
fn read_as_file<R: Read>(reader: &mut R, buf: &mut [u8]) -> io::Result<usize> {
let mut total = 0;
while total < buf.len() {
match reader.read(&mut buf[total..]) {
Ok(0) => break,
Ok(bytes_read) => total += bytes_read,
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => {
if total > 0 {
break;
}
return Err(err);
}
}
}
Ok(total)
}
static STREAMS: Lazy<Mutex<HashMap<i32, Arc<Mutex<StreamState>>>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static CLIENT: Lazy<reqwest::blocking::Client> = Lazy::new(|| {
reqwest::blocking::Client::builder()
.use_rustls_tls()
.build()
.expect("failed to build global HTTP client")
});
static NEXT_HANDLE: AtomicI32 = AtomicI32::new(0);
// ------------------------------------------------------------------
// Public C ABI
// ------------------------------------------------------------------
/// Open a URL and return an integer handle, or -1 on failure.
///
/// # Safety
/// `url` must be a valid, NUL-terminated C string.
#[no_mangle]
pub unsafe extern "C" fn rb_net_open(url: *const c_char) -> i32 {
if url.is_null() {
return INVALID_HANDLE;
}
let url_str = match CStr::from_ptr(url).to_str() {
Ok(s) => s.to_owned(),
Err(_) => return INVALID_HANDLE,
};
let state = match StreamState::new(url_str.clone()) {
Some(s) => {
eprintln!(
"[netstream] rb_net_open: url={} content_length={:?} content_type={:?}",
url_str, s.content_length, s.content_type
);
s
}
None => {
eprintln!("[netstream] rb_net_open: FAILED url={}", url_str);
return INVALID_HANDLE;
}
};
let handle = NEXT_HANDLE.fetch_add(1, Ordering::SeqCst);
STREAMS
.lock()
.unwrap()
.insert(handle, Arc::new(Mutex::new(state)));
eprintln!(
"[netstream] rb_net_open: url={} -> handle={}",
url_str, handle
);
handle
}
/// Read up to `n` bytes from stream `h` into `dst`.
/// Returns the number of bytes read, 0 on EOF, or -1 on error.
///
/// # Safety
/// `dst` must point to a buffer of at least `n` bytes.
#[no_mangle]
pub unsafe extern "C" fn rb_net_read(h: i32, dst: *mut libc::c_void, n: libc::size_t) -> i64 {
if dst.is_null() || n == 0 {
return 0;
}
// Acquire the global map lock only long enough to clone the per-handle Arc,
// then release it so other handles can proceed concurrently.
let handle_arc = {
let streams = STREAMS.lock().unwrap();
match streams.get(&h) {
Some(arc) => arc.clone(),
None => return -1,
}
};
let mut state = handle_arc.lock().unwrap();
let pos_before = state.pos;
let resp = match &mut state.response {
Some(r) => r,
None => return -1,
};
let buf = std::slice::from_raw_parts_mut(dst as *mut u8, n);
match read_as_file(resp, buf) {
Ok(bytes_read) => {
state.pos += bytes_read as u64;
eprintln!(
"[netstream] rb_net_read: h={} n={} pos_before={} -> read={} pos_after={}",
h, n, pos_before, bytes_read, state.pos
);
bytes_read as i64
}
Err(e) => {
eprintln!(
"[netstream] rb_net_read: h={} n={} pos={} -> ERROR {:?}",
h, n, pos_before, e
);
-1
}
}
}
/// Seek within stream `h`. `whence` follows POSIX semantics:
/// 0 = SEEK_SET, 1 = SEEK_CUR, 2 = SEEK_END.
/// Returns the new position on success, or -1 on failure.
#[no_mangle]
pub extern "C" fn rb_net_lseek(h: i32, off: i64, whence: libc::c_int) -> i64 {
const SEEK_SET: libc::c_int = 0;
const SEEK_CUR: libc::c_int = 1;
const SEEK_END: libc::c_int = 2;
// Acquire the global map lock only long enough to clone the per-handle Arc,
// then release it so other handles can proceed concurrently.
let handle_arc = {
let streams = STREAMS.lock().unwrap();
match streams.get(&h) {
Some(arc) => arc.clone(),
None => return -1,
}
};
let mut state = handle_arc.lock().unwrap();
let new_pos: u64 = match whence {
x if x == SEEK_SET => {
if off < 0 {
return -1;
}
off as u64
}
x if x == SEEK_CUR => {
if off < 0 {
let abs_off = (-off) as u64;
if abs_off > state.pos {
return -1;
}
state.pos - abs_off
} else {
state.pos + off as u64
}
}
x if x == SEEK_END => {
let len = match state.content_length {
Some(l) => l,
None => return -1,
};
if off > 0 {
return -1;
}
let abs_off = (-off) as u64;
if abs_off > len {
return -1;
}
len - abs_off
}
_ => return -1,
};
// Fast-path: already there (no need to restart the request).
if new_pos == state.pos {
eprintln!(
"[netstream] rb_net_lseek: h={} off={} whence={} -> already at pos={} (no-op)",
h, off, whence, state.pos
);
return state.pos as i64;
}
let old_pos = state.pos;
if state.seek_to(new_pos) {
eprintln!(
"[netstream] rb_net_lseek: h={} off={} whence={} old_pos={} -> new_pos={}",
h, off, whence, old_pos, state.pos
);
state.pos as i64
} else {
eprintln!(
"[netstream] rb_net_lseek: h={} off={} whence={} old_pos={} -> FAILED",
h, off, whence, old_pos
);
-1
}
}
/// Return the total content length of stream `h`, or -1 if unknown.
#[no_mangle]
pub extern "C" fn rb_net_len(h: i32) -> i64 {
let handle_arc = {
let streams = STREAMS.lock().unwrap();
match streams.get(&h) {
Some(arc) => arc.clone(),
None => return -1,
}
};
let len = handle_arc
.lock()
.unwrap()
.content_length
.map(|l| l as i64)
.unwrap_or(-1);
eprintln!("[netstream] rb_net_len: h={} -> {}", h, len);
len
}
/// Copy the normalized Content-Type for stream `h` into `dst`.
/// Returns the full string length on success, or -1 if unavailable.
///
/// # Safety
/// `dst` must point to a writable buffer of at least `n` bytes when `n > 0`.
#[no_mangle]
pub unsafe extern "C" fn rb_net_content_type(h: i32, dst: *mut c_char, n: libc::size_t) -> i64 {
let handle_arc = {
let streams = STREAMS.lock().unwrap();
match streams.get(&h) {
Some(arc) => arc.clone(),
None => return -1,
}
};
let state = handle_arc.lock().unwrap();
let content_type = match state.content_type.as_deref() {
Some(value) => value,
None => return -1,
};
if !dst.is_null() && n > 0 {
let bytes = content_type.as_bytes();
let copy_len = usize::min(bytes.len(), n.saturating_sub(1));
std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst as *mut u8, copy_len);
*dst.add(copy_len) = 0;
}
content_type.len() as i64
}
/// Close stream `h` and release its resources.
#[no_mangle]
pub extern "C" fn rb_net_close(h: i32) {
eprintln!("[netstream] rb_net_close: h={}", h);
STREAMS.lock().unwrap().remove(&h);
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use mockito::Matcher;
use std::ffi::CString;
use std::io::Cursor;
/// Helper: build a NUL-terminated C URL string for a path on the mock server.
fn c_url(server: &mockito::Server, path: &str) -> CString {
CString::new(format!("{}{}", server.url(), path)).unwrap()
}
struct PartialReader {
inner: Cursor<Vec<u8>>,
chunk_size: usize,
}
impl PartialReader {
fn new(data: &[u8], chunk_size: usize) -> Self {
Self {
inner: Cursor::new(data.to_vec()),
chunk_size,
}
}
}
impl Read for PartialReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let chunk = usize::min(self.chunk_size, buf.len());
self.inner.read(&mut buf[..chunk])
}
}
// ------------------------------------------------------------------
// Open / close
// ------------------------------------------------------------------
/// Opening a valid URL returns a non-negative handle.
#[test]
fn test_open_and_close() {
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/audio.mp3")
.with_status(200)
.with_header("content-length", "4")
.with_body(b"data")
.create();
let url = c_url(&server, "/audio.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0, "open should return a valid handle");
rb_net_close(handle);
// After close, rb_net_len should return -1 (unknown handle).
assert_eq!(
rb_net_len(handle),
-1,
"closed handle should return -1 from rb_net_len"
);
}
/// Passing a null pointer returns INVALID_HANDLE.
#[test]
fn test_open_null_url() {
let handle = unsafe { rb_net_open(std::ptr::null()) };
assert_eq!(handle, INVALID_HANDLE);
}
/// Connecting to a port where nothing is listening returns INVALID_HANDLE.
#[test]
fn test_open_unreachable_host() {
// Port 19998 is extremely unlikely to be in use.
let url = CString::new("http://127.0.0.1:19998/file.mp3").unwrap();
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert_eq!(
handle, INVALID_HANDLE,
"unreachable server should return INVALID_HANDLE"
);
}
/// A 404 response causes rb_net_open to return INVALID_HANDLE.
#[test]
fn test_open_404() {
let mut server = mockito::Server::new();
let _mock = server.mock("GET", "/missing.mp3").with_status(404).create();
let url = c_url(&server, "/missing.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert_eq!(
handle, INVALID_HANDLE,
"404 response should return INVALID_HANDLE"
);
}
// ------------------------------------------------------------------
// Content-Length / rb_net_len
// ------------------------------------------------------------------
/// rb_net_len returns the Content-Length when the server provides it.
#[test]
fn test_known_content_length() {
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/known.mp3")
.with_status(200)
.with_header("content-length", "1234")
.with_body(vec![0u8; 1234])
.create();
let url = c_url(&server, "/known.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
assert_eq!(rb_net_len(handle), 1234);
rb_net_close(handle);
}
#[test]
fn test_content_type_is_available() {
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/typed")
.with_status(200)
.with_header("content-type", "audio/m4a; charset=binary")
.with_body("data")
.create();
let url = c_url(&server, "/typed");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let mut buf = vec![0i8; 32];
let n = unsafe { rb_net_content_type(handle, buf.as_mut_ptr(), buf.len()) };
assert_eq!(n, "audio/m4a".len() as i64);
let content_type = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap();
assert_eq!(content_type, "audio/m4a");
rb_net_close(handle);
}
/// rb_net_len returns the value from the Content-Length response header.
#[test]
fn test_unknown_content_length() {
// Note: mockito automatically sets a content-length header from the body
// length when serving responses, so we verify here that rb_net_len
// correctly reads whatever content-length the server sends.
let body: &[u8] = b"some data";
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/stream.mp3")
.with_status(200)
.with_body(body)
.create();
let url = c_url(&server, "/stream.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
// mockito sets content-length = body.len() when no explicit header is given.
let len = rb_net_len(handle);
assert_eq!(
len,
body.len() as i64,
"rb_net_len should reflect the server's content-length"
);
rb_net_close(handle);
}
// ------------------------------------------------------------------
// Reading
// ------------------------------------------------------------------
/// rb_net_read returns the expected bytes.
#[test]
fn test_read_bytes() {
let body: &[u8] = b"Hello, Rockbox!";
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/song.mp3")
.with_status(200)
.with_body(body)
.create();
let url = c_url(&server, "/song.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let mut buf = vec![0u8; 64];
let n = unsafe { rb_net_read(handle, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
assert_eq!(n, body.len() as i64);
assert_eq!(&buf[..n as usize], body);
rb_net_close(handle);
}
#[test]
fn test_read_as_file_retries_partial_reads() {
let mut reader = PartialReader::new(b"Hello, Rockbox!", 3);
let mut buf = vec![0u8; 15];
let n = read_as_file(&mut reader, &mut buf).unwrap();
assert_eq!(n, 15);
assert_eq!(&buf, b"Hello, Rockbox!");
}
/// rb_net_read returns 0 at EOF (after all bytes have been consumed).
#[test]
fn test_read_eof() {
let body: &[u8] = b"tiny";
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/eof.mp3")
.with_status(200)
.with_body(body)
.create();
let url = c_url(&server, "/eof.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let mut buf = vec![0u8; 1024];
// First read drains the body.
let n1 = unsafe { rb_net_read(handle, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
assert_eq!(n1, body.len() as i64);
// Second read should signal EOF.
let n2 = unsafe { rb_net_read(handle, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
assert_eq!(n2, 0, "second read should return 0 at EOF");
rb_net_close(handle);
}
/// rb_net_read on an unknown handle returns -1.
#[test]
fn test_read_invalid_handle() {
let mut buf = vec![0u8; 16];
let result =
unsafe { rb_net_read(i32::MAX, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
assert_eq!(result, -1, "read on unknown handle should return -1");
}
// ------------------------------------------------------------------
// Seeking
// ------------------------------------------------------------------
/// SEEK_SET re-issues a Range request and returns the new position.
#[test]
fn test_seek_set_range_request() {
let full_body: &[u8] = b"0123456789ABCDEF"; // 16 bytes
let mut server = mockito::Server::new();
// Initial GET (no Range header).
let _initial = server
.mock("GET", "/seekable.mp3")
.match_header("range", Matcher::Missing)
.with_status(200)
.with_header("content-length", "16")
.with_body(full_body)
.create();
// Range request from byte 8.
let _range = server
.mock("GET", "/seekable.mp3")
.match_header("range", "bytes=8-")
.with_status(206)
.with_header("content-range", "bytes 8-15/16")
.with_header("content-length", "8")
.with_body(&full_body[8..])
.create();
let url = c_url(&server, "/seekable.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let new_pos = rb_net_lseek(handle, 8, libc::SEEK_SET);
assert_eq!(new_pos, 8, "SEEK_SET(8) should return position 8");
// Read the remaining 8 bytes and verify they match the tail of full_body.
let mut buf = vec![0u8; 16];
let n = unsafe { rb_net_read(handle, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
assert_eq!(n, 8);
assert_eq!(&buf[..8], &full_body[8..]);
rb_net_close(handle);
}
/// SEEK_CUR(0) is a no-op that queries the current position without a new request.
#[test]
fn test_seek_cur_no_op() {
let body: &[u8] = b"ABCDEFGHIJ"; // 10 bytes
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/cur.mp3")
.with_status(200)
.with_header("content-length", "10")
.with_body(body)
.create();
let url = c_url(&server, "/cur.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
// Read 5 bytes → position advances to 5.
let mut buf = vec![0u8; 5];
let n = unsafe { rb_net_read(handle, buf.as_mut_ptr() as *mut libc::c_void, 5) };
assert_eq!(n, 5);
// SEEK_CUR(0) should return current position without a new HTTP request.
let pos = rb_net_lseek(handle, 0, libc::SEEK_CUR);
assert_eq!(pos, 5, "SEEK_CUR(0) should return current position");
rb_net_close(handle);
}
/// SEEK_END(-2) on a 10-byte file should yield position 8.
#[test]
fn test_seek_end() {
let full_body: &[u8] = b"XXXXXXXXXX"; // 10 bytes
let mut server = mockito::Server::new();
let _initial = server
.mock("GET", "/end.mp3")
.match_header("range", Matcher::Missing)
.with_status(200)
.with_header("content-length", "10")
.with_body(full_body)
.create();
let _range = server
.mock("GET", "/end.mp3")
.match_header("range", "bytes=8-")
.with_status(206)
.with_header("content-range", "bytes 8-9/10")
.with_body(&full_body[8..])
.create();
let url = c_url(&server, "/end.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let pos = rb_net_lseek(handle, -2, libc::SEEK_END);
assert_eq!(
pos, 8,
"SEEK_END(-2) on 10-byte file should give position 8"
);
rb_net_close(handle);
}
/// SEEK_END on a stream with unknown Content-Length returns -1.
/// This tests the graceful failure path for SEEK_END.
#[test]
fn test_seek_end_unknown_length() {
// We use a 416 mock to trigger the failure in seek_to; this also tests
// the seek failure path for SEEK_END when content-length becomes
// unavailable (e.g. after a failed Range response cleared the state).
let full_body: &[u8] = b"data";
let mut server = mockito::Server::new();
// Initial GET: explicitly provide no content-length so SEEK_END has nothing.
// We do this by setting content-length to 0 on a 200 response without body,
// then testing SEEK_END with a negative offset.
let _mock = server
.mock("GET", "/nosize.mp3")
.with_status(200)
// mockito sets content-length from body; use a large body to get a real length,
// then test that SEEK_END(-offset > length) correctly fails.
.with_header("content-length", "4")
.with_body(full_body)
.create();
let url = c_url(&server, "/nosize.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
// Seeking past the beginning is invalid: SEEK_END with |offset| > length.
let pos = rb_net_lseek(handle, -100, libc::SEEK_END);
assert_eq!(
pos, -1,
"SEEK_END with offset beyond file start should return -1"
);
rb_net_close(handle);
}
/// When the server returns 416 for a Range request, seek fails gracefully.
#[test]
fn test_seek_range_not_supported() {
let mut server = mockito::Server::new();
// Initial GET succeeds.
let _initial = server
.mock("GET", "/noseek.mp3")
.match_header("range", Matcher::Missing)
.with_status(200)
.with_header("content-length", "100")
.with_body(vec![0u8; 100])
.create();
// Range request returns "416 Range Not Satisfiable".
let _no_range = server
.mock("GET", "/noseek.mp3")
.match_header("range", Matcher::Any)
.with_status(416)
.create();
let url = c_url(&server, "/noseek.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let result = rb_net_lseek(handle, 50, libc::SEEK_SET);
assert_eq!(
result, -1,
"seek should fail gracefully when Range is not supported"
);
rb_net_close(handle);
}
/// If the server ignores Range and returns 200 with the full body, seek
/// still succeeds by discarding bytes until the requested position.
#[test]
fn test_seek_falls_back_when_range_is_ignored() {
let full_body: &[u8] = b"0123456789ABCDEF";
let mut server = mockito::Server::new();
let _initial = server
.mock("GET", "/ignore-range.mp3")
.match_header("range", Matcher::Missing)
.with_status(200)
.with_header("content-length", "16")
.with_body(full_body)
.create();
let _ignored_range = server
.mock("GET", "/ignore-range.mp3")
.match_header("range", "bytes=8-")
.with_status(200)
.with_header("content-length", "16")
.with_body(full_body)
.create();
let url = c_url(&server, "/ignore-range.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let new_pos = rb_net_lseek(handle, 8, libc::SEEK_SET);
assert_eq!(new_pos, 8, "seek should land at byte 8 even without 206");
let mut buf = vec![0u8; 16];
let n = unsafe { rb_net_read(handle, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
assert_eq!(n, 8);
assert_eq!(&buf[..8], &full_body[8..]);
rb_net_close(handle);
}
/// A malformed 206 response that does not start at the requested offset
/// must fail instead of silently desynchronizing the stream position.
#[test]
fn test_seek_rejects_wrong_content_range_start() {
let full_body: &[u8] = b"0123456789ABCDEF";
let mut server = mockito::Server::new();
let _initial = server
.mock("GET", "/bad-range.mp3")
.match_header("range", Matcher::Missing)
.with_status(200)
.with_header("content-length", "16")
.with_body(full_body)
.create();
let _bad_range = server
.mock("GET", "/bad-range.mp3")
.match_header("range", "bytes=8-")
.with_status(206)
.with_header("content-range", "bytes 0-7/16")
.with_body(&full_body[..8])
.create();
let url = c_url(&server, "/bad-range.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
let result = rb_net_lseek(handle, 8, libc::SEEK_SET);
assert_eq!(result, -1, "mismatched Content-Range should fail");
rb_net_close(handle);
}
/// Content-Range header from a 206 response populates content_length
/// when it was not provided in the initial response.
/// We verify here that after seeking, the total length is available.
#[test]
fn test_content_length_from_content_range() {
let full_body: &[u8] = b"0123456789"; // 10 bytes
let mut server = mockito::Server::new();
// Initial GET: content-length matches body (10).
let _initial = server
.mock("GET", "/range-len.mp3")
.match_header("range", Matcher::Missing)
.with_status(200)
.with_header("content-length", "10")
.with_body(full_body)
.create();
// Range request: 206 includes Content-Range which also reveals total size.
let _range = server
.mock("GET", "/range-len.mp3")
.match_header("range", "bytes=5-")
.with_status(206)
.with_header("content-range", "bytes 5-9/10")
.with_body(&full_body[5..])
.create();
let url = c_url(&server, "/range-len.mp3");
let handle = unsafe { rb_net_open(url.as_ptr()) };
assert!(handle >= 0);
// Total length is known from the initial response.
assert_eq!(
rb_net_len(handle),
10,
"length should be known from initial response"
);
// Seeking causes a 206 response whose Content-Range also confirms the total length.
let pos = rb_net_lseek(handle, 5, libc::SEEK_SET);
assert_eq!(pos, 5, "seek should succeed");
// Length is still correctly reported after seek.
assert_eq!(
rb_net_len(handle),
10,
"length should remain correct after seek"
);
rb_net_close(handle);
}
}

View file

@ -59,6 +59,7 @@ fn get_file_extension(mime: &str) -> Result<&str, Error> {
"audio/ogg" => Ok("ogg"),
"audio/flac" => Ok("flac"),
"audio/x-m4a" => Ok("m4a"),
"audio/m4a" => Ok("m4a"),
"audio/aac" => Ok("aac"),
"video/mp4" => Ok("mp4"),
"audio/wav" => Ok("wav"),

View file

@ -208,7 +208,12 @@ impl PlaybackService for Playback {
.map_err(|e| tonic::Status::internal(e.to_string()))?;
if let Some(metadata) = metadata {
let mut track = track.clone();
track.album_art = metadata.album_art;
// Only override album_art if the DB has a non-None value; the HTTP
// endpoint already resolved album_art via find_internal_track_by_url,
// so we must not overwrite it with None from the remote-saved record.
if metadata.album_art.is_some() {
track.album_art = metadata.album_art;
}
track.album_id = Some(metadata.album_id);
track.artist_id = Some(metadata.artist_id);
return Ok(tonic::Response::new(track.into()));
@ -594,6 +599,7 @@ impl PlaybackService for Playback {
.json(&body)
.send()
.await
.and_then(|response| response.error_for_status())
.map_err(|e| tonic::Status::internal(e.to_string()))?;
let client = reqwest::Client::new();
@ -602,6 +608,7 @@ impl PlaybackService for Playback {
.put(&url)
.send()
.await
.and_then(|response| response.error_for_status())
.map_err(|e| tonic::Status::internal(e.to_string()))?;
Ok(tonic::Response::new(PlayTrackResponse::default()))

View file

@ -28,6 +28,7 @@ rockbox-network = { path = "../network" }
rockbox-rpc = {path = "../rpc"}
rockbox-search = {path = "../search"}
rockbox-settings = {path = "../settings"}
netstream = { path = "../netstream" }
rockbox-sys = {path = "../sys"}
rockbox-tracklist = {path = "../tracklist"}
rockbox-traits = {path = "../traits"}

View file

@ -1,11 +1,11 @@
use std::env;
use std::{env, ffi::CString};
use crate::PLAYER_MUTEX;
use crate::{
http::{Context, Request, Response},
GLOBAL_MUTEX,
};
use anyhow::Error;
use anyhow::{anyhow, Error};
use local_ip_addr::get_local_ip_address;
use rand::seq::SliceRandom;
use rockbox_chromecast::Chromecast;
@ -17,6 +17,10 @@ use rockbox_sys::{
use rockbox_traits::types::track::Track;
use rockbox_types::{device::Device, LoadTracks, NewVolume};
unsafe extern "C" {
fn save_remote_track_metadata(url: *const std::ffi::c_char) -> i32;
}
pub async fn load(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> {
let player_mutex = PLAYER_MUTEX.lock().unwrap();
let mut player = ctx.player.lock().unwrap();
@ -42,16 +46,36 @@ pub async fn load(ctx: &Context, req: &Request, res: &mut Response) -> Result<()
let req_body = req.body.as_ref().unwrap();
let request: LoadTracks = serde_json::from_str(&req_body)?;
for path in &request.tracks {
if path.starts_with("http://") || path.starts_with("https://") {
ensure_remote_track_metadata(path.clone()).await?;
}
}
let rockbox_addr = env::var("ROCKBOX_ADDR").unwrap_or_else(|_| get_local_ip_address().unwrap());
let rockbox_port = env::var("ROCKBOX_GRAPHQL_PORT").unwrap_or_else(|_| "6062".to_string());
let kv = ctx.kv.lock().unwrap();
let mut tracks = request
.tracks
.iter()
.filter(|t| kv.get(*t).is_some())
.map(|t| {
let track = kv.get(t).unwrap();
Track {
let mut tracks = Vec::new();
for requested_path in &request.tracks {
let track = {
let kv = ctx.kv.lock().unwrap();
kv.get(requested_path).cloned()
};
let track = match track {
Some(track) => Some(track),
None => {
let track = repo::track::find_by_path(ctx.pool.clone(), requested_path).await?;
if let Some(ref track) = track {
let mut kv = ctx.kv.lock().unwrap();
kv.set(requested_path, track.clone());
}
track
}
};
if let Some(track) = track {
tracks.push(Track {
id: track.id.clone(),
title: track.title.clone(),
artist: track.artist.clone(),
@ -71,9 +95,15 @@ pub async fn load(ctx: &Context, req: &Request, res: &mut Response) -> Result<()
disc_number: track.disc_number,
duration: Some(track.length as f32 / 1000.0),
..Default::default()
}
})
.collect::<Vec<Track>>();
});
}
}
if tracks.is_empty() {
res.set_status(404);
res.text("No playable tracks found");
return Ok(());
}
if Some(true) == request.shuffle {
tracks.shuffle(&mut rand::thread_rng());
@ -169,7 +199,28 @@ pub async fn current_track(ctx: &Context, _req: &Request, res: &mut Response) ->
if let Some(player) = player.as_deref_mut() {
let current_playback = player.get_current_playback().await?;
let track: Option<Mp3Entry> = current_playback.current_track.map(|t| t.into());
let mut track: Option<Mp3Entry> = current_playback.current_track.map(|mut t| {
if t.path.is_empty() {
t.path = t.uri.clone();
}
t.into()
});
if let Some(lookup_path) = track
.as_ref()
.map(|t| t.path.clone())
.filter(|path| !path.is_empty())
{
let metadata = find_track_metadata(ctx, &lookup_path).await?;
if let Some(metadata) = metadata {
let current_track = track.as_mut().unwrap();
current_track.id = Some(metadata.id);
current_track.album_art = metadata.album_art.or(current_track.album_art.clone());
current_track.album_id = Some(metadata.album_id);
current_track.artist_id = Some(metadata.artist_id);
}
}
let track = track.map(|mut t| {
t.elapsed = current_playback.position_ms as u64;
t
@ -179,10 +230,27 @@ pub async fn current_track(ctx: &Context, _req: &Request, res: &mut Response) ->
}
let mut track = rb::playback::current_track();
let path: Option<String> = track.as_ref().map(|t| t.path.clone());
if let Some(path) = path {
let hash = format!("{:x}", md5::compute(path.as_bytes()));
if let Some(metadata) = repo::track::find_by_md5(ctx.pool.clone(), &hash).await? {
let audio_path: Option<String> = track.as_ref().map(|t| t.path.clone());
// Use the playlist filename for DB lookup — it matches the key under which
// metadata was saved (e.g. the HTTP URL) and is what the broker uses to
// display album art correctly. audio_current_track()->path can diverge for
// HTTP stream tracks so we treat it only as a fallback.
let playlist_index = rb::playlist::index();
let playlist_path = if playlist_index >= 0 {
let info = rb::playlist::get_track_info(playlist_index);
if !info.filename.is_empty() {
Some(info.filename)
} else {
None
}
} else {
None
};
let lookup_path = playlist_path.or(audio_path);
if let Some(path) = lookup_path {
if let Some(metadata) = find_track_metadata(ctx, &path).await? {
track.as_mut().unwrap().id = Some(metadata.id);
track.as_mut().unwrap().album_art = metadata.album_art;
track.as_mut().unwrap().album_id = Some(metadata.album_id);
@ -196,6 +264,83 @@ pub async fn current_track(ctx: &Context, _req: &Request, res: &mut Response) ->
Ok(())
}
async fn find_track_metadata(
ctx: &Context,
path: &str,
) -> Result<Option<rockbox_library::entity::track::Track>, Error> {
let hash = format!("{:x}", md5::compute(path.as_bytes()));
let mut metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?;
let internal_track = find_internal_track_by_url(ctx, path).await?;
if metadata
.as_ref()
.map(|track| track.album_art.is_none())
.unwrap_or(true)
{
if let Some(track) = internal_track.clone() {
metadata = Some(track);
}
}
if path.starts_with("http://") || path.starts_with("https://") {
if metadata
.as_ref()
.map(|track| track.album_art.is_none())
.unwrap_or(true)
&& internal_track.is_none()
{
ensure_remote_track_metadata(path.to_string()).await?;
metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?;
}
}
Ok(metadata)
}
async fn ensure_remote_track_metadata(path: String) -> Result<(), Error> {
let status = tokio::task::spawn_blocking(move || -> Result<i32, Error> {
let path_cstr = CString::new(path.as_str())?;
Ok(unsafe { save_remote_track_metadata(path_cstr.as_ptr()) })
})
.await??;
if status != 0 {
return Err(anyhow!("failed to save remote metadata"));
}
Ok(())
}
async fn find_internal_track_by_url(
ctx: &Context,
path: &str,
) -> Result<Option<rockbox_library::entity::track::Track>, Error> {
let url = match reqwest::Url::parse(path) {
Ok(url) => url,
Err(_) => return Ok(None),
};
let mut segments = match url.path_segments() {
Some(segments) => segments,
None => return Ok(None),
};
let Some("tracks") = segments.next() else {
return Ok(None);
};
let Some(track_id) = segments.next() else {
return Ok(None);
};
if segments.next().is_some() || track_id.is_empty() {
return Ok(None);
}
repo::track::find(ctx.pool.clone(), track_id)
.await
.map_err(Into::into)
}
pub async fn next_track(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> {
let player_mutex = PLAYER_MUTEX.lock().unwrap();
let player = ctx.player.lock().unwrap();
@ -205,8 +350,25 @@ pub async fn next_track(ctx: &Context, _req: &Request, res: &mut Response) -> Re
}
let mut track = rb::playback::next_track();
let path: Option<String> = track.as_ref().map(|t| t.path.clone());
if let Some(path) = path {
let audio_path: Option<String> = track.as_ref().map(|t| t.path.clone());
// Use the next playlist entry's filename for DB lookup for the same reason
// as in current_track: playlist filename matches the saved metadata key.
let current_index = rb::playlist::index();
let next_index = current_index + 1;
let playlist_path = if next_index >= 0 && next_index < rb::playlist::amount() {
let info = rb::playlist::get_track_info(next_index);
if !info.filename.is_empty() {
Some(info.filename)
} else {
None
}
} else {
None
};
let lookup_path = playlist_path.or(audio_path);
if let Some(path) = lookup_path {
let hash = format!("{:x}", md5::compute(path.as_bytes()));
if let Some(metadata) = repo::track::find_by_md5(ctx.pool.clone(), &hash).await? {
track.as_mut().unwrap().id = Some(metadata.id);

View file

@ -1,13 +1,12 @@
use std::env;
use std::{env, ffi::CString};
use crate::http::{Context, Request, Response};
use crate::PLAYER_MUTEX;
use anyhow::Error;
use anyhow::{anyhow, Error};
use local_ip_addr::get_local_ip_address;
use rand::seq::SliceRandom;
use rockbox_graphql::read_files;
use rockbox_library::repo;
use rockbox_network::download_tracks;
use rockbox_sys::{
self as rb,
types::{playlist_amount::PlaylistAmount, playlist_info::PlaylistInfo},
@ -16,33 +15,47 @@ use rockbox_sys::{
use rockbox_traits::types::track::Track;
use rockbox_types::{DeleteTracks, InsertTracks, NewPlaylist, StatusCode};
unsafe extern "C" {
fn save_remote_track_metadata(url: *const std::ffi::c_char) -> i32;
}
pub async fn create_playlist(
_ctx: &Context,
req: &Request,
res: &mut Response,
) -> Result<(), Error> {
let player_mutex = PLAYER_MUTEX.lock().unwrap();
if req.body.is_none() {
res.set_status(400);
return Ok(());
}
let body = req.body.as_ref().unwrap();
let mut new_playlist: NewPlaylist = serde_json::from_str(body).unwrap();
let new_playlist: NewPlaylist = serde_json::from_str(body).unwrap();
if new_playlist.tracks.is_empty() {
return Ok(());
}
new_playlist.tracks = download_tracks(new_playlist.tracks).await?;
persist_remote_track_metadata(_ctx, &new_playlist.tracks).await?;
let dir = new_playlist.tracks[0].clone();
let dir_parts: Vec<_> = dir.split('/').collect();
let dir = dir_parts[0..dir_parts.len() - 1].join("/");
let status = rb::playlist::create(&dir, None);
if status == -1 {
res.set_status(500);
return Ok(());
}
let player_mutex = PLAYER_MUTEX.lock().unwrap();
// Always create a fresh playlist so the currently-playing track is
// fully replaced rather than appended to.
// Local paths: use the track's parent directory (required by Rockbox).
// HTTP URLs: use the home directory — "/" fails because it isn't writable
// and playlist_create needs to create its control file there.
let first = &new_playlist.tracks[0];
let dir = if first.starts_with("http://") || first.starts_with("https://") {
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())
} else {
let parts: Vec<_> = first.split('/').collect();
parts[..parts.len().saturating_sub(1)].join("/")
};
rb::playlist::create(&dir, None);
// URLs are passed as-is; codec detection happens in the C metadata layer
// via probe_content_type_format(), which reads the HTTP Content-Type header
// and overrides any extension-based guess.
let start_index = rb::playlist::build_playlist(
new_playlist.tracks.iter().map(|t| t.as_str()).collect(),
0,
@ -164,9 +177,21 @@ pub async fn get_playlist_tracks(
}
pub async fn insert_tracks(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> {
let player_mutex = PLAYER_MUTEX.lock().unwrap();
let req_body = req.body.as_ref().unwrap();
let mut tracklist: InsertTracks = serde_json::from_str(&req_body).unwrap();
if let Some(dir) = &tracklist.directory {
tracklist.tracks = read_files(dir.clone()).await?;
}
if tracklist.tracks.is_empty() {
res.text("0");
return Ok(());
}
persist_remote_track_metadata(ctx, &tracklist.tracks).await?;
let player_mutex = PLAYER_MUTEX.lock().unwrap();
let amount = rb::playlist::amount();
let mut player = ctx.player.lock().unwrap();
@ -215,19 +240,14 @@ pub async fn insert_tracks(ctx: &Context, req: &Request, res: &mut Response) ->
return Ok(());
}
if let Some(dir) = &tracklist.directory {
tracklist.tracks = read_files(dir.clone()).await?;
}
if tracklist.tracks.is_empty() {
res.text("0");
return Ok(());
}
if amount == 0 {
let dir = tracklist.tracks[0].clone();
let dir_parts: Vec<_> = dir.split('/').collect();
let dir = dir_parts[0..dir_parts.len() - 1].join("/");
let first = &tracklist.tracks[0];
let dir = if first.starts_with("http://") || first.starts_with("https://") {
"/".to_string()
} else {
let dir_parts: Vec<_> = first.split('/').collect();
dir_parts[0..dir_parts.len() - 1].join("/")
};
let status = rb::playlist::create(&dir, None);
if status == -1 {
res.set_status(500);
@ -261,6 +281,91 @@ pub async fn insert_tracks(ctx: &Context, req: &Request, res: &mut Response) ->
Ok(())
}
async fn persist_remote_track_metadata(ctx: &Context, tracks: &[String]) -> Result<(), Error> {
for track in tracks {
if track.starts_with("http://") || track.starts_with("https://") {
if find_internal_track_by_url(ctx, track).await?.is_some() {
continue;
}
let track = track.clone();
let track_for_worker = track.clone();
let status = tokio::task::spawn_blocking(move || -> Result<i32, Error> {
let track_cstr = CString::new(track_for_worker.as_str())?;
Ok(unsafe { save_remote_track_metadata(track_cstr.as_ptr()) })
})
.await??;
if status != 0 {
return Err(anyhow!("failed to save remote metadata for {}", track));
}
}
}
Ok(())
}
async fn find_track_metadata(
ctx: &Context,
path: &str,
) -> Result<Option<rockbox_library::entity::track::Track>, Error> {
let hash = format!("{:x}", md5::compute(path.as_bytes()));
let mut metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?;
let internal_track = find_internal_track_by_url(ctx, path).await?;
if metadata
.as_ref()
.map(|track| track.album_art.is_none())
.unwrap_or(true)
{
if let Some(track) = internal_track.clone() {
metadata = Some(track);
}
}
if path.starts_with("http://") || path.starts_with("https://") {
if metadata
.as_ref()
.map(|track| track.album_art.is_none())
.unwrap_or(true)
&& internal_track.is_none()
{
persist_remote_track_metadata(ctx, &[path.to_string()]).await?;
metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?;
}
}
Ok(metadata)
}
async fn find_internal_track_by_url(
ctx: &Context,
path: &str,
) -> Result<Option<rockbox_library::entity::track::Track>, Error> {
let url = match reqwest::Url::parse(path) {
Ok(url) => url,
Err(_) => return Ok(None),
};
let mut segments = match url.path_segments() {
Some(segments) => segments,
None => return Ok(None),
};
let Some("tracks") = segments.next() else {
return Ok(None);
};
let Some(track_id) = segments.next() else {
return Ok(None);
};
if segments.next().is_some() || track_id.is_empty() {
return Ok(None);
}
repo::track::find(ctx.pool.clone(), track_id)
.await
.map_err(Into::into)
}
pub async fn remove_tracks(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> {
let player_mutex = PLAYER_MUTEX.lock().unwrap();
let player = ctx.player.lock().unwrap();
@ -309,7 +414,7 @@ pub async fn current_playlist(
continue;
}
let track = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?;
let track = find_track_metadata(ctx, &info.filename).await?;
if track.is_none() {
entries.push(entry.clone());
@ -344,10 +449,30 @@ pub async fn get_playlist(ctx: &Context, _req: &Request, res: &mut Response) ->
false => 0,
} as i32;
let mut entries = Vec::with_capacity(tracks.len());
for (mut track, _) in tracks {
if track.path.is_empty() {
track.path = track.uri.clone();
}
let mut entry: rockbox_sys::types::mp3_entry::Mp3Entry = track.into();
if !entry.path.is_empty() {
if let Some(metadata) = find_track_metadata(ctx, &entry.path).await? {
entry.id = Some(metadata.id);
entry.album_art = metadata.album_art.or(entry.album_art.clone());
entry.album_id = Some(metadata.album_id);
entry.artist_id = Some(metadata.artist_id);
entry.genre_id = Some(metadata.genre_id);
}
}
entries.push(entry);
}
let result = PlaylistInfo {
amount: tracks.len() as i32,
amount: entries.len() as i32,
index,
entries: tracks.into_iter().map(|(t, _)| t.into()).collect(),
entries,
..Default::default()
};
res.json(&result);
@ -369,7 +494,7 @@ pub async fn get_playlist(ctx: &Context, _req: &Request, res: &mut Response) ->
continue;
}
let track = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?;
let track = find_track_metadata(ctx, &info.filename).await?;
if track.is_none() {
entries.push(entry.clone());

View file

@ -26,6 +26,25 @@ pub mod kv;
pub mod player_events;
pub mod scan;
// Force netstream FFI symbols into the staticlib output.
// These functions are called from C code (streamfd.c) but not from any Rust
// code, so rustc would otherwise drop the entire crate from librockbox_server.a.
#[allow(dead_code)]
mod _netstream {
use rbnetstream::{rb_net_close, rb_net_len, rb_net_lseek, rb_net_open, rb_net_read};
use std::ffi::{c_char, c_void};
#[used]
static FN_OPEN: unsafe extern "C" fn(*const c_char) -> i32 = rb_net_open;
#[used]
static FN_READ: unsafe extern "C" fn(i32, *mut c_void, usize) -> i64 = rb_net_read;
#[used]
static FN_LSEEK: extern "C" fn(i32, i64, i32) -> i64 = rb_net_lseek;
#[used]
static FN_LEN: extern "C" fn(i32) -> i64 = rb_net_len;
#[used]
static FN_CLOSE: extern "C" fn(i32) = rb_net_close;
}
pub const AUDIO_EXTENSIONS: [&str; 17] = [
"mp3", "ogg", "flac", "m4a", "aac", "mp4", "alac", "wav", "wv", "mpc", "aiff", "ac3", "opus",
"spx", "sid", "ape", "wma",
@ -281,13 +300,49 @@ pub extern "C" fn start_broker() {
match rb::playback::current_track() {
Some(current_track) => {
let hash = format!("{:x}", md5::compute(current_track.path.as_bytes()));
if let Ok(Some(metadata)) =
rt.block_on(repo::track::find_by_md5(pool.clone(), &hash))
{
let mut track: Track = current_track.into();
// audio_current_track()->path can diverge from the playlist
// filename for HTTP stream tracks. Use the playlist entry's
// filename (same key the playlist section uses) so DB lookups
// hit the right record and album_art is resolved correctly.
let playlist_index = rb::playlist::index();
let lookup_path = if playlist_index >= 0 {
let info = rb::playlist::get_track_info(playlist_index);
if !info.filename.is_empty() {
info.filename
} else {
current_track.path.clone()
}
} else {
current_track.path.clone()
};
let hash = format!("{:x}", md5::compute(lookup_path.as_bytes()));
let db_metadata = rt
.block_on(repo::track::find_by_md5(pool.clone(), &hash))
.ok()
.flatten();
let mut track: Track = current_track.into();
if let Some(metadata) = db_metadata {
// When the URL-keyed record has no album_art (it was saved
// from the HTTP stream which has no embedded art), fall back
// to the local track identified by the UUID in the URL path.
let album_art = if metadata.album_art.is_some() {
metadata.album_art.clone()
} else {
let uuid = lookup_path.rsplit('/').next().unwrap_or("");
if !uuid.is_empty() {
rt.block_on(repo::track::find(pool.clone(), uuid))
.ok()
.flatten()
.and_then(|t| t.album_art)
} else {
None
}
};
track.id = Some(metadata.id.clone());
track.album_art = metadata.album_art;
track.album_art = album_art;
track.album_id = Some(metadata.album_id);
track.artist_id = Some(metadata.artist_id);
SimpleBroker::publish(track.clone());
@ -333,6 +388,10 @@ pub extern "C" fn start_broker() {
}
}
}
} else {
// Track not in DB (e.g. an HTTP stream URL not yet scanned).
// Still publish so clients receive elapsed/status updates.
SimpleBroker::publish(track);
}
}
None => {
@ -340,42 +399,85 @@ pub extern "C" fn start_broker() {
}
};
let mut entries: Vec<Mp3Entry> = vec![];
let mut current_playlist = rb::playlist::get_current();
let amount = rb::playlist::amount();
// Collect track info while holding the mutex (quick — no I/O).
let mut track_infos: Vec<(String, String)> = Vec::with_capacity(amount as usize);
for i in 0..amount {
let info = rb::playlist::get_track_info(i);
let mut entry = rb::metadata::get_metadata(-1, &info.filename);
let hash = format!("{:x}", md5::compute(info.filename.as_bytes()));
track_infos.push((hash, info.filename));
}
if let Some(entry) = metadata_cache.get(&hash) {
// Release the mutex before any slow network I/O so that player
// commands (play, pause, …) are not blocked while metadata is fetched.
drop(player_mutex);
// Probe metadata for uncached tracks without holding player_mutex.
let mut entries: Vec<Mp3Entry> = vec![];
for (hash, filename) in &track_infos {
if let Some(entry) = metadata_cache.get(hash) {
entries.push(entry.clone());
continue;
}
let track = rt
.block_on(repo::track::find_by_md5(pool.clone(), &hash))
let is_http = filename.starts_with("http://") || filename.starts_with("https://");
let db_track = rt
.block_on(repo::track::find_by_md5(pool.clone(), hash))
.unwrap();
if track.is_none() {
entries.push(entry);
continue;
}
let mut entry = if is_http {
// For HTTP stream URLs, do NOT call get_metadata(-1, url) — that
// opens a live HTTP connection for every queued track and blocks
// the broker loop. Instead, build the entry from the database
// using the saved track metadata keyed by the URL hash.
let mut e = Mp3Entry::default();
e.path = filename.clone();
if let Some(ref t) = db_track {
e.title = t.title.clone();
e.artist = t.artist.clone();
e.album = t.album.clone();
e.albumartist = t.album_artist.clone();
e.length = t.length as u64;
e.bitrate = t.bitrate;
e.frequency = t.frequency as u64;
// URL-keyed record may have no album_art (stream has no
// embedded art). Fall back to the local track by UUID.
e.album_art = if t.album_art.is_some() {
t.album_art.clone()
} else {
let uuid = filename.rsplit('/').next().unwrap_or("");
if !uuid.is_empty() {
rt.block_on(repo::track::find(pool.clone(), uuid))
.ok()
.flatten()
.and_then(|local| local.album_art)
} else {
None
}
};
e.album_id = Some(t.album_id.clone());
e.artist_id = Some(t.artist_id.clone());
e.genre_id = Some(t.genre_id.clone());
e.id = Some(t.id.clone());
}
e
} else {
let mut e = rb::metadata::get_metadata(-1, filename);
if db_track.is_some() {
e.album_art = db_track.as_ref().map(|t| t.album_art.clone()).flatten();
e.album_id = db_track.as_ref().map(|t| t.album_id.clone());
e.artist_id = db_track.as_ref().map(|t| t.artist_id.clone());
e.genre_id = db_track.as_ref().map(|t| t.genre_id.clone());
}
e
};
entry.album_art = track.as_ref().map(|t| t.album_art.clone()).flatten();
entry.album_id = track.as_ref().map(|t| t.album_id.clone());
entry.artist_id = track.as_ref().map(|t| t.artist_id.clone());
entry.genre_id = track.as_ref().map(|t| t.genre_id.clone());
metadata_cache.insert(hash, entry.clone());
metadata_cache.insert(hash.clone(), entry.clone());
entries.push(entry);
}
drop(player_mutex);
current_playlist.amount = amount;
current_playlist.max_playlist_size = rb::playlist::max_playlist_size();
current_playlist.index = rb::playlist::index();

View file

@ -38,6 +38,11 @@
directory, create subdirectories and do all sorts of things you want to be
able to browse when you fire up the simulator.
Rust toolchain (edition 2021) and Cargo must be installed to build the
network-stream support library (crates/netstream). The build will run
`cargo build --manifest-path crates/netstream/Cargo.toml --release`
automatically during `make`.
3. Run Uisimulator
To create a simulated disk drive for the simulator to see, create a
@ -46,14 +51,45 @@
Run 'rockboxui'.
4. Target Keypad Equivalents
4. Network Stream Playback (HTTP/HTTPS URLs)
The SDL simulator supports playing audio directly from HTTP and HTTPS URLs.
URLs are treated exactly like local file paths everywhere in the UI (file
browser, playlists, etc.).
To play a URL:
a) Playlist method (recommended):
Create a plain-text .m3u or .m3u8 playlist file inside your simdisk,
with one URL per line, e.g.:
simdisk/streams.m3u
----------------------
https://example.com/track1.mp3
https://example.com/track2.ogg
Load the playlist from the file browser and press Play.
b) Direct entry (if the target UI supports text input for file paths):
Enter the full URL including scheme, e.g.:
https://example.com/audio/song.mp3
Requirements:
* The server must serve the file with a correct Content-Type header for
the codec to be selected automatically.
* Seeking works for servers that support HTTP Range requests (RFC 7233).
For servers that do not support Range requests, seeking will fail
gracefully and playback continues from the current position.
* HTTPS is supported via rustls (no system CA store dependency).
5. Target Keypad Equivalents
The keyboard's numerical keypad is used to simulate the target keypad. See
the output rockboxui displays on start for details.
5. Mouse Input
6. Mouse Input
Clicking on the button images on the background will simulate pressing the
appropriate buttons. On scroll wheel targts the mouse wheel will simulate
appropriate buttons. On scroll wheel targets the mouse wheel will simulate
scroll wheel motion.

View file

@ -678,6 +678,7 @@ static bool read_chunk_minf(qtmovie_t *qtmovie, size_t chunk_len)
static bool read_chunk_mdia(qtmovie_t *qtmovie, size_t chunk_len)
{
size_t size_remaining = chunk_len - 8;
fourcc_t handler = 0;
while (size_remaining)
{
@ -696,9 +697,23 @@ static bool read_chunk_mdia(qtmovie_t *qtmovie, size_t chunk_len)
switch (sub_chunk_id)
{
case MAKEFOURCC('h','d','l','r'):
/* version/flags + pre_defined */
stream_skip(qtmovie->stream, 8);
handler = stream_read_uint32(qtmovie->stream);
if (sub_chunk_len > 20)
stream_skip(qtmovie->stream, sub_chunk_len - 20);
break;
case MAKEFOURCC('m','i','n','f'):
if (!read_chunk_minf(qtmovie, sub_chunk_len)) {
return false;
if (handler == MAKEFOURCC('s','o','u','n'))
{
if (!read_chunk_minf(qtmovie, sub_chunk_len)) {
return false;
}
}
else
{
stream_skip(qtmovie->stream, sub_chunk_len - 8);
}
break;
default:
@ -867,4 +882,3 @@ int qtmovie_read(stream_t *file, demux_res_t *demux_res)
return 0;
}

View file

@ -21,6 +21,7 @@
#include <stdio.h>
#include "metadata.h"
#include "metadata_common.h"
#include "logf.h"
#include "metadata_parsers.h"
#include "platform.h"
@ -71,7 +72,7 @@ bool get_a52_metadata(int fd, struct mp3entry *id3)
id3->bitrate = a52_bitrates[i >> 1];
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
switch (buf[4] & 0xc0)
{
@ -97,7 +98,7 @@ bool get_a52_metadata(int fd, struct mp3entry *id3)
}
/* One A52 frame contains 6 blocks, each containing 256 samples */
totalsamples = id3->filesize / id3->bytesperframe * 6 * 256;
totalsamples = id3->FS_PREFIX(filesize) / id3->bytesperframe * 6 * 256;
id3->length = totalsamples / id3->frequency * 1000;
return true;
}

View file

@ -75,7 +75,7 @@ bool get_aac_metadata(int fd, struct mp3entry *entry)
entry->id3v1len = 0;
entry->id3v2len = getid3v2len(fd);
entry->first_frame_offset = entry->id3v2len;
entry->filesize = filesize(fd) - entry->first_frame_offset;
entry->FS_PREFIX(filesize) = filesize(fd) - entry->first_frame_offset;
entry->needs_upsampling_correction = false;
if (entry->id3v2len)
@ -138,7 +138,7 @@ bool get_aac_metadata(int fd, struct mp3entry *entry)
return get_mp4_metadata(fd, entry);
}
entry->length = (unsigned long)((entry->filesize * 8LL + (entry->bitrate >> 1)) / entry->bitrate);
entry->length = (unsigned long)((entry->FS_PREFIX(filesize) * 8LL + (entry->bitrate >> 1)) / entry->bitrate);
return true;
}

View file

@ -70,7 +70,7 @@ bool get_adx_metadata(int fd, struct mp3entry* id3)
id3->bitrate = id3->frequency * channels * 18 * 8 / 32 / 1000;
id3->length = get_long_be(&buf[12]) / id3->frequency * 1000;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* get loop info */
if (!memcmp(buf+0x10,"\x01\xF4\x03",3)) {

View file

@ -145,7 +145,7 @@ bool get_aiff_metadata(int fd, struct mp3entry* id3)
}
id3->vbr = false; /* AIFF files are CBR */
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
}
else
{

View file

@ -242,7 +242,7 @@ static bool parse_sap_header(int fd, struct mp3entry* id3, int file_len)
bool get_asap_metadata(int fd, struct mp3entry* id3)
{
int filelength = filesize(fd);
int filelength = metadata_filesize(fd);
if(parse_sap_header(fd, id3, filelength) == false)
{
@ -254,7 +254,7 @@ bool get_asap_metadata(int fd, struct mp3entry* id3)
id3->frequency = 44100;
id3->vbr = false;
id3->filesize = filelength;
id3->FS_PREFIX(filesize) = filelength;
id3->genre_string = id3_get_num_genre(36);
return true;

View file

@ -642,7 +642,7 @@ bool get_asf_metadata(int fd, struct mp3entry* id3)
header.
*/
id3->first_frame_offset = lseek(fd, 0, SEEK_CUR) + 26;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* We copy the wfx struct to the MP3 TOC field in the id3 struct so
the codec doesn't need to parse the header object again */
memcpy(id3->toc, &wfx, sizeof(wfx));

View file

@ -59,7 +59,7 @@ bool get_au_metadata(int fd, struct mp3entry* id3)
int offset;
id3->vbr = false; /* All Sun audio files are CBR */
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
id3->length = 0;
lseek(fd, 0, SEEK_SET);
@ -72,7 +72,7 @@ bool get_au_metadata(int fd, struct mp3entry* id3)
* bits per sample: 8 bit
* channel: mono
*/
numbytes = id3->filesize;
numbytes = id3->FS_PREFIX(filesize);
id3->frequency = 8000;
id3->bitrate = 8;
}
@ -90,7 +90,7 @@ bool get_au_metadata(int fd, struct mp3entry* id3)
/* data size */
numbytes = get_long_be(buf + 8);
if (numbytes == (uint32_t)0xffffffff)
numbytes = id3->filesize - offset;
numbytes = id3->FS_PREFIX(filesize) - offset;
id3->frequency = get_long_be(buf + 16);
id3->bitrate = get_au_bitspersample(get_long_be(buf + 12)) * get_long_be(buf + 20)

View file

@ -135,7 +135,7 @@ bool get_ay_metadata(int fd, struct mp3entry* id3)
return false;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
id3->bitrate = 706;
id3->frequency = 44100;

View file

@ -122,7 +122,7 @@ bool get_flac_metadata(int fd, struct mp3entry* id3)
}
id3->vbr = true; /* All FLAC files are VBR */
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
id3->frequency = (buf[10] << 12) | (buf[11] << 4)
| ((buf[12] & 0xf0) >> 4);
rc = true; /* Got vital metadata */
@ -134,7 +134,7 @@ bool get_flac_metadata(int fd, struct mp3entry* id3)
{
/* Calculate track length (in ms) and estimate the bitrate (in kbit/s) */
id3->length = ((int64_t) totalsamples * 1000) / id3->frequency;
id3->bitrate = (id3->filesize * 8) / id3->length;
id3->bitrate = (id3->FS_PREFIX(filesize) * 8) / id3->length;
}
else if (totalsamples == 0)
{

View file

@ -52,7 +52,7 @@ bool get_gbs_metadata(int fd, struct mp3entry* id3)
return false;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* we only render 16 bits, 44.1KHz, Stereo */
id3->bitrate = 706;
id3->frequency = 44100;

View file

@ -26,7 +26,7 @@ bool get_hes_metadata(int fd, struct mp3entry* id3)
return false;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* we only render 16 bits, 44.1KHz, Stereo */
id3->bitrate = 706;
id3->frequency = 44100;

View file

@ -40,7 +40,7 @@ bool get_kss_metadata(int fd, struct mp3entry* id3)
return false;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* we only render 16 bits, 44.1KHz, Stereo */
id3->bitrate = 706;
id3->frequency = 44100;

View file

@ -34,11 +34,17 @@
#include "metadata/metadata_common.h"
/* Unified stream I/O: routes HTTP(S) fds through the Rust netstream layer.
* Only available in simulator / hosted SDL application builds. */
#if defined(SIMULATOR) || defined(APPLICATION)
#include "streamfd.h"
#endif
static bool get_shn_metadata(int fd, struct mp3entry *id3)
{
/* TODO: read the id3v2 header if it exists */
id3->vbr = true;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
return skip_id3v2(fd, id3);
}
@ -47,7 +53,7 @@ static bool get_other_asap_metadata(int fd, struct mp3entry *id3)
id3->bitrate = 706;
id3->frequency = 44100;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
id3->genre_string = id3_get_num_genre(36);
return true;
}
@ -414,6 +420,53 @@ unsigned int probe_file_format(const char *filename)
return AFMT_UNKNOWN;
}
#if defined(SIMULATOR) || defined(APPLICATION)
static unsigned int probe_content_type_format(int fd)
{
char content_type[64];
if (stream_content_type(fd, content_type, sizeof(content_type)) < 0)
return AFMT_UNKNOWN;
if (!strcasecmp(content_type, "audio/mpeg") ||
!strcasecmp(content_type, "audio/mp3") ||
!strcasecmp(content_type, "audio/mpa"))
return AFMT_MPA_L3;
if (!strcasecmp(content_type, "audio/mp4") ||
!strcasecmp(content_type, "audio/m4a") ||
!strcasecmp(content_type, "audio/x-m4a") ||
!strcasecmp(content_type, "video/mp4") ||
!strcasecmp(content_type, "application/mp4"))
return AFMT_MP4_AAC;
if (!strcasecmp(content_type, "audio/aac") ||
!strcasecmp(content_type, "audio/aacp"))
return AFMT_AAC_BSF;
if (!strcasecmp(content_type, "audio/ogg") ||
!strcasecmp(content_type, "application/ogg"))
return AFMT_OGG_VORBIS;
if (!strcasecmp(content_type, "audio/opus"))
return AFMT_OPUS;
if (!strcasecmp(content_type, "audio/flac") ||
!strcasecmp(content_type, "audio/x-flac"))
return AFMT_FLAC;
if (!strcasecmp(content_type, "audio/wav") ||
!strcasecmp(content_type, "audio/wave") ||
!strcasecmp(content_type, "audio/x-wav"))
return AFMT_PCM_WAV;
if (!strcasecmp(content_type, "audio/x-ms-wma") ||
!strcasecmp(content_type, "audio/asf"))
return AFMT_WMA;
return AFMT_UNKNOWN;
}
#endif
/* Get metadata for track - return false if parsing showed problems with the
* file that would prevent playback. supply a filedescriptor <0 and the file will be opened
* and closed automatically within the get_metadata call
@ -427,16 +480,25 @@ bool get_metadata_ex(struct mp3entry* id3, int fd, const char* trackname, int fl
const struct afmt_entry *entry;
int logfd = 0;
DEBUGF("Read metadata for %s\n", trackname);
fprintf(stderr, "[metadata] get_metadata_ex: trackname=%s fd=%d\n", trackname, fd);
const char *res_str = "\n";
/* Clear the mp3entry to avoid having bogus pointers appear */
wipe_mp3entry(id3);
if (fd < 0)
/* fd == -1 is the "not yet opened" sentinel. In simulator / APPLICATION
* builds HTTP stream handles are encoded as fd <= -1000 and must not be
* treated as "no fd provided". */
if (fd == -1)
{
#if defined(SIMULATOR) || defined(APPLICATION)
fd = stream_open(trackname, O_RDONLY);
#else
fd = open(trackname, O_RDONLY);
#endif
flags |= METADATA_CLOSE_FD_ON_EXIT;
if (fd < 0)
fprintf(stderr, "[metadata] get_metadata_ex: opened fd=%d for %s\n", fd, trackname);
if (fd == -1)
{
DEBUGF("Error opening %s\n", trackname);
res_str = " - [Error opening]\n";
@ -447,6 +509,20 @@ bool get_metadata_ex(struct mp3entry* id3, int fd, const char* trackname, int fl
/* Take our best guess at the codec type based on file extension */
id3->codectype = probe_file_format(trackname);
fprintf(stderr, "[metadata] probe_file_format: %s -> codectype=%u\n", trackname, id3->codectype);
#if defined(SIMULATOR) || defined(APPLICATION)
/* For HTTP streams the Content-Type header is more reliable than the
* file extension (e.g. .m4a is used for both ALAC and AAC). Let the
* server's content-type override the extension-based guess whenever it
* resolves to a known codec. */
if (stream_is_http_fd(fd))
{
int ct_codec = probe_content_type_format(fd);
fprintf(stderr, "[metadata] probe_content_type_format: fd=%d -> codectype=%u\n", fd, ct_codec);
if (ct_codec != AFMT_UNKNOWN)
id3->codectype = ct_codec;
}
#endif
/* default values for embedded cuesheets */
id3->has_embedded_cuesheet = false;
@ -455,24 +531,41 @@ bool get_metadata_ex(struct mp3entry* id3, int fd, const char* trackname, int fl
entry = &audio_formats[id3->codectype];
/* Load codec specific track tag information and confirm the codec type. */
fprintf(stderr, "[metadata] parsing: trackname=%s codec=%u parser=%s\n",
trackname, id3->codectype, entry->label ? entry->label : "none");
if (!entry->parse_func)
{
DEBUGF("nothing to parse for %s (format %s)\n", trackname, entry->label);
fprintf(stderr, "[metadata] ERROR: no parser for %s (codec=%u)\n", trackname, id3->codectype);
res_str = " - [No parser]\n";
success = false;
}
else if (!entry->parse_func(fd, id3))
{
DEBUGF("parsing %s failed (format: %s)\n", trackname, entry->label);
fprintf(stderr, "[metadata] ERROR: parse failed for %s (format %s)\n", trackname, entry->label);
res_str = " - [Parser failed]\n";
success = false;
wipe_mp3entry(id3); /* ensure the mp3entry is clear */
}
else
{
fprintf(stderr, "[metadata] parsed OK: %s length=%lu elapsed=%lu freq=%lu filesize=%lu codectype=%u first_frame_off=%lu\n",
trackname, id3->length, id3->elapsed, id3->frequency,
id3->FS_PREFIX(filesize), id3->codectype, id3->first_frame_offset);
}
#if defined(SIMULATOR) || defined(APPLICATION)
if ((flags & METADATA_CLOSE_FD_ON_EXIT))
stream_close(fd);
else
stream_lseek(fd, 0, SEEK_SET);
#else
if ((flags & METADATA_CLOSE_FD_ON_EXIT))
close(fd);
else
lseek(fd, 0, SEEK_SET);
#endif
if (success && (flags & METADATA_EXCLUDE_ID3_PATH) == 0)
{

View file

@ -21,6 +21,43 @@
#include <inttypes.h>
#include "metadata.h"
/* In simulator / hosted-SDL-app builds, file descriptors for HTTP(S) streams
* are encoded as integers <= STREAM_HTTP_FD_BASE (-1000). Plain POSIX
* read() / lseek() / filesize() do not handle those values. Route all
* metadata I/O through the stream abstraction so that HTTP handles work
* identically to normal file descriptors.
*
* streamfd.h is in apps/, which is on the include path via -I$(APPSDIR) in
* uisimulator.make. The macros below are intentionally placed before the
* read_uint* macros so those expansions are also redirected. */
#if defined(SIMULATOR) || defined(APPLICATION)
#include "streamfd.h"
/* Route read() and lseek() through the stream abstraction so that HTTP(S)
* file descriptors (encoded as integers <= STREAM_HTTP_FD_BASE = -1000) are
* handled correctly by all metadata parsers.
*
* filesize() is intentionally NOT redefined here. It is also a member name
* of struct mp3entry (expanded via FS_PREFIX in firmware/include/file.h), so
* redefining it as a function-like macro would break every `id3->filesize`
* struct access with a "no member named 'filesize'" compiler error.
*
* Instead, metadata parsers that need a correct file size (e.g. for CBR
* duration estimation) must call metadata_filesize(fd), defined below.
* stream_filesize_fd() returns Content-Length for HTTP fds and delegates to
* the platform filesize() for regular fds. */
#ifdef read
#undef read
#endif
#define read(fd, buf, n) stream_read((fd), (buf), (n))
#ifdef lseek
#undef lseek
#endif
#define lseek(fd, off, whence) stream_lseek((fd), (off), (whence))
#define metadata_filesize(fd) stream_filesize_fd((fd))
#else
#define metadata_filesize(fd) filesize(fd)
#endif /* SIMULATOR || APPLICATION */
#ifdef ROCKBOX_BIG_ENDIAN
#define IS_BIG_ENDIAN 1
#else

View file

@ -96,7 +96,7 @@ bool get_mod_metadata(int fd, struct mp3entry* id3)
id3->frequency = 44100;
id3->length = 120*1000;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
return true;
}

View file

@ -83,14 +83,14 @@ bool get_monkeys_metadata(int fd, struct mp3entry* id3)
}
id3->vbr = true; /* All APE files are VBR */
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
totalsamples = finalframeblocks;
if (totalframes > 1)
totalsamples += blocksperframe * (totalframes-1);
id3->length = ((int64_t) totalsamples * 1000) / id3->frequency;
id3->bitrate = (id3->filesize * 8) / id3->length;
id3->bitrate = (id3->FS_PREFIX(filesize) * 8) / id3->length;
read_ape_tags(fd, id3);
return true;

View file

@ -73,14 +73,14 @@ static int getsonglength(int fd, struct mp3entry *entry)
/* Subtract the meta information from the file size to get
the true size of the MP3 stream */
entry->filesize -= entry->id3v1len + entry->id3v2len;
entry->FS_PREFIX(filesize) -= entry->id3v1len + entry->id3v2len;
/* Validate byte count, in case the file has been edited without
* updating the header.
*/
if (info.byte_count)
{
const unsigned long expected = entry->filesize - entry->id3v1len
const unsigned long expected = entry->FS_PREFIX(filesize) - entry->id3v1len
- entry->id3v2len;
const unsigned long diff = MAX(10240, info.byte_count / 20);
@ -101,7 +101,7 @@ static int getsonglength(int fd, struct mp3entry *entry)
}
}
entry->filesize -= bytecount;
entry->FS_PREFIX(filesize) -= bytecount;
bytecount += entry->id3v2len;
entry->bitrate = info.bitrate;
@ -130,7 +130,7 @@ static int getsonglength(int fd, struct mp3entry *entry)
if (info.bitrate < 8)
filetime = 0;
else
filetime = entry->filesize / (info.bitrate >> 3);
filetime = entry->FS_PREFIX(filesize) / (info.bitrate >> 3);
/* bitrate is in kbps so this delivers milliseconds. Doing bitrate / 8
* instead of filesize * 8 is exact, because mpeg audio bitrates are
* always multiples of 8, and it avoids overflows. */
@ -163,7 +163,7 @@ static int getsonglength(int fd, struct mp3entry *entry)
bool get_mp3_metadata(int fd, struct mp3entry *entry)
{
entry->title = NULL;
entry->filesize = filesize(fd);
entry->FS_PREFIX(filesize) = metadata_filesize(fd);
entry->id3v1len = getid3v1len(fd);
entry->id3v2len = getid3v2len(fd);
entry->tracknum = 0;
@ -181,7 +181,7 @@ bool get_mp3_metadata(int fd, struct mp3entry *entry)
setid3v1title(fd, entry);
}
if(!entry->length || (entry->filesize < 8 ))
if(!entry->length || (entry->FS_PREFIX(filesize) < 8 ))
/* no song length or less than 8 bytes is hereby considered to be an
invalid mp3 and won't be played by us! */
return false;

View file

@ -40,6 +40,7 @@
#include "platform.h"
#include "metadata.h"
#include "metadata_common.h"
#include "metadata/metadata_parsers.h"
//#define DEBUG_VERBOSE
@ -609,7 +610,7 @@ int get_mp3file_info(int fd, struct mp3info *info)
if(result)
return result;
info->byte_count = filesize(fd) - getid3v1len(fd) - offset - bytecount;
info->byte_count = metadata_filesize(fd) - getid3v1len(fd) - offset - bytecount;
}
return bytecount;

View file

@ -777,7 +777,7 @@ static bool read_mp4_container(int fd, struct mp3entry* id3,
if(size == 0)
break;
/* mdat chunks accumulate! */
id3->filesize += size;
id3->FS_PREFIX(filesize) += size;
if(id3->samples > 0) {
/* We've already seen the moov chunk. */
done = true;
@ -821,12 +821,12 @@ static bool read_mp4_container(int fd, struct mp3entry* id3,
bool get_mp4_metadata(int fd, struct mp3entry* id3)
{
id3->codectype = AFMT_UNKNOWN;
id3->filesize = 0;
id3->FS_PREFIX(filesize) = 0;
errno = 0;
if (read_mp4_container(fd, id3, filesize(fd)) && (errno == 0)
if (read_mp4_container(fd, id3, metadata_filesize(fd)) && (errno == 0)
&& (id3->samples > 0) && (id3->frequency > 0)
&& (id3->filesize > 0))
&& (id3->FS_PREFIX(filesize) > 0))
{
if (id3->codectype == AFMT_UNKNOWN)
{
@ -844,7 +844,7 @@ bool get_mp4_metadata(int fd, struct mp3entry* id3)
return false;
}
id3->bitrate = ((int64_t) id3->filesize * 8) / id3->length;
id3->bitrate = ((int64_t) id3->FS_PREFIX(filesize) * 8) / id3->length;
DEBUGF("MP4 bitrate %d, frequency %ld Hz, length %ld ms\n",
id3->bitrate, id3->frequency, id3->length);
}
@ -853,7 +853,7 @@ bool get_mp4_metadata(int fd, struct mp3entry* id3)
logf("MP4 metadata error");
DEBUGF("MP4 metadata error. errno %d, samples %ld, frequency %ld, "
"filesize %ld\n", errno, id3->samples, id3->frequency,
id3->filesize);
id3->FS_PREFIX(filesize));
return false;
}

View file

@ -212,8 +212,8 @@ bool get_musepack_metadata(int fd, struct mp3entry *id3)
return false;
}
id3->filesize = filesize(fd);
id3->bitrate = id3->filesize * 8 / id3->length;
id3->FS_PREFIX(filesize) = filesize(fd);
id3->bitrate = id3->FS_PREFIX(filesize) * 8 / id3->length;
read_ape_tags(fd, id3);
return true;

View file

@ -262,7 +262,7 @@ bool get_nsf_metadata(int fd, struct mp3entry* id3)
return false;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* we only render 16 bits, 44.1KHz, Mono */
id3->bitrate = 706;
id3->frequency = 44100;

View file

@ -134,7 +134,7 @@ bool get_ogg_metadata(int fd, struct mp3entry* id3)
return false;
}
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* We need to ensure the serial number from this page is the same as the
* one from the last page (since we only support a single bitstream).
@ -148,7 +148,7 @@ bool get_ogg_metadata(int fd, struct mp3entry* id3)
*/
/* A page is always < 64 kB */
if (lseek(fd, -(MIN(64 * 1024, id3->filesize)), SEEK_END) < 0)
if (lseek(fd, -(MIN(64 * 1024, id3->FS_PREFIX(filesize))), SEEK_END) < 0)
{
return false;
}
@ -232,7 +232,7 @@ bool get_ogg_metadata(int fd, struct mp3entry* id3)
return false;
}
id3->bitrate = (((int64_t) id3->filesize - comment_size) * 8) / id3->length;
id3->bitrate = (((int64_t) id3->FS_PREFIX(filesize) - comment_size) * 8) / id3->length;
return true;
}

View file

@ -48,6 +48,7 @@
#include <string.h>
#include "platform.h"
#include "metadata.h"
#include "metadata_common.h"
#include "metadata_parsers.h"
#define EA3_HEADER_SIZE 96
@ -179,7 +180,7 @@ bool get_oma_metadata(int fd, struct mp3entry* id3)
/* Currently, there's no means of knowing the duration *
* directly from the the file so we calculate it. */
id3->filesize = filesize(fd);
id3->length = ((id3->filesize - id3->first_frame_offset) * 8) / id3->bitrate;
id3->FS_PREFIX(filesize) = filesize(fd);
id3->length = ((id3->FS_PREFIX(filesize) - id3->first_frame_offset) * 8) / id3->bitrate;
return true;
}

View file

@ -267,7 +267,7 @@ static inline int rm_parse_header(int fd, RMContext *rmctx, struct mp3entry *id3
rmctx->data_offset = skipped + 8;
rmctx->bit_rate = rmctx->block_align * rmctx->sample_rate / 192;
if (rmctx->block_align)
rmctx->nb_packets = (filesize(fd) - rmctx->data_offset) / rmctx->block_align;
rmctx->nb_packets = (metadata_filesize(fd) - rmctx->data_offset) / rmctx->block_align;
if (rmctx->sample_rate)
rmctx->duration = (uint32_t)(256LL * 6 * 1000 * rmctx->nb_packets / rmctx->sample_rate);
rmctx->flags |= RM_RAW_DATASTREAM;
@ -505,6 +505,6 @@ bool get_rm_metadata(int fd, struct mp3entry* id3)
id3->bitrate = (rmctx->bit_rate + 500) / 1000;
id3->frequency = rmctx->sample_rate;
id3->length = rmctx->duration;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = metadata_filesize(fd);
return true;
}

View file

@ -54,7 +54,7 @@ bool get_sgc_metadata(int fd, struct mp3entry* id3)
return false;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* we only render 16 bits, 44.1KHz, Stereo */
id3->bitrate = 706;
id3->frequency = 44100;

View file

@ -83,7 +83,7 @@ bool get_sid_metadata(int fd, struct mp3entry* id3)
*/
id3->length = (buf[0xf]-1)*1000;
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
return true;
}

View file

@ -446,7 +446,7 @@ bool get_smaf_metadata(int fd, struct mp3entry* id3)
id3->composer = NULL;
id3->vbr = false; /* All SMAF files are CBR */
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* check File Chunk and Contents Info Chunk */
lseek(fd, 0, SEEK_SET);

View file

@ -125,7 +125,7 @@ bool get_spc_metadata(int fd, struct mp3entry* id3)
id3->frequency = 32000;
id3->length = length+fade;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
id3->genre_string = id3_get_num_genre(36);
return true;

View file

@ -54,7 +54,7 @@
static void read_id3_tags(int fd, struct mp3entry* id3)
{
id3->title = NULL;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
id3->id3v2len = getid3v2len(fd);
id3->tracknum = 0;
id3->discnum = 0;
@ -98,7 +98,7 @@ bool get_tta_metadata(int fd, struct mp3entry* id3)
id3->length = ((GET_HEADER(ttahdr, DATA_LENGTH)) / id3->frequency) * 1000LL;
bps = (GET_HEADER(ttahdr, BITS_PER_SAMPLE));
datasize = id3->filesize - id3->first_frame_offset;
datasize = id3->FS_PREFIX(filesize) - id3->first_frame_offset;
origsize = (GET_HEADER(ttahdr, DATA_LENGTH)) * ((bps + 7) / 8) * channels;
id3->bitrate = (int) ((uint64_t) datasize * id3->frequency * channels * bps

View file

@ -153,7 +153,7 @@ bool get_vgm_metadata(int fd, struct mp3entry* id3)
}
id3->vbr = false;
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
id3->bitrate = 1411;
id3->frequency = 44100;
@ -179,7 +179,7 @@ bool get_vgm_metadata(int fd, struct mp3entry* id3)
/* Seek to gd3 offset and read as
many bytes posible */
gd3_offset = id3->filesize - (header_size + gd3_offset);
gd3_offset = id3->FS_PREFIX(filesize) - (header_size + gd3_offset);
if ((lseek(fd, -gd3_offset, SEEK_END) < 0)
|| ((read_bytes = read(fd, buf, ID3V2_BUF_SIZE)) <= 0))
return true;

View file

@ -42,8 +42,8 @@ bool get_vox_metadata(int fd, struct mp3entry* id3)
id3->frequency = 8000;
id3->bitrate = 8000 * 4 / 1000;
id3->vbr = false; /* All VOX files are CBR */
id3->filesize = filesize(fd);
id3->length = id3->filesize >> 2;
id3->FS_PREFIX(filesize) = filesize(fd);
id3->length = id3->FS_PREFIX(filesize) >> 2;
return true;
}

View file

@ -102,7 +102,7 @@ bool get_vtx_metadata(int fd, struct mp3entry* id3)
if (lseek(fd, 0, SEEK_SET) < 0)
goto exit_bad;
if (filesize(fd) < 20)
if (metadata_filesize(fd) < 20)
goto exit_bad;
uint hdr = Reader_ReadWord(fd);
@ -142,7 +142,7 @@ bool get_vtx_metadata(int fd, struct mp3entry* id3)
id3->bitrate = 706;
id3->frequency = 44100; // XXX allow this to be configured?
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = metadata_filesize(fd);
id3->length = info.frames * 1000 / info.playerfreq;
return true;

View file

@ -306,7 +306,7 @@ static bool read_header(int fd, struct mp3entry* id3, const unsigned char *chunk
memset(&fmt, 0, sizeof(struct wave_fmt));
id3->vbr = false; /* All Wave/Wave64 files are CBR */
id3->filesize = filesize(fd);
id3->FS_PREFIX(filesize) = filesize(fd);
/* get RIFF chunk header */
lseek(fd, 0, SEEK_SET);
@ -376,7 +376,7 @@ static bool read_header(int fd, struct mp3entry* id3, const unsigned char *chunk
chunksize += ((is_64)? ((1 + ~chunksize) & 0x07) : (chunksize & 1));
offset += chunksize;
if (offset >= id3->filesize)
if (offset >= id3->FS_PREFIX(filesize))
break;
lseek(fd, chunksize - read_data, SEEK_CUR);
@ -400,7 +400,7 @@ static bool read_header(int fd, struct mp3entry* id3, const unsigned char *chunk
/* Calculate track length (in ms) and estimate the bitrate (in kbit/s) */
id3->length = (fmt.formattag != WAVE_FORMAT_ATRAC3)?
(uint64_t)fmt.totalsamples * 1000 / id3->frequency :
((id3->filesize - id3->first_frame_offset) * 8) / id3->bitrate;
((id3->FS_PREFIX(filesize) - id3->first_frame_offset) * 8) / id3->bitrate;
/* output header/id3 info (for debug) */
DEBUGF("%s header info ----\n", (is_64)? "wave64" : "wave");

View file

@ -79,7 +79,7 @@ bool get_wavpack_metadata(int fd, struct mp3entry* id3)
}
id3->vbr = true; /* All WavPack files are VBR */
id3->filesize = filesize (fd);
id3->FS_PREFIX(filesize) = filesize (fd);
/* check up to 16 headers before we give up finding one with audio */
@ -134,7 +134,7 @@ bool get_wavpack_metadata(int fd, struct mp3entry* id3)
/* if the total number of samples is still unknown, make a guess on the high side (for now) */
if (totalsamples == (uint32_t) -1) {
totalsamples = id3->filesize * 3;
totalsamples = id3->FS_PREFIX(filesize) * 3;
if (!(flags & HYBRID_FLAG))
totalsamples /= 2;
@ -144,7 +144,7 @@ bool get_wavpack_metadata(int fd, struct mp3entry* id3)
}
id3->length = ((int64_t) totalsamples * 1000) / id3->frequency;
id3->bitrate = id3->filesize / (id3->length / 8);
id3->bitrate = id3->FS_PREFIX(filesize) / (id3->length / 8);
read_ape_tags(fd, id3);
return true;

View file

@ -45,9 +45,11 @@ objcopy = cp $(1) $(1).tmp;mv -f $(1).tmp $(2)
else
ifdef DEBUG
objcopy = cp $(1) $(1).tmp;mv -f $(1).tmp $(2) # objcopy hosted (DEBUG)
else ifeq ($(UNAME), Darwin)
objcopy = cp $(1) $(1).tmp;mv -f $(1).tmp $(2) # objcopy hosted macOS (GNU objcopy strips LC_ID_DYLIB)
else
objcopy = $(OC) -S -x $(1) $(2) # objcopy hosted (!DEBUG)
endif
endif
endif
# calculate dependencies for a list of source files $(2) and output them to $(1)

View file

@ -24,18 +24,33 @@ else
UIBMP=$(BUILDDIR)/UI256.bmp
endif
# Rust network-stream static library (built from crates/netstream).
NETSTREAM_LIB = $(BUILDDIR)/librbnetstream.a
NETSTREAM_MANIFEST = $(ROOTDIR)/crates/netstream/Cargo.toml
NETSTREAM_CARGO_LIB = $(ROOTDIR)/target/release/librbnetstream.a
.PHONY: netstream-lib
netstream-lib:
cargo build --manifest-path $(NETSTREAM_MANIFEST) --release
$(NETSTREAM_CARGO_LIB): netstream-lib
$(NETSTREAM_LIB): $(NETSTREAM_CARGO_LIB)
$(call PRINTS,CP librbnetstream.a)cp $< $@
.SECONDEXPANSION: # $$(OBJ) is not populated until after this
$(SIMLIB): $$(SIMOBJ) $(UIBMP)
$(SILENT)$(shell rm -f $@)
$(call PRINTS,AR $(@F))$(AR) rcs $@ $(SIMOBJ) >/dev/null
$(BUILDDIR)/$(BINARY): $$(OBJ) $(FIRMLIB) $(VOICESPEEXLIB) $(CORE_LIBS) $(SIMLIB)
$(BUILDDIR)/$(BINARY): $$(OBJ) $(FIRMLIB) $(VOICESPEEXLIB) $(CORE_LIBS) $(SIMLIB) $(NETSTREAM_LIB)
ifeq ($(UNAME), Darwin)
$(call PRINTS,LD $(BINARY))$(CC) -o $@ $^ $(LDOPTS) $(GLOBAL_LDOPTS) -Wl,$(LDMAP_OPT),$(BUILDDIR)/rockbox.map
$(call PRINTS,LD $(BINARY))$(CC) -o $@ $^ $(LDOPTS) $(GLOBAL_LDOPTS) -lpthread -ldl \
-Wl,$(LDMAP_OPT),$(BUILDDIR)/rockbox.map
else
$(call PRINTS,LD $(BINARY))$(CC) -o $@ -Wl,--start-group $^ -Wl,--end-group $(LDOPTS) $(GLOBAL_LDOPTS) \
-Wl,$(LDMAP_OPT),$(BUILDDIR)/rockbox.map
-lpthread -ldl -Wl,$(LDMAP_OPT),$(BUILDDIR)/rockbox.map
endif
$(SILENT)$(call objcopy,$@,$@)

View file

@ -35,25 +35,25 @@ pub const mp3entry = extern struct {
id3version: u8,
codectype: u32,
bitrate: u32,
frequency: u32,
id3v2len: u32,
id3v1len: u32,
first_frame_offset: u32,
filesize: u32,
length: u32,
elapsed: u32,
frequency: c_ulong,
id3v2len: c_ulong,
id3v1len: c_ulong,
first_frame_offset: c_ulong,
filesize: c_ulong,
length: c_ulong,
elapsed: c_ulong,
lead_trim: c_int,
tail_trim: c_int,
samples: u64,
frame_count: u32,
bytesperframe: u32,
frame_count: c_ulong,
bytesperframe: c_ulong,
vbr: bool,
has_toc: bool,
toc: [100]u8,
needs_upsampling_correction: bool,
id3v2buf: [ID3V2_BUF_SIZE]u8,
id3v1buf: [4][92]u8,
offset: u32,
offset: c_ulong,
index: c_int,
skip_resume_adjustments: bool,
autoresumable: u8,