mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 01:37:05 +02:00
Merge branch 'main' of https://github.com/Dingo-Shenanigans/ReSkate
This commit is contained in:
commit
6780fe55c1
7 files changed
+640
-2
No files matched your search
@@ -1,4 +1,5 @@
|
||||
#include "external_player.h"
|
||||
#include "plex_web.h"
|
||||
#include "spotify_device.h"
|
||||
#include "youtube_music.h"
|
||||
#include <Windows.h>
|
||||
@@ -68,11 +69,53 @@ std::vector<std::uint8_t> read_artwork(const streams::IRandomAccessStreamReferen
|
||||
reader.ReadBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
// What Plex's own server says, laid over what Windows gives: the server's title, artist, album
|
||||
// and cover are the ones the player's library has, which a player's media session often shortens
|
||||
// or leaves without a cover. Used for Plex's own apps, and for any other that is playing the song
|
||||
// the server says an account of the player is.
|
||||
bool plex_is(const std::string& app) {
|
||||
std::string lower(app);
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return lower.find("plex") != std::string::npos;
|
||||
}
|
||||
bool same_text(const std::string& a, const std::string& b) {
|
||||
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); });
|
||||
}
|
||||
void lay_plex(Track& track, const Track& before, bool force) {
|
||||
const auto plex = plex_web::now_playing();
|
||||
if (!plex.valid || plex.title.empty()) return;
|
||||
if (!force && !same_text(plex.title, track.title)) return;
|
||||
track.title = plex.title;
|
||||
track.artist = plex.artist;
|
||||
track.album = plex.album;
|
||||
track.playing = plex.playing;
|
||||
if (!plex.art.empty()) {
|
||||
track.artwork = plex.art;
|
||||
track.artwork_revision = plex.art == before.artwork ? before.artwork_revision : before.artwork_revision + 1;
|
||||
}
|
||||
}
|
||||
// One look at the player: the track, with its cover read again only when the song changed.
|
||||
Track look(const control::GlobalSystemMediaTransportControlsSessionManager& manager, const Track& before) {
|
||||
Track track;
|
||||
const auto session = pick(manager);
|
||||
if (!session) return track;
|
||||
if (!session) {
|
||||
// Plex's player has no media session of its own to read (a Plex app on another device):
|
||||
// the server's word is all there is, and its transport is the server's.
|
||||
bool plex_wanted{};
|
||||
{
|
||||
std::lock_guard lock(app_mutex);
|
||||
plex_wanted = wanted_app.find("plex") != std::string::npos;
|
||||
}
|
||||
if (plex_wanted) {
|
||||
track.app = "plex";
|
||||
lay_plex(track, before, true);
|
||||
if (!track.title.empty()) {
|
||||
track.available = true;
|
||||
track.can_next = track.can_previous = track.can_pause = track.can_play = true;
|
||||
}
|
||||
}
|
||||
return track;
|
||||
}
|
||||
track.available = true;
|
||||
track.app = winrt::to_string(session.SourceAppUserModelId());
|
||||
track.spotify = spotify_app(track.app);
|
||||
@@ -101,6 +144,7 @@ Track look(const control::GlobalSystemMediaTransportControlsSessionManager& mana
|
||||
} catch (const winrt::hresult_error&) {}
|
||||
track.artwork_revision = track.artwork == before.artwork ? before.artwork_revision : before.artwork_revision + 1;
|
||||
}
|
||||
if (!track.spotify) lay_plex(track, before, plex_is(track.app));
|
||||
return track;
|
||||
}
|
||||
bool same(const Track& a, const Track& b) {
|
||||
@@ -110,7 +154,20 @@ bool same(const Track& a, const Track& b) {
|
||||
}
|
||||
void run_command(const control::GlobalSystemMediaTransportControlsSessionManager& manager, Command command, const std::string& app) {
|
||||
const auto session = pick(manager, &app);
|
||||
if (!session) return;
|
||||
if (!session) {
|
||||
// No media session for a Plex player: the server is asked to drive it.
|
||||
if (app.find("plex") != std::string::npos) {
|
||||
using plex_web::Action;
|
||||
switch (command) {
|
||||
case Command::play: plex_web::control(Action::play); break;
|
||||
case Command::pause: plex_web::control(Action::pause); break;
|
||||
case Command::toggle: plex_web::control(plex_web::now_playing().playing ? Action::pause : Action::play); break;
|
||||
case Command::next: plex_web::control(Action::next); break;
|
||||
case Command::previous: plex_web::control(Action::previous); break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (command) {
|
||||
case Command::play: session.TryPlayAsync().get(); break;
|
||||
case Command::pause: session.TryPauseAsync().get(); break;
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
#include "plex_web.h"
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Extension/Boot/user_data_redirect.h"
|
||||
#include <Windows.h>
|
||||
#include <bcrypt.h>
|
||||
#include <dpapi.h>
|
||||
#include <shellapi.h>
|
||||
#include <winhttp.h>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#pragma comment(lib, "winhttp")
|
||||
#pragma comment(lib, "bcrypt")
|
||||
#pragma comment(lib, "crypt32")
|
||||
#pragma comment(lib, "shell32")
|
||||
|
||||
namespace dingosdk::plex_web {
|
||||
namespace {
|
||||
constexpr wchar_t agent[] = L"ReSkate";
|
||||
constexpr wchar_t product[] = L"ReSkate";
|
||||
constexpr std::uint64_t approval_wait_ms = 5 * 60 * 1000;
|
||||
|
||||
std::mutex mutex; // everything below it
|
||||
std::condition_variable wake;
|
||||
std::deque<std::string> commands; // "login", "logout"
|
||||
std::string auth_token, client_id, user_name, problem_text;
|
||||
bool is_busy{};
|
||||
std::atomic<bool> started{};
|
||||
|
||||
void say(logging::Level level, const std::string& text) noexcept {
|
||||
try {
|
||||
logging::log(level, logging::Channel::music, "Plex account: {}", text);
|
||||
} catch (...) {}
|
||||
}
|
||||
void problem(const std::string& text) {
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
problem_text = text;
|
||||
}
|
||||
if (!text.empty()) say(logging::Level::warning, text);
|
||||
}
|
||||
|
||||
std::wstring wide(const std::string& text) {
|
||||
if (text.empty()) return {};
|
||||
std::wstring out(static_cast<std::size_t>(MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0)), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), out.data(), static_cast<int>(out.size()));
|
||||
return out;
|
||||
}
|
||||
std::string escaped(const std::string& text) {
|
||||
std::string out;
|
||||
for (const unsigned char c : text) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out.push_back(static_cast<char>(c));
|
||||
else out += std::format("%{:02X}", c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string random_hex(std::size_t bytes) {
|
||||
std::vector<unsigned char> data(bytes);
|
||||
if (BCryptGenRandom(nullptr, data.data(), static_cast<ULONG>(data.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) return {};
|
||||
std::string out;
|
||||
for (const auto b : data) out += std::format("{:02x}", b);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The token, kept so that only this Windows account can read it.
|
||||
std::string sealed(const std::string& text) {
|
||||
DATA_BLOB in{static_cast<DWORD>(text.size()), reinterpret_cast<BYTE*>(const_cast<char*>(text.data()))}, out{};
|
||||
if (text.empty() || !CryptProtectData(&in, L"ReSkate Plex", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return {};
|
||||
std::string hex;
|
||||
for (DWORD i = 0; i < out.cbData; ++i) hex += std::format("{:02x}", out.pbData[i]);
|
||||
LocalFree(out.pbData);
|
||||
return hex;
|
||||
}
|
||||
std::string unsealed(const std::string& hex) {
|
||||
std::vector<BYTE> bytes;
|
||||
for (std::size_t i = 0; i + 1 < hex.size(); i += 2) {
|
||||
const auto digit = [](char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : 0; };
|
||||
bytes.push_back(static_cast<BYTE>(digit(hex[i]) * 16 + digit(hex[i + 1])));
|
||||
}
|
||||
DATA_BLOB in{static_cast<DWORD>(bytes.size()), bytes.data()}, out{};
|
||||
if (bytes.empty() || !CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return {};
|
||||
std::string text(reinterpret_cast<const char*>(out.pbData), out.cbData);
|
||||
LocalFree(out.pbData);
|
||||
return text;
|
||||
}
|
||||
std::filesystem::path kept_file() { return redirected_local_app_data().parent_path() / L"plex.json"; }
|
||||
void keep() {
|
||||
try {
|
||||
Json json = Json::object();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
json["client_id"] = client_id;
|
||||
json["token"] = sealed(auth_token);
|
||||
json["user"] = user_name;
|
||||
}
|
||||
std::error_code ignored;
|
||||
std::filesystem::create_directories(kept_file().parent_path(), ignored);
|
||||
std::ofstream(kept_file(), std::ios::binary | std::ios::trunc) << json.dump(2);
|
||||
} catch (...) {}
|
||||
}
|
||||
void read_kept() {
|
||||
try {
|
||||
std::ifstream in(kept_file(), std::ios::binary);
|
||||
if (in) {
|
||||
const auto json = Json::parse(std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()));
|
||||
std::lock_guard lock(mutex);
|
||||
if (json.contains("client_id") && json.at("client_id").is_string()) client_id = json.at("client_id").string();
|
||||
if (json.contains("token") && json.at("token").is_string()) auth_token = unsealed(json.at("token").string());
|
||||
if (json.contains("user") && json.at("user").is_string()) user_name = json.at("user").string();
|
||||
}
|
||||
} catch (...) {}
|
||||
bool fresh{};
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (client_id.empty()) {
|
||||
client_id = "reskate-" + random_hex(12);
|
||||
fresh = true;
|
||||
}
|
||||
}
|
||||
if (fresh) keep();
|
||||
}
|
||||
|
||||
struct Reply {
|
||||
unsigned long status{};
|
||||
std::string body;
|
||||
};
|
||||
struct Handle {
|
||||
HINTERNET h{};
|
||||
~Handle() { if (h) WinHttpCloseHandle(h); }
|
||||
};
|
||||
// A call to plex.tv or to the player's server, as Plex's API asks for one: JSON back, and who is asking in its headers.
|
||||
Reply request(const wchar_t* method, const std::wstring& host, INTERNET_PORT port, bool secure, const std::wstring& path, const std::string& token = {}, const std::wstring& extra = {}) {
|
||||
Reply reply;
|
||||
std::wstring headers = L"Accept: application/json\r\nX-Plex-Product: " + std::wstring(product) + L"\r\nX-Plex-Client-Identifier: " + wide(client_identifier()) +
|
||||
L"\r\nX-Plex-Device-Name: ReSkate\r\nX-Plex-Platform: Windows\r\n" + extra;
|
||||
if (!token.empty()) headers += L"X-Plex-Token: " + wide(token) + L"\r\n";
|
||||
Handle session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)};
|
||||
if (!session.h) return reply;
|
||||
WinHttpSetTimeouts(session.h, 5000, 5000, 10000, 10000);
|
||||
Handle connection{WinHttpConnect(session.h, host.c_str(), port, 0)};
|
||||
Handle call{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0) : nullptr};
|
||||
if (!call.h || !WinHttpSendRequest(call.h, headers.c_str(), static_cast<DWORD>(-1), WINHTTP_NO_REQUEST_DATA, 0, 0, 0) || !WinHttpReceiveResponse(call.h, nullptr)) return reply;
|
||||
DWORD status{}, size = sizeof(status);
|
||||
WinHttpQueryHeaders(call.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &size, WINHTTP_NO_HEADER_INDEX);
|
||||
reply.status = status;
|
||||
char buffer[8192];
|
||||
for (DWORD got{}; WinHttpReadData(call.h, buffer, sizeof(buffer), &got) && got && reply.body.size() < 4 * 1024 * 1024;) reply.body.append(buffer, got);
|
||||
return reply;
|
||||
}
|
||||
Reply request(const wchar_t* method, const std::wstring& path, const std::string& token = {}) {
|
||||
return request(method, L"plex.tv", INTERNET_DEFAULT_HTTPS_PORT, true, path, token);
|
||||
}
|
||||
|
||||
// Who the token belongs to. False when plex.tv refuses it (true, with no name, when it could not be asked).
|
||||
bool check_token(const std::string& token, std::string& name, bool& refused) {
|
||||
refused = false;
|
||||
const auto reply = request(L"GET", L"/api/v2/user", token);
|
||||
if (reply.status == 401 || reply.status == 403) {
|
||||
refused = true;
|
||||
return false;
|
||||
}
|
||||
if (reply.status != 200) return false;
|
||||
try {
|
||||
const auto json = Json::parse(reply.body);
|
||||
for (const auto* key : {"username", "title", "email"})
|
||||
if (json.contains(key) && json.at(key).is_string() && !json.at(key).string().empty()) {
|
||||
name = json.at(key).string();
|
||||
break;
|
||||
}
|
||||
} catch (...) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
void sign_in() {
|
||||
problem({});
|
||||
const auto made = request(L"POST", L"/api/v2/pins?strong=true");
|
||||
std::uint64_t id{};
|
||||
std::string code;
|
||||
try {
|
||||
const auto json = Json::parse(made.body);
|
||||
if ((made.status != 200 && made.status != 201) || !json.contains("id") || !json.contains("code")) return problem(std::format("Plex would not start a sign-in ({})", made.status));
|
||||
id = json.at("id").get<std::uint64_t>();
|
||||
code = json.at("code").string();
|
||||
} catch (...) {
|
||||
return problem("Plex would not start a sign-in");
|
||||
}
|
||||
const auto address = "https://app.plex.tv/auth#?clientID=" + escaped(client_identifier()) + "&code=" + escaped(code) + "&context%5Bdevice%5D%5Bproduct%5D=ReSkate";
|
||||
ShellExecuteW(nullptr, L"open", wide(address).c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
say(logging::Level::info, "waiting for the sign-in in the browser.");
|
||||
const auto until = GetTickCount64() + approval_wait_ms;
|
||||
std::string granted;
|
||||
while (GetTickCount64() < until) {
|
||||
Sleep(2000);
|
||||
const auto polled = request(L"GET", L"/api/v2/pins/" + std::to_wstring(id) + L"?code=" + wide(escaped(code)));
|
||||
if (polled.status == 404) return problem("the sign-in expired; try again");
|
||||
if (polled.status != 200) continue;
|
||||
try {
|
||||
const auto json = Json::parse(polled.body);
|
||||
if (json.contains("authToken") && json.at("authToken").is_string() && !json.at("authToken").string().empty()) {
|
||||
granted = json.at("authToken").string();
|
||||
break;
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
if (granted.empty()) return problem("the sign-in was not finished in the browser");
|
||||
std::string name;
|
||||
bool refused{};
|
||||
check_token(granted, name, refused);
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
auth_token = granted;
|
||||
user_name = name;
|
||||
problem_text.clear();
|
||||
}
|
||||
keep();
|
||||
say(logging::Level::info, "signed in.");
|
||||
}
|
||||
|
||||
// The player's server, found through plex.tv (worker's alone, but for `session_now`).
|
||||
struct Server {
|
||||
std::wstring host;
|
||||
INTERNET_PORT port{};
|
||||
bool secure{};
|
||||
std::string token;
|
||||
};
|
||||
Server server;
|
||||
bool have_server{};
|
||||
std::uint64_t server_tried_at{};
|
||||
std::uint64_t polled_at{};
|
||||
std::string art_thumb, session_client;
|
||||
std::atomic<std::uint64_t> wanted_at{};
|
||||
NowPlaying session_now;
|
||||
|
||||
std::uint64_t tick() { return GetTickCount64(); }
|
||||
|
||||
bool parse_uri(const std::string& uri, Server& out) {
|
||||
const auto scheme = uri.find("://");
|
||||
if (scheme == std::string::npos) return false;
|
||||
out.secure = uri.compare(0, scheme, "https") == 0;
|
||||
auto rest = uri.substr(scheme + 3);
|
||||
if (const auto slash = rest.find('/'); slash != std::string::npos) rest.resize(slash);
|
||||
unsigned port = out.secure ? 443 : 80;
|
||||
if (const auto colon = rest.rfind(':'); colon != std::string::npos) {
|
||||
try {
|
||||
port = static_cast<unsigned>(std::stoul(rest.substr(colon + 1)));
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
rest.resize(colon);
|
||||
}
|
||||
if (rest.empty() || port == 0 || port > 65535) return false;
|
||||
out.host = wide(rest);
|
||||
out.port = static_cast<INTERNET_PORT>(port);
|
||||
return true;
|
||||
}
|
||||
Reply to_server(const wchar_t* method, const std::string& path, const std::wstring& extra = {}) {
|
||||
return request(method, server.host, server.port, server.secure, wide(path), server.token, extra);
|
||||
}
|
||||
|
||||
// Looks for a server the account owns that answers: its local address first, then the others, a relay last.
|
||||
bool find_server() {
|
||||
server_tried_at = tick();
|
||||
const auto reply = request(L"GET", L"/api/v2/resources?includeHttps=1&includeRelay=1", token());
|
||||
if (reply.status != 200) return false;
|
||||
try {
|
||||
const auto json = Json::parse(reply.body);
|
||||
for (int pass = 0; pass < 3; ++pass)
|
||||
for (std::size_t i = 0; i < json.size(); ++i) {
|
||||
const auto& resource = json.at(i);
|
||||
if (!resource.contains("provides") || resource.at("provides").string().find("server") == std::string::npos) continue;
|
||||
if (!resource.contains("connections")) continue;
|
||||
const auto access = resource.contains("accessToken") && resource.at("accessToken").is_string() ? resource.at("accessToken").string() : token();
|
||||
const auto& connections = resource.at("connections");
|
||||
for (std::size_t c = 0; c < connections.size(); ++c) {
|
||||
const auto& connection = connections.at(c);
|
||||
const bool local = connection.contains("local") && connection.at("local").get<bool>();
|
||||
const bool relay = connection.contains("relay") && connection.at("relay").get<bool>();
|
||||
if (pass != (local ? 0 : relay ? 2 : 1)) continue;
|
||||
Server candidate;
|
||||
candidate.token = access;
|
||||
if (!connection.contains("uri") || !parse_uri(connection.at("uri").string(), candidate)) continue;
|
||||
if (request(L"GET", candidate.host, candidate.port, candidate.secure, L"/identity", access).status != 200) continue;
|
||||
server = std::move(candidate);
|
||||
have_server = true;
|
||||
say(logging::Level::info, "found the Plex server.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (...) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
void poll() {
|
||||
if (token().empty()) return;
|
||||
if (!have_server && (tick() - server_tried_at < 30000 || !find_server())) return;
|
||||
const auto reply = to_server(L"GET", "/status/sessions");
|
||||
if (reply.status != 200) {
|
||||
have_server = false; // found again next time
|
||||
return;
|
||||
}
|
||||
std::string name;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
name = user_name;
|
||||
}
|
||||
NowPlaying found;
|
||||
std::string thumb, client;
|
||||
try {
|
||||
const auto json = Json::parse(reply.body);
|
||||
if (json.contains("MediaContainer") && json.at("MediaContainer").contains("Metadata")) {
|
||||
const auto& list = json.at("MediaContainer").at("Metadata");
|
||||
int matches{}, chosen{-1};
|
||||
std::size_t music{};
|
||||
int only{-1};
|
||||
for (std::size_t i = 0; i < list.size(); ++i) {
|
||||
const auto& item = list.at(i);
|
||||
if (item.value("type", "") != "track") continue;
|
||||
++music;
|
||||
only = static_cast<int>(i);
|
||||
const auto who = item.contains("User") ? item.at("User").value("title", "") : std::string();
|
||||
if (!name.empty() && _stricmp(who.c_str(), name.c_str()) == 0 && matches++ == 0) chosen = static_cast<int>(i);
|
||||
}
|
||||
if (chosen < 0 && music == 1) chosen = only;
|
||||
if (chosen >= 0) {
|
||||
const auto& item = list.at(static_cast<std::size_t>(chosen));
|
||||
found.valid = true;
|
||||
found.title = item.value("title", "");
|
||||
found.artist = item.value("grandparentTitle", "");
|
||||
found.album = item.value("parentTitle", "");
|
||||
for (const auto* key : {"parentThumb", "thumb", "grandparentThumb"})
|
||||
if (item.contains(key) && item.at(key).is_string() && !item.at(key).string().empty()) {
|
||||
thumb = item.at(key).string();
|
||||
break;
|
||||
}
|
||||
if (item.contains("Player")) {
|
||||
found.playing = item.at("Player").value("state", "") == "playing";
|
||||
client = item.at("Player").value("machineIdentifier", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (...) {}
|
||||
std::vector<std::uint8_t> art;
|
||||
if (found.valid && !thumb.empty() && thumb != art_thumb) {
|
||||
const auto picture = to_server(L"GET", "/photo/:/transcode?width=600&height=600&minSize=1&upscale=1&url=" + escaped(thumb));
|
||||
if (picture.status == 200 && !picture.body.empty()) {
|
||||
art.assign(picture.body.begin(), picture.body.end());
|
||||
art_thumb = thumb;
|
||||
}
|
||||
} else if (!found.valid) {
|
||||
art_thumb.clear();
|
||||
}
|
||||
std::lock_guard lock(mutex);
|
||||
found.art = session_now.art;
|
||||
found.art_revision = session_now.art_revision;
|
||||
if (!art.empty()) {
|
||||
found.art = std::move(art);
|
||||
++found.art_revision;
|
||||
} else if (!found.valid || thumb.empty()) {
|
||||
found.art.clear();
|
||||
}
|
||||
session_now = std::move(found);
|
||||
session_client = client;
|
||||
polled_at = tick();
|
||||
}
|
||||
|
||||
void send_control(const std::string& action) {
|
||||
std::string client;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
client = session_client;
|
||||
}
|
||||
if (client.empty() || !have_server) return;
|
||||
to_server(L"GET", "/player/playback/" + action + "?type=music&commandID=" + std::to_string(tick() & 0xFFFFFF), L"X-Plex-Target-Client-Identifier: " + wide(client) + L"\r\n");
|
||||
}
|
||||
|
||||
void run() noexcept {
|
||||
read_kept();
|
||||
try {
|
||||
std::string saved;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
saved = auth_token;
|
||||
}
|
||||
if (!saved.empty()) {
|
||||
std::string name;
|
||||
bool refused{};
|
||||
if (check_token(saved, name, refused)) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (!name.empty()) user_name = name;
|
||||
} else if (refused) {
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
auth_token.clear();
|
||||
user_name.clear();
|
||||
}
|
||||
keep();
|
||||
problem("the saved sign-in is no longer valid; sign in again");
|
||||
}
|
||||
}
|
||||
} catch (...) {}
|
||||
for (;;) {
|
||||
std::string command;
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
is_busy = false;
|
||||
wake.wait_for(lock, std::chrono::milliseconds(1500), [] { return !commands.empty(); });
|
||||
if (!commands.empty()) {
|
||||
command = std::move(commands.front());
|
||||
commands.pop_front();
|
||||
is_busy = command == "login";
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (command == "login") sign_in();
|
||||
else if (command == "logout") {
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
auth_token.clear();
|
||||
user_name.clear();
|
||||
problem_text.clear();
|
||||
session_now = {};
|
||||
}
|
||||
have_server = false;
|
||||
art_thumb.clear();
|
||||
keep();
|
||||
} else if (command.starts_with("ctl ")) {
|
||||
send_control(command.substr(4));
|
||||
Sleep(300);
|
||||
poll();
|
||||
} else if (tick() - wanted_at.load() < 15000) {
|
||||
poll();
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
void ask(std::string command) {
|
||||
start();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
std::erase(commands, command);
|
||||
commands.push_back(std::move(command));
|
||||
}
|
||||
wake.notify_one();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void start() noexcept {
|
||||
if (started.exchange(true)) return;
|
||||
try {
|
||||
std::thread(run).detach();
|
||||
} catch (...) {}
|
||||
}
|
||||
void login() { ask("login"); }
|
||||
void logout() { ask("logout"); }
|
||||
State state() {
|
||||
std::lock_guard lock(mutex);
|
||||
return {!auth_token.empty(), is_busy, user_name, problem_text};
|
||||
}
|
||||
std::string token() {
|
||||
std::lock_guard lock(mutex);
|
||||
return auth_token;
|
||||
}
|
||||
NowPlaying now_playing() {
|
||||
start();
|
||||
wanted_at.store(tick());
|
||||
std::lock_guard lock(mutex);
|
||||
NowPlaying copy = session_now;
|
||||
copy.valid = copy.valid && !auth_token.empty() && tick() - polled_at < 10000;
|
||||
return copy;
|
||||
}
|
||||
bool control(Action action) {
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (auth_token.empty() || !session_now.valid || session_client.empty()) return false;
|
||||
}
|
||||
switch (action) {
|
||||
case Action::play: ask("ctl play"); break;
|
||||
case Action::pause: ask("ctl pause"); break;
|
||||
case Action::next: ask("ctl skipNext"); break;
|
||||
case Action::previous: ask("ctl skipPrevious"); break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
std::string client_identifier() {
|
||||
std::lock_guard lock(mutex);
|
||||
return client_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The player's Plex account, signed in the way Plex's own apps do it: a PIN is asked of plex.tv,
|
||||
// the player approves it in their browser (app.plex.tv/auth), and plex.tv hands back a token.
|
||||
// Nothing is typed into the game or this page. The token is kept encrypted for their Windows
|
||||
// account in <Local AppData>\ReSkate\plex.json, with the random id this install goes by.
|
||||
namespace dingosdk::plex_web {
|
||||
struct State {
|
||||
bool signed_in{}, busy{};
|
||||
std::string user, problem;
|
||||
};
|
||||
// Reads what was kept and checks that the sign-in still holds. Once, on a thread of its own,
|
||||
// which does everything below.
|
||||
void start() noexcept;
|
||||
void login(); // opens the browser, and waits for the approval there
|
||||
void logout(); // forgets the sign-in
|
||||
State state();
|
||||
|
||||
// What the player's own Plex server says they are playing (its /status/sessions), with the
|
||||
// album's picture fetched from the server. The server is found through plex.tv; the poll runs
|
||||
// only while `now_playing` is being asked for.
|
||||
struct NowPlaying {
|
||||
bool valid{}; // a music session of this account was seen a moment ago
|
||||
bool playing{};
|
||||
std::string title, artist, album; // UTF-8
|
||||
std::vector<std::uint8_t> art; // JPEG bytes from the server, or empty until fetched
|
||||
std::uint64_t art_revision{}; // changes whenever the picture does
|
||||
};
|
||||
NowPlaying now_playing();
|
||||
enum class Action { play, pause, next, previous };
|
||||
// Asks the Plex player of that session to do it, through the server. Any thread; false when there is no session.
|
||||
bool control(Action action);
|
||||
// The token of the sign-in, for what talks to the player's server; empty when signed out.
|
||||
std::string token();
|
||||
// This install's client identifier, which Plex wants on every request.
|
||||
std::string client_identifier();
|
||||
}
|
||||
@@ -218,6 +218,22 @@ label.check input{width:18px;height:18px;margin-top:2px;accent-color:var(--blue)
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- PLEX -->
|
||||
<details class="card" id="plex">
|
||||
<summary><div class="grow"><h2>Plex</h2><div class="what">Link your Plex account so your music and its album art come through.</div></div><span class="pill" id="plPill">…</span><span class="chev"></span></summary>
|
||||
<div class="body">
|
||||
<ol class="steps">
|
||||
<li id="plStep1"><div class="title">Sign in with Plex</div>
|
||||
<p class="help">A Plex tab opens. Sign in if it asks, press <b>Allow</b>, then come back here. Your password is never shown to ReSkate.</p>
|
||||
<p class="help">Then play music in Plexamp (or any Plex app) and pick it under <b>External Audio</b> below. The game shows the song, artist and album art from your Plex server, and the game’s play, pause and next buttons drive it.</p>
|
||||
<div id="plSignedOut"><div class="row"><button id="plLogin" class="primary">Sign in with Plex</button></div></div>
|
||||
<div id="plSignedIn"><div class="note ok" id="plWho"></div><div class="row"><button id="plLogout" class="quiet">Sign out</button></div></div>
|
||||
<div class="note bad" id="plProblem"></div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- YOUTUBE -->
|
||||
<details class="card" id="youtube">
|
||||
<summary><div class="grow"><h2>YouTube Music</h2><div class="what">Paste links to videos or playlists. Nothing to install by hand.</div></div><span class="pill" id="ytPill">…</span><span class="chev"></span></summary>
|
||||
@@ -375,6 +391,7 @@ const demo = location.protocol === 'file:' || location.search === '?demo' ? {
|
||||
now:{artist:'Carter Cook',title:'Movin\'',playing:true,cover:''},station:true,
|
||||
device:{connected:true,in_use:true,text:'Spotify device: a Spotify account is on it (zee).'},
|
||||
account:{has_client:true,signed_in:true,busy:false,user:'zee',problem:'',client:'',playlists:[{name:'Liked Songs',tracks:49},{name:'Sad Drum and Bass',tracks:50}]},
|
||||
plex:{signed_in:false,busy:false,user:'',problem:''},
|
||||
youtube:{available:true,tools:true,busy:false,percent:0,problem:'',browser:'',cookies:false,runtime:'node',playlists:[{index:0,name:'Album - GNX',songs:12}]},
|
||||
local:{available:true,busy:false,problem:'',folders:[{index:1,name:'Skate mix',songs:3,path:'C:\\Users\\zee\\Music\\Skate mix'}]},
|
||||
radio:{available:true,busy:false,problem:'',searched:'soma',stations:[{index:2,name:'SomaFM Groove Salad',songs:1,address:'https://ice5.somafm.com/groovesalad-128-mp3'}],
|
||||
@@ -411,6 +428,8 @@ $('saveClient').onclick = () => post('/api/client', {id:$('client').value.trim()
|
||||
$('login').onclick = () => post('/api/login');
|
||||
$('logout').onclick = () => post('/api/logout');
|
||||
$('refresh').onclick = () => post('/api/refresh');
|
||||
$('plLogin').onclick = () => post('/api/plex/login');
|
||||
$('plLogout').onclick = () => post('/api/plex/logout');
|
||||
$('ytTools').onclick = () => post('/api/youtube/tools');
|
||||
$('ytAdd').onclick = () => { const link = $('ytLink').value.trim(); if (link) { $('ytLink').value = ''; post('/api/youtube/add', {link}); } };
|
||||
$('ytLink').onkeydown = e => { if (e.key === 'Enter') $('ytAdd').click(); };
|
||||
@@ -530,6 +549,15 @@ function render(s) {
|
||||
for (const p of a.playlists) { const li = document.createElement('li'), n = document.createElement('span'), c = document.createElement('small'); n.textContent = p.name; c.textContent = p.tracks + ' tracks'; li.append(n, c); pl.append(li); }
|
||||
card('spotify', premium || off ? 'problem' : linked ? 'ready' : 'todo', premium ? 'Needs Premium' : off ? 'Stopped' : linked ? 'Ready' : 'Set up');
|
||||
|
||||
// Plex.
|
||||
const pl2 = s.plex || {};
|
||||
step('plStep1', pl2.signed_in ? 'done' : 'now');
|
||||
show('plSignedOut', !pl2.signed_in); show('plSignedIn', !!pl2.signed_in);
|
||||
$('plLogin').disabled = !!pl2.busy; $('plLogin').textContent = pl2.busy ? 'Waiting for Plex…' : 'Sign in with Plex';
|
||||
$('plWho').textContent = 'Signed in' + (pl2.user ? ' as ' + pl2.user : '') + '.';
|
||||
$('plProblem').textContent = pl2.problem || '';
|
||||
card('plex', pl2.problem ? 'problem' : pl2.signed_in ? 'ready' : 'todo', pl2.problem ? 'Problem' : pl2.signed_in ? 'Linked' : 'Set up');
|
||||
|
||||
// YouTube Music.
|
||||
const y = s.youtube;
|
||||
show('ytSteps', y.available); show('ytMore', y.available && y.tools);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#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"
|
||||
@@ -106,6 +107,15 @@ std::string state_json() {
|
||||
}
|
||||
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();
|
||||
@@ -257,6 +267,10 @@ bool change(const std::string& path, const std::string& body) {
|
||||
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") {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "Extension/World/stack_check_report.h"
|
||||
#include "Extension/Music/music_inject.h"
|
||||
#include "Extension/Music/spotify_web.h"
|
||||
#include "Extension/Music/plex_web.h"
|
||||
#include "Extension/Music/spotify_device.h"
|
||||
#include "Extension/Music/spotify_dashboard.h"
|
||||
#include "Extension/Music/youtube_music.h"
|
||||
@@ -340,6 +341,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
|
||||
(void)dingosdk::spotify_rows::install(r.base);
|
||||
dingosdk::spotify_device::start(); // (the game as a device in the player's Spotify app)
|
||||
dingosdk::spotify_web::start();
|
||||
dingosdk::plex_web::start();
|
||||
dingosdk::youtube_music::start(); // (the player's YouTube playlists, read before the music screen is built)
|
||||
dingosdk::spotify_dashboard::start(); // (the page in the browser that sets Spotify up) // (the player's playlists, fetched before the music screen is built)
|
||||
const bool fixed_stop_provider = (global_offline_hook && progression_guard_armed) &&
|
||||
|
||||
@@ -79,6 +79,7 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Music/spotify_station.cpp
|
||||
Extension/Music/spotify_cover.cpp
|
||||
Extension/Music/spotify_web.cpp
|
||||
Extension/Music/plex_web.cpp
|
||||
Extension/Music/spotify_device.cpp
|
||||
Extension/Music/spotify_dashboard.cpp
|
||||
Extension/Music/external_audio.cpp
|
||||
|
||||
Reference in new issue
Block a user