#include "spotify_dashboard.h" #include "external_player.h" #include "external_audio.h" #include "youtube_music.h" #include "app_audio_capture.h" #include "audio_dsp.h" #include "local_music_ui.h" #include "music_inject.h" #include "spotify_cover.h" #include "spotify_device.h" #include "spotify_station.h" #include "spotify_web.h" #include "plex_web.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Log/logging.h" #include "Extension/Boot/user_data_redirect.h" #include #include #include #include #include #include #include #include #include #include #include #include namespace dingosdk::spotify_dashboard { namespace { constexpr unsigned short first_port = 8421; std::atomic port{}; std::atomic started{}; // Something built into the DLL (cmake/Runtime.cmake): the page itself (setup_page.html), and // the mod's own fonts, which the page is set in. std::string built_in(const wchar_t* name) { HMODULE module{}; if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(&built_in), &module)) return {}; const auto resource = FindResourceW(module, name, MAKEINTRESOURCEW(10) /* RT_RCDATA */); const auto handle = resource ? LoadResource(module, resource) : nullptr; const auto* data = handle ? static_cast(LockResource(handle)) : nullptr; return data ? std::string(data, SizeofResource(module, resource)) : std::string(); } // The sound's settings, as the player left them: \\ReSkate\\music-settings.json. std::filesystem::path settings_file() { return redirected_local_app_data().parent_path() / L"music-settings.json"; } void keep_settings() { try { Json json = Json::object(); json["gain"] = static_cast(music_inject::gain() * 100.0f + 0.5f); json["speakers"] = music_inject::mode() != 3; json["levelling"] = audio_dsp::levelling(); json["notices"] = youtube_music::notices(); Json bands = Json::array(); for (int i = 0; i < audio_dsp::bands; ++i) bands.push_back(static_cast(std::lround(audio_dsp::band(i)))); json["equaliser"] = std::move(bands); std::ofstream(settings_file(), std::ios::binary | std::ios::trunc) << json.dump(2); } catch (...) {} } void read_settings() { try { std::ifstream in(settings_file(), std::ios::binary); if (!in) return; const auto json = Json::parse(std::string(std::istreambuf_iterator(in), std::istreambuf_iterator())); if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast(std::clamp(json.at("gain").get(), 0, 400)) / 100.0f); if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get() ? 1 : 3); if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get()); if (json.contains("notices") && json.at("notices").is_boolean()) youtube_music::set_notices(json.at("notices").get()); if (json.contains("equaliser") && json.at("equaliser").is_array()) for (std::size_t i = 0; i < json.at("equaliser").size() && i < audio_dsp::bands; ++i) if (json.at("equaliser").at(i).is_number()) audio_dsp::set_band(static_cast(i), static_cast(std::clamp(json.at("equaliser").at(i).get(), -12, 12))); } catch (...) {} } std::string state_json() { Json out = Json::object(); const auto track = external_player::now(); Json now = Json::object(); now["artist"] = track.available ? track.artist : std::string(); now["title"] = track.available ? track.title : std::string(); now["playing"] = track.available && track.playing; now["cover"] = track.available ? spotify_cover::url() : std::string(); now["loading"] = youtube_music::on() && youtube_music::state().loading; out["now"] = std::move(now); out["station"] = spotify_station::active(); Json device = Json::object(); device["connected"] = spotify_device::connected(); device["in_use"] = spotify_device::in_use(); device["text"] = spotify_device::status(); out["device"] = std::move(device); const auto account_state = spotify_web::state(); Json account = Json::object(); account["has_client"] = account_state.has_client; account["signed_in"] = account_state.signed_in; account["busy"] = account_state.busy; account["user"] = account_state.user; account["problem"] = account_state.problem; account["client"] = spotify_web::client(); Json playlists = Json::array(); for (const auto& playlist : spotify_web::playlists()) { Json entry = Json::object(); entry["name"] = playlist.name; entry["tracks"] = static_cast(playlist.tracks.size()); playlists.push_back(std::move(entry)); } account["playlists"] = std::move(playlists); out["account"] = std::move(account); { const auto plex_state = plex_web::state(); Json plex = Json::object(); plex["signed_in"] = plex_state.signed_in; plex["busy"] = plex_state.busy; plex["user"] = plex_state.user; plex["problem"] = plex_state.problem; out["plex"] = std::move(plex); } { const auto tube = youtube_music::state(); Json youtube = Json::object(); youtube["available"] = tube.available; youtube["tools"] = tube.tools; youtube["busy"] = tube.busy; youtube["percent"] = static_cast(tube.percent); youtube["problem"] = tube.problem; youtube["browser"] = tube.browser; youtube["cookies"] = tube.cookies; youtube["runtime"] = tube.runtime; // The playlists of each kind (youtube_music.h), each with its place among them all, // which is what it is taken out by. using youtube_music::Kind; Json lists = Json::array(), folders = Json::array(), stations = Json::array(); for (std::size_t i = 0; i < tube.playlists.size(); ++i) { const auto& playlist = tube.playlists[i]; Json entry = Json::object(); entry["index"] = static_cast(i); entry["name"] = playlist.name; entry["songs"] = static_cast(playlist.songs.size()); if (playlist.kind == Kind::files) entry["path"] = playlist.url; if (playlist.kind == Kind::radio && !playlist.songs.empty()) entry["address"] = playlist.songs.front().source; (playlist.kind == Kind::files ? folders : playlist.kind == Kind::radio ? stations : lists).push_back(std::move(entry)); } youtube["playlists"] = std::move(lists); const auto problem_of = [&](Kind kind) { return tube.problem_kind == kind ? tube.problem : std::string(); }; youtube["problem"] = problem_of(Kind::youtube); out["youtube"] = std::move(youtube); Json local = Json::object(); local["available"] = tube.available; local["busy"] = tube.busy; local["problem"] = problem_of(Kind::files); local["folders"] = std::move(folders); out["local"] = std::move(local); Json radio = Json::object(); radio["available"] = tube.available; radio["busy"] = tube.busy; radio["problem"] = problem_of(Kind::radio); radio["stations"] = std::move(stations); radio["searched"] = tube.searched; Json found = Json::array(); for (const auto& station : tube.found) { Json entry = Json::object(); entry["name"] = station.name; entry["address"] = station.address; entry["icon"] = station.icon; entry["about"] = station.about; found.push_back(std::move(entry)); } radio["found"] = std::move(found); out["radio"] = std::move(radio); // What is lined up: the songs of the playlist that is playing, when it is one of these. if (tube.playing && tube.playing_list < tube.playlists.size()) { const auto& playlist = tube.playlists[tube.playing_list]; Json queue = Json::object(); queue["playlist"] = static_cast(tube.playing_list); queue["name"] = playlist.name; queue["kind"] = playlist.kind == Kind::files ? "local" : playlist.kind == Kind::radio ? "radio" : "youtube"; queue["at"] = static_cast(tube.playing_song); Json songs = Json::array(); for (const auto& song : playlist.songs) { Json entry = Json::object(); entry["artist"] = song.artist; entry["title"] = song.title; // Its picture: its video's and its station's from where they are; its file's from here. entry["cover"] = playlist.kind == Kind::youtube ? "https://i.ytimg.com/vi/" + song.id + "/mqdefault.jpg" : playlist.kind == Kind::radio ? (playlist.url.starts_with("https://") ? playlist.url : std::string()) : std::format("/api/art/{}/{}?{}", tube.playing_list, songs.size(), song.id); songs.push_back(std::move(entry)); } queue["songs"] = std::move(songs); out["queue"] = std::move(queue); } } // (And Spotify's own queue, when Spotify is what is playing and the player has signed in: // the song playing, then those Spotify says come after it.) if (!out.contains("queue") && spotify_station::active() && !youtube_music::on() && !external_audio::on() && account_state.signed_in) { spotify_web::want_queue(); // (Looked at again at once when the song has changed.) static std::atomic seen{}; if (seen.exchange(track.revision) != track.revision) spotify_web::want_queue(true); if (const auto lined_up = spotify_web::queue(); !lined_up.empty()) { Json queue = Json::object(); queue["playlist"] = static_cast(0); queue["name"] = "Spotify"; queue["kind"] = "spotify"; queue["at"] = static_cast(0); Json songs = Json::array(); for (const auto& song : lined_up) { Json entry = Json::object(); entry["artist"] = song.artist; entry["title"] = song.title; entry["cover"] = song.cover; songs.push_back(std::move(entry)); } queue["songs"] = std::move(songs); out["queue"] = std::move(queue); } } Json external = Json::object(); external["app"] = external_audio::app(); external["on"] = external_audio::on(); { // (How its capture is going, for the player who hears nothing.) const auto capture = app_audio::state(); external["capture"] = !external_audio::on() ? std::string() : !capture.capturing ? "Not capturing it" + (capture.problem.empty() ? std::string() : ": " + capture.problem) : capture.level > 0.0005f ? std::string("Capturing it: sound is coming through") : std::string("Capturing it, but it is silent: is it playing, and not muted in Windows' volume mixer?"); } Json apps = Json::array(); for (const auto& name : external_audio::apps()) apps.push_back(name); external["apps"] = std::move(apps); out["external"] = std::move(external); Json settings = Json::object(); settings["gain"] = static_cast(music_inject::gain() * 100.0f + 0.5f); settings["speakers"] = music_inject::mode() != 3; settings["levelling"] = audio_dsp::levelling(); Json bands = Json::array(); for (int i = 0; i < audio_dsp::bands; ++i) bands.push_back(static_cast(std::lround(audio_dsp::band(i)))); settings["equaliser"] = std::move(bands); out["settings"] = std::move(settings); return out.dump(); } // What a change asks for, done. False when it is not one of them. bool change(const std::string& path, const std::string& body) { Json json = Json::object(); try { if (!body.empty()) json = Json::parse(body); } catch (...) { return false; } if (!json.is_object()) return false; if (path == "/api/command") { const auto command = json.value("command", ""); using external_player::Command; if (command == "play") external_player::send(Command::play); else if (command == "pause") external_player::send(Command::pause); else if (command == "toggle") external_player::send(Command::toggle); else if (command == "next") external_player::send(Command::next); else if (command == "previous") external_player::send(Command::previous); else return false; } else if (path == "/api/client") { const auto id = json.value("id", ""); if (id.empty() || id.size() > 64 || !std::all_of(id.begin(), id.end(), [](unsigned char c) { return std::isalnum(c) != 0; })) return false; spotify_web::set_client(id); } else if (path == "/api/login") { spotify_web::login(); } else if (path == "/api/logout") { spotify_web::logout(); } else if (path == "/api/plex/login") { plex_web::login(); } else if (path == "/api/plex/logout") { plex_web::logout(); } else if (path == "/api/refresh") { spotify_web::refresh(); } else if (path == "/api/youtube/tools") { youtube_music::fetch_tools(); } else if (path == "/api/youtube/add") { youtube_music::add(json.value("link", "")); } else if (path == "/api/youtube/cookies") { if (!youtube_music::set_cookies(json.value("text", ""))) return false; } else if (path == "/api/youtube/browser") { youtube_music::set_browser(json.value("browser", "")); } else if (path == "/api/local/add") { youtube_music::add_folder(json.value("path", "")); } else if (path == "/api/radio/search") { youtube_music::search_radio(json.value("query", "")); } else if (path == "/api/radio/add") { youtube_music::add_station(json.value("name", ""), json.value("address", ""), json.value("icon", "")); } else if (path == "/api/queue/play") { if (!json.contains("playlist") || !json.at("playlist").is_number() || !json.contains("song") || !json.at("song").is_number()) return false; // Spotify has no way to go straight to a song of its queue: it is gone on to, song by song. if (json.contains("spotify") && json.at("spotify").is_boolean() && json.at("spotify").get()) { const auto count = static_cast(std::clamp(json.at("song").get(), 0, 25)); // (One at a time: a press while it is still going on is not taken.) static std::atomic going{}; if (going.exchange(true)) return true; std::thread([count] { for (int i = 0; i < count; ++i) { external_player::send(external_player::Command::next); Sleep(450); } spotify_web::want_queue(true); going.store(false); }).detach(); return true; } youtube_music::play_at(static_cast(std::clamp(json.at("playlist").get(), 0, 1000)), static_cast(std::clamp(json.at("song").get(), 0, 100000))); } else if (path == "/api/youtube/remove" || path == "/api/library/remove") { if (!json.contains("index") || !json.at("index").is_number()) return false; youtube_music::remove(static_cast(std::clamp(json.at("index").get(), 0, 1000))); } else if (path == "/api/external") { external_audio::set_app(json.value("app", "")); profile_runtime::request_music_catalog_refresh(); if (external_audio::on()) external_player::use_app(external_audio::app()); } else if (path == "/api/settings") { if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast(std::clamp(json.at("gain").get(), 0, 400)) / 100.0f); if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get() ? 1 : 3); if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get()); if (json.contains("equaliser") && json.at("equaliser").is_array()) for (std::size_t i = 0; i < json.at("equaliser").size() && i < audio_dsp::bands; ++i) if (json.at("equaliser").at(i).is_number()) audio_dsp::set_band(static_cast(i), static_cast(std::clamp(json.at("equaliser").at(i).get(), -12, 12))); keep_settings(); } else { return false; } return true; } void answer(SOCKET client, const char* status, const char* type, const std::string& body, const char* cache = "no-store") { const auto head = std::format("HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", status, type, body.size(), cache); send(client, head.data(), static_cast(head.size()), 0); for (std::size_t sent = 0; sent < body.size();) { const int did = send(client, body.data() + sent, static_cast(std::min(body.size() - sent, 65536)), 0); if (did <= 0) break; sent += static_cast(did); } } std::string header(const std::string& request, const std::string& name) { // (Names are matched as browsers write them here.) const auto at = request.find("\r\n" + name + ": "); if (at == std::string::npos) return {}; const auto from = at + name.size() + 4; return request.substr(from, request.find("\r\n", from) - from); } void serve(SOCKET client) { DWORD timeout = 3000; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeout), sizeof(timeout)); std::string request; char buffer[4096]; std::size_t head_end = std::string::npos; while (head_end == std::string::npos && request.size() < 16384) { const int got = recv(client, buffer, sizeof(buffer), 0); if (got <= 0) return; request.append(buffer, static_cast(got)); head_end = request.find("\r\n\r\n"); } if (head_end == std::string::npos) return; std::size_t length{}; try { if (const auto text = header(request, "Content-Length"); !text.empty()) length = std::stoul(text); } catch (...) { return; } if (length > 2 * 1024 * 1024) return answer(client, "413 Payload Too Large", "text/plain", "too large"); while (request.size() < head_end + 4 + length) { const int got = recv(client, buffer, sizeof(buffer), 0); if (got <= 0) return; request.append(buffer, static_cast(got)); } const auto line = request.substr(0, request.find("\r\n")); const auto method = line.substr(0, line.find(' ')); const auto path_from = method.size() + 1; const auto path = line.substr(path_from, line.find(' ', path_from) - path_from); // Only as itself: a request that names another host (a site made to resolve to this // computer) is not answered. const auto host = header(request, "Host"); const auto own = std::to_string(port.load()); if (host != "127.0.0.1:" + own && host != "localhost:" + own) return answer(client, "403 Forbidden", "text/plain", "not for this address"); if (method == "GET" && (path == "/" || path == "/index.html")) { const auto page = built_in(L"MUSIC_SETUP_HTML"); return page.empty() ? answer(client, "500 Internal Server Error", "text/plain", "the page was not built into this ReSkate.dll") : answer(client, "200 OK", "text/html; charset=utf-8", page); } if (method == "GET" && path.starts_with("/font/")) { const auto font = built_in(path == "/font/heading.ttf" ? L"FONT_HEADING" : path == "/font/body.ttf" ? L"FONT_BODY" : path == "/font/brush.ttf" ? L"FONT_BRUSH" : L""); return font.empty() ? answer(client, "404 Not Found", "text/plain", "no such font") : answer(client, "200 OK", "font/ttf", font, "public, max-age=86400"); } if (method == "GET" && path.starts_with("/api/art/")) { // The picture in a file of the player's, for the queue: /api/art//. std::size_t playlist{}, song{}; if (sscanf_s(path.c_str(), "/api/art/%zu/%zu", &playlist, &song) != 2) return answer(client, "404 Not Found", "text/plain", "no such picture"); const auto image = youtube_music::art(playlist, song); const bool png = image.size() > 8 && image[0] == 0x89 && image[1] == 'P', jpeg = image.size() > 8 && image[0] == 0xff && image[1] == 0xd8; if (!png && !jpeg) return answer(client, "404 Not Found", "text/plain", "no picture", "private, max-age=3600"); return answer(client, "200 OK", png ? "image/png" : "image/jpeg", std::string(image.begin(), image.end()), "private, max-age=3600"); } if (method == "GET" && path == "/api/state") return answer(client, "200 OK", "application/json", state_json()); if (method == "POST") { // A change comes from this page alone: its script sends a header that a page of another // site cannot send without the browser asking first, which is refused by not answering. if (header(request, "X-ReSkate") != "1") return answer(client, "403 Forbidden", "text/plain", "not from the page"); return change(path, request.substr(head_end + 4, length)) ? answer(client, "200 OK", "application/json", "{}") : answer(client, "400 Bad Request", "text/plain", "not understood"); } answer(client, "404 Not Found", "text/plain", "nothing here"); } void run() noexcept { WSADATA data{}; if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return; SOCKET listener = INVALID_SOCKET; for (unsigned short candidate = first_port; candidate < first_port + 10 && listener == INVALID_SOCKET; ++candidate) { const SOCKET made = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in at{}; at.sin_family = AF_INET; at.sin_addr.s_addr = htonl(INADDR_LOOPBACK); at.sin_port = htons(candidate); if (made != INVALID_SOCKET && bind(made, reinterpret_cast(&at), sizeof(at)) == 0 && listen(made, 8) == 0) { listener = made; port.store(candidate); } else if (made != INVALID_SOCKET) { closesocket(made); } } if (listener == INVALID_SOCKET) { try { logging::log(logging::Level::warning, logging::Channel::music, "Music setup page: no port on this computer was free for it."); } catch (...) {} return; } try { logging::log(logging::Level::info, logging::Channel::music, "Music setup page: {} (\"music setup\" in the console opens it).", address()); } catch (...) {} for (;;) { const SOCKET client = accept(listener, nullptr, nullptr); if (client == INVALID_SOCKET) { Sleep(50); continue; } try { serve(client); } catch (...) {} shutdown(client, SD_SEND); closesocket(client); } } } // namespace void start() noexcept { if (started.exchange(true)) return; read_settings(); try { std::thread(run).detach(); } catch (...) {} } void save_settings() noexcept { keep_settings(); } std::string address() { const auto at = port.load(); return at ? "http://127.0.0.1:" + std::to_string(at) + "/" : std::string(); } bool open() noexcept { try { const auto where = address(); if (where.empty()) return false; const std::wstring wide(where.begin(), where.end()); return reinterpret_cast(ShellExecuteW(nullptr, L"open", wide.c_str(), nullptr, nullptr, SW_SHOWNORMAL)) > 32; } catch (...) { return false; } } } // namespace dingosdk::spotify_dashboard