From 53bd90f260bec54ec09c9e91e15c7e34e515bff8 Mon Sep 17 00:00:00 2001 From: Victor Date: Mon, 23 Feb 2026 13:42:04 +0000 Subject: [PATCH 1/7] Add HTTP/S share support --- Makefile | 3 +- README.md | 4 +- src/context.cpp | 11 +- src/fs/fs_common.hpp | 9 + src/fs/fs_http.cpp | 472 ++++++++++++++++++++++++++++++++++++++++ src/fs/fs_http.hpp | 77 +++++++ src/main.cpp | 13 +- src/ui/ui_main_menu.cpp | 16 +- 8 files changed, 599 insertions(+), 6 deletions(-) create mode 100644 src/fs/fs_http.cpp create mode 100644 src/fs/fs_http.hpp diff --git a/Makefile b/Makefile index 1bacf2f..bbe5a19 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ FFMPEG_CONFIG := --enable-asm \ --disable-muxers \ --target-os=horizon --enable-cross-compile \ --cross-prefix=aarch64-none-elf- --arch=aarch64 --cpu=cortex-a57 --enable-neon \ + --enable-mbedtls --enable-version3 \ --enable-pic --disable-autodetect --disable-runtime-cpudetect --disable-debug MPV_CONFIG := --enable-libmpv-static --disable-libmpv-shared --disable-manpage-build \ @@ -37,7 +38,7 @@ BUILD := build ROMFS := $(BUILD)/romfs INSTALL := $(TOPDIR)/$(BUILD)/install PACKAGES := mpv libavcodec libavformat libavfilter libavutil libswscale libswresample \ - uam libsmb2 libnfs libssh2 freetype2 libarchive + uam libsmb2 libnfs libssh2 libcurl freetype2 libarchive LIBDIRS := $(INSTALL) DEFINES := __SWITCH__ _GNU_SOURCE _POSIX_VERSION=200809L timegm=mktime \ diff --git a/README.md b/README.md index d50e0ab..88c6114 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A hardware-accelerated media player for the Nintendo Switch, built on mpv and FF - Direct rendering (faster software decoding) - Custom post-processing shaders - Custom audio backend for mpv using native Nintendo APIs, supporting layouts up to 5.1 surround -- Network playback through Samba, NFS or SFTP +- Network playback through HTTP/S, Samba, NFS or SFTP - External drive support using [libusbhsfs](https://github.com/DarkMatterCore/libusbhsfs) - Rich and responsive user interface, even under load @@ -47,7 +47,7 @@ GIMP_VERSION=3 ./build-docker.sh ### Manual - Set up a [devkitpro](https://devkitpro.org/wiki/devkitPro_pacman) environment for Switch homebrew development. -- Install the following packages: `switch-bzip2`, `switch-dav1d`, `switch-freetype`, `switch-glm`, `switch-harfbuzz`, `switch-libarchive`, `switch-libass`, `switch-libfribidi`, `switch-libjpeg-turbo`, `switch-libpng`, `switch-libwebp`, `switch-libssh2`, `switch-mbedtls`, `switch-ntfs-3g` and `switch-lwext4`. In addition, the following build dependencies are required: `switch-pkg-config`, `dkp-meson-scripts`, `dkp-toolchain-vars`, and [GIMP](https://www.gimp.org/) (2 or 3). +- Install the following packages: `switch-bzip2`, `switch-dav1d`, `switch-freetype`, `switch-glm`, `switch-harfbuzz`, `switch-libarchive`, `switch-libass`, `switch-libfribidi`, `switch-libjpeg-turbo`, `switch-libpng`, `switch-libwebp`, `switch-curl`, `switch-libssh2`, `switch-mbedtls`, `switch-ntfs-3g` and `switch-lwext4`. In addition, the following build dependencies are required: `switch-pkg-config`, `dkp-meson-scripts`, `dkp-toolchain-vars`, and [GIMP](https://www.gimp.org/) (2 or 3). - Compile and install a GPL build of [libusbhsfs](https://github.com/DarkMatterCore/libusbhsfs). - Compile and install [libsmb2](misc/libsmb2/) and [libnfs](misc/libnfs/). - Configure, compile and install FFmpeg: `make configure-ffmpeg && make build-ffmpeg -j$(nproc)`. diff --git a/src/context.cpp b/src/context.cpp index 4f0bbe8..49f7193 100644 --- a/src/context.cpp +++ b/src/context.cpp @@ -25,6 +25,7 @@ #include "fs/fs_smb.hpp" #include "fs/fs_nfs.hpp" #include "fs/fs_sftp.hpp" +#include "fs/fs_http.hpp" #include "context.hpp" @@ -70,7 +71,9 @@ int Context::read_from_file() { if (n == "protocol") info->protocol = (v == "smb" ? fs::NetworkFilesystem::Protocol::Smb : - (v == "nfs" ? fs::NetworkFilesystem::Protocol::Nfs : fs::NetworkFilesystem::Protocol::Sftp)); + (v == "nfs" ? fs::NetworkFilesystem::Protocol::Nfs : + (v == "http" ? fs::NetworkFilesystem::Protocol::Http : + (v == "https" ? fs::NetworkFilesystem::Protocol::Https : fs::NetworkFilesystem::Protocol::Sftp)))); else if (n == "connect") info->want_connect = v != "no"; else if (n == "share") @@ -152,10 +155,16 @@ int Context::register_network_fs(NetworkFsInfo &info) { case fs::NetworkFilesystem::Protocol::Sftp: fs = std::make_shared(*this, info.fs_name, info.mountpoint); break; + case fs::NetworkFilesystem::Protocol::Http: + case fs::NetworkFilesystem::Protocol::Https: + fs = std::make_shared(*this, info.fs_name, info.mountpoint); + break; default: return -1; } + fs->protocol = info.protocol; + if (auto rc = fs->initialize(); rc) return rc; diff --git a/src/fs/fs_common.hpp b/src/fs/fs_common.hpp index f9f9103..d3d93af 100644 --- a/src/fs/fs_common.hpp +++ b/src/fs/fs_common.hpp @@ -177,6 +177,8 @@ class NetworkFilesystem: public Filesystem { Smb, Nfs, Sftp, + Http, + Https, ProtocolMax, }; @@ -201,9 +203,16 @@ class NetworkFilesystem: public Filesystem { return "nfs"; case Protocol::Sftp: return "sftp"; + case Protocol::Http: + return "http"; + case Protocol::Https: + return "https"; } } + public: + Protocol protocol = Protocol::Smb; + protected: bool is_connected = false; }; diff --git a/src/fs/fs_http.cpp b/src/fs/fs_http.cpp new file mode 100644 index 0000000..a51f349 --- /dev/null +++ b/src/fs/fs_http.cpp @@ -0,0 +1,472 @@ +// Copyright (c) 2024 averne +// +// This file is part of SwitchWave. +// +// SwitchWave is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// SwitchWave is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with SwitchWave. If not, see . + +#include +#include +#include +#include +#include +#include + +#include + +#include "fs/fs_http.hpp" + +namespace sw::fs { + +namespace { + +struct DirEntry { + std::string href; + bool is_dir; +}; + +struct DirData { + std::vector entries; + std::size_t index = 0; +}; + +std::size_t string_write_cb(char *ptr, std::size_t size, std::size_t nmemb, void *userdata) { + auto *str = static_cast(userdata); + auto total = size * nmemb; + str->append(ptr, total); + return total; +} + +std::string url_decode(std::string_view s) { + std::string result; + result.reserve(s.size()); + + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '%' && i + 2 < s.size()) { + char hex[3] = { s[i+1], s[i+2], '\0' }; + char *end; + auto val = std::strtoul(hex, &end, 16); + if (end == hex + 2) { + result += static_cast(val); + i += 2; + continue; + } + } + result += s[i]; + } + + return result; +} + +std::string url_encode_path(std::string_view s) { + std::string result; + result.reserve(s.size()); + + for (auto c: s) { + if (c == '/' || std::isalnum(static_cast(c)) || + c == '-' || c == '_' || c == '.' || c == '~') + result += c; + else { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%%%02X", static_cast(c)); + result += buf; + } + } + + return result; +} + +void parse_autoindex(std::string_view html, std::vector &entries) { + // Narrow search to or if present + auto body = html; + if (auto pos = html.find(" tags + std::string_view needle = "href=\""; + std::size_t search_pos = 0; + + while (search_pos < body.size()) { + auto href_start = body.find(needle, search_pos); + if (href_start == std::string_view::npos) + break; + + href_start += needle.size(); + auto href_end = body.find('"', href_start); + if (href_end == std::string_view::npos) + break; + + search_pos = href_end + 1; + + auto href = body.substr(href_start, href_end - href_start); + + // Skip parent directory links + if (href == "../" || href == "..") + continue; + + // Skip external links (absolute URLs) + if (href.find("://") != std::string_view::npos) + continue; + + // Skip query/anchor-only links + if (!href.empty() && (href[0] == '?' || href[0] == '#')) + continue; + + // Skip absolute paths that go to parent + if (!href.empty() && href[0] == '/') + continue; + + auto decoded = url_decode(href); + bool is_dir = !decoded.empty() && decoded.back() == '/'; + + // Remove trailing slash for directory names + if (is_dir && decoded.size() > 1) + decoded.pop_back(); + + if (!decoded.empty()) + entries.push_back({ std::move(decoded), is_dir }); + } +} + +} // namespace + +HttpFs::HttpFs(Context &context, std::string_view name, std::string_view mount_name): context(context) { + this->type = Filesystem::Type::Network; + this->name = name; + this->mount_name = mount_name; + + this->devoptab = { + .name = this->name.data(), + + .structSize = sizeof(HttpFs), + .open_r = HttpFs::http_open, + .close_r = HttpFs::http_close, + .read_r = HttpFs::http_read, + .seek_r = HttpFs::http_seek, + .fstat_r = HttpFs::http_fstat, + + .stat_r = HttpFs::http_stat, + + .dirStateSize = sizeof(HttpFs), + .diropen_r = HttpFs::http_diropen, + .dirreset_r = HttpFs::http_dirreset, + .dirnext_r = HttpFs::http_dirnext, + .dirclose_r = HttpFs::http_dirclose, + + .deviceData = this, + + .lstat_r = HttpFs::http_lstat, + }; +} + +HttpFs::~HttpFs() { + if (this->is_connected) + this->disconnect(); + + this->unregister_fs(); +} + +int HttpFs::initialize() { + if (HttpFs::lib_refcount++ == 0) { + if (auto rc = ::curl_global_init(CURL_GLOBAL_DEFAULT); rc) + return EIO; + } + + return 0; +} + +int HttpFs::connect(std::string_view host, std::uint16_t port, std::string_view share, + std::string_view username, std::string_view password) { + bool is_https = (this->protocol == Protocol::Https); + auto scheme = is_https ? "https://" : "http://"; + auto default_port = is_https ? std::uint16_t(443) : std::uint16_t(80); + + // Build base URL: http(s)://host:port/share/ + this->base_url = scheme; + this->base_url += host; + if (port && port != default_port) { + this->base_url += ':'; + this->base_url += std::to_string(port); + } + this->base_url += '/'; + if (!share.empty()) { + if (share.front() == '/') + share = share.substr(1); + this->base_url += share; + if (this->base_url.back() != '/') + this->base_url += '/'; + } + + // Store credentials for curl operations + this->userpwd.clear(); + if (!username.empty()) { + this->userpwd = username; + this->userpwd += ':'; + this->userpwd += password; + } + + // Build auth URL prefix with embedded credentials for make_url() + this->auth_url_prefix = scheme; + if (!username.empty()) { + this->auth_url_prefix += username; + this->auth_url_prefix += ':'; + this->auth_url_prefix += password; + this->auth_url_prefix += '@'; + } + this->auth_url_prefix += host; + if (port && port != default_port) { + this->auth_url_prefix += ':'; + this->auth_url_prefix += std::to_string(port); + } + this->auth_url_prefix += '/'; + if (!share.empty()) { + this->auth_url_prefix += share; + if (this->auth_url_prefix.back() != '/') + this->auth_url_prefix += '/'; + } + + // Test connection with HEAD request + auto *curl = ::curl_easy_init(); + if (!curl) + return ENOMEM; + + this->setup_curl_handle(curl); + ::curl_easy_setopt(curl, CURLOPT_URL, this->base_url.c_str()); + ::curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); + ::curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L); + + auto res = ::curl_easy_perform(curl); + ::curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + std::printf("HTTP connect failed: %s\n", ::curl_easy_strerror(res)); + return ECONNREFUSED; + } + + auto lk = std::scoped_lock(this->session_mutex); + this->is_connected = true; + + return 0; +} + +int HttpFs::disconnect() { + auto lk = std::scoped_lock(this->session_mutex); + + this->base_url.clear(); + this->userpwd.clear(); + this->auth_url_prefix.clear(); + this->is_connected = false; + + if (--HttpFs::lib_refcount == 0) + ::curl_global_cleanup(); + + return 0; +} + +void HttpFs::setup_curl_handle(void *handle) { + auto *curl = static_cast(handle); + + ::curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + ::curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + ::curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L); + ::curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + ::curl_easy_setopt(curl, CURLOPT_USERAGENT, "SwitchWave/1.0"); + + if (!this->userpwd.empty()) { + ::curl_easy_setopt(curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + ::curl_easy_setopt(curl, CURLOPT_USERPWD, this->userpwd.c_str()); + } +} + +std::string HttpFs::translate_path(const char *path) { + return this->cwd + (path + this->mount_name.length()); +} + +std::string HttpFs::make_url(std::string_view path) const { + // path is like "mountname:/some/dir/file.mkv", strip mountpoint + auto internal = Path::internal(path); + // Skip leading slash + if (!internal.empty() && internal.front() == '/') + internal = internal.substr(1); + + return this->auth_url_prefix + url_encode_path(internal); +} + +// File operations: not supported, files are accessed via direct HTTP URL +int HttpFs::http_open(struct _reent *r, void *fileStruct, const char *path, int flags, int mode) { + __errno_r(r) = ENOSYS; + return -1; +} + +int HttpFs::http_close(struct _reent *r, void *fd) { + __errno_r(r) = ENOSYS; + return -1; +} + +ssize_t HttpFs::http_read(struct _reent *r, void *fd, char *ptr, size_t len) { + __errno_r(r) = ENOSYS; + return -1; +} + +off_t HttpFs::http_seek(struct _reent *r, void *fd, off_t pos, int dir) { + __errno_r(r) = ENOSYS; + return -1; +} + +int HttpFs::http_fstat(struct _reent *r, void *fd, struct stat *st) { + __errno_r(r) = ENOSYS; + return -1; +} + +int HttpFs::http_stat(struct _reent *r, const char *file, struct stat *st) { + auto *priv = static_cast(r->deviceData); + + auto internal_path = priv->translate_path(file); + // Build full URL for stat + auto url = priv->base_url + url_encode_path(std::string_view(internal_path).substr(1)); + + auto lk = std::scoped_lock(priv->session_mutex); + + auto *curl = ::curl_easy_init(); + if (!curl) { + __errno_r(r) = ENOMEM; + return -1; + } + + priv->setup_curl_handle(curl); + ::curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + ::curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); + + auto res = ::curl_easy_perform(curl); + if (res != CURLE_OK) { + ::curl_easy_cleanup(curl); + __errno_r(r) = ENOENT; + return -1; + } + + long http_code = 0; + ::curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + *st = {}; + + if (http_code == 200) { + // Check content-length + curl_off_t cl = -1; + ::curl_easy_getinfo(curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD_T, &cl); + st->st_size = (cl >= 0) ? cl : 0; + st->st_mode = S_IFREG; + } else if (http_code == 301 || http_code == 302) { + st->st_mode = S_IFDIR; + } else if (http_code == 404) { + ::curl_easy_cleanup(curl); + __errno_r(r) = ENOENT; + return -1; + } else if (http_code == 403) { + ::curl_easy_cleanup(curl); + __errno_r(r) = EACCES; + return -1; + } else { + ::curl_easy_cleanup(curl); + __errno_r(r) = EIO; + return -1; + } + + ::curl_easy_cleanup(curl); + return 0; +} + +int HttpFs::http_lstat(struct _reent *r, const char *file, struct stat *st) { + return HttpFs::http_stat(r, file, st); +} + +DIR_ITER *HttpFs::http_diropen(struct _reent *r, DIR_ITER *dirState, const char *path) { + auto *priv = static_cast(r->deviceData); + auto *priv_dir = static_cast(dirState->dirStruct); + + auto internal_path = priv->translate_path(path); + auto url = priv->base_url; + if (internal_path.size() > 1) + url += url_encode_path(std::string_view(internal_path).substr(1)); + if (url.back() != '/') + url += '/'; + + auto lk = std::scoped_lock(priv->session_mutex); + + auto *curl = ::curl_easy_init(); + if (!curl) { + __errno_r(r) = ENOMEM; + return nullptr; + } + + priv->setup_curl_handle(curl); + + std::string html; + ::curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + ::curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, string_write_cb); + ::curl_easy_setopt(curl, CURLOPT_WRITEDATA, &html); + + auto res = ::curl_easy_perform(curl); + ::curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + __errno_r(r) = EIO; + return nullptr; + } + + auto *dir_data = new DirData(); + parse_autoindex(html, dir_data->entries); + + priv_dir->data = dir_data; + return dirState; +} + +int HttpFs::http_dirreset(struct _reent *r, DIR_ITER *dirState) { + auto *priv_dir = static_cast(dirState->dirStruct); + auto *dir_data = static_cast(priv_dir->data); + + if (dir_data) + dir_data->index = 0; + + return 0; +} + +int HttpFs::http_dirnext(struct _reent *r, DIR_ITER *dirState, char *filename, struct stat *filestat) { + auto *priv_dir = static_cast(dirState->dirStruct); + auto *dir_data = static_cast(priv_dir->data); + + if (!dir_data || dir_data->index >= dir_data->entries.size()) { + __errno_r(r) = ENOENT; + return -1; + } + + auto &entry = dir_data->entries[dir_data->index++]; + std::strncpy(filename, entry.href.c_str(), NAME_MAX); + + *filestat = {}; + filestat->st_mode = entry.is_dir ? S_IFDIR : S_IFREG; + + return 0; +} + +int HttpFs::http_dirclose(struct _reent *r, DIR_ITER *dirState) { + auto *priv_dir = static_cast(dirState->dirStruct); + + delete static_cast(priv_dir->data); + priv_dir->data = nullptr; + + return 0; +} + +} // namespace sw::fs diff --git a/src/fs/fs_http.hpp b/src/fs/fs_http.hpp new file mode 100644 index 0000000..8a60661 --- /dev/null +++ b/src/fs/fs_http.hpp @@ -0,0 +1,77 @@ +// Copyright (c) 2024 averne +// +// This file is part of SwitchWave. +// +// SwitchWave is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// SwitchWave is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with SwitchWave. If not, see . + +#pragma once + +#include +#include +#include +#include + +#include "context.hpp" +#include "fs/fs_common.hpp" + +namespace sw::fs { + +class HttpFs final: public NetworkFilesystem { + public: + HttpFs(Context &context, std::string_view name, std::string_view mount_name); + virtual ~HttpFs() override; + + virtual int initialize() override; + virtual int connect(std::string_view host, std::uint16_t port, std::string_view share, + std::string_view username, std::string_view password) override; + virtual int disconnect() override; + + std::string make_url(std::string_view path) const; + + private: + std::string translate_path(const char *path); + void setup_curl_handle(void *curl); + + static int http_open (struct _reent *r, void *fileStruct, const char *path, int flags, int mode); + static int http_close (struct _reent *r, void *fd); + static ssize_t http_read (struct _reent *r, void *fd, char *ptr, size_t len); + static off_t http_seek (struct _reent *r, void *fd, off_t pos, int dir); + static int http_fstat (struct _reent *r, void *fd, struct stat *st); + static int http_stat (struct _reent *r, const char *file, struct stat *st); + static DIR_ITER *http_diropen (struct _reent *r, DIR_ITER *dirState, const char *path); + static int http_dirreset(struct _reent *r, DIR_ITER *dirState); + static int http_dirnext (struct _reent *r, DIR_ITER *dirState, char *filename, struct stat *filestat); + static int http_dirclose(struct _reent *r, DIR_ITER *dirState); + static int http_lstat (struct _reent *r, const char *file, struct stat *st); + + private: + struct HttpFsDir { + void *data; + }; + + private: + static inline std::atomic_int lib_refcount = 0; + + Context &context; + + std::string base_url; + std::string userpwd; + std::string auth_url_prefix; + + std::string cwd = ""; + + std::mutex session_mutex; +}; + +} // namespace sw::fs diff --git a/src/main.cpp b/src/main.cpp index 7b52bad..1edb6d5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -36,6 +36,7 @@ #include "fs/fs_common.hpp" #include "fs/fs_ums.hpp" #include "fs/fs_recent.hpp" +#include "fs/fs_http.hpp" using namespace std::chrono_literals; @@ -240,7 +241,17 @@ int video_loop(sw::Renderer &renderer, sw::Context &context) { auto lk = std::scoped_lock(g_setup_mtx); - lmpv.command("loadfile", context.cur_file.c_str()); + // For HTTP filesystems, pass the full HTTP URL directly to mpv + std::string loadfile_path = context.cur_file; + if (auto *fs = context.get_filesystem(sw::fs::Path::mountpoint(context.cur_file)); + fs && fs->type == sw::fs::Filesystem::Type::Network) { + auto *net_fs = static_cast(fs); + if (net_fs->protocol == sw::fs::NetworkFilesystem::Protocol::Http || + net_fs->protocol == sw::fs::NetworkFilesystem::Protocol::Https) + loadfile_path = static_cast(net_fs)->make_url(context.cur_file); + } + + lmpv.command("loadfile", loadfile_path.c_str()); auto player_ui = std::make_unique(renderer, context, lmpv); diff --git a/src/ui/ui_main_menu.cpp b/src/ui/ui_main_menu.cpp index af80c95..82b1700 100644 --- a/src/ui/ui_main_menu.cpp +++ b/src/ui/ui_main_menu.cpp @@ -42,6 +42,7 @@ extern "C" { #include "utils.hpp" #include "fs/fs_recent.hpp" +#include "fs/fs_http.hpp" #include "ui/ui_main_menu.hpp" @@ -234,8 +235,21 @@ void MediaExplorer::metadata_thread_fn(std::stop_token token) { MediaMetadata media_info = {}; if (entry) { + auto entry_path = Explorer::path_from_entry_name(entry->name); + + // For HTTP filesystems, pass the URL directly to avformat (ffmpeg supports HTTP natively) + std::string path; + if (auto *fs_ptr = this->context.get_filesystem(fs::Path::mountpoint(entry_path)); + fs_ptr && fs_ptr->type == fs::Filesystem::Type::Network) { + auto *net_fs = static_cast(fs_ptr); + if (net_fs->protocol == fs::NetworkFilesystem::Protocol::Http || + net_fs->protocol == fs::NetworkFilesystem::Protocol::Https) + path = static_cast(net_fs)->make_url(entry_path); + } + // Add explicit protocol prefix, otherwise ffmpeg confuses the mountpoint for a protocol - auto path = std::string("file:") + Explorer::path_from_entry_name(entry->name).data(); + if (path.empty()) + path = std::string("file:") + entry_path.data(); auto *avformat_ctx = avformat_alloc_context(); SW_SCOPEGUARD([&avformat_ctx] { avformat_close_input(&avformat_ctx); }); From f23774a259daa7e3e60cd8c1a30cb3d3a52b95e9 Mon Sep 17 00:00:00 2001 From: Victor Date: Mon, 23 Feb 2026 18:33:06 +0000 Subject: [PATCH 2/7] Also show full name of directories in side panel --- src/ui/ui_main_menu.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ui/ui_main_menu.cpp b/src/ui/ui_main_menu.cpp index 82b1700..8d8dcde 100644 --- a/src/ui/ui_main_menu.cpp +++ b/src/ui/ui_main_menu.cpp @@ -371,14 +371,15 @@ void MediaExplorer::render() { return; auto &entry = this->explorer.entries[ent_idx]; - if (entry.type == fs::Node::Type::Directory) - return; ImGui::NewLine(); auto fname = Explorer::filename_from_entry_name(entry.name); ImGui::TextWrapped("Name: %.*s", int(fname.length()), fname.data()); + if (entry.type == fs::Node::Type::Directory) + return; + auto [size, suffix] = utils::to_human_size(entry.size); ImGui::Text("Size: %.2f%s", size, suffix.data()); From e4893a1a46963e0404f31156bfdffe58cd5d7a8d Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 24 Feb 2026 00:44:28 +0000 Subject: [PATCH 3/7] Address comments --- src/fs/fs_http.cpp | 182 ++++++++++++++++++++++----------------------- src/fs/fs_http.hpp | 13 ++-- 2 files changed, 96 insertions(+), 99 deletions(-) diff --git a/src/fs/fs_http.cpp b/src/fs/fs_http.cpp index a51f349..77e3a3e 100644 --- a/src/fs/fs_http.cpp +++ b/src/fs/fs_http.cpp @@ -17,9 +17,9 @@ #include #include +#include #include #include -#include #include #include @@ -30,16 +30,6 @@ namespace sw::fs { namespace { -struct DirEntry { - std::string href; - bool is_dir; -}; - -struct DirData { - std::vector entries; - std::size_t index = 0; -}; - std::size_t string_write_cb(char *ptr, std::size_t size, std::size_t nmemb, void *userdata) { auto *str = static_cast(userdata); auto total = size * nmemb; @@ -86,7 +76,7 @@ std::string url_encode_path(std::string_view s) { return result; } -void parse_autoindex(std::string_view html, std::vector &entries) { +void parse_autoindex(std::string_view html, std::vector &entries) { // Narrow search to
or if present auto body = html; if (auto pos = html.find("devoptab = { .name = this->name.data(), - .structSize = sizeof(HttpFs), + .structSize = 0, .open_r = HttpFs::http_open, .close_r = HttpFs::http_close, .read_r = HttpFs::http_read, @@ -159,7 +149,7 @@ HttpFs::HttpFs(Context &context, std::string_view name, std::string_view mount_n .stat_r = HttpFs::http_stat, - .dirStateSize = sizeof(HttpFs), + .dirStateSize = sizeof(HttpFsDir), .diropen_r = HttpFs::http_diropen, .dirreset_r = HttpFs::http_dirreset, .dirnext_r = HttpFs::http_dirnext, @@ -179,10 +169,8 @@ HttpFs::~HttpFs() { } int HttpFs::initialize() { - if (HttpFs::lib_refcount++ == 0) { - if (auto rc = ::curl_global_init(CURL_GLOBAL_DEFAULT); rc) - return EIO; - } + if (auto rc = ::curl_global_init(CURL_GLOBAL_DEFAULT); rc) + return EIO; return 0; } @@ -193,22 +181,24 @@ int HttpFs::connect(std::string_view host, std::uint16_t port, std::string_view auto scheme = is_https ? "https://" : "http://"; auto default_port = is_https ? std::uint16_t(443) : std::uint16_t(80); - // Build base URL: http(s)://host:port/share/ - this->base_url = scheme; - this->base_url += host; + // Build host[:port] string + auto host_port = std::string(host); if (port && port != default_port) { - this->base_url += ':'; - this->base_url += std::to_string(port); - } - this->base_url += '/'; - if (!share.empty()) { - if (share.front() == '/') - share = share.substr(1); - this->base_url += share; - if (this->base_url.back() != '/') - this->base_url += '/'; + host_port += ':'; + host_port += std::to_string(port); } + while (!share.empty() && share.front() == '/') + share.remove_prefix(1); + while (!share.empty() && share.back() == '/') + share.remove_suffix(1); + + // Build base URL: http(s)://host:port[/share] + auto base = Path(std::string(scheme) + host_port); + if (!share.empty()) + base /= Path(std::string(share)); + this->base_url = base.base(); + // Store credentials for curl operations this->userpwd.clear(); if (!username.empty()) { @@ -218,24 +208,19 @@ int HttpFs::connect(std::string_view host, std::uint16_t port, std::string_view } // Build auth URL prefix with embedded credentials for make_url() - this->auth_url_prefix = scheme; + std::string auth_authority = scheme; if (!username.empty()) { - this->auth_url_prefix += username; - this->auth_url_prefix += ':'; - this->auth_url_prefix += password; - this->auth_url_prefix += '@'; - } - this->auth_url_prefix += host; - if (port && port != default_port) { - this->auth_url_prefix += ':'; - this->auth_url_prefix += std::to_string(port); - } - this->auth_url_prefix += '/'; - if (!share.empty()) { - this->auth_url_prefix += share; - if (this->auth_url_prefix.back() != '/') - this->auth_url_prefix += '/'; + auth_authority += username; + auth_authority += ':'; + auth_authority += password; + auth_authority += '@'; } + auth_authority += host_port; + + auto auth_base = Path(std::move(auth_authority)); + if (!share.empty()) + auth_base /= Path(std::string(share)); + this->auth_url_prefix = auth_base.base(); // Test connection with HEAD request auto *curl = ::curl_easy_init(); @@ -264,13 +249,9 @@ int HttpFs::connect(std::string_view host, std::uint16_t port, std::string_view int HttpFs::disconnect() { auto lk = std::scoped_lock(this->session_mutex); - this->base_url.clear(); - this->userpwd.clear(); - this->auth_url_prefix.clear(); this->is_connected = false; - if (--HttpFs::lib_refcount == 0) - ::curl_global_cleanup(); + ::curl_global_cleanup(); return 0; } @@ -295,12 +276,7 @@ std::string HttpFs::translate_path(const char *path) { } std::string HttpFs::make_url(std::string_view path) const { - // path is like "mountname:/some/dir/file.mkv", strip mountpoint auto internal = Path::internal(path); - // Skip leading slash - if (!internal.empty() && internal.front() == '/') - internal = internal.substr(1); - return this->auth_url_prefix + url_encode_path(internal); } @@ -334,8 +310,7 @@ int HttpFs::http_stat(struct _reent *r, const char *file, struct stat *st) { auto *priv = static_cast(r->deviceData); auto internal_path = priv->translate_path(file); - // Build full URL for stat - auto url = priv->base_url + url_encode_path(std::string_view(internal_path).substr(1)); + auto url = priv->base_url + url_encode_path(internal_path); auto lk = std::scoped_lock(priv->session_mutex); @@ -361,30 +336,32 @@ int HttpFs::http_stat(struct _reent *r, const char *file, struct stat *st) { *st = {}; - if (http_code == 200) { - // Check content-length - curl_off_t cl = -1; - ::curl_easy_getinfo(curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD_T, &cl); - st->st_size = (cl >= 0) ? cl : 0; - st->st_mode = S_IFREG; - } else if (http_code == 301 || http_code == 302) { - st->st_mode = S_IFDIR; - } else if (http_code == 404) { - ::curl_easy_cleanup(curl); - __errno_r(r) = ENOENT; - return -1; - } else if (http_code == 403) { - ::curl_easy_cleanup(curl); - __errno_r(r) = EACCES; - return -1; - } else { - ::curl_easy_cleanup(curl); - __errno_r(r) = EIO; - return -1; + int ret = 0; + switch (http_code) { + case 200: { + curl_off_t cl = -1; + ::curl_easy_getinfo(curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD_T, &cl); + st->st_size = (cl >= 0) ? cl : 0; + st->st_mode = S_IFREG; + break; + } + case 301: + case 302: + st->st_mode = S_IFDIR; + break; + case 404: + __errno_r(r) = ENOENT, ret = -1; + break; + case 403: + __errno_r(r) = EACCES, ret = -1; + break; + default: + __errno_r(r) = EIO, ret = -1; + break; } ::curl_easy_cleanup(curl); - return 0; + return ret; } int HttpFs::http_lstat(struct _reent *r, const char *file, struct stat *st) { @@ -395,10 +372,11 @@ DIR_ITER *HttpFs::http_diropen(struct _reent *r, DIR_ITER *dirState, const char auto *priv = static_cast(r->deviceData); auto *priv_dir = static_cast(dirState->dirStruct); + priv_dir->entries = nullptr; + priv_dir->index = 0; + auto internal_path = priv->translate_path(path); - auto url = priv->base_url; - if (internal_path.size() > 1) - url += url_encode_path(std::string_view(internal_path).substr(1)); + auto url = priv->base_url + url_encode_path(internal_path); if (url.back() != '/') url += '/'; @@ -425,33 +403,48 @@ DIR_ITER *HttpFs::http_diropen(struct _reent *r, DIR_ITER *dirState, const char return nullptr; } - auto *dir_data = new DirData(); - parse_autoindex(html, dir_data->entries); + auto *entries = new(std::nothrow) std::vector; + if (!entries) { + __errno_r(r) = ENOMEM; + return nullptr; + } + + parse_autoindex(html, *entries); + + priv_dir->entries = entries; + priv_dir->index = 0; - priv_dir->data = dir_data; return dirState; } int HttpFs::http_dirreset(struct _reent *r, DIR_ITER *dirState) { auto *priv_dir = static_cast(dirState->dirStruct); - auto *dir_data = static_cast(priv_dir->data); - if (dir_data) - dir_data->index = 0; + if (!priv_dir->entries) { + __errno_r(r) = EINVAL; + return -1; + } + + priv_dir->index = 0; return 0; } int HttpFs::http_dirnext(struct _reent *r, DIR_ITER *dirState, char *filename, struct stat *filestat) { auto *priv_dir = static_cast(dirState->dirStruct); - auto *dir_data = static_cast(priv_dir->data); + auto *entries = priv_dir->entries; + + if (!entries) { + __errno_r(r) = EINVAL; + return -1; + } - if (!dir_data || dir_data->index >= dir_data->entries.size()) { + if (priv_dir->index >= entries->size()) { __errno_r(r) = ENOENT; return -1; } - auto &entry = dir_data->entries[dir_data->index++]; + auto &entry = (*entries)[priv_dir->index++]; std::strncpy(filename, entry.href.c_str(), NAME_MAX); *filestat = {}; @@ -463,8 +456,9 @@ int HttpFs::http_dirnext(struct _reent *r, DIR_ITER *dirState, char *filename, s int HttpFs::http_dirclose(struct _reent *r, DIR_ITER *dirState) { auto *priv_dir = static_cast(dirState->dirStruct); - delete static_cast(priv_dir->data); - priv_dir->data = nullptr; + delete priv_dir->entries; + priv_dir->entries = nullptr; + priv_dir->index = 0; return 0; } diff --git a/src/fs/fs_http.hpp b/src/fs/fs_http.hpp index 8a60661..874f351 100644 --- a/src/fs/fs_http.hpp +++ b/src/fs/fs_http.hpp @@ -17,10 +17,10 @@ #pragma once -#include #include #include #include +#include #include "context.hpp" #include "fs/fs_common.hpp" @@ -39,6 +39,11 @@ class HttpFs final: public NetworkFilesystem { std::string make_url(std::string_view path) const; + struct DirEntry { + std::string href; + bool is_dir; + }; + private: std::string translate_path(const char *path); void setup_curl_handle(void *curl); @@ -55,14 +60,12 @@ class HttpFs final: public NetworkFilesystem { static int http_dirclose(struct _reent *r, DIR_ITER *dirState); static int http_lstat (struct _reent *r, const char *file, struct stat *st); - private: struct HttpFsDir { - void *data; + std::vector *entries; + std::size_t index; }; private: - static inline std::atomic_int lib_refcount = 0; - Context &context; std::string base_url; From 8383675b829883810844c0a0dd8ea9678ba6ec99 Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 24 Feb 2026 01:53:02 +0000 Subject: [PATCH 4/7] Make metadata requests work even if not challenged --- src/ui/ui_main_menu.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ui/ui_main_menu.cpp b/src/ui/ui_main_menu.cpp index 8d8dcde..8278775 100644 --- a/src/ui/ui_main_menu.cpp +++ b/src/ui/ui_main_menu.cpp @@ -239,12 +239,16 @@ void MediaExplorer::metadata_thread_fn(std::stop_token token) { // For HTTP filesystems, pass the URL directly to avformat (ffmpeg supports HTTP natively) std::string path; + AVDictionary *format_opts = nullptr; + SW_SCOPEGUARD([&format_opts] { av_dict_free(&format_opts); }); if (auto *fs_ptr = this->context.get_filesystem(fs::Path::mountpoint(entry_path)); fs_ptr && fs_ptr->type == fs::Filesystem::Type::Network) { auto *net_fs = static_cast(fs_ptr); if (net_fs->protocol == fs::NetworkFilesystem::Protocol::Http || - net_fs->protocol == fs::NetworkFilesystem::Protocol::Https) + net_fs->protocol == fs::NetworkFilesystem::Protocol::Https) { path = static_cast(net_fs)->make_url(entry_path); + av_dict_set(&format_opts, "auth_type", "basic", 0); + } } // Add explicit protocol prefix, otherwise ffmpeg confuses the mountpoint for a protocol @@ -256,7 +260,7 @@ void MediaExplorer::metadata_thread_fn(std::stop_token token) { if (!avformat_ctx) goto end; - if (auto rc = avformat_open_input(&avformat_ctx, path.c_str(), nullptr, nullptr); rc) { + if (auto rc = avformat_open_input(&avformat_ctx, path.c_str(), nullptr, &format_opts); rc) { char buf[AV_ERROR_MAX_STRING_SIZE]; std::printf("Failed to open input %s: %s\n", path.c_str(), av_make_error_string(buf, sizeof(buf), rc)); this->context.set_error(rc, Context::ErrorType::LibAv); From b00955b4b49048087f6b454320484d37b96c607f Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 24 Feb 2026 02:04:53 +0000 Subject: [PATCH 5/7] Make playback work even if not challenged --- src/main.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 1edb6d5..3c79943 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -243,15 +243,23 @@ int video_loop(sw::Renderer &renderer, sw::Context &context) { // For HTTP filesystems, pass the full HTTP URL directly to mpv std::string loadfile_path = context.cur_file; + bool use_http_basic_preemptive_auth = false; if (auto *fs = context.get_filesystem(sw::fs::Path::mountpoint(context.cur_file)); fs && fs->type == sw::fs::Filesystem::Type::Network) { auto *net_fs = static_cast(fs); if (net_fs->protocol == sw::fs::NetworkFilesystem::Protocol::Http || - net_fs->protocol == sw::fs::NetworkFilesystem::Protocol::Https) + net_fs->protocol == sw::fs::NetworkFilesystem::Protocol::Https) { loadfile_path = static_cast(net_fs)->make_url(context.cur_file); + use_http_basic_preemptive_auth = true; + } } - lmpv.command("loadfile", loadfile_path.c_str()); + if (use_http_basic_preemptive_auth) { + lmpv.command("loadfile", loadfile_path.c_str(), "replace", + "demuxer-lavf-o-add=auth_type=basic,stream-lavf-o-add=auth_type=basic"); + } else { + lmpv.command("loadfile", loadfile_path.c_str()); + } auto player_ui = std::make_unique(renderer, context, lmpv); From c8a274ac2142485a7190361fdda0ab7d73426f0b Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 24 Feb 2026 02:06:24 +0000 Subject: [PATCH 6/7] Unify all user agents --- src/libmpv.cpp | 1 + src/ui/ui_main_menu.cpp | 1 + 2 files changed, 2 insertions(+) diff --git a/src/libmpv.cpp b/src/libmpv.cpp index 2f1fd48..e095511 100644 --- a/src/libmpv.cpp +++ b/src/libmpv.cpp @@ -31,6 +31,7 @@ int LibmpvController::initialize() { MPV_CALL(mpv_set_option_string(this->mpv, "config", "yes")); MPV_CALL(mpv_set_option_string(this->mpv, "config-dir", LibmpvController::MpvDirectory.data())); + MPV_CALL(mpv_set_option_string(this->mpv, "user-agent", "SwitchWave/1.0")); MPV_CALL(mpv_initialize(this->mpv)); diff --git a/src/ui/ui_main_menu.cpp b/src/ui/ui_main_menu.cpp index 8278775..e51f08b 100644 --- a/src/ui/ui_main_menu.cpp +++ b/src/ui/ui_main_menu.cpp @@ -248,6 +248,7 @@ void MediaExplorer::metadata_thread_fn(std::stop_token token) { net_fs->protocol == fs::NetworkFilesystem::Protocol::Https) { path = static_cast(net_fs)->make_url(entry_path); av_dict_set(&format_opts, "auth_type", "basic", 0); + av_dict_set(&format_opts, "user_agent", "SwitchWave/1.0", 0); } } From 6791a408e639a8f4888f1ce58f75fa457256ec51 Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 24 Feb 2026 12:19:27 +0000 Subject: [PATCH 7/7] Optimize away pointer in HttpFsDir --- src/fs/fs_http.cpp | 44 ++++++++++---------------------------------- src/fs/fs_http.hpp | 2 +- 2 files changed, 11 insertions(+), 35 deletions(-) diff --git a/src/fs/fs_http.cpp b/src/fs/fs_http.cpp index 77e3a3e..df72359 100644 --- a/src/fs/fs_http.cpp +++ b/src/fs/fs_http.cpp @@ -17,7 +17,7 @@ #include #include -#include +#include #include #include #include @@ -369,10 +369,8 @@ int HttpFs::http_lstat(struct _reent *r, const char *file, struct stat *st) { } DIR_ITER *HttpFs::http_diropen(struct _reent *r, DIR_ITER *dirState, const char *path) { - auto *priv = static_cast(r->deviceData); - auto *priv_dir = static_cast(dirState->dirStruct); - - priv_dir->entries = nullptr; + auto *priv = static_cast(r->deviceData); + auto *priv_dir = std::construct_at(reinterpret_cast(dirState->dirStruct)); priv_dir->index = 0; auto internal_path = priv->translate_path(path); @@ -384,6 +382,7 @@ DIR_ITER *HttpFs::http_diropen(struct _reent *r, DIR_ITER *dirState, const char auto *curl = ::curl_easy_init(); if (!curl) { + std::destroy_at(priv_dir); __errno_r(r) = ENOMEM; return nullptr; } @@ -399,32 +398,18 @@ DIR_ITER *HttpFs::http_diropen(struct _reent *r, DIR_ITER *dirState, const char ::curl_easy_cleanup(curl); if (res != CURLE_OK) { + std::destroy_at(priv_dir); __errno_r(r) = EIO; return nullptr; } - auto *entries = new(std::nothrow) std::vector; - if (!entries) { - __errno_r(r) = ENOMEM; - return nullptr; - } - - parse_autoindex(html, *entries); - - priv_dir->entries = entries; - priv_dir->index = 0; + parse_autoindex(html, priv_dir->entries); return dirState; } int HttpFs::http_dirreset(struct _reent *r, DIR_ITER *dirState) { auto *priv_dir = static_cast(dirState->dirStruct); - - if (!priv_dir->entries) { - __errno_r(r) = EINVAL; - return -1; - } - priv_dir->index = 0; return 0; @@ -432,19 +417,13 @@ int HttpFs::http_dirreset(struct _reent *r, DIR_ITER *dirState) { int HttpFs::http_dirnext(struct _reent *r, DIR_ITER *dirState, char *filename, struct stat *filestat) { auto *priv_dir = static_cast(dirState->dirStruct); - auto *entries = priv_dir->entries; - if (!entries) { - __errno_r(r) = EINVAL; - return -1; - } - - if (priv_dir->index >= entries->size()) { + if (priv_dir->index >= priv_dir->entries.size()) { __errno_r(r) = ENOENT; return -1; } - auto &entry = (*entries)[priv_dir->index++]; + auto &entry = priv_dir->entries[priv_dir->index++]; std::strncpy(filename, entry.href.c_str(), NAME_MAX); *filestat = {}; @@ -454,11 +433,8 @@ int HttpFs::http_dirnext(struct _reent *r, DIR_ITER *dirState, char *filename, s } int HttpFs::http_dirclose(struct _reent *r, DIR_ITER *dirState) { - auto *priv_dir = static_cast(dirState->dirStruct); - - delete priv_dir->entries; - priv_dir->entries = nullptr; - priv_dir->index = 0; + auto *priv_dir = reinterpret_cast(dirState->dirStruct); + std::destroy_at(priv_dir); return 0; } diff --git a/src/fs/fs_http.hpp b/src/fs/fs_http.hpp index 874f351..97d2591 100644 --- a/src/fs/fs_http.hpp +++ b/src/fs/fs_http.hpp @@ -61,7 +61,7 @@ class HttpFs final: public NetworkFilesystem { static int http_lstat (struct _reent *r, const char *file, struct stat *st); struct HttpFsDir { - std::vector *entries; + std::vector entries; std::size_t index; };