From 853f1a8a071e239732874e3f4d93e9f585a1851b Mon Sep 17 00:00:00 2001 From: Langegen Date: Mon, 26 Oct 2026 16:35:56 +0300 Subject: [PATCH 01/13] Fix History/Jackett crashes, Jackett playback and magnet metadata stalls Crashes - History tab: rebuilding the list freed the focused row (after leaving the player, Remove or Clear) while borealis still pointed at it, both as the current focus and in its focus stack. Rebuild now waits until the tab is the top activity, parks the focus and hands it back to the same position. The deferred rebuild no longer captures a raw `this`. - Jackett tab: Retry / Configure Jackett freed themselves through refresh() while focused. Focus is parked on the search bar first. Jackett - Every result was downloaded to one shared cache.bin_jackett.torrent, so History resumed whichever torrent played last. Each link now gets its own file under /jackett/ (FNV-1a of the link). - Indexer links that redirect to a magnet: URI failed with "Protocol not supported". New http::downloadTorrent() reads the Location header and returns the magnet, which is then resolved and played (or saved). - The search log line no longer includes the API key. - JackettView was never freed (one leak per tab visit); the tab owns it now, and async callbacks check the alive flag. Engine - Magnet metadata: when trackers returned peers but none served the metadata, the fetch failed without asking the DHT ("peer 80/80 0%", upstream issue #3). A DHT round with untried peers now follows. - torrent_announce() stops the remaining announces once the peer list is full instead of waiting for every slow tracker. Other - History: periodic saves run off the UI thread; find() returns a copy instead of a pointer into the shared list; load() falls back to the .tmp left by an interrupted save. - Updater points at Langegen/NX-torrent-player (it would replace the fork with an upstream build); the release-by-tag URL is configurable too. - File pickers prefix the folder when two videos share a name (season packs: "Season 1/E01.mkv" vs "Season 2/E01.mkv"). Builds clean; offline pctest cases pass. Not yet tested on a console. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 10 +++- app/appdata.hpp | 5 ++ app/browse.cpp | 26 ++++++++++ app/browse.hpp | 6 +++ app/history.cpp | 52 ++++++++++++++------ app/history.hpp | 15 ++++-- app/history_tab.cpp | 79 ++++++++++++++++++++++++++++-- app/http.cpp | 85 ++++++++++++++++++++++++++++++++ app/http.hpp | 7 +++ app/jackett.cpp | 43 +++++++++++++--- app/jackett.hpp | 11 ++++- app/jackett_tab.cpp | 77 ++++++++++++++++++++--------- app/main.cpp | 41 ++++++++-------- app/player.cpp | 17 ++++--- app/update.cpp | 4 +- engine/torrent.c | 116 +++++++++++++++++++++++++++++++------------- 16 files changed, 473 insertions(+), 121 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 316d010..734ae5c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,9 +30,14 @@ set(PROJECT_TITLEID "0x010000000000A123") # entirely -- a local HTTP server serving a fake release JSON is how the update # flow gets tested without publishing one: # cmake . -DUPDATE_API_URL=http://192.168.1.20:8000/latest.json -set(UPDATE_REPO "shodowlo/NX-torrent-player" CACHE STRING "GitHub owner/name for updates") +# This fork publishes its own releases: pointing at upstream made the updater +# replace the fork with an upstream build (losing Jackett, History, ...). +set(UPDATE_REPO "Langegen/NX-torrent-player" CACHE STRING "GitHub owner/name for updates") set(UPDATE_API_URL "https://api.github.com/repos/${UPDATE_REPO}/releases/latest" CACHE STRING "Release API endpoint the updater polls") +# A tag's release (its changelog), the tag appended. Same repo as above. +set(UPDATE_TAG_URL "https://api.github.com/repos/${UPDATE_REPO}/releases/tags/" + CACHE STRING "Release-by-tag endpoint, the tag is appended") set(PROJECT_ICON ${CMAKE_CURRENT_SOURCE_DIR}/assets/icon.jpg) set(PROJECT_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/library/borealis/resources) @@ -82,7 +87,8 @@ target_compile_options(${PROJECT_NAME} PRIVATE -Wformat -Werror=format) # Options, and is what the updater compares against the latest tag. target_compile_definitions(${PROJECT_NAME} PRIVATE APP_VERSION="${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_ALTER}" - UPDATE_API_URL="${UPDATE_API_URL}") + UPDATE_API_URL="${UPDATE_API_URL}" + UPDATE_TAG_URL="${UPDATE_TAG_URL}") if (PLATFORM_SWITCH) # Bundle borealis' resources into the nro's romfs (borealis reads them from diff --git a/app/appdata.hpp b/app/appdata.hpp index c5fdfd8..3049dae 100644 --- a/app/appdata.hpp +++ b/app/appdata.hpp @@ -21,6 +21,11 @@ // in the player. Kept so re-picking the same one costs nothing; they are a few // tens of KB each. #define APPDATA_SUBS APPDATA_DIR "/subtitles" +// .torrent files fetched to play a Jackett result, one per link (named by a +// hash of it). Kept, not overwritten: a history entry points at its file to +// resume later, and a single shared temp file made every entry open whichever +// torrent was played last. +#define APPDATA_JACKETT APPDATA_DIR "/jackett" // Watch history and saved playback positions. #define APPDATA_HISTORY APPDATA_DIR "/history.json" diff --git a/app/browse.cpp b/app/browse.cpp index 3e698bb..9c0fbdd 100644 --- a/app/browse.cpp +++ b/app/browse.cpp @@ -58,6 +58,32 @@ bool naturalCompare(const std::string& a, const std::string& b) return ia == a.size() && ib < b.size(); } +std::vector pickerNames(const std::vector& paths) +{ + auto baseOf = [](const std::string& p) { + size_t s = p.find_last_of("/\\"); + return s == std::string::npos ? p : p.substr(s + 1); + }; + auto withFolder = [](const std::string& p) { + size_t s = p.find_last_of("/\\"); + if (s == std::string::npos || s == 0) return p; + size_t s2 = p.find_last_of("/\\", s - 1); + return s2 == std::string::npos ? p : p.substr(s2 + 1); + }; + + std::vector out; + out.reserve(paths.size()); + for (const auto& p : paths) out.push_back(baseOf(p)); + for (size_t i = 0; i < out.size(); i++) + { + bool dup = false; + for (size_t j = 0; j < paths.size() && !dup; j++) + dup = j != i && baseOf(paths[j]) == baseOf(paths[i]); + if (dup) out[i] = withFolder(paths[i]); + } + return out; +} + namespace { diff --git a/app/browse.hpp b/app/browse.hpp index d6fa47f..11445bb 100644 --- a/app/browse.hpp +++ b/app/browse.hpp @@ -16,6 +16,12 @@ // Natural/alphabetical string comparison (e.g. S01E01 < S01E02 < S01E10). bool naturalCompare(const std::string& a, const std::string& b); +// Picker labels for the video files of one torrent, index-matched to `paths`: +// the file name, prefixed by its folder when two videos share a name -- season +// packs ship "Season 1/E01.mkv" and "Season 2/E01.mkv", which were otherwise +// two identical rows. +std::vector pickerNames(const std::vector& paths); + struct Row { std::string label; diff --git a/app/history.cpp b/app/history.cpp index f966cff..6eb7a8c 100644 --- a/app/history.cpp +++ b/app/history.cpp @@ -1,6 +1,7 @@ #include "history.hpp" #include +#include #include #include #include @@ -20,6 +21,13 @@ std::vector g_items; bool g_loaded = false; uint32_t g_generation = 0; +// Serialises the file writes themselves: a background save and a forced one +// (the player closing) must not interleave on history.json.tmp. +std::mutex g_saveLock; +// A background save is already queued; it snapshots the list when it runs, so +// a second request in the meantime has nothing to add. +std::atomic g_savePending{ false }; + constexpr size_t kMaxItems = 100; } // namespace @@ -45,7 +53,12 @@ void load() g_items.clear(); g_loaded = true; + // save() removes the old file before renaming the new one into place (FAT + // will not rename over an existing file); a crash between the two leaves + // only the .tmp, which is complete -- read that rather than lose everything. + std::string tmp = std::string(APPDATA_HISTORY) + ".tmp"; FILE* f = std::fopen(APPDATA_HISTORY, "rb"); + if (!f) f = std::fopen(tmp.c_str(), "rb"); if (!f) return; std::fseek(f, 0, SEEK_END); @@ -100,6 +113,7 @@ void load() void save() { + std::lock_guard saveGuard(g_saveLock); std::vector copy; { std::lock_guard guard(g_lock); @@ -147,31 +161,28 @@ void save() std::vector all() { std::lock_guard guard(g_lock); - if (!g_loaded) - { - // Unlock while load() locks, so call without holding guard - // load() will acquire g_lock - } return g_items; } -const Item* find(const std::string& source, int fileIndex, const std::string& stremioVideoId) +bool find(const std::string& source, int fileIndex, const std::string& stremioVideoId, + Item& out) { std::lock_guard guard(g_lock); std::string targetId = makeId(source, fileIndex, stremioVideoId); for (const auto& it : g_items) { - if (it.id == targetId) - return ⁢ - if (!stremioVideoId.empty() && it.stremioVideoId == stremioVideoId) - return ⁢ - if (it.source == source && it.fileIndex == fileIndex) - return ⁢ + if (it.id == targetId || + (!stremioVideoId.empty() && it.stremioVideoId == stremioVideoId) || + (it.source == source && it.fileIndex == fileIndex)) + { + out = it; + return true; + } } - return nullptr; + return false; } -void record(const Item& item) +void record(const Item& item, bool saveNow) { std::string id = item.id.empty() ? makeId(item.source, item.fileIndex, item.stremioVideoId) : item.id; int64_t now = item.lastWatched != 0 ? item.lastWatched : (int64_t)std::time(nullptr); @@ -215,7 +226,18 @@ void record(const Item& item) g_generation++; } - save(); + if (saveNow) + { + save(); + return; + } + if (!g_savePending.exchange(true)) + { + brls::async([]() { + g_savePending = false; // before the snapshot: a later change re-queues + save(); + }); + } } void remove(const std::string& id) diff --git a/app/history.hpp b/app/history.hpp index 3b63f67..ea64849 100644 --- a/app/history.hpp +++ b/app/history.hpp @@ -39,12 +39,17 @@ void save(); // Returns all history items, sorted newest to oldest (max 100) std::vector all(); -// Finds an item by source, fileIndex, and optional stremioVideoId. -// Returns nullptr if not found. Note: internal reference remains valid until next record/remove/clear call. -const Item* find(const std::string& source, int fileIndex, const std::string& stremioVideoId = ""); +// Finds an item by source, fileIndex, and optional stremioVideoId, copying it +// into `out`. A copy, not a pointer: the list is shared with the player, which +// records into it while the UI reads, and a record() reallocates it. +bool find(const std::string& source, int fileIndex, const std::string& stremioVideoId, + Item& out); -// Adds or updates an item in history, re-sorts and limits to 100 items, and saves -void record(const Item& item); +// Adds or updates an item in history, re-sorts and limits to 100 items, and +// saves. `saveNow` writes the file before returning; the player's periodic +// update passes false so the SD write runs off the UI thread instead of +// stalling a frame every 15 seconds. +void record(const Item& item, bool saveNow = true); // Removes a single item by id void remove(const std::string& id); diff --git a/app/history_tab.cpp b/app/history_tab.cpp index eba03f7..dc7a6d5 100644 --- a/app/history_tab.cpp +++ b/app/history_tab.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -54,6 +55,14 @@ std::string formatDate(int64_t timestamp) return buf; } +// True if `v` is `ancestor` or sits under it. +bool isUnder(brls::View* v, brls::View* ancestor) +{ + for (; v; v = v->getParent()) + if (v == ancestor) return true; + return false; +} + class HistoryTabRoot : public brls::Box { public: @@ -65,20 +74,55 @@ class HistoryTabRoot : public brls::Box rebuild(); } + ~HistoryTabRoot() override + { + *alive = false; + } + void draw(NVGcontext* vg, float x, float y, float width, float height, brls::Style style, brls::FrameContext* ctx) override { brls::Box::draw(vg, x, y, width, height, style, ctx); - if (history::generation() != seenGen) + // The list changes under us when the player records progress or a + // dialog removes an entry. Rebuild only once we are the top activity + // again: while a dialog (or the player) sits above us, borealis' + // focus stack holds a raw pointer to the row that opened it, and + // freeing that row would crash when the dialog closes and hands the + // focus back. + if (history::generation() != seenGen && !rebuildQueued && isOnTop()) { - seenGen = history::generation(); - brls::sync([this]() { rebuild(); }); + rebuildQueued = true; + auto live = alive; + brls::sync([this, live]() { + if (!*live) return; // the tab was switched away meanwhile + rebuildQueued = false; + if (!isOnTop()) return; // draw() retries next frame + seenGen = history::generation(); + rebuild(); + }); } } void rebuild() { + // clearViews() frees the rows, and the focused one with them: park the + // focus on ourselves first (hidden highlight), then hand it to the row + // at the same place in the new list. Leaving it on a freed row is an + // immediate crash on the next input (see StremioTab::parkFocusOffList). + int focusIdx = -1; + brls::View* cur = brls::Application::getCurrentFocus(); + if (cur && isUnder(cur, this)) + { + focusIdx = 0; + for (size_t i = 0; i < rows.size(); i++) + if (isUnder(cur, rows[i])) focusIdx = (int)i; + this->setFocusable(true); + this->setHideHighlight(true); + brls::Application::giveFocus(this); + } + this->clearViews(); + rows.clear(); std::vector items = history::all(); if (items.empty()) @@ -96,6 +140,10 @@ class HistoryTabRoot : public brls::Box emptyBox->addView(label); this->addView(emptyBox); + // Nothing focusable is left: if we parked the focus on ourselves, + // it stays here (B still returns to the tab bar), otherwise the + // tab is not focusable at all. + if (focusIdx < 0) this->setFocusable(false); return; } @@ -108,14 +156,37 @@ class HistoryTabRoot : public brls::Box for (const auto& it : items) { - list->addView(createItemRow(it)); + brls::Box* row = createItemRow(it); + rows.push_back(row); + list->addView(row); } scroll->setContentView(list); this->addView(scroll); + + if (focusIdx >= 0) + { + int idx = std::min(focusIdx, (int)rows.size() - 1); + this->setFocusable(false); + brls::Application::giveFocus(rows[idx]); + } + else + { + this->setFocusable(false); + } } private: + bool isOnTop() + { + auto stack = brls::Application::getActivitiesStack(); + return !stack.empty() && stack.back() == this->getParentActivity(); + } + + std::shared_ptr alive = std::make_shared(true); + std::vector rows; // owned by the list; cleared with it + bool rebuildQueued = false; + brls::Box* createItemRow(const history::Item& item) { auto* row = new brls::Box(); diff --git a/app/http.cpp b/app/http.cpp index cf32749..313f545 100644 --- a/app/http.cpp +++ b/app/http.cpp @@ -3,6 +3,7 @@ #include #include +#include // strncasecmp #include @@ -219,6 +220,90 @@ bool download(const std::string& url, const std::string& path, std::string& err, return true; } +// Keeps the last Location: header of the transfer. It is read from the raw +// headers rather than from curl, because curl itself gives up on a redirect to +// a scheme it does not speak (magnet:) before telling us where it pointed. +static size_t locationHeaderCb(char* buf, size_t size, size_t nitems, void* userdata) +{ + size_t n = size * nitems; + static const char kName[] = "location:"; + const size_t kLen = sizeof(kName) - 1; + if (n > kLen && strncasecmp(buf, kName, kLen) == 0) + { + std::string v(buf + kLen, n - kLen); + size_t a = v.find_first_not_of(" \t"); + size_t b = v.find_last_not_of(" \t\r\n"); + *(std::string*)userdata = (a == std::string::npos) ? "" : v.substr(a, b - a + 1); + } + return n; +} + +bool downloadTorrent(const std::string& url, const std::string& path, + std::string& magnet, std::string& err) +{ + magnet.clear(); + + DlCtx ctx; + ctx.f = std::fopen(path.c_str(), "wb"); + if (!ctx.f) + { + err = "cannot write " + path; + return false; + } + + CURL* curl = curl_easy_init(); + if (!curl) + { + std::fclose(ctx.f); + std::remove(path.c_str()); + err = "cannot initialise the network"; + return false; + } + + std::string location; + char errbuf[CURL_ERROR_SIZE] = { 0 }; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "NX-torrent-player"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, fileWriteCb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, locationHeaderCb); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &location); + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 8L); + // Only plain web redirects are followed; a magnet: one stops the transfer + // and is picked up from the header below. + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, (long)(CURLPROTO_HTTP | CURLPROTO_HTTPS)); + verifyTls(curl); + + CURLcode rc = curl_easy_perform(curl); + long code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + curl_easy_cleanup(curl); + std::fclose(ctx.f); + + if (location.compare(0, 7, "magnet:") == 0 && (rc != CURLE_OK || (code >= 300 && code < 400))) + { + std::remove(path.c_str()); + magnet = location; + return true; + } + if (rc != CURLE_OK) + { + std::remove(path.c_str()); + err = tlsAwareErr(rc, errbuf); + return false; + } + if (code >= 400) + { + std::remove(path.c_str()); + err = "HTTP " + std::to_string(code); + return false; + } + return true; +} + std::string urlEncode(const std::string& s) { static const char* hex = "0123456789ABCDEF"; diff --git a/app/http.hpp b/app/http.hpp index 34a43ff..c51a16f 100644 --- a/app/http.hpp +++ b/app/http.hpp @@ -30,6 +30,13 @@ bool postJson(const char* url, const std::string& body, std::string& resp, bool download(const std::string& url, const std::string& path, std::string& err, std::function progress = nullptr); +// Fetches a .torrent to `path`, for indexer links (Jackett's /dl/...). Many of +// those answer with a redirect to a magnet: URI instead of a file, which curl +// cannot follow; that case returns true with the URI in `magnet` and no file. +// Otherwise true means the file is on disk and `magnet` is empty. +bool downloadTorrent(const std::string& url, const std::string& path, + std::string& magnet, std::string& err); + // Percent-encodes a URL path segment. std::string urlEncode(const std::string& s); diff --git a/app/jackett.cpp b/app/jackett.cpp index 50faae5..ffdea53 100644 --- a/app/jackett.cpp +++ b/app/jackett.cpp @@ -161,7 +161,9 @@ void searchAsync(const std::string& query, Category category, url += "&Category[]=" + std::to_string(static_cast(category)); } - brls::Logger::info("[jackett] querying {}", url); + // Not the URL itself: it carries the API key, and the log is a plain file + // on the SD card that people attach to bug reports. + brls::Logger::info("[jackett] querying {} for \"{}\"", base, query); brls::async([url, cb]() { std::string resp, err; @@ -284,18 +286,36 @@ void testConnectionAsync(const std::string& url, const std::string& apiKey, } void downloadTorrentAsync(const std::string& link, const std::string& destPath, - std::function cb) + std::function cb) { std::string targetUrl = normalizeDownloadLink(link); - brls::Logger::info("[jackett] downloading torrent from {} to {}", targetUrl, destPath); + brls::Logger::info("[jackett] downloading torrent to {}", destPath); brls::async([targetUrl, destPath, cb]() { - std::string err; - bool ok = http::download(targetUrl, destPath, err); - brls::sync([cb, ok, err]() { cb(ok, err); }); + std::string err, magnet; + bool ok = http::downloadTorrent(targetUrl, destPath, magnet, err); + if (ok && !magnet.empty()) + brls::Logger::info("[jackett] link redirected to a magnet"); + brls::sync([cb, ok, err, magnet]() { cb(ok, err, magnet); }); }); } +std::string cachedTorrentPath(const std::string& link) +{ + // FNV-1a over the link: stable across runs, which is all a file name needs. + uint64_t h = 1469598103934665603ULL; + for (unsigned char c : link) + { + h ^= c; + h *= 1099511628211ULL; + } + char name[32]; + std::snprintf(name, sizeof(name), "%016llx.torrent", (unsigned long long)h); + mkdir(APPDATA_JACKETT, 0777); + return std::string(APPDATA_JACKETT) + "/" + name; +} + void saveToLocalAsync(const Item& item, std::function cb) { @@ -310,7 +330,16 @@ void saveToLocalAsync(const Item& item, std::string filename = sanitizeFilename(item.title) + ".torrent"; std::string destPath = std::string(APPDATA_TORRENTS) + "/" + filename; - downloadTorrentAsync(item.link, destPath, [item, destPath, magnet, cb](bool ok, const std::string& err) { + downloadTorrentAsync(item.link, destPath, [item, destPath, magnet, cb](bool ok, const std::string& err, + const std::string& redirected) { + if (ok && !redirected.empty()) + { + if (appendMagnetToFile(APPDATA_MAGNETS, redirected)) + cb(true, tr("Saved magnet link to Local library")); + else + cb(false, tr("Failed to save magnet link")); + return; + } if (ok) { brls::Logger::info("[jackett] saved torrent to {}", destPath); diff --git a/app/jackett.hpp b/app/jackett.hpp index 613b386..1a75fd5 100644 --- a/app/jackett.hpp +++ b/app/jackett.hpp @@ -64,9 +64,16 @@ void testConnectionAsync(const std::string& url, const std::string& apiKey, std::function cb); // Downloads a .torrent file from Jackett to `destPath`. -// Callback is invoked on the UI thread with (ok, errorMsg). +// Callback is invoked on the UI thread with (ok, errorMsg, magnet). When the +// indexer answers with a redirect to a magnet: URI instead of a file, ok is +// true, no file is written and `magnet` carries the URI. void downloadTorrentAsync(const std::string& link, const std::string& destPath, - std::function cb); + std::function cb); + +// Where the .torrent for a result's link is kept for playback (stable per +// link, under APPDATA_JACKETT). Creates the folder. +std::string cachedTorrentPath(const std::string& link); // Saves a torrent item to the local library (downloads .torrent to APPDATA_TORRENTS // or appends magnet to APPDATA_MAGNETS). diff --git a/app/jackett_tab.cpp b/app/jackett_tab.cpp index 2aa2238..d2b3a5b 100644 --- a/app/jackett_tab.cpp +++ b/app/jackett_tab.cpp @@ -46,6 +46,24 @@ bool isVideoExtension(const std::string& path) return false; } +// The video files of a loaded torrent, with picker labels (unsorted). +std::vector videosOf(const torrent_meta* m) +{ + std::vector idx; + std::vector paths; + for (int i = 0; i < m->file_count; i++) + { + if (!isVideoExtension(m->files[i].path)) continue; + idx.push_back(i); + paths.push_back(m->files[i].path); + } + std::vector names = pickerNames(paths); + std::vector out; + for (size_t k = 0; k < idx.size(); k++) + out.push_back({ idx[k], names[k], m->files[idx[k]].length }); + return out; +} + std::vector parseTorrentVideos(const std::string& path) { std::vector out; @@ -54,14 +72,7 @@ std::vector parseTorrentVideos(const std::string& path) char err[128] = { 0 }; if (torrent_load(m, path.c_str(), err, sizeof(err)) == 0) { - for (int i = 0; i < m->file_count; i++) - { - std::string p = m->files[i].path; - if (!isVideoExtension(p)) continue; - size_t slash = p.find_last_of("/\\"); - out.push_back({ i, slash == std::string::npos ? p : p.substr(slash + 1), - m->files[i].length }); - } + out = videosOf(m); torrent_unload(m); } std::free(m); @@ -97,6 +108,9 @@ class JackettTabRoot : public brls::Box } std::shared_ptr alive = std::make_shared(true); + // The controller behind this tab. Held here so it dies with the tab -- + // it used to be `new`ed and never freed, one leak per visit to the tab. + std::shared_ptr controller; }; class JackettView @@ -345,6 +359,18 @@ class JackettView void refresh() { + // Retry / Configure live inside contentContainer and call back into + // here: clearViews() would free the very button holding the focus, + // and the next input would dereference it. Park it on the search bar. + brls::View* cur = brls::Application::getCurrentFocus(); + for (brls::View* v = cur; v; v = v->getParent()) + { + if (v == contentContainer) + { + brls::Application::giveFocus(searchBar); + break; + } + } contentContainer->clearViews(); const auto& cfg = config::get(); @@ -679,7 +705,7 @@ class JackettView brls::Dialog* loading, LoadingBox* box, std::shared_ptr cancelled) { - brls::async([magnet, title, loading, box, cancelled, this]() { + brls::async([magnet, title, loading, box, cancelled, this, live = alive]() { torrent_meta* m = (torrent_meta*)std::calloc(1, sizeof(torrent_meta)); char err[128] = { 0 }; int rc = -1; @@ -691,14 +717,7 @@ class JackettView if (rc == 0) { resolvedName = m->name; - for (int i = 0; i < m->file_count; i++) - { - std::string p = m->files[i].path; - if (!isVideoExtension(p)) continue; - size_t slash = p.find_last_of("/\\"); - vids.push_back({ i, slash == std::string::npos ? p : p.substr(slash + 1), - m->files[i].length }); - } + vids = videosOf(m); std::sort(vids.begin(), vids.end(), [](const VideoFileInfo& a, const VideoFileInfo& b) { return naturalCompare(a.name, b.name); }); @@ -708,8 +727,8 @@ class JackettView } std::string e = err; - brls::sync([this, magnet, title, resolvedName, vids, rc, e, loading, box, cancelled]() { - if (*cancelled) return; + brls::sync([this, live, magnet, title, resolvedName, vids, rc, e, loading, box, cancelled]() { + if (*cancelled || !*live) return; box->cancelled = nullptr; loading->close([this, magnet, title, resolvedName, vids, rc, e]() { if (rc != 0) @@ -765,9 +784,19 @@ class JackettView if (isHttp) { - std::string tempPath = std::string(APPDATA_CACHE) + "_jackett.torrent"; - jackett::downloadTorrentAsync(link, tempPath, [this, item, tempPath, magnet, loading, box, cancelled](bool ok, const std::string& err) { - if (*cancelled) return; + // One file per link: history entries resume from this path later, + // so it must not be overwritten by the next result played. + std::string tempPath = jackett::cachedTorrentPath(link); + jackett::downloadTorrentAsync(link, tempPath, [this, live = alive, item, tempPath, magnet, loading, box, cancelled]( + bool ok, const std::string& err, const std::string& redirected) { + if (*cancelled || !*live) return; + // The indexer pointed at a magnet instead of a file (common for + // public trackers): resolve that, it is the same torrent. + if (ok && !redirected.empty()) + { + resolveAndPlayMagnet(redirected, item.title, loading, box, cancelled); + return; + } if (ok) { auto vids = parseTorrentVideos(tempPath); @@ -817,7 +846,7 @@ class JackettView brls::View* buildJackettTab() { - auto* root = new JackettTabRoot(); - new JackettView(root); + auto* root = new JackettTabRoot(); + root->controller = std::make_shared(root); return root; } diff --git a/app/main.cpp b/app/main.cpp index ef16003..f6d1626 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -528,7 +528,25 @@ struct VideoFile int64_t length; }; -// The video files a .torrent holds, largest first. Empty if it cannot be read. +// The video files of a loaded torrent, with picker labels (unsorted). +std::vector videoFilesOf(const torrent_meta* m) +{ + std::vector idx; + std::vector paths; + for (int i = 0; i < m->file_count; i++) + { + if (!isVideoFile(m->files[i].path)) continue; + idx.push_back(i); + paths.push_back(m->files[i].path); + } + std::vector names = pickerNames(paths); + std::vector out; + for (size_t k = 0; k < idx.size(); k++) + out.push_back({ idx[k], names[k], m->files[idx[k]].length }); + return out; +} + +// The video files a .torrent holds, in natural order. Empty if it cannot be read. std::vector torrentVideoFiles(const std::string& path) { std::vector out; @@ -538,14 +556,7 @@ std::vector torrentVideoFiles(const std::string& path) char err[128] = { 0 }; if (torrent_load(m, path.c_str(), err, sizeof(err)) == 0) { - for (int i = 0; i < m->file_count; i++) - { - std::string p = m->files[i].path; - if (!isVideoFile(p)) continue; - size_t slash = p.find_last_of("/\\"); - out.push_back({ i, slash == std::string::npos ? p : p.substr(slash + 1), - m->files[i].length }); - } + out = videoFilesOf(m); torrent_unload(m); } else @@ -986,17 +997,7 @@ void resolveMagnetNames(std::shared_ptr alive, total = m->total_len; // whole-torrent size, like torrentScanInfo // Extract + cache its video files (largest first) in the same // fetch, so playing it can offer a picker with no extra wait. - std::vector vids; - for (int i = 0; i < m->file_count; i++) - { - std::string p = m->files[i].path; - if (!isVideoFile(p)) continue; - size_t slash = p.find_last_of("/\\"); - vids.push_back({ i, - slash == std::string::npos ? p - : p.substr(slash + 1), - m->files[i].length }); - } + std::vector vids = videoFilesOf(m); torrent_unload(m); if (!magnetFilesCached(magnetHash(magnet))) cacheMagnetFiles(magnetHash(magnet), vids, total); diff --git a/app/player.cpp b/app/player.cpp index dc907b2..0beb063 100644 --- a/app/player.cpp +++ b/app/player.cpp @@ -2743,7 +2743,9 @@ void MpvView::maybeUpdateHistory(bool force) it.stremioType = watch.type; it.id = history::makeId(it.source, it.fileIndex, it.stremioVideoId); - history::record(it); + // Only the final write (the player closing) waits for the SD card; the + // periodic one is handed to a background thread so it cannot drop a frame. + history::record(it, force); } // Enter scrub mode: pause and pin the cursor to where playback currently is. @@ -3026,18 +3028,19 @@ void startPlaybackPrompt(const std::string& source, const PlayerArt& art, const std::string& title, int fileIndex, WatchInfo watch, const std::string& sourceType) { - const history::Item* item = history::find(source, fileIndex, watch.videoId); + history::Item item; + bool known = history::find(source, fileIndex, watch.videoId, item); double resumePos = 0.0; double resumeDur = 0.0; std::string itemTitle = title; - if (item) + if (known) { - if (history::hasValidResume(*item)) + if (history::hasValidResume(item)) { - resumePos = item->positionSec; - resumeDur = item->durationSec; - if (itemTitle.empty()) itemTitle = item->title; + resumePos = item.positionSec; + resumeDur = item.durationSec; + if (itemTitle.empty()) itemTitle = item.title; } } else if (watch.resumeSec > 15.0) diff --git a/app/update.cpp b/app/update.cpp index 23679f9..f3ad00d 100644 --- a/app/update.cpp +++ b/app/update.cpp @@ -19,11 +19,11 @@ #define APP_VERSION "0.0.0" #endif #ifndef UPDATE_API_URL -#define UPDATE_API_URL "https://api.github.com/repos/shodowlo/NX-torrent-player/releases/latest" +#define UPDATE_API_URL "https://api.github.com/repos/Langegen/NX-torrent-player/releases/latest" #endif // Base for a specific tag's release; the tag (e.g. "v0.2.0") is appended. #ifndef UPDATE_TAG_URL -#define UPDATE_TAG_URL "https://api.github.com/repos/shodowlo/NX-torrent-player/releases/tags/" +#define UPDATE_TAG_URL "https://api.github.com/repos/Langegen/NX-torrent-player/releases/tags/" #endif namespace update diff --git a/engine/torrent.c b/engine/torrent.c index 60a5a17..1df2805 100644 --- a/engine/torrent.c +++ b/engine/torrent.c @@ -301,15 +301,59 @@ typedef struct { int total; int max; Mutex lock; + volatile bool full; // no room left: further announces have nothing to add } collector; static void collect_cb(void *ctx, const peer_addr *peers, int n) { collector *c = ctx; mutexLock(&c->lock); merge_peers(c->peers, &c->total, c->max, peers, n); + if (c->total >= c->max) c->full = true; mutexUnlock(&c->lock); } +// One BEP 9 round: asks `n` peers for the metadata, META_WORKERS at a time; the +// first to deliver wins. Returns the metadata (caller frees) or NULL. +static uint8_t *fetch_metadata_round(peer_addr *peers, int n, + const uint8_t *info_hash, + const uint8_t *peer_id, size_t *len_out) { + torrent_meta_peers_total = n; + torrent_meta_peers_tried = 0; + + meta_fetch fetch; + memset(&fetch, 0, sizeof(fetch)); + mutexInit(&fetch.lock); + fetch.peers = peers; + fetch.n = n; + fetch.info_hash = info_hash; + fetch.peer_id = peer_id; + + int nw = n < META_WORKERS ? n : META_WORKERS; + Thread workers[META_WORKERS]; + int started = 0; + for (int i = 0; i < nw; i++) { + if (threadCreate(&workers[started], meta_worker, &fetch, NULL, 0x10000, + 0x2C, -2) != 0) + break; + if (threadStart(&workers[started]) != 0) { + threadClose(&workers[started]); + break; + } + started++; + } + // No worker could start: still fetch it, just on this thread. Slow beats + // "no peer provided the metadata" when the swarm was fine. + if (started == 0) + meta_worker(&fetch); + + for (int i = 0; i < started; i++) { + threadWaitForExit(&workers[i]); + threadClose(&workers[i]); + } + *len_out = fetch.meta_len; + return fetch.metadata; +} + int torrent_load_magnet(torrent_meta *t, const char *magnet_uri, char *err, size_t errlen) { return torrent_load_magnet_peers(t, magnet_uri, NULL, 0, NULL, err, errlen); @@ -356,7 +400,9 @@ int torrent_load_magnet_peers(torrent_meta *t, const char *magnet_uri, } // If trackers yielded no peers, search the DHT for bootstrap peers. + bool dht_tried = false; if (n <= 0) { + dht_tried = true; torrent_meta_state = META_DHT; collector c; memset(&c, 0, sizeof(c)); @@ -386,42 +432,43 @@ int torrent_load_magnet_peers(torrent_meta *t, const char *magnet_uri, // Ask several peers at once for the metadata (BEP 9); the first to answer // wins and the rest stop. Progress is published for the UI to poll. - torrent_meta_peers_total = n; - torrent_meta_peers_tried = 0; - - meta_fetch fetch; - memset(&fetch, 0, sizeof(fetch)); - mutexInit(&fetch.lock); - fetch.peers = peers; - fetch.n = n; - fetch.info_hash = m.info_hash; - fetch.peer_id = peer_id; + size_t meta_len = 0; + uint8_t *metadata = fetch_metadata_round(peers, n, m.info_hash, peer_id, &meta_len); + + // Trackers often hand out stale swarms: every peer listed is long gone, and + // the fetch used to fail right there ("peer 80/80 0%") without ever asking + // the DHT, which only ran when trackers returned nobody at all. Give the + // DHT its turn with the peers we have not tried yet. + if (!metadata && !dht_tried) { + torrent_meta_state = META_DHT; + peer_addr fresh[80]; + collector c; + memset(&c, 0, sizeof(c)); + c.peers = fresh; + c.total = 0; + c.max = 80; + mutexInit(&c.lock); + dht_find_peers(m.info_hash, 30, 8000, collect_cb, &c, NULL, err, errlen); - int nw = n < META_WORKERS ? n : META_WORKERS; - Thread workers[META_WORKERS]; - int started = 0; - for (int i = 0; i < nw; i++) { - if (threadCreate(&workers[started], meta_worker, &fetch, NULL, 0x10000, - 0x2C, -2) != 0) - break; - if (threadStart(&workers[started]) != 0) { - threadClose(&workers[started]); - break; + // Keep only peers the first round did not already try. + int fn = 0; + for (int i = 0; i < c.total; i++) { + bool seen = false; + for (int j = 0; j < n && !seen; j++) + seen = peers[j].ip == fresh[i].ip && peers[j].port == fresh[i].port; + if (!seen) fresh[fn++] = fresh[i]; + } + if (fn > 0) { + torrent_meta_state = META_FETCH; + metadata = fetch_metadata_round(fresh, fn, m.info_hash, peer_id, &meta_len); + if (metadata) { + // Hand the DHT peers on below: they are the ones that answered. + memcpy(peers, fresh, (size_t)fn * sizeof(peers[0])); + n = fn; + } } - started++; - } - // No worker could start: still fetch it, just on this thread. Slow beats - // "no peer provided the metadata" when the swarm was fine. - if (started == 0) - meta_worker(&fetch); - - for (int i = 0; i < started; i++) { - threadWaitForExit(&workers[i]); - threadClose(&workers[i]); } - uint8_t *metadata = fetch.metadata; - size_t meta_len = fetch.meta_len; if (!metadata) { magnet_free(&m); torrent_meta_state = META_FAIL; @@ -840,7 +887,10 @@ int torrent_announce(const torrent_meta *t, peer_addr *peers, int max_peers, c.total = 0; c.max = max_peers; mutexInit(&c.lock); - torrent_announce_cb(t, collect_cb, &c, NULL, err, errlen); + // Stop as soon as the list is full rather than waiting out every slow or + // dead tracker (up to 32 of them, 6 at a time): peers they would add have + // nowhere to go, and the metadata fetch is waiting on this list. + torrent_announce_cb(t, collect_cb, &c, &c.full, err, errlen); if (c.total == 0) { set_err(err, errlen, "no peer from the trackers"); return -1; } return c.total; } From 32e4a91a20ebc7f2623033e97919eed139129a61 Mon Sep 17 00:00:00 2001 From: Langegen Date: Mon, 26 Oct 2026 17:01:29 +0300 Subject: [PATCH 02/13] Stabilise for 0.5.1: CI release builds, offline engine tests, tracker cleanup CI - .github/workflows/build.yml: offline engine tests, then the .nro in the devkitpro/devkita64 image, on every pull request. A vX.Y.Z tag also opens a draft release with the .nro attached; publishing stays manual because the in-app updater offers the latest published release to everyone. The tag must match VERSION_* in CMakeLists.txt, or a build would keep offering itself as an update (or never update again). - Version bumped to 0.5.1. Tests - pctest/test_suite.c no longer touches the network: udp_announce() and dht_find_peers() are mocked at link time, and a mock peer on loopback serves the metadata over BEP 10/9, so peer.c runs as on the console. Six tests, including the DHT round after stale tracker peers (upstream #3) and the announce stopping once the peer list is full. Disabling the DHT fallback makes test 5 fail. - pctest/run_tests.sh builds and runs them (gcc + libcurl only). App / engine - Jackett: the per-link .torrent files under /jackett/ are pruned at startup when no history entry points at them; nothing else ever removed them. - Default trackers: dropped openbittorrent, exodus.desync, explodie and moeking (gone from ngosang/trackerslist), added open.tracker.cl. The engine's list and the two magnet lists in the app now match. - History dates: no change needed. libnx sets TZ from the console's time zone at startup, so localtime() is local; noted where the date is formatted. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 84 ++++++ .gitignore | 2 + CMakeLists.txt | 2 +- README.md | 13 +- README.ru.md | 12 +- app/browse.cpp | 10 +- app/history_tab.cpp | 2 + app/jackett.cpp | 33 +++ app/jackett.hpp | 6 + app/main.cpp | 14 +- engine/torrent.c | 9 +- pctest/run_tests.sh | 25 ++ pctest/test_suite.c | 530 ++++++++++++++++++++++++++++++++---- 13 files changed, 666 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100755 pctest/run_tests.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..812ce48 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,84 @@ +# Builds NX-torrent-player.nro in the devkitPro image. +# +# - Pull requests and manual runs: build, keep the .nro as a run artifact. +# - A pushed tag vX.Y.Z: build, then open a DRAFT release with the .nro +# attached. Publishing it is a manual step on purpose: the in-app updater +# offers the latest *published* release to every user, and shows the release +# body as the changelog -- write it before pressing Publish. +# +# git tag v0.5.1 && git push origin v0.5.1 + +name: Build + +on: + push: + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: write # creating the draft release + +jobs: + # Offline engine tests (pctest/test_suite.c): mocked trackers and DHT, a mock + # peer on loopback. No network, so they are safe to gate on. + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install libcurl + run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + - name: Run + run: sh pctest/run_tests.sh + + nro: + needs: tests + runs-on: ubuntu-latest + container: devkitpro/devkita64:latest + steps: + - uses: actions/checkout@v4 + + # The updater compares the tag with the version compiled into the .nro. + # A tag that disagrees with CMakeLists.txt would ship a build that keeps + # offering itself as an update forever (or never updates again). + - name: Check the tag matches the version + if: startsWith(github.ref, 'refs/tags/') + run: | + v() { sed -n "s/^set(VERSION_$1 \"\([0-9]*\)\")/\1/p" CMakeLists.txt; } + version="$(v MAJOR).$(v MINOR).$(v ALTER)" + tag="${GITHUB_REF_NAME#v}" + echo "CMakeLists.txt: $version, tag: $tag" + if [ "$version" != "$tag" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match VERSION_* ($version) in CMakeLists.txt" + exit 1 + fi + + - name: Install portlibs + run: | + dkp-pacman -Sy --needed --noconfirm \ + switch-libmpv switch-curl switch-libwebp switch-mbedtls \ + switch-glfw switch-mesa + + - name: Configure + run: cmake -B build -DPLATFORM_SWITCH=ON -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build --target NX-torrent-player.nro -j "$(nproc)" + + # The .elf rides along so a crash report's offsets can be resolved against + # the exact build (aarch64-none-elf-addr2line -Cfe ... ). + - uses: actions/upload-artifact@v4 + with: + name: NX-torrent-player + path: | + build/NX-torrent-player.nro + build/NX-torrent-player.elf + if-no-files-found: error + + - name: Draft the release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + draft: true + name: ${{ github.ref_name }} + files: build/NX-torrent-player.nro diff --git a/.gitignore b/.gitignore index 7f27659..4b3d561 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ /NX-torrent-player.nacp /pctest/enginetest *.o +/pctest/test_suite +/pctest/test_suite.exe diff --git a/CMakeLists.txt b/CMakeLists.txt index 734ae5c..8c02ecf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ include(${BOREALIS_LIBRARY}/cmake/toolchain.cmake) project(NX-torrent-player) set(VERSION_MAJOR "0") set(VERSION_MINOR "5") -set(VERSION_ALTER "0") +set(VERSION_ALTER "1") set(PROJECT_AUTHOR "shodowlo") # On-device app name shown in the Homebrew Menu. set(APP_TITLE "NX Torrent Player") diff --git a/README.md b/README.md index cc47db7..7b459a4 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,19 @@ docker run --rm -v st_build:/build -v "$PWD":/project \ > On Windows (using Git Bash or MSYS2), prefix docker commands with `MSYS_NO_PATHCONV=1` to prevent path conversions. ### Desktop Engine Tests (PC) -You can build and run the standalone torrent engine tests on Linux/macOS or WSL: +The offline test suite needs only gcc and libcurl headers (Linux, macOS, WSL or +MSYS2). Trackers and the DHT are mocked and a mock peer serves the metadata on +loopback, so it needs no network; CI runs it on every pull request: +```sh +sh pctest/run_tests.sh +``` + +`enginetest` runs the real engine against a live swarm, for measuring download +behaviour (needs libmbedtls and zlib as well): ```sh -# Requires gcc, libcurl, libmbedtls, and zlib development packages cd pctest ./build.sh -./enginetest +./enginetest [seconds] ``` --- diff --git a/README.ru.md b/README.ru.md index dd5364b..80f242e 100644 --- a/README.ru.md +++ b/README.ru.md @@ -161,11 +161,19 @@ docker run --rm -v st_build:/build -v "$PWD":/project \ > В среде Windows (Git Bash или MSYS2) добавляйте перед командами `MSYS_NO_PATHCONV=1`, чтобы избежать искажения путей к каталогам. ### Сборка тестов для ПК -Скомпилировать автономный тест движка можно в Linux/macOS или WSL: +Офлайн-тестам нужны только gcc и заголовки libcurl (Linux, macOS, WSL или +MSYS2). Трекеры и DHT заменены моками, метаданные отдаёт мок-пир на loopback, +так что сеть не нужна. CI запускает их на каждый pull request: +```sh +sh pctest/run_tests.sh +``` + +`enginetest` гоняет настоящий движок на живом рое, чтобы измерять скорость +загрузки (нужны ещё libmbedtls и zlib): ```sh cd pctest ./build.sh -./enginetest +./enginetest [секунды] ``` --- diff --git a/app/browse.cpp b/app/browse.cpp index 9c0fbdd..cca2db7 100644 --- a/app/browse.cpp +++ b/app/browse.cpp @@ -97,15 +97,13 @@ bool isUnder(brls::View* v, brls::View* ancestor) // Trackers appended to the bare infoHash an addon gives us. These are the // long-running public UDP trackers most public torrents announce to anyway. +// Same set as DEFAULT_TRACKERS in engine/torrent.c (see there). constexpr const char* kPublicTrackers = "&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce" - "&tr=udp%3A%2F%2Fopen.demonii.com%3A1337%2Fannounce" - "&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce" - "&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce" - "&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce" "&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce" - "&tr=udp%3A%2F%2Ftracker.moeking.me%3A6969%2Fannounce" - "&tr=udp%3A%2F%2Fexplodie.org%3A6969%2Fannounce"; + "&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce" + "&tr=udp%3A%2F%2Fopen.demonii.com%3A1337%2Fannounce" + "&tr=udp%3A%2F%2Fopen.tracker.cl%3A1337%2Fannounce"; // The Switch's shared system font has no colour emoji, so the pictographs // addons use as field markers render as blanks/boxes. Swap the common ones for diff --git a/app/history_tab.cpp b/app/history_tab.cpp index dc7a6d5..e6ca88b 100644 --- a/app/history_tab.cpp +++ b/app/history_tab.cpp @@ -48,6 +48,8 @@ std::string formatDate(int64_t timestamp) { if (timestamp <= 0) return ""; std::time_t t = (std::time_t)timestamp; + // Local time, not UTC: libnx's __libnx_init_time() reads the console's + // time zone (timeToCalendarTimeWithMyRule) at startup and sets TZ from it. std::tm* tm = std::localtime(&t); if (!tm) return ""; char buf[64]; diff --git a/app/jackett.cpp b/app/jackett.cpp index ffdea53..b1070c4 100644 --- a/app/jackett.cpp +++ b/app/jackett.cpp @@ -3,12 +3,15 @@ #include #include #include +#include +#include #include #include #include "appdata.hpp" #include "config.hpp" +#include "history.hpp" #include "http.hpp" #include "i18n.hpp" #include "json.hpp" @@ -316,6 +319,36 @@ std::string cachedTorrentPath(const std::string& link) return std::string(APPDATA_JACKETT) + "/" + name; } +void pruneCache() +{ + DIR* d = opendir(APPDATA_JACKETT); + if (!d) return; + + // Matched by file name: the names are hashes, so the folder part of a + // stored path ("sdmc:/..." or not) does not matter. + static const std::string marker = "/jackett/"; + std::set used; + for (const auto& it : history::all()) + { + size_t at = it.source.rfind(marker); + if (at != std::string::npos) + used.insert(it.source.substr(at + marker.size())); + } + + int removed = 0; + struct dirent* e; + while ((e = readdir(d))) + { + std::string name = e->d_name; + if (name == "." || name == ".." || used.count(name)) continue; + if (std::remove((std::string(APPDATA_JACKETT) + "/" + name).c_str()) == 0) + removed++; + } + closedir(d); + if (removed) + brls::Logger::info("[jackett] pruned {} cached torrent(s)", removed); +} + void saveToLocalAsync(const Item& item, std::function cb) { diff --git a/app/jackett.hpp b/app/jackett.hpp index 1a75fd5..e236242 100644 --- a/app/jackett.hpp +++ b/app/jackett.hpp @@ -75,6 +75,12 @@ void downloadTorrentAsync(const std::string& link, const std::string& destPath, // link, under APPDATA_JACKETT). Creates the folder. std::string cachedTorrentPath(const std::string& link); +// Deletes the files in APPDATA_JACKETT that no history entry points at: they +// are only kept so an entry can resume, and nothing else ever removes them. +// Call after history::load(), before anything can start a playback (a file +// fetched for a result being played is not in the history yet). +void pruneCache(); + // Saves a torrent item to the local library (downloads .torrent to APPDATA_TORRENTS // or appends magnet to APPDATA_MAGNETS). void saveToLocalAsync(const Item& item, diff --git a/app/main.cpp b/app/main.cpp index f6d1626..93b00c0 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -37,6 +37,7 @@ #include "theme.hpp" #include "update.hpp" #include "stremio.hpp" +#include "jackett.hpp" #include "jackett_tab.hpp" #include "history.hpp" #include "history_tab.hpp" @@ -281,14 +282,14 @@ void scanTorrentsRec(const std::string& dir, std::vector& out) } // Trackers appended to a bare info-hash (same set as the Stremio path). Public -// UDP trackers most torrents announce to anyway; DHT fills the rest. +// UDP trackers most torrents announce to anyway; DHT fills the rest. Same set +// as DEFAULT_TRACKERS in engine/torrent.c (see there). constexpr const char* kMagnetTrackers = "&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce" - "&tr=udp%3A%2F%2Fopen.demonii.com%3A1337%2Fannounce" - "&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce" - "&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce" + "&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce" "&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce" - "&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce"; + "&tr=udp%3A%2F%2Fopen.demonii.com%3A1337%2Fannounce" + "&tr=udp%3A%2F%2Fopen.tracker.cl%3A1337%2Fannounce"; // Turn what the user pasted/typed into a playable magnet URI, or "" if it is not // one. Accepts a full "magnet:?..." link OR a bare info-hash (40 hex / 32 base32) @@ -1743,6 +1744,9 @@ int main(int argc, char* argv[]) config::load(); history::load(); + // Now, while nothing can be playing: a file fetched for the result being + // played is only referenced once the player records it. + jackett::pruneCache(); // Straight after the config it reads, and before anything can build a view: // tr() is resolved when a string is handed to a view, and the header, the // tab bar and the browser are built once and never again. diff --git a/engine/torrent.c b/engine/torrent.c index 1df2805..c5ebdb0 100644 --- a/engine/torrent.c +++ b/engine/torrent.c @@ -36,14 +36,17 @@ static void tlog(const char *fmt, ...) { // Well-known public UDP trackers, appended to every torrent so we discover far // more peers than the torrent's own (often stale) tracker list provides. UDP // only: HTTP(S) trackers need a CA bundle we don't ship. add_tracker dedups. +// A dead entry costs an announce slot and its timeout on every load, so the +// list is kept to trackers that stay on ngosang/trackerslist's "best" list +// (checked Oct 2026; openbittorrent, exodus.desync, explodie are gone). Keep it +// in sync with kPublicTrackers (app/browse.cpp) and kMagnetTrackers +// (app/main.cpp), which a bare info-hash gets before any metadata exists. static const char *DEFAULT_TRACKERS[] = { "udp://tracker.opentrackr.org:1337/announce", "udp://open.stealth.si:80/announce", "udp://tracker.torrent.eu.org:451/announce", - "udp://exodus.desync.com:6969/announce", "udp://open.demonii.com:1337/announce", - "udp://tracker.openbittorrent.com:6969/announce", - "udp://explodie.org:6969/announce", + "udp://open.tracker.cl:1337/announce", }; static void add_tracker(torrent_meta *t, const char *url, size_t len); diff --git a/pctest/run_tests.sh b/pctest/run_tests.sh new file mode 100755 index 0000000..a9165c4 --- /dev/null +++ b/pctest/run_tests.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Builds and runs the offline engine test suite (pctest/test_suite.c). +# +# Needs a C compiler and libcurl headers (Debian/Ubuntu: build-essential +# libcurl4-openssl-dev; MSYS2/Cygwin: gcc libcurl-devel). No mbedtls: SHA-1 +# comes from pctest/compat. No network: the trackers and the DHT are mocked in +# the test file itself, which is why udp_tracker.c, dht.c and dhtclient.c are +# left out here. +set -e +cd "$(dirname "$0")/.." + +out="${OUT:-./pctest/test_suite}" +${CC:-gcc} -O1 -g -Wall \ + -Ipctest/compat -Iengine \ + engine/bencode.c \ + engine/torrent.c \ + engine/peer.c \ + engine/magnet.c \ + pctest/compat/sha1.c \ + pctest/utpstub.c \ + pctest/test_suite.c \ + -o "$out" \ + -lpthread -lcurl + +"$out" diff --git a/pctest/test_suite.c b/pctest/test_suite.c index 428f65c..b1b00fc 100644 --- a/pctest/test_suite.c +++ b/pctest/test_suite.c @@ -1,13 +1,326 @@ +// Offline test suite for the torrent engine. +// +// Nothing here touches the internet. The trackers and the DHT are replaced at +// link time: this file defines udp_announce() and dht_find_peers(), so +// engine/udp_tracker.c, engine/dht.c and engine/dhtclient.c are NOT linked (see +// run_tests.sh). The peer side is real: a mock peer listens on 127.0.0.1 and +// serves the metadata over BEP 10 / BEP 9, so engine/peer.c's handshake and +// metadata fetch run exactly as on the console. +// +// ./pctest/run_tests.sh + +#include +#include +#include +#include #include #include #include -#include #include +#include + +#include +#include +#include #include +#include + +#include "dhtclient.h" #include "magnet.h" #include "torrent.h" -#include "dhtclient.h" +#include "udp_tracker.h" + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAILED %s:%d: %s\n", __FILE__, __LINE__, \ + #cond); \ + exit(1); \ + } \ + } while (0) + +static double now_s(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec + ts.tv_nsec / 1e9; +} + +static peer_addr loopback(uint16_t port) { + peer_addr a = { htonl(INADDR_LOOPBACK), port }; + return a; +} + +//--------------------------------------------------------------------------- +// Mock trackers: udp_announce() answers from a table set up by each test. +// Any tracker not in the table -- the engine's DEFAULT_TRACKERS -- fails at +// once, as an unreachable one would, minus the timeout. +//--------------------------------------------------------------------------- + +#define MOCK_MAX 8 +typedef struct { + const char *url; + peer_addr peers[80]; + int n; + bool slow; // never answers: waits on the cancel flag (or 10 s) + int delay_ms; // answers, but only after this long +} mock_tracker; + +static mock_tracker g_trackers[MOCK_MAX]; +static int g_tracker_count; +static int g_announce_calls; // every call, mocked or not +static int g_slow_cancelled; // slow trackers that saw the cancel flag +static pthread_mutex_t g_mock_lock = PTHREAD_MUTEX_INITIALIZER; + +static void trackers_reset(void) { + memset(g_trackers, 0, sizeof(g_trackers)); + g_tracker_count = 0; + g_announce_calls = 0; + g_slow_cancelled = 0; +} + +static mock_tracker *tracker_add(const char *url) { + assert(g_tracker_count < MOCK_MAX); + mock_tracker *t = &g_trackers[g_tracker_count++]; + t->url = url; + return t; +} + +int udp_announce(const char *url, const uint8_t info_hash[20], + const uint8_t peer_id[20], int64_t left, + peer_addr *peers, int max_peers, + const volatile bool *cancel, char *err, size_t errlen) { + (void)info_hash; (void)peer_id; (void)left; + pthread_mutex_lock(&g_mock_lock); + g_announce_calls++; + pthread_mutex_unlock(&g_mock_lock); + + for (int i = 0; i < g_tracker_count; i++) { + mock_tracker *t = &g_trackers[i]; + if (strcmp(t->url, url) != 0) continue; + if (t->slow) { + double end = now_s() + 10.0; + while (now_s() < end) { + if (cancel && *cancel) { + pthread_mutex_lock(&g_mock_lock); + g_slow_cancelled++; + pthread_mutex_unlock(&g_mock_lock); + break; + } + usleep(5000); + } + snprintf(err, errlen, "timeout"); + return -1; + } + if (t->delay_ms) usleep((useconds_t)t->delay_ms * 1000); + int n = t->n < max_peers ? t->n : max_peers; + memcpy(peers, t->peers, (size_t)n * sizeof(*peers)); + return n; + } + snprintf(err, errlen, "unreachable (mock)"); + return -1; +} + +//--------------------------------------------------------------------------- +// Mock DHT +//--------------------------------------------------------------------------- + +static peer_addr g_dht_peers[16]; +static int g_dht_n; +static int g_dht_calls; + +int dht_find_peers(const uint8_t info_hash[20], int target_peers, int budget_ms, + dht_peer_cb cb, void *ctx, const volatile bool *cancel, + char *err, size_t errlen) { + (void)info_hash; (void)target_peers; (void)budget_ms; (void)cancel; + g_dht_calls++; + if (g_dht_n == 0) { + snprintf(err, errlen, "no DHT peers (mock)"); + return 0; + } + cb(ctx, g_dht_peers, g_dht_n); + return g_dht_n; +} + +void dht_set_log(void (*fn)(const char *msg)) { (void)fn; } + +//--------------------------------------------------------------------------- +// The torrent the mock peer serves: two files, 1000 pieces, so the info dict +// (~20 KB) spans two 16 KiB metadata pieces. +//--------------------------------------------------------------------------- + +#define PIECE_LEN 16384 +#define PIECES 1000 +#define FILE1_LEN ((int64_t)PIECE_LEN * 600 + 123) +#define FILE2_LEN ((int64_t)PIECE_LEN * PIECES - FILE1_LEN - 5000) + +static char *g_info; // bencoded info dict +static size_t g_info_len; +static uint8_t g_info_hash[20]; +static char g_hash_hex[41]; + +static void build_info(void) { + char head[256]; + int hn = snprintf(head, sizeof(head), + "d5:filesl" + "d6:lengthi%llde4:pathl8:ep01.mkvee" + "d6:lengthi%llde4:pathl6:Extras8:ep02.mkvee" + "e4:name7:Brigada12:piece lengthi%de6:pieces%d:", + (long long)FILE1_LEN, (long long)FILE2_LEN, PIECE_LEN, + PIECES * 20); + g_info_len = (size_t)hn + PIECES * 20 + 1; + g_info = malloc(g_info_len); + memcpy(g_info, head, (size_t)hn); + for (int i = 0; i < PIECES * 20; i++) g_info[hn + i] = (char)(i * 7 + 3); + g_info[g_info_len - 1] = 'e'; + + mbedtls_sha1((const unsigned char *)g_info, g_info_len, g_info_hash); + for (int i = 0; i < 20; i++) sprintf(g_hash_hex + i * 2, "%02x", g_info_hash[i]); +} + +//--------------------------------------------------------------------------- +// Mock peer: BitTorrent handshake, BEP 10 extended handshake, BEP 9 data. +//--------------------------------------------------------------------------- + +#define MOCK_UT_ID 3 // our ut_metadata id; the engine advertises 1 for its own + +typedef struct { + int listen_fd; + uint16_t port; + pthread_t thread; + volatile int served; // connections that got the whole metadata + volatile int accepted; +} mock_peer; + +static int read_all(int fd, void *buf, size_t n) { + size_t got = 0; + while (got < n) { + ssize_t r = recv(fd, (char *)buf + got, n - got, 0); + if (r <= 0) return -1; + got += (size_t)r; + } + return 0; +} + +static int write_all(int fd, const void *buf, size_t n) { + size_t sent = 0; + while (sent < n) { + ssize_t r = send(fd, (const char *)buf + sent, n - sent, 0); + if (r <= 0) return -1; + sent += (size_t)r; + } + return 0; +} + +static int send_ext(int fd, uint8_t sub, const void *a, size_t an, + const void *b, size_t bn) { + uint32_t len = htonl((uint32_t)(2 + an + bn)); + uint8_t hdr[6]; + memcpy(hdr, &len, 4); + hdr[4] = 20; // extended + hdr[5] = sub; + if (write_all(fd, hdr, 6) != 0 || write_all(fd, a, an) != 0) return -1; + return bn ? write_all(fd, b, bn) : 0; +} + +static void serve_one(mock_peer *mp, int fd) { + uint8_t hs[68]; + if (read_all(fd, hs, 68) != 0 || hs[0] != 19 || + memcmp(hs + 28, g_info_hash, 20) != 0) + return; + hs[25] |= 0x10; // we speak the extension protocol + memcpy(hs + 48, "-MOCK00-abcdefghijkl", 20); + if (write_all(fd, hs, 68) != 0) return; + + char ehs[96]; + int en = snprintf(ehs, sizeof(ehs), "d1:md11:ut_metadatai%dee13:metadata_sizei%zuee", + MOCK_UT_ID, g_info_len); + if (send_ext(fd, 0, ehs, (size_t)en, NULL, 0) != 0) return; + + int npieces = (int)((g_info_len + 16383) / 16384); + int sent = 0; + while (sent < npieces) { + uint32_t len; + if (read_all(fd, &len, 4) != 0) return; + len = ntohl(len); + if (len == 0) continue; // keep-alive + if (len > 1 << 20) return; + uint8_t *msg = malloc(len); + if (read_all(fd, msg, len) != 0) { free(msg); return; } + int piece = -1; + if (len > 2 && msg[0] == 20 && msg[1] == MOCK_UT_ID) { + const char *p = strstr((const char *)msg + 2, "5:piecei"); + if (p) piece = atoi(p + 8); + } + free(msg); + if (piece < 0 || piece >= npieces) continue; + + size_t off = (size_t)piece * 16384; + size_t n = g_info_len - off < 16384 ? g_info_len - off : 16384; + char d[96]; + int dn = snprintf(d, sizeof(d), "d8:msg_typei1e5:piecei%de10:total_sizei%zuee", + piece, g_info_len); + // Sub-id 1: the id the engine advertised for its ut_metadata. + if (send_ext(fd, 1, d, (size_t)dn, g_info + off, n) != 0) return; + sent++; + } + mp->served++; +} + +static void *peer_main(void *arg) { + mock_peer *mp = arg; + for (;;) { + int fd = accept(mp->listen_fd, NULL, NULL); + if (fd < 0) return NULL; // listen socket closed: test over + mp->accepted++; + serve_one(mp, fd); + close(fd); + } +} + +static void peer_start(mock_peer *mp) { + memset(mp, 0, sizeof(*mp)); + mp->listen_fd = socket(AF_INET, SOCK_STREAM, 0); + CHECK(mp->listen_fd >= 0); + struct sockaddr_in sa = {0}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + CHECK(bind(mp->listen_fd, (struct sockaddr *)&sa, sizeof(sa)) == 0); + CHECK(listen(mp->listen_fd, 8) == 0); + socklen_t sl = sizeof(sa); + getsockname(mp->listen_fd, (struct sockaddr *)&sa, &sl); + mp->port = ntohs(sa.sin_port); + pthread_create(&mp->thread, NULL, peer_main, mp); +} + +static void peer_stop(mock_peer *mp) { + shutdown(mp->listen_fd, SHUT_RDWR); + close(mp->listen_fd); + pthread_join(mp->thread, NULL); +} + +// A loopback port nobody listens on: connecting is refused at once, which is +// what most of a stale swarm looks like (minus the timeouts). +static uint16_t dead_port(void) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in sa = {0}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind(fd, (struct sockaddr *)&sa, sizeof(sa)); + socklen_t sl = sizeof(sa); + getsockname(fd, (struct sockaddr *)&sa, &sl); + close(fd); + return ntohs(sa.sin_port); +} + +static void magnet_for(char *out, size_t n, const char *tracker) { + snprintf(out, n, "magnet:?xt=urn:btih:%s&dn=Brigada%s%s", g_hash_hex, + tracker ? "&tr=" : "", tracker ? tracker : ""); +} + +//--------------------------------------------------------------------------- +// Tests +//--------------------------------------------------------------------------- // Test magnet with 31 trackers from the user's issue description static const char *USER_MAGNET = @@ -46,99 +359,204 @@ static const char *USER_MAGNET = "&tr=udp%3a%2f%2fexodus.desync.com%3a6969"; static void test_magnet_parsing(void) { - printf("[1/3] Testing magnet parsing with 31 trackers...\n"); + printf("[1/6] Magnet parsing: 31 trackers, dedup, case...\n"); magnet_info m; char err[256] = {0}; - int rc = magnet_parse(USER_MAGNET, &m, err, sizeof(err)); - assert(rc == 0); - assert(m.tracker_count == 31); - assert(strcmp(m.name, "Бригада_[scarabe.org]") == 0); + CHECK(magnet_parse(USER_MAGNET, &m, err, sizeof(err)) == 0); + CHECK(m.tracker_count == 31); + CHECK(strcmp(m.name, "Бригада_[scarabe.org]") == 0); - // Verify deduplication magnet_add_tracker(&m, "udp://open.stealth.si:80"); - assert(m.tracker_count == 31); - - // Test case insensitivity - magnet_info m_case; - const char *case_magnet = "MAGNET:?XT=URN:BTIH:B64D91F1DFA21B7DB1D628C58BBC734876453FF0&DN=Brigada"; - rc = magnet_parse(case_magnet, &m_case, err, sizeof(err)); - assert(rc == 0); - assert(strcmp(m_case.name, "Brigada") == 0); - magnet_free(&m_case); - + CHECK(m.tracker_count == 31); magnet_free(&m); - printf(" Magnet parsing & deduplication tests PASSED.\n"); + + magnet_info mc; + const char *upper = "MAGNET:?XT=URN:BTIH:B64D91F1DFA21B7DB1D628C58BBC734876453FF0&DN=Brigada"; + CHECK(magnet_parse(upper, &mc, err, sizeof(err)) == 0); + CHECK(strcmp(mc.name, "Brigada") == 0); + magnet_free(&mc); + printf(" PASSED\n"); } -static void test_announce_pool_bounds(void) { - printf("[2/3] Testing announce worker pool bounds...\n"); +static void test_announce_cancel(void) { + printf("[2/6] Announce: a pre-set cancel flag returns at once...\n"); + trackers_reset(); - // Construct a meta with 20 dummy trackers torrent_meta t; memset(&t, 0, sizeof(t)); for (int i = 0; i < 20; i++) { - char url[128]; - snprintf(url, sizeof(url), "udp://127.0.0.1:%d/announce", 60000 + i); + char url[64]; + snprintf(url, sizeof(url), "udp://slow%d.test:1/announce", i); t.trackers[i] = strdup(url); + // Only MOCK_MAX of them are in the table; the rest fail at once. + if (i < MOCK_MAX) tracker_add(t.trackers[i])->slow = true; } t.tracker_count = 20; - // Use cancel flag set to true to test immediate cancellation handling volatile bool cancel = true; char err[256] = {0}; - u64 t0 = armGetSystemTick(); + double t0 = now_s(); int answered = torrent_announce_cb(&t, NULL, NULL, &cancel, err, sizeof(err)); - u64 dt = armGetSystemTick() - t0; - - printf(" Announce completed: answered=%d, time=%.2f ms\n", - answered, (double)dt / 1000000.0); + double dt = now_s() - t0; + printf(" answered=%d in %.0f ms\n", answered, dt * 1000); + CHECK(answered == 0); + CHECK(dt < 1.0); for (int i = 0; i < t.tracker_count; i++) free(t.trackers[i]); - printf(" Announce worker pool bounds test PASSED.\n"); + printf(" PASSED\n"); +} + +static void test_announce_stops_when_full(void) { + printf("[3/6] Announce: stops waiting once the peer list is full...\n"); + trackers_reset(); + + torrent_meta t; + memset(&t, 0, sizeof(t)); + // The slow one goes first, so it is already waiting when the list fills. + t.trackers[0] = "udp://slow.test:1/announce"; + t.trackers[1] = "udp://full.test:1/announce"; + t.tracker_count = 2; + tracker_add(t.trackers[0])->slow = true; + mock_tracker *full = tracker_add(t.trackers[1]); + for (int i = 0; i < 80; i++) full->peers[i] = loopback((uint16_t)(20000 + i)); + full->n = 80; + full->delay_ms = 100; + + peer_addr peers[80]; + char err[256] = {0}; + double t0 = now_s(); + int n = torrent_announce(&t, peers, 80, err, sizeof(err)); + double dt = now_s() - t0; + printf(" %d peers in %.0f ms (slow tracker cancelled: %d)\n", + n, dt * 1000, g_slow_cancelled); + CHECK(n == 80); + CHECK(dt < 2.0); // not the slow tracker's 10 s + CHECK(g_slow_cancelled == 1); + printf(" PASSED\n"); +} + +static void check_loaded(const torrent_meta *t) { + CHECK(strcmp(t->name, "Brigada") == 0); + CHECK(t->piece_len == PIECE_LEN); + CHECK(t->piece_count == PIECES); + CHECK(t->file_count == 2); + CHECK(t->files[0].length == FILE1_LEN); + CHECK(t->files[1].length == FILE2_LEN); + CHECK(t->files[1].offset == FILE1_LEN); + CHECK(t->total_len == FILE1_LEN + FILE2_LEN); + CHECK(strstr(t->files[1].path, "ep02.mkv") != NULL); + CHECK(memcmp(t->info_hash, g_info_hash, 20) == 0); } -static void test_trackerless_and_dht_fallback(void) { - printf("[3/3] Testing trackerless magnet link and DHT fallback...\n"); +static void test_magnet_metadata(void) { + printf("[4/6] Magnet: metadata from a peer the tracker lists...\n"); + trackers_reset(); + g_dht_n = 0; + g_dht_calls = 0; - // Bare magnet link with NO trackers - const char *bare_magnet = "magnet:?xt=urn:btih:b64d91f1dfa21b7db1d628c58bbc734876453ff0"; + mock_peer mp; + peer_start(&mp); + mock_tracker *tr = tracker_add("udp://mock.test:1/announce"); + tr->peers[0] = loopback(dead_port()); // a stale peer first + tr->peers[1] = loopback(mp.port); + tr->n = 2; + + char magnet[256]; + magnet_for(magnet, sizeof(magnet), "udp%3a%2f%2fmock.test%3a1%2fannounce"); torrent_meta t; + peer_addr out[16]; + int out_n = 0; char err[256] = {0}; + int rc = torrent_load_magnet_peers(&t, magnet, out, 16, &out_n, err, sizeof(err)); + printf(" rc=%d err='%s' served=%d peers handed on=%d\n", + rc, err, mp.served, out_n); + CHECK(rc == 0); + CHECK(mp.served == 1); + CHECK(out_n == 2); + CHECK(g_dht_calls == 0); // the trackers were enough + check_loaded(&t); + // The magnet's tracker plus the defaults, so the download has them too. + CHECK(t.tracker_count > 1); + torrent_unload(&t); + peer_stop(&mp); + printf(" PASSED\n"); +} + +static void test_dht_after_stale_peers(void) { + printf("[5/6] Magnet: stale tracker peers, metadata through the DHT (upstream #3)...\n"); + trackers_reset(); - // Run magnet load: previously failed with "magnet has no tracker" - // Now it should populate fallback trackers, announce, attempt DHT, and fail only with seed absence. - u64 t0 = armGetSystemTick(); - int rc = torrent_load_magnet(&t, bare_magnet, err, sizeof(err)); - u64 dt = armGetSystemTick() - t0; + mock_peer mp; + peer_start(&mp); + mock_tracker *tr = tracker_add("udp://mock.test:1/announce"); + for (int i = 0; i < 5; i++) tr->peers[i] = loopback(dead_port()); + tr->n = 5; + g_dht_peers[0] = tr->peers[0]; // already tried: must be skipped + g_dht_peers[1] = loopback(mp.port); + g_dht_n = 2; + g_dht_calls = 0; - printf(" Result: rc=%d, error='%s', time=%.2f s\n", - rc, err, (double)dt / 1000000000.0); + char magnet[256]; + magnet_for(magnet, sizeof(magnet), "udp%3a%2f%2fmock.test%3a1%2fannounce"); - // It must NOT fail with "magnet has no tracker" - assert(strstr(err, "magnet has no tracker") == NULL); - assert(strstr(err, "DHT bootstrap not supported") == NULL); + torrent_meta t; + peer_addr out[16]; + int out_n = 0; + char err[256] = {0}; + int rc = torrent_load_magnet_peers(&t, magnet, out, 16, &out_n, err, sizeof(err)); + printf(" rc=%d err='%s' dht calls=%d peers handed on=%d\n", + rc, err, g_dht_calls, out_n); + CHECK(rc == 0); + CHECK(g_dht_calls == 1); + CHECK(mp.served == 1); + CHECK(out_n == 1); // only the DHT peer that answered + CHECK(out[0].port == mp.port); + check_loaded(&t); + torrent_unload(&t); + peer_stop(&mp); + printf(" PASSED\n"); +} - // It must report either no peers found or no connectable peers - assert(strstr(err, "no peers found") != NULL || - strstr(err, "no connectable peers") != NULL || - rc == 0); +static void test_trackerless_no_peers(void) { + printf("[6/6] Magnet: no tracker, nobody anywhere -> clean failure...\n"); + trackers_reset(); + g_dht_n = 0; + g_dht_calls = 0; - printf(" Trackerless magnet & DHT fallback test PASSED.\n"); + char magnet[256]; + magnet_for(magnet, sizeof(magnet), NULL); + + torrent_meta t; + char err[256] = {0}; + int rc = torrent_load_magnet(&t, magnet, err, sizeof(err)); + printf(" rc=%d err='%s' announces=%d dht calls=%d\n", + rc, err, g_announce_calls, g_dht_calls); + CHECK(rc != 0); + CHECK(g_announce_calls > 0); // the default trackers were asked + CHECK(g_dht_calls == 1); // then the DHT + CHECK(strstr(err, "no peers found") != NULL); + CHECK(torrent_meta_state == META_FAIL); + printf(" PASSED\n"); } int main(void) { printf("=========================================\n"); - printf(" AUTOMATED TEST SUITE: NX TORRENT PLAYER \n"); + printf(" NX TORRENT PLAYER: OFFLINE ENGINE TESTS \n"); printf("=========================================\n\n"); + build_info(); + printf("mock torrent: %s, info dict %zu bytes\n\n", g_hash_hex, g_info_len); + test_magnet_parsing(); - test_announce_pool_bounds(); - test_trackerless_and_dht_fallback(); + test_announce_cancel(); + test_announce_stops_when_full(); + test_magnet_metadata(); + test_dht_after_stale_peers(); + test_trackerless_no_peers(); - printf("\n=========================================\n"); - printf(" ALL AUTOMATED TESTS COMPLETED SUCCESSFULLY!\n"); - printf("=========================================\n"); + printf("\nALL TESTS PASSED\n"); + free(g_info); return 0; } From b2a0d545bba71c04e7e8a997d415bcf6a2f4a339 Mon Sep 17 00:00:00 2001 From: Langegen Date: Mon, 26 Oct 2026 17:32:30 +0300 Subject: [PATCH 03/13] Play direct http(s) links, stream through TorrServer, add proxy and Russian UI Direct links - Stremio streams that are plain http(s) URLs (debrid and the like) now play instead of being marked "unsupported". - The Switch's FFmpeg is built without the https protocol (only http, tls, tcp, ...), so mpv opens http:// and HLS over it itself, and an https:// URL is played as nxhttps:// through engine/httpsrc.c: curl with the app's CA bundle, 4 MiB Range requests, redirect shortcut, retries, cancel. - Player: a no-engine path (loading screen, open errors shown with their reason, ZR panel, watch-state sync); buffering and seeking already went through mpv. HLS over https is not supported yet. TorrServer (Options -> Network) - With a server address set, magnets, .torrent files from the SD card (turned into a magnet) and Jackett results are streamed by TorrServer and played as HTTP. Without a file index the server's file list picks the largest video. History keeps the torrent as the source. Proxy (Options -> Network) - HTTP or SOCKS proxy for the app's requests and https playback; hosts on the local network (RFC 1918, .local, bare names) always go direct. FFmpeg's http:// path takes an http proxy only. Russian UI - app/i18n_ru.cpp: all 332 strings (every tr() literal, the language and colour labels, engine errors). Space Grotesk has no Cyrillic, so in Russian the UI uses the system font throughout: new borealis local patch SwitchFontLoader::useBundledFont (VENDORED.md). Tests - pctest/test_httpsrc.c: range reads against a mock HTTP server (chunk boundaries, seeks, no-range servers, redirect, retry, cancel). - pctest/test_net.cpp: TorrServer URLs and file choice, local-host bypass. - 17 offline tests in all; run_tests.sh builds the three suites. Also: http::get takes a timeout; http.hpp includes ; CLAUDE.md notes the missing https protocol and the font constraint. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + CLAUDE.md | 18 +- CMakeLists.txt | 2 +- README.md | 10 +- README.ru.md | 8 +- app/browse.cpp | 65 ++- app/config.cpp | 6 + app/config.hpp | 10 + app/http.cpp | 48 +- app/http.hpp | 13 +- app/i18n.cpp | 30 +- app/i18n.hpp | 6 +- app/i18n_ru.cpp | 454 ++++++++++++++++++ app/i18n_tables.hpp | 19 + app/main.cpp | 15 + app/netutil.hpp | 68 +++ app/player.cpp | 227 ++++++++- app/player.hpp | 17 + app/settings.cpp | 88 ++++ app/torrserver.cpp | 122 +++++ app/torrserver.hpp | 44 ++ engine/httpsrc.c | 228 +++++++++ engine/httpsrc.h | 52 ++ engine/stream.c | 76 +++ engine/stream.h | 10 + library/borealis/VENDORED.md | 6 + .../borealis/platforms/switch/switch_font.hpp | 7 + .../lib/platforms/switch/switch_font.cpp | 10 +- pctest/run_tests.sh | 37 +- pctest/test_httpsrc.c | 310 ++++++++++++ pctest/test_net.cpp | 164 +++++++ 31 files changed, 2111 insertions(+), 63 deletions(-) create mode 100644 app/i18n_ru.cpp create mode 100644 app/i18n_tables.hpp create mode 100644 app/netutil.hpp create mode 100644 app/torrserver.cpp create mode 100644 app/torrserver.hpp create mode 100644 engine/httpsrc.c create mode 100644 engine/httpsrc.h create mode 100644 pctest/test_httpsrc.c create mode 100644 pctest/test_net.cpp diff --git a/.gitignore b/.gitignore index 4b3d561..ddd6e53 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ *.o /pctest/test_suite /pctest/test_suite.exe +/pctest/test_httpsrc +/pctest/test_httpsrc.exe +/pctest/test_net +/pctest/test_net.exe diff --git a/CLAUDE.md b/CLAUDE.md index b926ffc..b636cac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,15 @@ rewrites the container paths. ## Architecture - `app/` — the UI (C++, borealis): `main` (browser/tabs), `player` (mpv view + - loading screen + seek), `stremio` (API, library, artwork), `browse` (the - Stremio drill-down), `settings`, `config` (config.json) + loading screen + seek; torrents, direct http(s) links, TorrServer), `stremio` + (API, library, artwork), `browse` (the Stremio drill-down), `settings`, + `config` (config.json), `http` (curl + proxy), `torrserver`, `i18n` (+ + `i18n_ru.cpp`, one table per language) - `engine/` — the torrent engine (C): `torrentfs` (piece picking, cache, the streaming read), `peer`, `swarm`, `torrent`, `udp_tracker`, `dht`, `magnet`, - `stream` (the devoptab mpv reads through), plus vendored libutp (`utp*.cpp`) + `stream` (the mpv `stream_cb` protocols: `torrent://` and `nxhttps://`), + `httpsrc` (https read through curl range requests), plus vendored libutp + (`utp*.cpp`) - `include/` — libutp headers, paired with `engine/utp*.cpp` - `assets/` — app icon, logos, list icons (copied into the nro's romfs) - `library/borealis/` — vendored UI framework, locally patched: see its @@ -75,6 +79,14 @@ The entry point is `int main()` in `app/main.cpp`. focus parking in `StremioTab::onAuthenticated`. - The Switch outputs stereo: mpv is forced to `audio-channels=stereo` or 5.1 dialogue (centre channel) is lost. +- The portlibs **FFmpeg has no `https` protocol** (only file, http, ftp, tcp, + udp, rtmp, tls, httpproxy; `tls` is libnx's ssl service). mpv opens `http://` + and HLS over it itself; an `https://` URL is played as `nxhttps://` through + `engine/httpsrc.c` (curl, our CA bundle, Range requests). HLS over https is + therefore not supported yet. No `crypto` protocol either (AES-128 HLS). +- **Space Grotesk (the primary UI font) has no Cyrillic**: in Russian the app + turns it off before `Application::init()` (`SwitchFontLoader::useBundledFont`, + a local borealis patch) so the UI is the system font throughout. ## Debugging on device diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c02ecf..dbd140b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,7 +63,7 @@ set(ENGINE_SRC ${ENGINE_DIR}/bencode.c ${ENGINE_DIR}/torrent.c ${ENGINE_DIR}/torrentfs.c ${ENGINE_DIR}/peer.c ${ENGINE_DIR}/swarm.c ${ENGINE_DIR}/magnet.c ${ENGINE_DIR}/udp_tracker.c ${ENGINE_DIR}/dht.c ${ENGINE_DIR}/dhtclient.c - ${ENGINE_DIR}/udpdiag.c ${ENGINE_DIR}/stream.c + ${ENGINE_DIR}/udpdiag.c ${ENGINE_DIR}/stream.c ${ENGINE_DIR}/httpsrc.c ${ENGINE_DIR}/utp.cpp ${ENGINE_DIR}/utp_utils.cpp ${ENGINE_DIR}/utpbridge.cpp) # libutp needs -DPOSIX for its socket types (SOCKADDR_STORAGE etc). set_source_files_properties(${ENGINE_SRC} PROPERTIES COMPILE_DEFINITIONS "POSIX") diff --git a/README.md b/README.md index 7b459a4..057c0fa 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,11 @@ Whether you stream through **Stremio** catalogs and addons, search across dozens - 🕒 **Watch History & Resume Playback** — Dedicated **History** tab tracking your recently watched videos across all sources (Local, Jackett, Stremio). Automatically remembers your playback position so you can resume right where you left off. - 📂 **Multi-File Selection & Natural Sorting** — Smart file picker for torrents and magnets containing multiple videos with natural alphanumeric episode sorting (e.g., Episode 1, 2, ..., 10 instead of 1, 10, 2). - ⚡ **Enhanced Engine & Tracker Pooling** — Background announce worker pool supporting concurrent announces across 30+ trackers, asynchronous metadata resolution with cancelable progress dialogs, and robust DHT peer search fallback. -- 🧪 **Desktop Test Suite** — Standalone PC test suite (`pctest/`) with custom SHA-1 compatibility layer to validate the torrent engine without needing a physical Switch or devkitPro toolchain. +- 🔗 **Direct Video Links** — Stremio addon streams that are plain `http(s)` URLs (debrid services and the like) now play, with seeking. `https` goes through the app's own curl with certificate checks, since the Switch's FFmpeg has no `https` of its own. +- 🖥️ **TorrServer** — Set your TorrServer address in **Options → Network** and every torrent (Local, Jackett, Stremio) is streamed by that server and played on the console as a regular video stream. +- 🌐 **Proxy** — An HTTP or SOCKS proxy for catalogs, addons, posters and `https` video links (**Options → Network**); servers on your local network are always reached directly. +- 🇷🇺 **Russian Interface** — A full Russian translation, switchable in **Options → General → Language**. +- 🧪 **Desktop Test Suite** — Offline PC tests (`pctest/`) for the torrent engine, `https` range reads and TorrServer links, with mock trackers, peers and servers on loopback — no physical Switch, devkitPro toolchain or network needed. ### 🎬 Core Features - **True Sequential Streaming** — Starts playing almost immediately; the engine streams ahead of the playhead and automatically re-centers when you seek. @@ -162,9 +166,9 @@ docker run --rm -v st_build:/build -v "$PWD":/project \ > On Windows (using Git Bash or MSYS2), prefix docker commands with `MSYS_NO_PATHCONV=1` to prevent path conversions. ### Desktop Engine Tests (PC) -The offline test suite needs only gcc and libcurl headers (Linux, macOS, WSL or +The offline test suites need only gcc/g++ and libcurl headers (Linux, macOS, WSL or MSYS2). Trackers and the DHT are mocked and a mock peer serves the metadata on -loopback, so it needs no network; CI runs it on every pull request: +loopback, so they need no network; CI runs them on every pull request: ```sh sh pctest/run_tests.sh ``` diff --git a/README.ru.md b/README.ru.md index 80f242e..ec52a0a 100644 --- a/README.ru.md +++ b/README.ru.md @@ -39,7 +39,11 @@ - 🕒 **История просмотров и возобновление** — Вкладка **History** отслеживает просмотренные видео из всех источников (локальные, Jackett, Stremio). Запоминается прогресс и позиция, позволяя продолжить просмотр в один клик. - 📂 **Умный выбор файлов и естественная сортировка** — Удобное окно выбора файлов для раздач с несколькими сериями/файлами. Сортировка учитывает числа в названиях (серии 1, 2, ..., 10 идут по порядку, а не 1, 10, 2). - ⚡ **Улучшенный движок и опрос трекеров** — Фоновый пул потоков для одновременного анонса на более чем 30 трекерах, асинхронное получение метаданных для magnet-ссылок с возможностью отмены, а также фолбэк на DHT для бестрекерных раздач. -- 🧪 **Тестовый набор для ПК** — Автономный тестовый набор (`pctest/`) с встроенной поддержкой SHA-1 для проверки и отладки торрент-движка на компьютере без необходимости сборки под Switch. +- 🔗 **Прямые ссылки на видео** — Источники аддонов Stremio в виде обычных `http(s)`-ссылок (дебрид-сервисы и подобные) теперь воспроизводятся, с перемоткой. `https` идёт через собственный curl приложения с проверкой сертификатов: в FFmpeg для Switch своего `https` нет. +- 🖥️ **TorrServer** — Укажите адрес TorrServer в **Options → Network**, и любой торрент (локальный, из Jackett или Stremio) будет качать этот сервер, а консоль получит обычный видеопоток. +- 🌐 **Прокси** — HTTP- или SOCKS-прокси для каталогов, аддонов, постеров и `https`-ссылок на видео (**Options → Network**). К серверам в локальной сети подключение всегда напрямую. +- 🇷🇺 **Русский интерфейс** — Полный перевод на русский, включается в **Options → General → Language**. +- 🧪 **Тестовый набор для ПК** — Офлайн-тесты (`pctest/`) для торрент-движка, чтения `https` по диапазонам и ссылок TorrServer: мок-трекеры, мок-пиры и мок-серверы на loopback. Не нужны ни Switch, ни devkitPro, ни сеть. ### 🎬 Базовые возможности - **Последовательный стриминг** — Быстрый запуск воспроизведения; движок скачивает куски с опережением и мгновенно перестраивается при перемотке. @@ -161,7 +165,7 @@ docker run --rm -v st_build:/build -v "$PWD":/project \ > В среде Windows (Git Bash или MSYS2) добавляйте перед командами `MSYS_NO_PATHCONV=1`, чтобы избежать искажения путей к каталогам. ### Сборка тестов для ПК -Офлайн-тестам нужны только gcc и заголовки libcurl (Linux, macOS, WSL или +Офлайн-тестам нужны только gcc/g++ и заголовки libcurl (Linux, macOS, WSL или MSYS2). Трекеры и DHT заменены моками, метаданные отдаёт мок-пир на loopback, так что сеть не нужна. CI запускает их на каждый pull request: ```sh diff --git a/app/browse.cpp b/app/browse.cpp index cca2db7..3ca0484 100644 --- a/app/browse.cpp +++ b/app/browse.cpp @@ -105,6 +105,22 @@ constexpr const char* kPublicTrackers = "&tr=udp%3A%2F%2Fopen.demonii.com%3A1337%2Fannounce" "&tr=udp%3A%2F%2Fopen.tracker.cl%3A1337%2Fannounce"; +// What the player is handed for an addon stream: a magnet for a torrent, the +// URL itself for a direct http(s) stream (debrid, TorrServer-style addons), or +// "" when it cannot be played (a YouTube id, a link to an external page). +// +// Addons hand back a bare infoHash. The magnet loader needs trackers -- it +// announces to them to find the peers that serve the metadata -- so attach the +// usual public ones, which is what a bare infoHash relies on everywhere else. +std::string playableSource(const stremio::Stream& s) +{ + if (!s.infoHash.empty()) + return "magnet:?xt=urn:btih:" + s.infoHash + kPublicTrackers; + if (isDirectUrl(s.url)) + return s.url; + return ""; +} + // The Switch's shared system font has no colour emoji, so the pictographs // addons use as field markers render as blanks/boxes. Swap the common ones for // short labels rather than dropping the numbers they introduce. @@ -562,10 +578,13 @@ void showStreams(const stremio::Addon& addon, const std::string& type, // is this" information. Single-line, so it scrolls on focus. row.sub = deEmoji(joinSpace(titleLines)); - // Only BitTorrent sources are playable by our engine; say so up - // front rather than failing after the tap. - if (s.infoHash.empty()) + // Torrents and direct http(s) links play; anything else (a + // YouTube id, an external page) does not -- say so up front + // rather than failing after the tap. + if (playableSource(s).empty()) row.detail = "unsupported"; + else if (s.infoHash.empty()) + row.detail = "link"; rows.push_back(row); } @@ -574,21 +593,15 @@ void showStreams(const stremio::Addon& addon, const std::string& type, label, tr("Pick a source"), rows, [streams, art, label, watch](int i) { const auto& s = (*streams)[i]; - if (s.infoHash.empty()) + std::string src = playableSource(s); + if (src.empty()) { - dialog(tr("Unsupported source: only torrents (infoHash) " - "can be played for now.")); + dialog(tr("Unsupported source: only torrents and direct " + "video links can be played.")); return; } - // Addons hand back a bare infoHash. Our magnet loader - // needs trackers -- it announces to them to find the peers - // that serve the metadata, and rejects a trackerless magnet - // outright (no DHT bootstrap for metadata yet). So attach - // the usual public trackers, which is what a bare infoHash - // relies on everywhere else too. - std::string magnet = "magnet:?xt=urn:btih:" + s.infoHash + - kPublicTrackers; - brls::Logger::info("[stremio] play {}", magnet); + brls::Logger::info("[stremio] play {}", + s.infoHash.empty() ? "a direct link" : src); // At EOF, pop the player + this source list + the addon list // to land back on the library (film) or the episode list // (series). @@ -596,7 +609,9 @@ void showStreams(const stremio::Addon& addon, const std::string& type, w.endPop = 3; // The addon's file index picks the right episode inside a // season pack (or all-seasons torrent); -1 = largest file. - startPlaybackPrompt(magnet, art, label, s.fileIdx, w, "Stremio"); + startPlaybackPrompt(src, art, label, + s.infoHash.empty() ? -1 : s.fileIdx, w, + "Stremio"); }, art.posterPath)); }); @@ -1031,15 +1046,15 @@ class AddonSourcePicker : public brls::Activity std::string cardLabel = label; card->registerClickAction( [stream, cardArt, cardW, cardLabel](brls::View*) { - if (stream.infoHash.empty()) + std::string src = playableSource(stream); + if (src.empty()) { - dialog(tr("Unsupported source: only torrents (infoHash) can be " - "played for now.")); + dialog(tr("Unsupported source: only torrents and direct " + "video links can be played.")); return true; } - std::string magnet = - "magnet:?xt=urn:btih:" + stream.infoHash + kPublicTrackers; - brls::Logger::info("[stremio] play {}", magnet); + brls::Logger::info("[stremio] play {}", + stream.infoHash.empty() ? "a direct link" : src); WatchInfo w = cardW; // Player sits directly on the detail screen: pop both at EOF to // land back on the library (film) or the series screen (episode). @@ -1052,7 +1067,9 @@ class AddonSourcePicker : public brls::Activity if (lw.itemId == w.itemId && lw.progress() >= 0.0 && (w.videoId.empty() || lw.videoId == w.videoId)) w.resumeSec = resumeFrom(lw.offsetMs, lw.durationMs); - startPlaybackPrompt(magnet, cardArt, cardLabel, stream.fileIdx, w, "Stremio"); + startPlaybackPrompt(src, cardArt, cardLabel, + stream.infoHash.empty() ? -1 : stream.fileIdx, + w, "Stremio"); return true; }); card->addGestureRecognizer(new brls::TapGestureRecognizer(card)); @@ -1087,7 +1104,7 @@ class AddonSourcePicker : public brls::Activity shown++; } - if (s.infoHash.empty()) + if (playableSource(s).empty()) { auto* u = new brls::Label(); u->setText("unsupported"); diff --git a/app/config.cpp b/app/config.cpp index 6110515..2574754 100644 --- a/app/config.cpp +++ b/app/config.cpp @@ -248,6 +248,8 @@ void load() (sTab == "history") ? Tab::HISTORY : Tab::LOCAL; cfg.jackettUrl = readStr(body, "jackettUrl", cfg.jackettUrl); cfg.jackettApiKey = readStr(body, "jackettApiKey", cfg.jackettApiKey); + cfg.torrServerUrl = readStr(body, "torrServerUrl", cfg.torrServerUrl); + cfg.proxyUrl = readStr(body, "proxyUrl", cfg.proxyUrl); cfg.logging = readBool(body, "logging", cfg.logging); cfg.hide4k = readBool(body, "hide4k", cfg.hide4k); cfg.rateGovernor = readBool(body, "rateGovernor", cfg.rateGovernor); @@ -293,6 +295,8 @@ bool save() " \"startupTab\": \"%s\",\n" " \"jackettUrl\": \"%s\",\n" " \"jackettApiKey\": \"%s\",\n" + " \"torrServerUrl\": \"%s\",\n" + " \"proxyUrl\": \"%s\",\n" " \"logging\": %s,\n" " \"hide4k\": %s,\n" " \"rateGovernor\": %s,\n" @@ -313,6 +317,8 @@ bool save() sTabStr, cfg.jackettUrl.c_str(), cfg.jackettApiKey.c_str(), + cfg.torrServerUrl.c_str(), + cfg.proxyUrl.c_str(), cfg.logging ? "true" : "false", cfg.hide4k ? "true" : "false", cfg.rateGovernor ? "true" : "false", cfg.ramStream ? "true" : "false", diff --git a/app/config.hpp b/app/config.hpp index 5853746..babd49c 100644 --- a/app/config.hpp +++ b/app/config.hpp @@ -120,6 +120,16 @@ struct Config // Jackett server configuration std::string jackettUrl = ""; std::string jackettApiKey = ""; + + // TorrServer ("http://192.168.1.10:8090"). Set: torrents are streamed by + // that server and played here as plain HTTP, instead of by the on-device + // engine. Empty: the engine, as before. + std::string torrServerUrl = ""; + + // Proxy for the app's HTTP requests and https playback, as curl takes it: + // "http://host:3128", "socks5h://host:1080". Empty: direct. Addresses on + // the local network (Jackett, TorrServer) never go through it. + std::string proxyUrl = ""; }; // The live settings. Mutate, then call save(). diff --git a/app/http.cpp b/app/http.cpp index 313f545..86996b5 100644 --- a/app/http.cpp +++ b/app/http.cpp @@ -3,10 +3,15 @@ #include #include +#include #include // strncasecmp #include +#include "config.hpp" +#include "netutil.hpp" + + namespace http { @@ -16,6 +21,30 @@ static size_t writeCb(char* ptr, size_t size, size_t nmemb, void* userdata) return size * nmemb; } +// Copied from config by applyProxy(): requests run on worker threads, which +// must not read a std::string the settings screen may be assigning to. +static std::mutex g_proxyLock; +static std::string g_proxy; + +void applyProxy() +{ + std::string p = config::get().proxyUrl; + { + std::lock_guard g(g_proxyLock); + g_proxy = p; + } + // https playback (engine/httpsrc.c) takes its proxy from the player, per + // URL, through proxyFor(). + brls::Logger::info("[http] proxy {}", p.empty() ? "off" : "on"); +} + +std::string proxyFor(const std::string& url) +{ + if (netutil::isLocalHost(netutil::hostOf(url))) return ""; + std::lock_guard g(g_proxyLock); + return g_proxy; +} + // Turn certificate checking on, against the CA store we ship ourselves. The // console mounts none, so the roots ride along in the nro's romfs as // cacert.pem (Mozilla's, from curl.se/ca -- see the CMakeLists copy). @@ -25,13 +54,18 @@ static size_t writeCb(char* ptr, size_t size, size_t nmemb, void* userdata) // devoptab, so mbedtls_x509_crt_parse_file fopen()s it like any other path. // It is re-parsed on every handshake -- an ASN.1 walk over ~120 roots, no // signature maths, which is small next to the handshake it precedes. -static void verifyTls(CURL* curl) +// +// The proxy (Options -> Network) is set here too, since every request passes +// through: on for the internet, off for a host on the local network. +static void verifyTls(CURL* curl, const std::string& url) { #ifdef __SWITCH__ curl_easy_setopt(curl, CURLOPT_CAINFO, "romfs:/cacert.pem"); #endif curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + // "" turns off any proxy curl might take from the environment. + curl_easy_setopt(curl, CURLOPT_PROXY, proxyFor(url).c_str()); } // curl's own words for a rejected chain ("SSL peer certificate ... not OK") send @@ -75,7 +109,7 @@ bool postJson(const char* url, const std::string& body, std::string& resp, curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 25L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - verifyTls(curl); // the Stremio password goes through here + verifyTls(curl, url); // the Stremio password goes through here CURLcode rc = curl_easy_perform(curl); curl_slist_free_all(hdrs); @@ -94,7 +128,7 @@ bool postJson(const char* url, const std::string& body, std::string& resp, // to answer with WebP -- which stb_image (nanovg's decoder) cannot read, so the // poster silently never appeared. bool get(const std::string& url, std::string& resp, std::string& err, - const char* accept) + const char* accept, long timeoutSec) { CURL* curl = curl_easy_init(); if (!curl) @@ -115,9 +149,9 @@ bool get(const std::string& url, std::string& resp, std::string& err, curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 25L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeoutSec); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - verifyTls(curl); + verifyTls(curl, url); CURLcode rc = curl_easy_perform(curl); if (hdrs) curl_slist_free_all(hdrs); curl_easy_cleanup(curl); @@ -192,7 +226,7 @@ bool download(const std::string& url, const std::string& path, std::string& err, curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); // GitHub redirects to a CDN - verifyTls(curl); // this one lands an executable nro on the SD card + verifyTls(curl, url); // this one lands an executable nro on the SD card curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progressCb); curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx); @@ -275,7 +309,7 @@ bool downloadTorrent(const std::string& url, const std::string& path, // Only plain web redirects are followed; a magnet: one stops the transfer // and is picked up from the header below. curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, (long)(CURLPROTO_HTTP | CURLPROTO_HTTPS)); - verifyTls(curl); + verifyTls(curl, url); CURLcode rc = curl_easy_perform(curl); long code = 0; diff --git a/app/http.hpp b/app/http.hpp index c51a16f..00e4158 100644 --- a/app/http.hpp +++ b/app/http.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -16,8 +17,10 @@ namespace http // GET `url` into `resp`. False on failure, with a readable reason in `err`. // `accept` is an optional full header line ("Accept: image/jpeg"). +// `timeoutSec` bounds the whole request (TorrServer answers only once it has +// a torrent's metadata, which can take longer than the default). bool get(const std::string& url, std::string& resp, std::string& err, - const char* accept = nullptr); + const char* accept = nullptr, long timeoutSec = 25); // POST `body` as application/json. bool postJson(const char* url, const std::string& body, std::string& resp, @@ -40,4 +43,12 @@ bool downloadTorrent(const std::string& url, const std::string& path, // Percent-encodes a URL path segment. std::string urlEncode(const std::string& s); +// Re-reads the proxy from config (Options -> Network). Call after load and +// after every change. +void applyProxy(); + +// The proxy to use for `url`: the configured one, or "" for a host on the +// local network (Jackett, TorrServer, a NAS) and when none is set. +std::string proxyFor(const std::string& url); + } // namespace http diff --git a/app/i18n.cpp b/app/i18n.cpp index 4b76f0c..b04f726 100644 --- a/app/i18n.cpp +++ b/app/i18n.cpp @@ -3,6 +3,7 @@ #include #include "config.hpp" +#include "i18n_tables.hpp" namespace { @@ -428,13 +429,26 @@ namespace i18n void load() { g_lang = config::get().language; - if (g_lang != "fr") - return; + if (g_lang == "fr") + { + g_table = new std::unordered_map(); + g_table->reserve(sizeof(kFr) / sizeof(kFr[0]) * 2); + for (const auto& e : kFr) + (*g_table)[e.en] = e.fr; + } + else if (g_lang == "ru") + { + g_table = new std::unordered_map(); + g_table->reserve(i18n_tables::kRuCount * 2); + for (size_t i = 0; i < i18n_tables::kRuCount; i++) + (*g_table)[i18n_tables::kRu[i].en] = i18n_tables::kRu[i].tr; + } +} - g_table = new std::unordered_map(); - g_table->reserve(sizeof(kFr) / sizeof(kFr[0]) * 2); - for (const auto& e : kFr) - (*g_table)[e.en] = e.fr; +bool needsSystemFont() +{ + // The bundled Space Grotesk covers Latin only. + return g_lang == "ru"; } const std::string& lang() @@ -444,7 +458,7 @@ const std::string& lang() const std::vector& langIds() { - static const std::vector v = { "en", "fr" }; + static const std::vector v = { "en", "fr", "ru" }; return v; } @@ -453,7 +467,7 @@ const std::vector& langLabels() // Endonyms, and never translated: a language picker has to be readable to // someone who cannot read the language the UI is currently in. static const std::vector v = { "English (default)", - "Français" }; + "Français", "Русский" }; return v; } diff --git a/app/i18n.hpp b/app/i18n.hpp index 5f0245b..fb8a213 100644 --- a/app/i18n.hpp +++ b/app/i18n.hpp @@ -21,9 +21,13 @@ namespace i18n // config::load() and before any view exists. void load(); -// The active language, as stored in config: "en" or "fr". +// The active language, as stored in config: "en", "fr" or "ru". const std::string& lang(); +// True when the active language has letters the bundled UI font lacks +// (Cyrillic), so the UI should use the console's system font throughout. +bool needsSystemFont(); + // The offered languages: ids and labels, index-matched. Labels are endonyms // ("Francais"), which is what a language picker should show whatever the UI is // currently in. diff --git a/app/i18n_ru.cpp b/app/i18n_ru.cpp new file mode 100644 index 0000000..62d54f9 --- /dev/null +++ b/app/i18n_ru.cpp @@ -0,0 +1,454 @@ +// Russian UI strings, keyed on the English source string exactly as written at +// the tr() call site (see i18n.hpp). A string missing here shows in English. +// +// Kept complete with a check over the sources: every literal passed to tr(), +// plus the labels translated through a variable (languages, accent colours) +// and the engine's error messages. Trailing spaces are part of a key: those +// strings are concatenated with a value after them. +// +// Style: short, plain words for a TV screen; "серия" for an episode, "магнет" +// for a magnet link, "аддон" as Stremio's own Russian UI says. + +#include "i18n_tables.hpp" + +namespace i18n_tables +{ + +const Pair kRu[] = { + // ---- browse: addons, sources, episodes -------------------------------- + { "Addons...", "Аддоны..." }, + { "Addons unavailable: ", "Аддоны недоступны: " }, + { "Addons unavailable", "Аддоны недоступны" }, + { "No addon provides a source for this title.", + "Ни один аддон не даёт источников для этого названия." }, + { "No addon provides a source for this title", + "Ни один аддон не даёт источников для этого названия" }, + { "Pick an addon", "Выберите аддон" }, + { "Sources...", "Источники..." }, + { "Sources unavailable: ", "Источники недоступны: " }, + { "Sources unavailable", "Источники недоступны" }, + { "This addon has no source for this title.", + "У этого аддона нет источников для этого названия." }, + { "This addon only offers 4K sources, which are hidden in Options.", + "Этот аддон предлагает только 4K-источники, а они скрыты в настройках." }, + { "Source", "Источник" }, + { "Pick a source", "Выберите источник" }, + { "Unsupported source: only torrents and direct video links can be played.", + "Источник не поддерживается: воспроизводятся только торренты и прямые " + "ссылки на видео." }, + { "Unsupported source: only torrents (infoHash) can be played for now.", + "Источник не поддерживается: пока воспроизводятся только торренты." }, + { "Episode ", "Серия " }, + { " - Season ", " — сезон " }, + { "Season ", "Сезон " }, + { "Pick an episode", "Выберите серию" }, + { "No source from this addon", "Нет источников от этого аддона" }, + { "Loading sources...", "Загрузка источников..." }, + { "Remove from library", "Убрать из библиотеки" }, + { "Add to library", "Добавить в библиотеку" }, + { "Could not add it to your library.", "Не удалось добавить в библиотеку." }, + { "Could not remove it from your library.", "Не удалось убрать из библиотеки." }, + { "Show", "Сериал" }, + { "Film", "Фильм" }, + { "Movie", "Фильм" }, + { "Loading...", "Загрузка..." }, + { "Previous episode", "Предыдущая серия" }, + { "Next episode", "Следующая серия" }, + { "No episodes found", "Серии не найдены" }, + { "Episodes unavailable", "Серии недоступны" }, + { "No episodes", "Нет серий" }, + { "Specials", "Спецвыпуски" }, + { "Next episode...", "Следующая серия..." }, + { "Could not open that episode.", "Не удалось открыть эту серию." }, + { "Episode", "Серия" }, + + // ---- history ------------------------------------------------------------ + { "History", "История" }, + { "Watch History", "История просмотров" }, + { "No watch history yet", "История просмотров пока пуста" }, + { "Completed", "Просмотрено" }, + { "Watched", "Просмотрено" }, + { "Remove", "Удалить" }, + { "Remove this item from watch history?", "Удалить эту запись из истории?" }, + { "Cancel", "Отмена" }, + { "Clear all history", "Очистить всю историю" }, + { "Are you sure you want to clear your entire watch history?", + "Точно очистить всю историю просмотров?" }, + { "Clear", "Очистить" }, + { "Resume playback?", "Продолжить просмотр?" }, + { "Resume", "Продолжить" }, + { "Start over", "Сначала" }, + + // ---- jackett ------------------------------------------------------------ + { "Jackett is not configured. Set URL in Options.", + "Jackett не настроен. Укажите адрес в настройках." }, + { "Invalid response from Jackett server", "Некорректный ответ сервера Jackett" }, + { "Invalid response from server. Check URL and API key.", + "Некорректный ответ сервера. Проверьте адрес и ключ API." }, + { "Saved magnet link to Local library", "Магнет сохранён в локальную библиотеку" }, + { "Failed to save magnet link", "Не удалось сохранить магнет" }, + { "Saved to Local library", "Сохранено в локальную библиотеку" }, + { "Failed to save torrent", "Не удалось сохранить торрент" }, + { "No playable link or magnet URI found for this torrent.", + "У этой раздачи нет ни ссылки, ни магнета для воспроизведения." }, + { "Search Jackett...", "Поиск в Jackett..." }, + { "All", "Все" }, + { "Movies", "Фильмы" }, + { "Series", "Сериалы" }, + { "Anime", "Аниме" }, + { "Sort: Seeders", "Сорт.: сиды" }, + { "Sort: Size", "Сорт.: размер" }, + { "Sort: Date", "Сорт.: дата" }, + { "Search Jackett", "Поиск в Jackett" }, + { "Jackett API Key", "Ключ API Jackett" }, + { "Jackett API Key (optional)", "Ключ API Jackett (необязательно)" }, + { "Leave empty if disabled in Jackett", "Оставьте пустым, если в Jackett он отключён" }, + { "Jackett Server URL", "Адрес сервера Jackett" }, + { "e.g. http://192.168.1.100:9117", "например, http://192.168.1.100:9117" }, + { "Jackett is not configured", "Jackett не настроен" }, + { "Set your Jackett Server URL in Options, or click below to configure now.", + "Укажите адрес сервера Jackett в настройках или нажмите кнопку ниже." }, + { "Set your Jackett Server URL and API Key in Options, or click below to " + "configure now.", + "Укажите адрес сервера и ключ API Jackett в настройках или нажмите кнопку ниже." }, + { "Please set both Server URL and API Key first.", + "Сначала укажите адрес сервера и ключ API." }, + { "Configure Jackett", "Настроить Jackett" }, + { "Enter a search query to search torrents via Jackett", + "Введите запрос, чтобы искать торренты через Jackett" }, + { "Searching Jackett...", "Поиск в Jackett..." }, + { "Could not search Jackett", "Не удалось выполнить поиск в Jackett" }, + { "Retry", "Повторить" }, + { "No results found", "Ничего не найдено" }, + { "No torrents matched \"", "Не найдено торрентов по запросу \"" }, + { "Save to Local", "В локальные" }, + { "This torrent has no video file to stream.", "В этой раздаче нет видеофайла." }, + { "Pick a file", "Выберите файл" }, + { "Could not resolve torrent metadata: ", "Не удалось получить метаданные торрента: " }, + { "Preparing torrent...", "Подготовка торрента..." }, + { "Could not download torrent: ", "Не удалось скачать торрент: " }, + + // ---- local tab ---------------------------------------------------------- + { "Magnet ", "Магнет " }, + { "Magnet", "Магнет" }, + { "Still fetching this magnet's files -- please wait.", + "Список файлов магнета ещё загружается — подождите." }, + { " (waiting)", " (ожидание)" }, + { "Magnet link or info hash", "Магнет-ссылка или инфо-хэш" }, + { "magnet:?xt=... or a 40-character hash", "magnet:?xt=... или хэш из 40 символов" }, + { "That does not look like a magnet link or an info hash.", + "Это не похоже на магнет-ссылку или инфо-хэш." }, + { "No torrents found", "Торренты не найдены" }, + { "Drop .torrent files in this folder on your SD card, or add magnet links " + "to magnet.txt inside it (one per line):", + "Положите файлы .torrent в эту папку на SD-карте или добавьте " + "магнет-ссылки в magnet.txt внутри неё (по одной на строку):" }, + { "Delete", "Удалить" }, + { "Remove this magnet from the list?", "Убрать этот магнет из списка?" }, + { "Delete this .torrent file from the SD card?", + "Удалить этот файл .torrent с SD-карты?" }, + { "+ Add magnet", "+ Добавить магнет" }, + { "Back", "Назад" }, + { "Local", "Локальные" }, + { "View", "Вид" }, + + // ---- player ------------------------------------------------------------- + { "Normal", "Обычная" }, + { "Torrent", "Торрент" }, + { "Connecting...", "Подключение..." }, + { "Player initialisation failed", "Не удалось запустить плеер" }, + { "Waiting for TorrServer...", "Ожидание TorrServer..." }, + { "Failed: ", "Ошибка: " }, + { "Fetching metadata...", "Получение метаданных..." }, + { "Opening torrent...", "Открытие торрента..." }, + { "Lock", "Блокировка" }, + { "Pause", "Пауза" }, + { "Seek -", "Назад" }, + { "Seek +", "Вперёд" }, + { "Slower", "Медленнее" }, + { "Faster", "Быстрее" }, + { "Info", "Инфо" }, + { "Options", "Настройки" }, + { "None", "Нет" }, + { "Subtitle", "Субтитры" }, + { "Downloading ", "Загрузка: " }, + { "Subtitle download failed", "Не удалось скачать субтитры" }, + { "Subtitle could not be loaded", "Не удалось загрузить субтитры" }, + { " loaded", " — загружено" }, + { "Subtitles ", "Субтитры " }, + { "Speed ", "Скорость " }, + { "Up next", "Далее" }, + { "Track ", "Дорожка " }, + { "Off", "Выкл." }, + { "Close", "Закрыть" }, + { "Playback", "Воспроизведение" }, + { "AUDIO", "АУДИО" }, + { "Track", "Дорожка" }, + { "Speed", "Скорость" }, + { "SUBTITLES", "СУБТИТРЫ" }, + { "Unknown", "Неизвестно" }, + { "Searching addons...", "Поиск в аддонах..." }, + { "Subtitles", "Субтитры" }, + { "Subtitle size", "Размер субтитров" }, + { "Subtitle delay", "Задержка субтитров" }, + { "L / R shift the subtitles by 0.1 s. Later is positive. The video keeps " + "playing behind this.", + "L / R сдвигают субтитры на 0,1 с. Плюс — позже. Видео при этом " + "продолжает идти." }, + { "Subtitles earlier", "Субтитры раньше" }, + { "Subtitles later", "Субтитры позже" }, + { "Connecting to peers...", "Подключение к пирам..." }, + { "Press B to go back", "Нажмите B, чтобы вернуться" }, + { "Buffering...", "Буферизация..." }, + { "Searching DHT for peers...", "Поиск пиров в DHT..." }, + { "Announcing to trackers...", "Опрос трекеров..." }, + { "Downloading header...", "Загрузка заголовка..." }, + { "NEEDED", "НУЖЕН" }, + { "INFLIGHT", "В ПУТИ" }, + { "DONE", "ГОТОВ" }, + { "VERIFYING", "ПРОВЕРКА" }, + + // ---- engine errors (shown after "Failed: ") ---------------------------- + { "no peers found (torrent has no active seeds on trackers or DHT)", + "пиры не найдены (у торрента нет активных сидов ни на трекерах, ни в DHT)" }, + { "no connectable peers: all peers timed out or refused connection (no " + "active seeds)", + "не удалось подключиться ни к одному пиру: все не ответили или отказали " + "(нет активных сидов)" }, + { "peers connected but refused to share metadata", + "пиры подключились, но не отдали метаданные" }, + + // ---- options: general ------------------------------------------------- + { " (default)", " (по умолчанию)" }, + { "Category on startup", "Раздел при запуске" }, + { "Language", "Язык" }, + { "The language applies when you restart the app.", + "Язык сменится после перезапуска приложения." }, + { "Theme", "Тема" }, + { "Dark (default)", "Тёмная (по умолчанию)" }, + { "Light", "Светлая" }, + { "Follow the console", "Как на консоли" }, + { "The theme applies when you restart the app.", + "Тема сменится после перезапуска приложения." }, + { "Accent colour", "Акцентный цвет" }, + { "List style", "Вид списков" }, + { "Posters (default)", "Постеры (по умолчанию)" }, + { "Cards", "Карточки" }, + { "Classic", "Классический" }, + { "UI size", "Размер интерфейса" }, + { "Docked", "В доке" }, + { "Handheld", "Портативный" }, + { "Purple", "Фиолетовый" }, + { "Blue", "Синий" }, + { "Teal", "Бирюзовый" }, + { "Green", "Зелёный" }, + { "Orange", "Оранжевый" }, + { "Red", "Красный" }, + { "Pink", "Розовый" }, + + // ---- options: playback ------------------------------------------------ + { "Audio language", "Язык аудио" }, + { "Subtitle language", "Язык субтитров" }, + { "Console language", "Язык консоли" }, + { "English", "Английский" }, + { "French", "Французский" }, + { "Spanish", "Испанский" }, + { "German", "Немецкий" }, + { "Italian", "Итальянский" }, + { "Portuguese", "Португальский" }, + { "Dutch", "Нидерландский" }, + { "Russian", "Русский" }, + { "Japanese", "Японский" }, + { "Korean", "Корейский" }, + { "Chinese", "Китайский" }, + { "Boost quiet audio in handheld", "Усиливать тихий звук в портативном режиме" }, + { "On by default. Lifts a 5.1 mix folded down to stereo, which plays much " + "quieter.", + "Включено по умолчанию. Поднимает громкость 5.1, сведённого в стерео: " + "так он звучит намного тише." }, + { "Hardware decoding", "Аппаратное декодирование" }, + { "On by default. Off decodes in software: slower, may stutter on 1080p.", + "Включено по умолчанию. Если выключить, видео декодируется программно: " + "медленнее, 1080p может подтормаживать." }, + + // ---- options: streaming ----------------------------------------------- + { "Stream to RAM (no SD cache)", "Буфер в ОЗУ (без кэша на SD)" }, + { "Off (Not recommended)", "Выкл. (не рекомендуется)" }, + { "On (Recommended)", "Вкл. (рекомендуется)" }, + { "Keep pieces in memory instead of writing them to the SD card. Removes the " + "stutter on each finished piece, at the cost of no resume and a limited " + "seek-back range.", + "Хранить куски в памяти, а не на SD-карте. Убирает подтормаживания после " + "каждого куска, но тогда нельзя продолжить с места и далеко перемотать " + "назад." }, + { "Limit download rate", "Ограничивать скорость загрузки" }, + { "Off (default)", "Выкл. (по умолчанию)" }, + { "On", "Вкл." }, + { "Once the buffer is comfortably ahead, cap the download speed instead of " + "bursting \xE2\x80\x94 the bursts can stutter the system. Off by default.", + "Когда буфер с запасом, ограничивать скорость вместо рывков — рывки " + "могут подтормаживать систему. По умолчанию выключено." }, + { "Hide 4K sources", "Скрывать 4K-источники" }, + { "Poster cache", "Кэш постеров" }, + { "Reading...", "Чтение..." }, + { " on the SD card", " на SD-карте" }, + { "Clear poster cache", "Очистить кэш постеров" }, + { "Clearing...", "Очистка..." }, + { "Poster cache cleared. The library reloads when you go back to it.", + "Кэш постеров очищен. Библиотека загрузится заново, когда вы к ней " + "вернётесь." }, + + // ---- options: jackett, network ---------------------------------------- + { "Server URL", "Адрес сервера" }, + { "Not set", "Не задан" }, + { "The URL of your Jackett server including port (e.g. " + "http://192.168.1.100:9117).", + "Адрес вашего сервера Jackett с портом (например, " + "http://192.168.1.100:9117)." }, + { "API Key", "Ключ API" }, + { "The API Key shown in the top right corner of the Jackett web interface " + "(leave empty if disabled).", + "Ключ API из правого верхнего угла веб-интерфейса Jackett (оставьте " + "пустым, если он отключён)." }, + { "The API Key shown in the top right corner of the Jackett web interface.", + "Ключ API из правого верхнего угла веб-интерфейса Jackett." }, + { "Test connection", "Проверить подключение" }, + { "Please set Server URL first.", "Сначала укажите адрес сервера." }, + { "Testing...", "Проверка..." }, + { "Connection successful!", "Подключение успешно!" }, + { "Connection failed: ", "Не удалось подключиться: " }, + { "TorrServer URL", "Адрес TorrServer" }, + { "e.g. http://192.168.1.10:8090", "например, http://192.168.1.10:8090" }, + { "When set, torrents are streamed by your TorrServer and played here as a " + "plain video stream, instead of being downloaded by the console. Leave " + "empty to use the built-in engine.", + "Если адрес указан, торренты качает ваш TorrServer, а сюда приходит " + "обычный видеопоток — консоль сама их не загружает. Оставьте пустым, " + "чтобы использовать встроенный движок." }, + { "Connected to TorrServer ", "Подключено к TorrServer " }, + { "Proxy", "Прокси" }, + { "e.g. http://host:3128 or socks5h://host:1080", + "например, http://host:3128 или socks5h://host:1080" }, + { "Used for catalogs, addons, posters and https video links. Servers on your " + "local network (Jackett, TorrServer) are always reached directly.", + "Для каталогов, аддонов, постеров и https-ссылок на видео. К серверам в " + "локальной сети (Jackett, TorrServer) подключение всегда напрямую." }, + + // ---- options: about ----------------------------------------------------- + { "Version ", "Версия " }, + { " \xE2\x80\x94 an update is installed, restart to use it", + " — обновление установлено, перезапустите приложение" }, + { "Check for updates on startup", "Проверять обновления при запуске" }, + { "View changelog", "Список изменений" }, + { "Check now", "Проверить сейчас" }, + { "Checking...", "Проверка..." }, + { "Could not check for updates: ", "Не удалось проверить обновления: " }, + { "You are on the latest version (", "У вас последняя версия (" }, + { " is out, but that release has no .nro to install. Get it from GitHub.", + " вышла, но в релизе нет .nro для установки. Скачайте её с GitHub." }, + { "Diagnostics", "Диагностика" }, + { "Log file", "Файл журнала" }, + { "The log is written to the SD card continuously. Turn it on to diagnose a " + "problem, then restart the app.", + "Журнал постоянно пишется на SD-карту. Включите его, чтобы разобраться в " + "проблеме, и перезапустите приложение." }, + { "General", "Общие" }, + { "Streaming", "Стриминг" }, + { "Network", "Сеть" }, + { "About", "О программе" }, + + // ---- account ------------------------------------------------------------ + { "Not signed in", "Вход не выполнен" }, + { "The sign-in form is on the Stremio tab.", "Форма входа — на вкладке Stremio." }, + { "Account", "Аккаунт" }, + { "this console", "эта консоль" }, + { "Signed in to Stremio", "Вход в Stremio выполнен" }, + { "In library", "В библиотеке" }, + { "Addons", "Аддоны" }, + { "Installed addons", "Установленные аддоны" }, + { "Reading the account's addons...", "Чтение аддонов аккаунта..." }, + { "Could not read them: ", "Не удалось прочитать: " }, + { "Metadata", "Метаданные" }, + { "Streams (disabled)", "Потоки (отключены)" }, + { "Streams", "Потоки" }, + { "Nothing this app uses", "Ничего из того, что использует приложение" }, + { "None. Install them from Stremio on another device.", + "Нет. Установите их в Stremio на другом устройстве." }, + { "Sign out of Stremio", "Выйти из Stremio" }, + { "Signed out. Restart the app to get back to the sign-in screen.", + "Вы вышли. Перезапустите приложение, чтобы снова увидеть экран входа." }, + + // ---- stremio -------------------------------------------------------------- + { "Continue", "Продолжить" }, + { "Shows", "Сериалы" }, + { "Library", "Библиотека" }, + { "Search", "Поиск" }, + { "Wrong email or password", "Неверный e-mail или пароль" }, + { "Unexpected answer from the addon", "Неожиданный ответ аддона" }, + { "Not a Stremio playback", "Это воспроизведение не из Stremio" }, + { "Sign in to Stremio", "Вход в Stremio" }, + { "Your library, your addons and their sources.", + "Ваша библиотека, аддоны и их источники." }, + { "EMAIL", "E-MAIL" }, + { "Not entered", "Не введён" }, + { "PASSWORD", "ПАРОЛЬ" }, + { "Sign in", "Войти" }, + { "Reload", "Обновить" }, + { "Stremio email", "E-mail Stremio" }, + { "Stremio password", "Пароль Stremio" }, + { "Enter an email and a password.", "Введите e-mail и пароль." }, + { "Signing in...", "Вход..." }, + { "Sign-in failed: ", "Не удалось войти: " }, + { "Signed in", "Вход выполнен" }, + { "Loading library...", "Загрузка библиотеки..." }, + { "Error", "Ошибка" }, + { "Library unavailable: ", "Библиотека недоступна: " }, + { "Continue Watching", "Продолжить просмотр" }, + { "Continue watching", "Продолжить просмотр" }, + { "Nothing in progress", "Нечего продолжать" }, + { " items", " шт." }, + { "Library is empty", "Библиотека пуста" }, + { "Popular Movies", "Популярные фильмы" }, + { "No popular movies", "Нет популярных фильмов" }, + { "Popular Shows", "Популярные сериалы" }, + { "No popular shows", "Нет популярных сериалов" }, + { "Search movies & shows...", "Поиск фильмов и сериалов..." }, + { "Searching...", "Поиск..." }, + { "No results for \"", "Ничего не найдено по запросу \"" }, + { "Search Stremio", "Поиск в Stremio" }, + { "This catalogue is unavailable: ", "Каталог недоступен: " }, + { "Popular", "Популярное" }, + { "See More", "Ещё" }, + { "Nothing here", "Здесь пусто" }, + { "Catalog unavailable", "Каталог недоступен" }, + { "Year", "Год" }, + { "Genre", "Жанр" }, + { "Featured", "Подборка" }, + { " – present", " – н. в." }, + { "Could not remove it", "Не удалось удалить" }, + + // ---- updater ------------------------------------------------------------ + { "unknown install path", "путь установки неизвестен" }, + { "no release found", "релиз не найден" }, + { "no changelog for this version", "для этой версии нет списка изменений" }, + { "Changelog \xE2\x80\x94 ", "Изменения — " }, + { "nothing to download", "нечего скачивать" }, + { "Loading changelog...", "Загрузка списка изменений..." }, + { "Could not load the changelog: ", "Не удалось загрузить список изменений: " }, + { " is available.", " доступна." }, + { "Later", "Позже" }, + { "Update", "Обновить" }, + { "Downloading... 0%", "Загрузка... 0%" }, + { "Downloading...", "Загрузка..." }, + { "Downloading... ", "Загрузка... " }, + { "Update failed: ", "Ошибка обновления: " }, + { "Update installed. The app will restart to use it.", + "Обновление установлено. Приложение перезапустится." }, + { "Restart", "Перезапустить" }, + { "Close and reopen the app to use the new version.", + "Закройте и снова откройте приложение, чтобы запустить новую версию." }, +}; + +const size_t kRuCount = sizeof(kRu) / sizeof(kRu[0]); + +} // namespace i18n_tables diff --git a/app/i18n_tables.hpp b/app/i18n_tables.hpp new file mode 100644 index 0000000..e5c6989 --- /dev/null +++ b/app/i18n_tables.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +// Translation tables kept in their own files (i18n_ru.cpp, ...), so a language +// is one self-contained list. See i18n.hpp for how they are used. +namespace i18n_tables +{ + +struct Pair +{ + const char* en; // the English source string, as written at the tr() call + const char* tr; // its translation +}; + +extern const Pair kRu[]; +extern const size_t kRuCount; + +} // namespace i18n_tables diff --git a/app/main.cpp b/app/main.cpp index 93b00c0..4f1f3bf 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -21,6 +21,9 @@ #include #include +#ifdef __SWITCH__ +#include +#endif #include #include #include @@ -37,12 +40,14 @@ #include "theme.hpp" #include "update.hpp" #include "stremio.hpp" +#include "http.hpp" #include "jackett.hpp" #include "jackett_tab.hpp" #include "history.hpp" #include "history_tab.hpp" extern "C" { +#include "httpsrc.h" #include "torrent.h" #include "torrentfs.h" } @@ -1741,8 +1746,12 @@ int main(int argc, char* argv[]) } ensureAppDataDir(); + // https:// playback verifies against the same bundle as the app's own + // requests (app/http.cpp): the console mounts no CA store. + httpsrc_set_ca("romfs:/cacert.pem"); config::load(); + http::applyProxy(); history::load(); // Now, while nothing can be playing: a file fetched for the result being // played is only referenced once the player records it. @@ -1807,6 +1816,12 @@ int main(int argc, char* argv[]) // (Stremio login) without ever being initialised. curl_global_init(CURL_GLOBAL_DEFAULT); +#ifdef __SWITCH__ + // Space Grotesk has no Cyrillic: in Russian the whole UI uses the system + // font (which has it) rather than mixing two typefaces in every label. + brls::SwitchFontLoader::useBundledFont = !i18n::needsSystemFont(); +#endif + if (!brls::Application::init()) { brls::Logger::error("Unable to init borealis"); diff --git a/app/netutil.hpp b/app/netutil.hpp new file mode 100644 index 0000000..6e3e223 --- /dev/null +++ b/app/netutil.hpp @@ -0,0 +1,68 @@ +#pragma once + +// Small URL helpers, header-only so the PC tests can use them as is. + +#include +#include +#include + +namespace netutil +{ + +// "https://user:pw@Host.Example:8090/a?b" -> "host.example". Lower-cased, no +// port, no credentials; IPv6 brackets dropped. +inline std::string hostOf(const std::string& url) +{ + size_t b = url.find("://"); + b = (b == std::string::npos) ? 0 : b + 3; + size_t e = url.find_first_of("/?#", b); + std::string h = url.substr(b, e == std::string::npos ? std::string::npos : e - b); + size_t at = h.rfind('@'); + if (at != std::string::npos) h = h.substr(at + 1); + if (!h.empty() && h[0] == '[') + { + size_t close = h.find(']'); + h = h.substr(1, close == std::string::npos ? std::string::npos : close - 1); + } + else + { + size_t colon = h.find(':'); + if (colon != std::string::npos) h = h.substr(0, colon); + } + for (char& c : h) + if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + return h; +} + +// A host on this machine or the local network: loopback, the RFC 1918 ranges, +// link-local, and the names a home network hands out (no dot, .local, .lan, +// .home, .internal). A proxy can never reach those, so requests to them go +// direct. +inline bool isLocalHost(const std::string& host) +{ + if (host.empty()) return false; + if (host == "localhost" || host == "::1") return true; + if (host.find('.') == std::string::npos && host.find(':') == std::string::npos) + return true; // a bare name: "nas", "raspberrypi" + auto endsWith = [&](const char* s) { + std::string x = s; + return host.size() > x.size() && + host.compare(host.size() - x.size(), x.size(), x) == 0; + }; + if (endsWith(".local") || endsWith(".lan") || endsWith(".home") || + endsWith(".internal") || endsWith(".localhost")) + return true; + + // Dotted IPv4. + int o[4]; + char tail; + if (std::sscanf(host.c_str(), "%d.%d.%d.%d%c", &o[0], &o[1], &o[2], &o[3], &tail) != 4) + return false; + for (int v : o) + if (v < 0 || v > 255) return false; + return o[0] == 127 || o[0] == 10 || (o[0] == 192 && o[1] == 168) || + (o[0] == 172 && o[1] >= 16 && o[1] <= 31) || + (o[0] == 169 && o[1] == 254); +} + +} // namespace netutil diff --git a/app/player.cpp b/app/player.cpp index 0beb063..efd95c0 100644 --- a/app/player.cpp +++ b/app/player.cpp @@ -24,11 +24,14 @@ #include "browse.hpp" // openEpisodeById, for the next-episode card #include "config.hpp" #include "history.hpp" +#include "http.hpp" +#include "torrserver.hpp" #include "stremio.hpp" // cached artwork -> blurred background #include "theme.hpp" // muted text for the in-menu hints extern "C" { #include "torrentfs.h" +#include "httpsrc.h" #include "stream.h" #include "torrent.h" // magnet metadata-fetch progress counters } @@ -201,6 +204,59 @@ std::string fmtTime(double s) return buf; } +bool isDirectUrl(const std::string& source) +{ + if (source.rfind("http://", 0) != 0 && source.rfind("https://", 0) != 0) + return false; + std::string path = source.substr(0, source.find_first_of("?#")); + return !(path.size() >= 8 && + path.compare(path.size() - 8, 8, ".torrent") == 0); +} + +namespace +{ +// "https://host:8090/a/b/Film.2024.mkv?x=1" -> "host:8090". For the loading +// screen and the ZR panel: where the bytes come from, without the token-laden +// query a debrid link carries. +std::string urlHost(const std::string& url) +{ + size_t b = url.find("://"); + b = (b == std::string::npos) ? 0 : b + 3; + size_t e = url.find_first_of("/?#", b); + return url.substr(b, e == std::string::npos ? std::string::npos : e - b); +} + +// What TorrServer is given for a source: a magnet or an http link to a +// .torrent as is; a .torrent on the SD card -- which the server cannot reach -- +// as a magnet built from its info hash and trackers. "" on failure (err set). +std::string torrServerLink(const std::string& source, std::string& err) +{ + if (source.rfind("magnet:", 0) == 0 || source.rfind("http://", 0) == 0 || + source.rfind("https://", 0) == 0) + return source; + + torrent_meta t; + char e[256] = { 0 }; + if (torrent_load(&t, source.c_str(), e, sizeof(e)) != 0) + { + err = e; + return ""; + } + static const char* hex = "0123456789abcdef"; + std::string m = "magnet:?xt=urn:btih:"; + for (int i = 0; i < 20; i++) + { + m += hex[t.info_hash[i] >> 4]; + m += hex[t.info_hash[i] & 0xF]; + } + if (t.name[0]) m += "&dn=" + http::urlEncode(t.name); + for (int i = 0; i < t.tracker_count && i < 12; i++) + m += "&tr=" + http::urlEncode(t.trackers[i]); + torrent_unload(&t); + return m; +} +} // namespace + MpvView::MpvView(const std::string& source, const PlayerArt& art, const std::string& titleOverride, int fileIndex, WatchInfo watchInfo, const std::string& sourceType) @@ -211,10 +267,17 @@ MpvView::MpvView(const std::string& source, const PlayerArt& art, , currentTitle(titleOverride) , currentSourceType(sourceType) { + direct = isDirectUrl(source); + if (direct) + playUrl = source; + else + viaTorrServer = !torrserver::base().empty(); if (currentSourceType.empty()) { if (!this->watch.itemId.empty()) currentSourceType = "Stremio"; + else if (direct) + currentSourceType = "Link"; else if (source.find(".torrent") != std::string::npos) currentSourceType = "Torrent"; else if (source.rfind("magnet:", 0) == 0) @@ -256,6 +319,8 @@ MpvView::MpvView(const std::string& source, const PlayerArt& art, } else { + // A URL's query is a token, not a name. + if (direct) title = title.substr(0, title.find_first_of("?#")); size_t slash = title.find_last_of("/\\"); if (slash != std::string::npos) title = title.substr(slash + 1); if (title.size() > 8 && @@ -278,6 +343,50 @@ MpvView::MpvView(const std::string& source, const PlayerArt& art, // it inline froze the whole app on a black screen until it finished or failed. void MpvView::startEngine(const std::string& source, int fileIndex) { + // A URL needs no engine: mpv opens it itself (the connect happens on mpv's + // own thread, inside loadfile). + if (direct) + { + if (statusLabel) statusLabel->setText(tr("Connecting...")); + if (!startMpv() && statusLabel) + statusLabel->setText(tr("Player initialisation failed")); + return; + } + + // TorrServer streams it: ask the server for the file's URL (for a + // magnet without a file index it first waits for the metadata), then + // play that like any other link. + if (viaTorrServer) + { + if (statusLabel) statusLabel->setText(tr("Waiting for TorrServer...")); + auto liveFlag = this->alive; + std::string base = torrserver::base(); + brls::async([this, liveFlag, source, fileIndex, base]() { + std::string url, err; + std::string link = torrServerLink(source, err); + bool ok = !link.empty() && + torrserver::resolve(base, link, fileIndex, url, err); + brls::sync([this, liveFlag, ok, url, err]() { + if (!*liveFlag) return; + if (!ok) + { + brls::Logger::error("[torrserver] {}", err); + if (statusLabel) + statusLabel->setText(std::string(tr("Failed: ")) + err); + openFailed = true; + return; + } + brls::Logger::info("[torrserver] playing through {}", urlHost(url)); + playUrl = url; + direct = true; + if (statusLabel) statusLabel->setText(tr("Connecting...")); + if (!startMpv() && statusLabel) + statusLabel->setText(tr("Player initialisation failed")); + }); + }); + return; + } + if (statusLabel) statusLabel->setText(source.rfind("magnet:", 0) == 0 ? tr("Fetching metadata...") @@ -422,6 +531,10 @@ bool MpvView::startMpv() // (black frame). This is what the other Switch mpv players set too. mpv_set_option_string(mpv, "opengl-glfinish", "yes"); mpv_set_option_string(mpv, "vd-lavc-dr", "yes"); + // A dead host should fail the open with a reason, not hang on the + // loading screen (FFmpeg's http has no timeout of its own by default). + if (direct) + mpv_set_option_string(mpv, "network-timeout", "30"); if (mpv_initialize(mpv) < 0) { @@ -468,6 +581,35 @@ bool MpvView::startMpv() mpv_observe_property(mpv, 0, "time-pos", MPV_FORMAT_DOUBLE); mpv_observe_property(mpv, 0, "duration", MPV_FORMAT_DOUBLE); + if (direct) + { + mpv_observe_property(mpv, 0, "cache-speed", MPV_FORMAT_DOUBLE); + // http:// (and HLS over it) is FFmpeg's own; https:// is not compiled + // into this FFmpeg, so it goes through curl as nxhttps://. + std::string url = playUrl; + std::string proxy = http::proxyFor(url); // "" for a local server + if (url.rfind("https://", 0) == 0) + { + httpsrc_set_proxy(proxy.c_str()); + stream_register_http(mpv); + url = "nx" + url; + } + else if (proxy.rfind("http://", 0) == 0) + { + // FFmpeg's http speaks to an http proxy only, not socks. + mpv_set_property_string(mpv, "http-proxy", proxy.c_str()); + } + else if (!proxy.empty()) + { + brls::Logger::warning("[player] a socks proxy cannot carry an http:// " + "stream; connecting directly"); + } + const char* cmd[] = { "loadfile", url.c_str(), nullptr }; + mpv_command(mpv, cmd); + brls::Logger::info("loadfile direct stream from {}", urlHost(playUrl)); + return true; + } + // Wire our torrent:// stream (source/stream.c) and start playback. stream_register(mpv, tfs); const char* cmd[] = { "loadfile", "torrent://stream", nullptr }; @@ -1479,6 +1621,19 @@ void MpvView::pumpEvents() // Played to the end (not stop/error): close the player and // return. B is handled separately and pops only one level. auto* ef = (mpv_event_end_file*)ev->data; + // The open itself failed (a direct URL that is dead, refused, + // or not a video). Say why on the loading screen; B leaves. + if (ef && ef->reason == MPV_END_FILE_REASON_ERROR && !fileLoaded && + statusLabel) + { + std::string why = stream_http_err[0] ? stream_http_err + : mpv_error_string(ef->error); + brls::Logger::error("[mpv event] open failed: {}", why); + statusLabel->setText(std::string(tr("Failed: ")) + why); + if (statsLabel) statsLabel->setText(""); + openFailed = true; + break; + } if (ef && ef->reason == MPV_END_FILE_REASON_EOF && !ended) { ended = true; @@ -1521,6 +1676,8 @@ void MpvView::pumpEvents() obsPos = v; else if (std::strcmp(p->name, "duration") == 0) obsDur = v; + else if (std::strcmp(p->name, "cache-speed") == 0) + obsCacheSpeed = v; } else if (p->format == MPV_FORMAT_FLAG && std::strcmp(p->name, "demuxer-cache-idle") == 0) @@ -2117,14 +2274,18 @@ void MpvView::buildLoadingOverlay(const std::string& title) void MpvView::updateLoadingOverlay() { - if (!loadingOverlay) + if (!loadingOverlay || openFailed) + return; + // Still asking TorrServer: the status line already says so, and the + // engine's metadata counters below belong to some earlier magnet. + if (viaTorrServer && !direct) return; // The engine isn't open yet: for a magnet that means we're walking the // swarm for whoever will serve the metadata, one peer at a time. Show that // count -- without it the screen looks frozen for a minute and there is no // way to tell a slow fetch from a hung one. - if (!tfs) + if (!tfs && !direct) { int tried = torrent_meta_peers_tried; int total = torrent_meta_peers_total; @@ -2182,7 +2343,17 @@ void MpvView::updateLoadingOverlay() // Text stats: refresh ~twice a second (each setText relayouts). auto now = std::chrono::steady_clock::now(); double dt = std::chrono::duration(now - lastSample).count(); - if (dt >= 0.5) + if (dt >= 0.5 && direct) + { + lastSample = now; + statusLabel->setText(fileLoaded ? tr("Buffering...") : tr("Connecting...")); + char sbuf[192]; + std::snprintf(sbuf, sizeof(sbuf), "%s \xC2\xB7 %.1f MB/s", + urlHost(playUrl).c_str(), + obsCacheSpeed / (1024.0 * 1024.0)); + statsLabel->setText(sbuf); + } + else if (dt >= 0.5) { int64_t bytes = tfs ? torrentfs_bytes_recv(tfs) : 0; speedBps = (double)(bytes - lastBytes) / dt; @@ -2243,6 +2414,31 @@ void MpvView::updateInfoOverlay() if (dt < 0.5) return; + // A URL: no engine, so what mpv knows is all there is to show. + if (direct || viaTorrServer) + { + infoLastSample = now; + bool viaCurl = playUrl.rfind("https://", 0) == 0; + char m[512]; + std::snprintf(m, sizeof(m), + "%s\n" + "Host: %s\n" + "Transport: %s\n" + "Speed: %.1f MB/s\n" + "Buffered ahead: %.0f s\n" + "Last error: %s", + !viaTorrServer ? "DIRECT STREAM" + : direct ? "TORRSERVER STREAM" + : "TORRSERVER: waiting for the stream URL", + playUrl.empty() ? "-" : urlHost(playUrl).c_str(), + viaCurl ? "https (curl, range requests)" : "http (FFmpeg)", + obsCacheSpeed / (1024.0 * 1024.0), obsCacheSecs, + stream_http_err[0] ? stream_http_err : "-"); + infoLabel->setText(m); + if (infoLabel2) infoLabel2->setText(""); + return; + } + // Before torrentfs_open returns there is no engine to report on -- but that // is exactly the phase (a magnet's metadata fetch) that used to look hung, // so show what IS happening rather than an empty panel. @@ -2546,10 +2742,33 @@ void MpvView::updateInfoOverlay() void MpvView::logStats() { + auto now = std::chrono::steady_clock::now(); + + // A URL has no engine to log, but the Stremio watch-state sync rides on + // this 2 s cadence too and must keep running. + if (direct) + { + if (!statsStarted) + { + statsStarted = true; + statsLastSample = now; + return; + } + if (std::chrono::duration(now - statsLastSample).count() < 2.0) + return; + statsLastSample = now; + if (ready && mpv) + { + if (obsPos > 0) lastPosSec = obsPos; + if (obsDur > 0) lastDurSec = obsDur; + maybePushWatchState(false); + } + return; + } + if (!tfs) return; - auto now = std::chrono::steady_clock::now(); if (!statsStarted) { statsStarted = true; diff --git a/app/player.hpp b/app/player.hpp index fe100db..f0bf638 100644 --- a/app/player.hpp +++ b/app/player.hpp @@ -74,6 +74,10 @@ struct WatchInfo // Formats seconds into "M:SS" or "H:MM:SS" std::string fmtTime(double s); +// True for an http(s) URL the player streams directly (not a link to a +// .torrent file, which is still a torrent). +bool isDirectUrl(const std::string& source); + // Prompts the user with a dialog to resume from saved position if valid history exists, // or starts from the beginning if requested or if no history exists. void startPlaybackPrompt(const std::string& source, const PlayerArt& art = {}, @@ -209,6 +213,17 @@ class MpvView : public brls::Box bool boostApplied = false; torrentfs* tfs = nullptr; + // The source is an http(s) URL played as is (a Stremio url stream, a + // TorrServer link), not a torrent: there is no engine, tfs stays null and + // mpv reads the URL -- https through our nxhttps:// stream (stream.c), + // since this FFmpeg has no https of its own. + bool direct = false; + std::string playUrl; // what mpv opens when direct + // A torrent handed to TorrServer (Options -> Network): resolved to the + // server's HTTP stream in startEngine, then played as direct. History + // keeps the torrent as the source, so a resume works either way. + bool viaTorrServer = false; + double obsCacheSpeed = 0.0; // mpv "cache-speed", bytes/s (direct only) mpv_handle* mpv = nullptr; mpv_render_context* renderCtx = nullptr; @@ -299,6 +314,8 @@ class MpvView : public brls::Box bool fileLoaded = false; // mpv opened the stream (header downloaded) bool overlayHidden = false; // loading screen has been taken down bool ended = false; // reached EOF; the auto-close is scheduled + bool openFailed = false; // mpv could not open the source; the reason + // stays on the loading screen int shownPct = -1; // last buffering % pushed to the bar // Download-speed sampling. diff --git a/app/settings.cpp b/app/settings.cpp index 119fd38..d12d1cd 100644 --- a/app/settings.cpp +++ b/app/settings.cpp @@ -16,6 +16,8 @@ #include "config.hpp" #include "jackett.hpp" +#include "http.hpp" +#include "torrserver.hpp" #include "theme.hpp" #include "update.hpp" #include "stremio.hpp" @@ -62,6 +64,7 @@ brls::View* playbackPane(); brls::View* streamingPane(); brls::View* stremioPane(); brls::View* jackettPane(); +brls::View* networkPane(); brls::View* aboutPane(); // --------------------------------------------------------------------------- @@ -516,6 +519,90 @@ brls::View* jackettPane() return pane; } +brls::View* networkPane() +{ + brls::Box* list = nullptr; + auto* pane = newPane(&list); + config::Config& cfg = config::get(); + + auto* hdr = new brls::Header(); + hdr->setTitle("TorrServer"); + list->addView(hdr); + + auto* tsCell = new brls::DetailCell(); + tsCell->setText(tr("Server URL")); + tsCell->setDetailText(cfg.torrServerUrl.empty() ? tr("Off") : cfg.torrServerUrl); + tsCell->registerClickAction([tsCell](brls::View*) { + brls::Application::getImeManager()->openForText( + [tsCell](std::string out) { + config::get().torrServerUrl = torrserver::normalizeBase(out); + config::save(); + const std::string& v = config::get().torrServerUrl; + tsCell->setDetailText(v.empty() ? tr("Off") : v); + }, + tr("TorrServer URL"), tr("e.g. http://192.168.1.10:8090"), 256, + config::get().torrServerUrl); + return true; + }); + list->addView(tsCell); + list->addView(caption( + tr("When set, torrents are streamed by your TorrServer and played here as " + "a plain video stream, instead of being downloaded by the console. " + "Leave empty to use the built-in engine."))); + + auto* tsTest = new brls::Button(); + tsTest->setText(tr("Test connection")); + tsTest->setMargins(14.0f, 20.0f, 18.0f, 20.0f); + tsTest->registerClickAction([tsTest](brls::View*) { + if (config::get().torrServerUrl.empty()) + { + note(tr("Please set Server URL first.")); + return true; + } + tsTest->setState(brls::ButtonState::DISABLED); + tsTest->setText(tr("Testing...")); + torrserver::testConnectionAsync( + config::get().torrServerUrl, + [tsTest](bool ok, const std::string& msg) { + tsTest->setState(brls::ButtonState::ENABLED); + tsTest->setText(tr("Test connection")); + if (ok) + note(tr("Connected to TorrServer ") + msg); + else + note(tr("Connection failed: ") + msg); + }); + return true; + }); + list->addView(tsTest); + + auto* phdr = new brls::Header(); + phdr->setTitle(tr("Proxy")); + list->addView(phdr); + + auto* proxyCell = new brls::DetailCell(); + proxyCell->setText(tr("Proxy")); + proxyCell->setDetailText(cfg.proxyUrl.empty() ? tr("Off") : cfg.proxyUrl); + proxyCell->registerClickAction([proxyCell](brls::View*) { + brls::Application::getImeManager()->openForText( + [proxyCell](std::string out) { + while (!out.empty() && out.back() == ' ') out.pop_back(); + config::get().proxyUrl = out; + config::save(); + http::applyProxy(); + proxyCell->setDetailText(out.empty() ? tr("Off") : out); + }, + tr("Proxy"), tr("e.g. http://host:3128 or socks5h://host:1080"), 256, + config::get().proxyUrl); + return true; + }); + list->addView(proxyCell); + list->addView(caption( + tr("Used for catalogs, addons, posters and https video links. Servers on " + "your local network (Jackett, TorrServer) are always reached directly."))); + + return pane; +} + brls::View* aboutPane() { brls::Box* list = nullptr; @@ -652,6 +739,7 @@ brls::View* SettingsActivity::createContentView() tabs->addTab(tr("Streaming"), [] { return streamingPane(); }); tabs->addTab("Stremio", [] { return stremioPane(); }); tabs->addTab("Jackett", [] { return jackettPane(); }); + tabs->addTab(tr("Network"), [] { return networkPane(); }); tabs->addTab(tr("About"), [] { return aboutPane(); }); auto* frame = new brls::AppletFrame(); diff --git a/app/torrserver.cpp b/app/torrserver.cpp new file mode 100644 index 0000000..e7dd7dc --- /dev/null +++ b/app/torrserver.cpp @@ -0,0 +1,122 @@ +#include "torrserver.hpp" + +#include +#include + +#include "http.hpp" +#include "json.hpp" + +#ifndef TORRSERVER_NO_UI // the PC tests build the pure functions alone +#include +#include "config.hpp" +#endif + +namespace torrserver +{ + +namespace +{ +bool hasVideoExt(const std::string& path) +{ + static const char* exts[] = { ".mkv", ".mp4", ".avi", ".m4v", ".mov", + ".ts", ".m2ts", ".webm", ".wmv", ".mpg", + ".mpeg" }; + size_t dot = path.find_last_of('.'); + if (dot == std::string::npos) return false; + std::string e = path.substr(dot); + for (char& c : e) c = (char)std::tolower((unsigned char)c); + for (const char* x : exts) + if (e == x) return true; + return false; +} +} // namespace + +std::string normalizeBase(std::string url) +{ + while (!url.empty() && std::isspace((unsigned char)url.back())) url.pop_back(); + size_t lead = 0; + while (lead < url.size() && std::isspace((unsigned char)url[lead])) lead++; + url = url.substr(lead); + while (!url.empty() && url.back() == '/') url.pop_back(); + if (url.empty()) return url; + if (url.find("://") == std::string::npos) url = "http://" + url; + return url; +} + +std::string statUrl(const std::string& base, const std::string& link) +{ + return base + "/stream?link=" + http::urlEncode(link) + "&stat"; +} + +std::string playUrl(const std::string& base, const std::string& link, int id) +{ + return base + "/stream?link=" + http::urlEncode(link) + + "&index=" + std::to_string(id) + "&play"; +} + +int pickFile(const std::string& statJson, int fileIndex) +{ + if (fileIndex >= 0) return fileIndex + 1; + + int bestVideo = -1, bestAny = -1; + double videoLen = -1, anyLen = -1; + for (const auto& f : json::objects(statJson, "file_stats")) + { + int id = (int)json::integer(f, "id", -1); + double len = json::number(f, "length", 0); + if (id <= 0) continue; + if (len > anyLen) { anyLen = len; bestAny = id; } + if (hasVideoExt(json::str(f, "path")) && len > videoLen) + { + videoLen = len; + bestVideo = id; + } + } + return bestVideo > 0 ? bestVideo : bestAny; +} + +bool resolve(const std::string& base, const std::string& link, int fileIndex, + std::string& url, std::string& err) +{ + int id = fileIndex >= 0 ? fileIndex + 1 : -1; + if (id < 0) + { + std::string resp; + // The server answers once it has the metadata: give it the time a + // slow swarm needs, the on-device engine would wait as long. + if (!http::get(statUrl(base, link), resp, err, nullptr, 120)) + { + err = "TorrServer: " + err; + return false; + } + id = pickFile(resp, -1); + if (id < 0) + { + err = "TorrServer returned no files for this torrent"; + return false; + } + } + url = playUrl(base, link, id); + return true; +} + +#ifndef TORRSERVER_NO_UI +std::string base() { return normalizeBase(config::get().torrServerUrl); } + +void testConnectionAsync(const std::string& base, + std::function cb) +{ + std::string url = normalizeBase(base) + "/echo"; + brls::async([url, cb]() { + std::string resp, err; + bool ok = http::get(url, resp, err, nullptr, 10); + while (!resp.empty() && std::isspace((unsigned char)resp.back())) resp.pop_back(); + if (ok && resp.empty()) ok = false, err = "empty answer"; + // Anything long is a web page, not TorrServer's "MatriX.135". + if (ok && resp.size() > 64) ok = false, err = "not a TorrServer"; + brls::sync([cb, ok, resp, err]() { cb(ok, ok ? resp : err); }); + }); +} +#endif + +} // namespace torrserver diff --git a/app/torrserver.hpp b/app/torrserver.hpp new file mode 100644 index 0000000..4049f26 --- /dev/null +++ b/app/torrserver.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +// TorrServer (github.com/YouROK/TorrServer): a torrent client on another +// machine that serves a torrent's file over HTTP while it downloads. With its +// address in Options, the player hands it the magnet and plays the HTTP stream +// instead of running the on-device engine -- a PC or NAS has far more +// bandwidth, peers and storage to stream from than the console. +// +// API used: GET /echo (version, for "Test connection"), +// /stream?link=&stat -> JSON, with "file_stats":[{id,path,length}] +// /stream?link=&index=&play -> the file itself, range requests ok +// File ids are 1-based, in the torrent's own file order -- the order the +// engine's file index counts in, so engine index i is TorrServer id i+1. +namespace torrserver +{ + +// "192.168.1.10:8090/" -> "http://192.168.1.10:8090". "" stays "". +std::string normalizeBase(std::string url); + +// The configured server, normalized; "" when TorrServer is off. +std::string base(); + +std::string statUrl(const std::string& base, const std::string& link); +std::string playUrl(const std::string& base, const std::string& link, int id); + +// The TorrServer file id to play. `fileIndex` >= 0 (engine order) maps to +// fileIndex + 1 without looking at the list; -1 picks the largest video file +// in `statJson` (else the largest file). -1 when there is nothing to pick. +int pickFile(const std::string& statJson, int fileIndex); + +// Blocking (call off the UI thread): the URL to play `link` (a magnet) through +// the server. With fileIndex -1 this asks the server for the file list first, +// which waits until it has the torrent's metadata. +bool resolve(const std::string& base, const std::string& link, int fileIndex, + std::string& url, std::string& err); + +// GET /echo; the callback runs on the UI thread with the server's version. +void testConnectionAsync(const std::string& base, + std::function cb); + +} // namespace torrserver diff --git a/engine/httpsrc.c b/engine/httpsrc.c new file mode 100644 index 0000000..f30c07b --- /dev/null +++ b/engine/httpsrc.c @@ -0,0 +1,228 @@ +#include "httpsrc.h" + +#include +#include +#include +#include +#include + +#include + +// 4 MiB per request: at 10 MB/s that is 0.4 s of transfer for one round-trip +// of setup, and a seek costs at most this much wasted download. +#define CHUNK (4 * 1024 * 1024) +#define RETRIES 3 + +static char g_ca[512]; +static char g_proxy[512]; +static volatile int64_t g_bytes_total; + +void httpsrc_set_ca(const char *p) { snprintf(g_ca, sizeof(g_ca), "%s", p ? p : ""); } +void httpsrc_set_proxy(const char *p) { snprintf(g_proxy, sizeof(g_proxy), "%s", p ? p : ""); } +int64_t httpsrc_bytes_total(void) { return __atomic_load_n(&g_bytes_total, __ATOMIC_RELAXED); } + +struct httpsrc { + CURL *curl; + char *url; // original URL + char *direct; // where the redirects led (NULL = same as url) + int64_t size; + volatile bool cancelled; + + uint8_t *buf; // the buffered chunk + int64_t buf_pos; // its offset in the resource + int64_t buf_len; + + // State of the transfer in progress. + int64_t want; // bytes asked for + int64_t got; + int64_t content_range_total; // from "Content-Range: bytes a-b/TOTAL", -1 + char err[160]; +}; + +static void set_err(char *err, size_t n, const char *msg) { + if (err && n) snprintf(err, n, "%s", msg); +} + +static size_t write_cb(char *p, size_t sz, size_t nm, void *ud) { + httpsrc *s = ud; + size_t n = sz * nm; + size_t room = (size_t)(s->want - s->got); + // A server that ignores Range sends the whole file: keep what we asked + // for and stop the transfer there (curl reports a write error, which the + // caller recognises by got == want). + size_t take = n < room ? n : room; + memcpy(s->buf + s->got, p, take); + s->got += (int64_t)take; + __atomic_fetch_add(&g_bytes_total, (int64_t)take, __ATOMIC_RELAXED); + return take == n ? n : 0; +} + +static size_t header_cb(char *p, size_t sz, size_t nm, void *ud) { + httpsrc *s = ud; + size_t n = sz * nm; + // "Content-Range: bytes 0-0/123456" (case-insensitive name). + static const char name[] = "content-range:"; + if (n > sizeof(name) && strncasecmp(p, name, sizeof(name) - 1) == 0) { + const char *slash = memchr(p, '/', n); + if (slash && slash + 1 < p + n && isdigit((unsigned char)slash[1])) + s->content_range_total = strtoll(slash + 1, NULL, 10); + } + return n; +} + +static int progress_cb(void *ud, curl_off_t dt, curl_off_t dn, curl_off_t ut, + curl_off_t un) { + (void)dt; (void)dn; (void)ut; (void)un; + return ((httpsrc *)ud)->cancelled ? 1 : 0; +} + +static void setup(httpsrc *s) { + CURL *c = s->curl; + curl_easy_reset(c); + curl_easy_setopt(c, CURLOPT_USERAGENT, "NX-torrent-player"); + curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(c, CURLOPT_MAXREDIRS, 8L); + curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 15L); + // A stalled server: give up after 20 s under 1 KB/s rather than hanging + // the demuxer forever (the read is retried). + curl_easy_setopt(c, CURLOPT_LOW_SPEED_LIMIT, 1024L); + curl_easy_setopt(c, CURLOPT_LOW_SPEED_TIME, 20L); + curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(c, CURLOPT_WRITEDATA, s); + curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, header_cb); + curl_easy_setopt(c, CURLOPT_HEADERDATA, s); + curl_easy_setopt(c, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(c, CURLOPT_XFERINFOFUNCTION, progress_cb); + curl_easy_setopt(c, CURLOPT_XFERINFODATA, s); + curl_easy_setopt(c, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(c, CURLOPT_SSL_VERIFYHOST, 2L); + if (g_ca[0]) curl_easy_setopt(c, CURLOPT_CAINFO, g_ca); + if (g_proxy[0]) curl_easy_setopt(c, CURLOPT_PROXY, g_proxy); +} + +// One ranged GET of [pos, pos+len) into s->buf. Returns the HTTP status (206 +// on success), or -1 with s->err set. +static long fetch(httpsrc *s, const char *url, int64_t pos, int64_t len) { + setup(s); + char range[64]; + snprintf(range, sizeof(range), "%lld-%lld", (long long)pos, + (long long)(pos + len - 1)); + curl_easy_setopt(s->curl, CURLOPT_URL, url); + curl_easy_setopt(s->curl, CURLOPT_RANGE, range); + s->want = len; + s->got = 0; + s->content_range_total = -1; + + char eb[CURL_ERROR_SIZE] = {0}; + curl_easy_setopt(s->curl, CURLOPT_ERRORBUFFER, eb); + CURLcode rc = curl_easy_perform(s->curl); + long code = 0; + curl_easy_getinfo(s->curl, CURLINFO_RESPONSE_CODE, &code); + + if (s->cancelled) { snprintf(s->err, sizeof(s->err), "cancelled"); return -1; } + // The write error we caused ourselves on an over-long body is not one. + if (rc == CURLE_WRITE_ERROR && s->got == s->want) rc = CURLE_OK; + if (rc != CURLE_OK) { + if (rc == CURLE_PEER_FAILED_VERIFICATION) + snprintf(s->err, sizeof(s->err), + "certificate rejected -- check the console's date and time"); + else + snprintf(s->err, sizeof(s->err), "%.150s", eb[0] ? eb : curl_easy_strerror(rc)); + return -1; + } + if (code >= 400) { + snprintf(s->err, sizeof(s->err), "HTTP %ld", code); + return -1; + } + return code; +} + +httpsrc *httpsrc_open(const char *url, char *err, size_t errlen) { + httpsrc *s = calloc(1, sizeof(*s)); + if (!s) { set_err(err, errlen, "out of memory"); return NULL; } + s->url = strdup(url); + s->buf = malloc(CHUNK); + s->curl = curl_easy_init(); + s->size = -1; + if (!s->url || !s->buf || !s->curl) { + set_err(err, errlen, "out of memory"); + httpsrc_close(s); + return NULL; + } + + // Probe with the first chunk itself: the size comes from Content-Range, + // and the data is the start of the file mpv is about to ask for anyway. + long code = fetch(s, s->url, 0, CHUNK); + if (code < 0) { + set_err(err, errlen, s->err); + httpsrc_close(s); + return NULL; + } + if (code != 206 || s->content_range_total <= 0) { + // No ranges: playable only from the start, never seekable. Not worth a + // half-working player -- say why instead. + set_err(err, errlen, "the server does not support seeking (no HTTP range requests)"); + httpsrc_close(s); + return NULL; + } + s->size = s->content_range_total; + s->buf_pos = 0; + s->buf_len = s->got; + + // Later requests go straight to where the redirects ended (a debrid link + // usually bounces to a CDN), saving a round-trip per chunk. + char *eff = NULL; + curl_easy_getinfo(s->curl, CURLINFO_EFFECTIVE_URL, &eff); + if (eff && strcmp(eff, s->url) != 0) s->direct = strdup(eff); + return s; +} + +int64_t httpsrc_size(const httpsrc *s) { return s->size; } + +int64_t httpsrc_read(httpsrc *s, int64_t pos, void *out, int64_t n) { + if (s->cancelled) return -1; + if (pos < 0) return -1; + if (pos >= s->size) return 0; + + if (!(pos >= s->buf_pos && pos < s->buf_pos + s->buf_len)) { + int64_t len = s->size - pos < CHUNK ? s->size - pos : CHUNK; + long code = -1; + for (int attempt = 0; attempt < RETRIES && !s->cancelled; attempt++) { + // After a failure, go back through the original URL: a CDN link + // from the redirect may have expired. + const char *u = (s->direct && attempt == 0) ? s->direct : s->url; + code = fetch(s, u, pos, len); + if (code == 206 && s->got > 0) break; + if (code > 0 && code != 206) { + snprintf(s->err, sizeof(s->err), "server ignored the range (HTTP %ld)", code); + code = -1; + } + } + if (code != 206 || s->got <= 0) { + s->buf_len = 0; + return -1; + } + s->buf_pos = pos; + s->buf_len = s->got; + } + + int64_t off = pos - s->buf_pos; + int64_t take = s->buf_len - off; + if (take > n) take = n; + memcpy(out, s->buf + off, (size_t)take); + return take; +} + +void httpsrc_cancel(httpsrc *s) { s->cancelled = true; } + +const char *httpsrc_last_err(const httpsrc *s) { return s->err; } + +void httpsrc_close(httpsrc *s) { + if (!s) return; + if (s->curl) curl_easy_cleanup(s->curl); + free(s->buf); + free(s->url); + free(s->direct); + free(s); +} diff --git a/engine/httpsrc.h b/engine/httpsrc.h new file mode 100644 index 0000000..0cef250 --- /dev/null +++ b/engine/httpsrc.h @@ -0,0 +1,52 @@ +#ifndef HTTPSRC_H +#define HTTPSRC_H + +// Random-access reads of one HTTP(S) resource, through curl Range requests. +// +// Exists because the Switch's FFmpeg is built without the https protocol +// (only http, tcp, tls...): mpv cannot open an https:// URL by itself. Reading +// it through curl also means the app's own CA bundle and proxy apply. The mpv +// side is in stream.c (stream_register_http). +// +// Reads are served from one buffered chunk; a read outside it fetches the next +// chunk starting there (one request per CHUNK, on a kept-alive connection). +// mpv's demuxer cache reads ahead on its own thread, so this does not need a +// prefetcher of its own. + +#include +#include +#include + +typedef struct httpsrc httpsrc; + +// Process-wide settings, read at every open. NULL/"" = unset. +// `ca_path`: CA bundle file for TLS verification (the Switch mounts none, the +// app ships romfs:/cacert.pem). Unset = curl's default store. +void httpsrc_set_ca(const char *ca_path); +// `proxy`: a curl proxy string ("http://host:3128", "socks5h://host:1080"). +void httpsrc_set_proxy(const char *proxy); + +// Opens `url` (http:// or https://): one request to learn the size and that +// the server honours ranges. NULL on failure, with a reason in err. +httpsrc *httpsrc_open(const char *url, char *err, size_t errlen); + +// Total size in bytes. +int64_t httpsrc_size(const httpsrc *s); + +// Reads up to `n` bytes at `pos`. Returns the count (>0), 0 at end of file, or +// -1 on error or after httpsrc_cancel. +int64_t httpsrc_read(httpsrc *s, int64_t pos, void *buf, int64_t n); + +// Makes an in-flight read (and every later one) fail at once. Safe from any +// thread; the only call that is. +void httpsrc_cancel(httpsrc *s); + +void httpsrc_close(httpsrc *s); + +// Last error of a failed read, for the log / the info panel. +const char *httpsrc_last_err(const httpsrc *s); + +// Bytes received by every httpsrc so far (for a download-speed readout). +int64_t httpsrc_bytes_total(void); + +#endif diff --git a/engine/stream.c b/engine/stream.c index 25472f2..dd8bf3d 100644 --- a/engine/stream.c +++ b/engine/stream.c @@ -6,6 +6,7 @@ #include +#include "httpsrc.h" #include "torrentfs.h" // Per-open-stream state: the shared torrent store plus this stream's cursor. @@ -56,3 +57,78 @@ static int open_cb(void *user_data, char *uri, mpv_stream_cb_info *info) { int stream_register(mpv_handle *mpv, torrentfs *tfs) { return mpv_stream_cb_add_ro(mpv, "torrent", tfs, open_cb); } + +//--------------------------------------------------------------------------- +// nxhttps:// -- an https:// URL read through curl (httpsrc.c), because this +// FFmpeg has no https protocol of its own. +//--------------------------------------------------------------------------- + +typedef struct { + httpsrc *src; + int64_t pos; +} http_ctx; + +static int64_t http_read_cb(void *cookie, char *buf, uint64_t nbytes) { + http_ctx *h = cookie; + int64_t n = httpsrc_read(h->src, h->pos, buf, (int64_t)nbytes); + if (n > 0) h->pos += n; + return n; +} + +static int64_t http_seek_cb(void *cookie, int64_t offset) { + http_ctx *h = cookie; + if (offset < 0 || offset > httpsrc_size(h->src)) return MPV_ERROR_GENERIC; + h->pos = offset; // the data is fetched by the next read + return offset; +} + +static int64_t http_size_cb(void *cookie) { + return httpsrc_size(((http_ctx *)cookie)->src); +} + +static void http_close_cb(void *cookie) { + http_ctx *h = cookie; + httpsrc_close(h->src); + free(h); +} + +static void http_cancel_cb(void *cookie) { + httpsrc_cancel(((http_ctx *)cookie)->src); +} + +static int http_open_cb(void *user_data, char *uri, mpv_stream_cb_info *info) { + (void)user_data; + // "nxhttps://host/path" -> "https://host/path" + static const char prefix[] = "nxhttps://"; + if (strncmp(uri, prefix, sizeof(prefix) - 1) != 0) return MPV_ERROR_LOADING_FAILED; + size_t rest = strlen(uri) - (sizeof(prefix) - 1); + char *url = malloc(rest + sizeof("https://")); + if (!url) return MPV_ERROR_LOADING_FAILED; + snprintf(url, rest + sizeof("https://"), "https://%s", uri + sizeof(prefix) - 1); + + char err[160] = {0}; + httpsrc *src = httpsrc_open(url, err, sizeof(err)); + free(url); + if (!src) { + snprintf(stream_http_err, sizeof(stream_http_err), "%s", err); + return MPV_ERROR_LOADING_FAILED; + } + http_ctx *h = calloc(1, sizeof(*h)); + if (!h) { httpsrc_close(src); return MPV_ERROR_LOADING_FAILED; } + h->src = src; + + info->cookie = h; + info->read_fn = http_read_cb; + info->seek_fn = http_seek_cb; + info->size_fn = http_size_cb; + info->close_fn = http_close_cb; + info->cancel_fn = http_cancel_cb; + return 0; +} + +char stream_http_err[160]; + +int stream_register_http(mpv_handle *mpv) { + stream_http_err[0] = 0; + return mpv_stream_cb_add_ro(mpv, "nxhttps", NULL, http_open_cb); +} diff --git a/engine/stream.h b/engine/stream.h index 4e033c3..bb0d036 100644 --- a/engine/stream.h +++ b/engine/stream.h @@ -12,4 +12,14 @@ // Returns 0 on success. `tfs` must outlive the mpv instance. int stream_register(mpv_handle *mpv, torrentfs *tfs); +// Registers `nxhttps://`: `loadfile nxhttps://host/path` plays +// https://host/path, read through curl with range requests (httpsrc.c). This +// FFmpeg build has no https protocol, so mpv cannot open such a URL itself; +// plain http:// (and HLS over it) needs no help. +int stream_register_http(mpv_handle *mpv); + +// Why the last nxhttps:// open failed ("" if it did not), for the player to +// show: mpv itself only reports "loading failed". +extern char stream_http_err[160]; + #endif diff --git a/library/borealis/VENDORED.md b/library/borealis/VENDORED.md index 2212765..64d308c 100644 --- a/library/borealis/VENDORED.md +++ b/library/borealis/VENDORED.md @@ -64,6 +64,12 @@ primary carries Latin glyphs, so a `FONT_REGULAR` fallback was never reached for Latin. Falls back to the stock system-Chinese-primary setup if the romfs file is missing. The `.ttf` is bundled by the top-level CMakeLists. +`SwitchFontLoader::useBundledFont` (a public static, declared in +`include/borealis/platforms/switch/switch_font.hpp`, default `true`) turns the +bundled font off: the app clears it before `Application::init()` when the UI is +in Russian, since Space Grotesk has no Cyrillic and a label would otherwise mix +two typefaces. + The patch is marked with a `LOCAL PATCH` comment in the source. ### 5. `library/lib/views/label.cpp` — three-dot ellipsis diff --git a/library/borealis/library/include/borealis/platforms/switch/switch_font.hpp b/library/borealis/library/include/borealis/platforms/switch/switch_font.hpp index b51f5d7..e0b5ee0 100644 --- a/library/borealis/library/include/borealis/platforms/switch/switch_font.hpp +++ b/library/borealis/library/include/borealis/platforms/switch/switch_font.hpp @@ -26,6 +26,13 @@ class SwitchFontLoader : public FontLoader { public: void loadFonts() override; + + // LOCAL PATCH (see VENDORED.md): whether to make the app's bundled + // romfs:/SpaceGrotesk-Medium.ttf the primary font. Set before + // Application::init(). The app turns it off for a language the font has + // no glyphs for (Russian), so the UI is one system font instead of Space + // Grotesk for Latin and the system font for everything else. + static bool useBundledFont; }; } // namespace brls diff --git a/library/borealis/library/lib/platforms/switch/switch_font.cpp b/library/borealis/library/lib/platforms/switch/switch_font.cpp index ed6b2cd..3e04d80 100644 --- a/library/borealis/library/lib/platforms/switch/switch_font.cpp +++ b/library/borealis/library/lib/platforms/switch/switch_font.cpp @@ -24,6 +24,8 @@ namespace brls { +bool SwitchFontLoader::useBundledFont = true; // LOCAL PATCH, see the header + void SwitchFontLoader::loadFonts() { PlFontData font; @@ -47,7 +49,8 @@ void SwitchFontLoader::loadFonts() // as fallbacks for the glyphs it lacks. Loading it merely as FONT_REGULAR did // not work: the system Chinese font that used to be the primary carries Latin // glyphs, so the fallback to Space Grotesk was never reached for Latin text. - bool sg = Application::loadFontFromFile(FONT_CHINESE_SIMPLIFIED, + bool sg = useBundledFont && + Application::loadFontFromFile(FONT_CHINESE_SIMPLIFIED, "romfs:/SpaceGrotesk-Medium.ttf"); if (sg) { @@ -66,7 +69,10 @@ void SwitchFontLoader::loadFonts() { // Space Grotesk missing: stock behaviour -- system Simplified Chinese is // the primary, Standard its Latin fallback. - Logger::error("switch: Space Grotesk not found, falling back to system fonts"); + if (useBundledFont) + Logger::error("switch: Space Grotesk not found, falling back to system fonts"); + else + Logger::info("switch: system fonts only (bundled font turned off)"); rc = plGetSharedFontByType(&font, PlSharedFontType_ChineseSimplified); if (R_SUCCEEDED(rc) && Application::loadFontFromMemory(FONT_CHINESE_SIMPLIFIED, font.address, font.size, false)) diff --git a/pctest/run_tests.sh b/pctest/run_tests.sh index a9165c4..dc48224 100755 --- a/pctest/run_tests.sh +++ b/pctest/run_tests.sh @@ -1,16 +1,21 @@ #!/bin/sh -# Builds and runs the offline engine test suite (pctest/test_suite.c). +# Builds and runs the offline test suites: +# test_suite -- the torrent engine (pctest/test_suite.c) +# test_httpsrc -- HTTP range reads for https playback (pctest/test_httpsrc.c) +# test_net -- TorrServer URLs and the proxy bypass (pctest/test_net.cpp) # # Needs a C compiler and libcurl headers (Debian/Ubuntu: build-essential # libcurl4-openssl-dev; MSYS2/Cygwin: gcc libcurl-devel). No mbedtls: SHA-1 # comes from pctest/compat. No network: the trackers and the DHT are mocked in -# the test file itself, which is why udp_tracker.c, dht.c and dhtclient.c are -# left out here. +# test_suite.c itself (which is why udp_tracker.c, dht.c and dhtclient.c are +# left out), and the suites talk to mock servers on 127.0.0.1 or mock http. set -e cd "$(dirname "$0")/.." -out="${OUT:-./pctest/test_suite}" -${CC:-gcc} -O1 -g -Wall \ +out="${OUT:-./pctest}" +cc="${CC:-gcc}" + +$cc -O1 -g -Wall \ -Ipctest/compat -Iengine \ engine/bencode.c \ engine/torrent.c \ @@ -19,7 +24,25 @@ ${CC:-gcc} -O1 -g -Wall \ pctest/compat/sha1.c \ pctest/utpstub.c \ pctest/test_suite.c \ - -o "$out" \ + -o "$out/test_suite" \ + -lpthread -lcurl + +$cc -O1 -g -Wall \ + -Iengine \ + engine/httpsrc.c \ + pctest/test_httpsrc.c \ + -o "$out/test_httpsrc" \ -lpthread -lcurl -"$out" +${CXX:-g++} -O1 -g -Wall -std=c++17 \ + -DTORRSERVER_NO_UI -Iapp \ + app/torrserver.cpp \ + app/json.cpp \ + pctest/test_net.cpp \ + -o "$out/test_net" + +"$out/test_suite" +echo +"$out/test_httpsrc" +echo +"$out/test_net" diff --git a/pctest/test_httpsrc.c b/pctest/test_httpsrc.c new file mode 100644 index 0000000..9eab4b4 --- /dev/null +++ b/pctest/test_httpsrc.c @@ -0,0 +1,310 @@ +// Offline tests for engine/httpsrc.c (HTTP range reads through curl). +// +// A mock HTTP/1.1 server on 127.0.0.1 serves a generated 10 MiB file, with or +// without Range support, so chunk boundaries, seeks, redirects and failures +// are exercised without the network. Plain http: TLS is curl's business, the +// chunking logic is ours. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include "httpsrc.h" + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAILED %s:%d: %s\n", __FILE__, __LINE__, \ + #cond); \ + exit(1); \ + } \ + } while (0) + +#define FILE_SIZE (10 * 1024 * 1024 + 12345) + +static uint8_t byte_at(int64_t i) { return (uint8_t)((i * 31) ^ (i >> 9)); } + +//--------------------------------------------------------------------------- +// Mock server +//--------------------------------------------------------------------------- + +typedef struct { + int fd; + uint16_t port; + pthread_t thread; + bool ranges; // honour Range (206) or always send the whole file + bool redirect; // "/start" answers 302 to "/file" + int fail_next; // answer this many requests with 503 first + volatile int requests; + volatile int redirects_served; +} server; + +static int send_all(int fd, const void *b, size_t n) { + const char *p = b; + while (n) { + ssize_t r = send(fd, p, n, 0); + if (r <= 0) return -1; + p += r; + n -= (size_t)r; + } + return 0; +} + +static void serve(server *sv, int fd) { + char req[4096]; + size_t len = 0; + while (len < sizeof(req) - 1) { + ssize_t r = recv(fd, req + len, sizeof(req) - 1 - len, 0); + if (r <= 0) return; + len += (size_t)r; + req[len] = 0; + if (strstr(req, "\r\n\r\n")) break; + } + sv->requests++; + + char hdr[512]; + if (sv->fail_next > 0) { + sv->fail_next--; + int n = snprintf(hdr, sizeof(hdr), + "HTTP/1.1 503 Busy\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + send_all(fd, hdr, (size_t)n); + return; + } + if (sv->redirect && strncmp(req, "GET /start", 10) == 0) { + sv->redirects_served++; + int n = snprintf(hdr, sizeof(hdr), + "HTTP/1.1 302 Found\r\nLocation: /file\r\nContent-Length: 0\r\n" + "Connection: close\r\n\r\n"); + send_all(fd, hdr, (size_t)n); + return; + } + + long long a = 0, b = FILE_SIZE - 1; + bool ranged = false; + char *rg = strstr(req, "\r\nRange: bytes="); // curl spells it so + if (sv->ranges && rg && sscanf(rg + 15, "%lld-%lld", &a, &b) >= 1) { + ranged = true; + if (b >= FILE_SIZE) b = FILE_SIZE - 1; + } + int n; + if (ranged) + n = snprintf(hdr, sizeof(hdr), + "HTTP/1.1 206 Partial Content\r\nContent-Length: %lld\r\n" + "Content-Range: bytes %lld-%lld/%d\r\nConnection: close\r\n\r\n", + b - a + 1, a, b, FILE_SIZE); + else + n = snprintf(hdr, sizeof(hdr), + "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", + FILE_SIZE); + if (send_all(fd, hdr, (size_t)n) != 0) return; + + uint8_t blk[16384]; + for (long long i = a; i <= b;) { + size_t m = 0; + while (m < sizeof(blk) && i <= b) blk[m++] = byte_at(i++); + if (send_all(fd, blk, m) != 0) return; // client stopped reading + } +} + +static void *server_main(void *arg) { + server *sv = arg; + for (;;) { + int c = accept(sv->fd, NULL, NULL); + if (c < 0) return NULL; + serve(sv, c); + shutdown(c, SHUT_WR); + close(c); + } +} + +static void server_start(server *sv, bool ranges) { + memset(sv, 0, sizeof(*sv)); + sv->ranges = ranges; + sv->fd = socket(AF_INET, SOCK_STREAM, 0); + CHECK(sv->fd >= 0); + struct sockaddr_in sa = {0}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + CHECK(bind(sv->fd, (struct sockaddr *)&sa, sizeof(sa)) == 0); + CHECK(listen(sv->fd, 16) == 0); + socklen_t sl = sizeof(sa); + getsockname(sv->fd, (struct sockaddr *)&sa, &sl); + sv->port = ntohs(sa.sin_port); + pthread_create(&sv->thread, NULL, server_main, sv); +} + +static void server_stop(server *sv) { + shutdown(sv->fd, SHUT_RDWR); + close(sv->fd); + pthread_join(sv->thread, NULL); +} + +static void url_of(char *out, size_t n, const server *sv, const char *path) { + snprintf(out, n, "http://127.0.0.1:%u%s", sv->port, path); +} + +static bool matches(const uint8_t *b, int64_t pos, int64_t n) { + for (int64_t i = 0; i < n; i++) + if (b[i] != byte_at(pos + i)) return false; + return true; +} + +//--------------------------------------------------------------------------- +// Tests +//--------------------------------------------------------------------------- + +static void test_sequential_read(void) { + printf("[1/6] Size, then a sequential read of the whole file...\n"); + server sv; + server_start(&sv, true); + char url[128], err[160] = {0}; + url_of(url, sizeof(url), &sv, "/file"); + + httpsrc *s = httpsrc_open(url, err, sizeof(err)); + CHECK(s != NULL); + CHECK(httpsrc_size(s) == FILE_SIZE); + + // Odd-sized reads, so they straddle the 4 MiB chunk boundaries. + uint8_t *buf = malloc(100000); + int64_t pos = 0; + while (pos < FILE_SIZE) { + int64_t n = httpsrc_read(s, pos, buf, 99991); + CHECK(n > 0); + CHECK(matches(buf, pos, n)); + pos += n; + } + CHECK(httpsrc_read(s, pos, buf, 10) == 0); // EOF + printf(" %d requests for %d bytes\n", sv.requests, FILE_SIZE); + CHECK(sv.requests == 3); // 4 + 4 + 2 MiB + free(buf); + httpsrc_close(s); + server_stop(&sv); + printf(" PASSED\n"); +} + +static void test_seek(void) { + printf("[2/6] Seeks: far ahead, back to the start, inside the chunk...\n"); + server sv; + server_start(&sv, true); + char url[128], err[160] = {0}; + url_of(url, sizeof(url), &sv, "/file"); + httpsrc *s = httpsrc_open(url, err, sizeof(err)); + CHECK(s != NULL); + + uint8_t buf[4096]; + int64_t at[] = { FILE_SIZE - 100, 0, 9000000, 9000000 + 4000, 5 }; + for (size_t i = 0; i < sizeof(at) / sizeof(*at); i++) { + int64_t n = httpsrc_read(s, at[i], buf, sizeof(buf)); + CHECK(n > 0); + CHECK(matches(buf, at[i], n)); + } + // One chunk is kept: the open, then tail, start, 9000000 (+4000 is a hit + // in that chunk) and 5 (the start chunk was replaced) -- 5 requests. + printf(" %d requests\n", sv.requests); + CHECK(sv.requests == 5); + CHECK(httpsrc_read(s, FILE_SIZE, buf, 1) == 0); + httpsrc_close(s); + server_stop(&sv); + printf(" PASSED\n"); +} + +static void test_no_ranges(void) { + printf("[3/6] A server without Range support is refused with a reason...\n"); + server sv; + server_start(&sv, false); + char url[128], err[160] = {0}; + url_of(url, sizeof(url), &sv, "/file"); + httpsrc *s = httpsrc_open(url, err, sizeof(err)); + printf(" err='%s'\n", err); + CHECK(s == NULL); + CHECK(strstr(err, "range") != NULL); + server_stop(&sv); + printf(" PASSED\n"); +} + +static void test_redirect(void) { + printf("[4/6] A redirect is followed once, then read directly...\n"); + server sv; + server_start(&sv, true); + sv.redirect = true; + char url[128], err[160] = {0}; + url_of(url, sizeof(url), &sv, "/start"); + httpsrc *s = httpsrc_open(url, err, sizeof(err)); + CHECK(s != NULL); + uint8_t buf[512]; + CHECK(httpsrc_read(s, 6000000, buf, sizeof(buf)) == sizeof(buf)); + CHECK(matches(buf, 6000000, sizeof(buf))); + printf(" redirects served: %d\n", sv.redirects_served); + CHECK(sv.redirects_served == 1); + httpsrc_close(s); + server_stop(&sv); + printf(" PASSED\n"); +} + +static void test_retry(void) { + printf("[5/6] A failed chunk request is retried...\n"); + server sv; + server_start(&sv, true); + char url[128], err[160] = {0}; + url_of(url, sizeof(url), &sv, "/file"); + httpsrc *s = httpsrc_open(url, err, sizeof(err)); + CHECK(s != NULL); + sv.fail_next = 2; // two 503s, the third attempt succeeds + uint8_t buf[512]; + CHECK(httpsrc_read(s, 7000000, buf, sizeof(buf)) == sizeof(buf)); + CHECK(matches(buf, 7000000, sizeof(buf))); + sv.fail_next = 10; // more than the retries: the read fails, with a reason + CHECK(httpsrc_read(s, 1000, buf, sizeof(buf)) == -1); + printf(" err='%s'\n", httpsrc_last_err(s)); + CHECK(strstr(httpsrc_last_err(s), "503") != NULL); + httpsrc_close(s); + server_stop(&sv); + printf(" PASSED\n"); +} + +static void test_cancel(void) { + printf("[6/6] After cancel every read fails at once...\n"); + server sv; + server_start(&sv, true); + char url[128], err[160] = {0}; + url_of(url, sizeof(url), &sv, "/file"); + httpsrc *s = httpsrc_open(url, err, sizeof(err)); + CHECK(s != NULL); + int before = sv.requests; + httpsrc_cancel(s); + uint8_t buf[16]; + CHECK(httpsrc_read(s, 0, buf, sizeof(buf)) == -1); + CHECK(httpsrc_read(s, 8000000, buf, sizeof(buf)) == -1); + CHECK(sv.requests == before); // nothing was sent + httpsrc_close(s); + server_stop(&sv); + printf(" PASSED\n"); +} + +int main(void) { + printf("=========================================\n"); + printf(" NX TORRENT PLAYER: HTTP SOURCE TESTS \n"); + printf("=========================================\n\n"); + curl_global_init(CURL_GLOBAL_DEFAULT); + + test_sequential_read(); + test_seek(); + test_no_ranges(); + test_redirect(); + test_retry(); + test_cancel(); + + curl_global_cleanup(); + printf("\nALL TESTS PASSED\n"); + return 0; +} diff --git a/pctest/test_net.cpp b/pctest/test_net.cpp new file mode 100644 index 0000000..58c6eff --- /dev/null +++ b/pctest/test_net.cpp @@ -0,0 +1,164 @@ +// Offline tests for the URL logic of TorrServer playback and the proxy bypass: +// app/torrserver.cpp (built with TORRSERVER_NO_UI, without borealis) and +// app/netutil.hpp. http::get is replaced below, so nothing leaves the process. + +#include +#include +#include + +#include "http.hpp" +#include "netutil.hpp" +#include "torrserver.hpp" + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, " FAILED %s:%d: %s\n", __FILE__, \ + __LINE__, #cond); \ + std::exit(1); \ + } \ + } while (0) + +// --- http stand-ins --------------------------------------------------------- +static std::string g_lastUrl, g_answer; +static long g_lastTimeout; +static int g_gets; + +namespace http +{ +bool get(const std::string& url, std::string& resp, std::string& err, + const char*, long timeoutSec) +{ + g_gets++; + g_lastUrl = url; + g_lastTimeout = timeoutSec; + if (g_answer.empty()) { err = "connection refused"; return false; } + resp = g_answer; + return true; +} + +std::string urlEncode(const std::string& s) // same as app/http.cpp +{ + static const char* hex = "0123456789ABCDEF"; + std::string out; + for (unsigned char c : s) + { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') + out += (char)c; + else + { + out += '%'; + out += hex[c >> 4]; + out += hex[c & 0xF]; + } + } + return out; +} +} // namespace http + +// --- tests ------------------------------------------------------------------ +static const char* kStat = R"({"title":"Show","hash":"abc","file_stats":[ + {"id":1,"path":"Show/Sample/sample.mkv","length":52428800}, + {"id":2,"path":"Show/Show.S01E01.mkv","length":1500000000}, + {"id":3,"path":"Show/Show.S01E02.MKV","length":1600000000}, + {"id":4,"path":"Show/extras.zip","length":9000000000}, + {"id":5,"path":"Show/Show.nfo","length":2000}]})"; + +static void test_normalize() +{ + std::printf("[1/5] Server address normalisation...\n"); + CHECK(torrserver::normalizeBase("192.168.1.10:8090/") == "http://192.168.1.10:8090"); + CHECK(torrserver::normalizeBase(" https://ts.example.com// ") == "https://ts.example.com"); + CHECK(torrserver::normalizeBase("") == ""); + CHECK(torrserver::normalizeBase(" ") == ""); + std::printf(" PASSED\n"); +} + +static void test_pick() +{ + std::printf("[2/5] File choice: given index, largest video, fallbacks...\n"); + CHECK(torrserver::pickFile(kStat, 0) == 1); // engine index -> 1-based id + CHECK(torrserver::pickFile(kStat, 4) == 5); + CHECK(torrserver::pickFile(kStat, -1) == 3); // largest *video*, not the zip + CHECK(torrserver::pickFile(R"({"file_stats":[{"id":7,"path":"a.iso","length":5}]})", -1) == 7); + CHECK(torrserver::pickFile(R"({"file_stats":[]})", -1) == -1); + CHECK(torrserver::pickFile("not json", -1) == -1); + std::printf(" PASSED\n"); +} + +static void test_resolve() +{ + std::printf("[3/5] Resolve: index known -> no request; unknown -> stat first...\n"); + const std::string base = "http://nas:8090"; + const std::string magnet = "magnet:?xt=urn:btih:abc&dn=Show S01&tr=udp://t:1"; + std::string url, err; + + g_gets = 0; + CHECK(torrserver::resolve(base, magnet, 2, url, err)); + CHECK(g_gets == 0); + CHECK(url == "http://nas:8090/stream?link=magnet%3A%3Fxt%3Durn%3Abtih%3Aabc%26dn%3DShow%20S01" + "%26tr%3Dudp%3A%2F%2Ft%3A1&index=3&play"); + + g_answer = kStat; + CHECK(torrserver::resolve(base, magnet, -1, url, err)); + CHECK(g_gets == 1); + CHECK(g_lastUrl.find("&stat") != std::string::npos); + CHECK(g_lastTimeout >= 60); // waits for the metadata + CHECK(url.size() > 11 && url.compare(url.size() - 13, 13, "&index=3&play") == 0); + std::printf(" PASSED\n"); +} + +static void test_resolve_errors() +{ + std::printf("[4/5] Resolve failures carry a reason...\n"); + std::string url, err; + g_answer.clear(); + CHECK(!torrserver::resolve("http://nas:8090", "magnet:?xt=urn:btih:abc", -1, url, err)); + CHECK(err.find("TorrServer") != std::string::npos); + CHECK(err.find("refused") != std::string::npos); + g_answer = R"({"file_stats":[]})"; + CHECK(!torrserver::resolve("http://nas:8090", "magnet:?xt=urn:btih:abc", -1, url, err)); + CHECK(err.find("no files") != std::string::npos); + std::printf(" PASSED\n"); +} + +static void test_local_hosts() +{ + std::printf("[5/5] Proxy bypass: which hosts count as local...\n"); + using netutil::hostOf; + using netutil::isLocalHost; + CHECK(hostOf("http://user:pw@NAS.Lan:8090/stream?x") == "nas.lan"); + CHECK(hostOf("https://[::1]:443/") == "::1"); + CHECK(hostOf("http://192.168.1.10:9117") == "192.168.1.10"); + + const char* local[] = { "localhost", "127.0.0.1", "10.0.0.5", "192.168.1.10", + "172.16.0.1", "172.31.255.255", "169.254.3.4", "nas", + "nas.local", "media.lan", "::1" }; + for (const char* h : local) + { + if (!isLocalHost(h)) std::fprintf(stderr, " not local: %s\n", h); + CHECK(isLocalHost(h)); + } + const char* remote[] = { "api.strem.io", "8.8.8.8", "172.32.0.1", "172.15.0.1", + "192.169.1.1", "1.2.3.4.5", "256.1.1.1", "" }; + for (const char* h : remote) + { + if (isLocalHost(h)) std::fprintf(stderr, " local: %s\n", h); + CHECK(!isLocalHost(h)); + } + std::printf(" PASSED\n"); +} + +int main() +{ + std::printf("=========================================\n"); + std::printf(" NX TORRENT PLAYER: TORRSERVER / NETWORK \n"); + std::printf("=========================================\n\n"); + test_normalize(); + test_pick(); + test_resolve(); + test_resolve_errors(); + test_local_hosts(); + std::printf("\nALL TESTS PASSED\n"); + return 0; +} From bdbc091ba56e9cf4433d591f2ac734b5f56b232e Mon Sep 17 00:00:00 2001 From: Langegen Date: Tue, 27 Oct 2026 07:40:47 +0300 Subject: [PATCH 04/13] Add the TMDB home screen and title card, laid out like the roadmap mock-ups Home tab (app/home.cpp), first in the header and the default on startup - A hero banner for the top trending title: backdrop darkened from the left, title, meta line, synopsis, "Watch" and "Details". - "Continue watching" from the local history as 16:9 cards with progress. - Poster rows: popular now, popular films / shows, in cinemas, top rated films / shows; each row loads on its own. - "-" searches TMDB (films and shows on a results page). - Without a TMDB key: a note on where to get and enter one. Title card (app/card.cpp) - Full-screen backdrop shaded left and bottom, poster, title, chips (TMDB rating, year, genres, runtime or seasons/episodes), synopsis, cast, "Watch" and "Torrents"; for a series a season switcher (buttons and L/R, focus parked before the strip is rebuilt) and the season's episodes. - Opens at once from the list data, fills in from the details. - Sources: the Stremio addons by IMDb id when signed in (existing film and episode screens), otherwise a Jackett search by title (+ year / SxxEyy). TMDB client (app/tmdb.cpp, app/content.hpp) - Trending, popular, top rated, now playing, multi search, details with external_ids and credits, seasons; in the UI language. - v3 key or v4 token, from Options -> Network or the build (-DTMDB_API_KEY, CI secret TMDB_API_KEY); the API address can be a mirror; goes through the proxy. - Answers cached on the SD card (lists 6 h, details 3 days); offline, any cached copy is used. - json::topLevel / json::object: TMDB nests "name" (created_by, genres, crew) ahead of the title's own. Shared widgets (app/widgets.*) - FadeStrip and TopRoundedImage moved out of stremio.cpp unchanged; new shade box, poster and wide cards, chips, action buttons. - Jackett: openJackettSearch() opens the search screen on a query. - Poster memory: borealis' refcounted LRU texture cache already bounds it; rows use TMDB's w185 posters. Also: Russian strings for all of it; Options -> Network -> TMDB (key, address, test, clear cache); pctest/test_tmdb.cpp (6 tests on trimmed real answers, 23 offline tests in all). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 6 +- .gitignore | 2 + CLAUDE.md | 4 +- CMakeLists.txt | 7 +- README.md | 1 + README.ru.md | 1 + app/appdata.hpp | 3 + app/card.cpp | 529 ++++++++++++++++++++++++++++++++++++ app/card.hpp | 17 ++ app/config.cpp | 17 +- app/config.hpp | 10 +- app/content.hpp | 82 ++++++ app/home.cpp | 378 ++++++++++++++++++++++++++ app/home.hpp | 12 + app/i18n_ru.cpp | 55 ++++ app/jackett_tab.cpp | 31 +++ app/jackett_tab.hpp | 6 + app/json.cpp | 89 ++++++ app/json.hpp | 8 + app/main.cpp | 27 +- app/settings.cpp | 92 ++++++- app/stremio.cpp | 81 +----- app/tmdb.cpp | 465 +++++++++++++++++++++++++++++++ app/tmdb.hpp | 120 ++++++++ app/widgets.cpp | 220 +++++++++++++++ app/widgets.hpp | 169 ++++++++++++ pctest/run_tests.sh | 10 + pctest/test_tmdb.cpp | 234 ++++++++++++++++ 28 files changed, 2581 insertions(+), 95 deletions(-) create mode 100644 app/card.cpp create mode 100644 app/card.hpp create mode 100644 app/content.hpp create mode 100644 app/home.cpp create mode 100644 app/home.hpp create mode 100644 app/tmdb.cpp create mode 100644 app/tmdb.hpp create mode 100644 app/widgets.cpp create mode 100644 app/widgets.hpp create mode 100644 pctest/test_tmdb.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 812ce48..975cb7e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,8 +59,12 @@ jobs: switch-libmpv switch-curl switch-libwebp switch-mbedtls \ switch-glfw switch-mesa + # TMDB_API_KEY (a repository secret, optional): the catalog key a release + # ships with. Without it users enter their own in Options -> Network. - name: Configure - run: cmake -B build -DPLATFORM_SWITCH=ON -DCMAKE_BUILD_TYPE=Release + env: + TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} + run: cmake -B build -DPLATFORM_SWITCH=ON -DCMAKE_BUILD_TYPE=Release -DTMDB_API_KEY="$TMDB_API_KEY" - name: Build run: cmake --build build --target NX-torrent-player.nro -j "$(nproc)" diff --git a/.gitignore b/.gitignore index ddd6e53..422ebc0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ /pctest/test_httpsrc.exe /pctest/test_net /pctest/test_net.exe +/pctest/test_tmdb +/pctest/test_tmdb.exe diff --git a/CLAUDE.md b/CLAUDE.md index b636cac..9988f60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,9 @@ rewrites the container paths. loading screen + seek; torrents, direct http(s) links, TorrServer), `stremio` (API, library, artwork), `browse` (the Stremio drill-down), `settings`, `config` (config.json), `http` (curl + proxy), `torrserver`, `i18n` (+ - `i18n_ru.cpp`, one table per language) + `i18n_ru.cpp`, one table per language), `home` (the TMDB home tab), `card` + (film/series card), `tmdb` (client + SD cache), `content` (the model the + catalog screens share), `widgets` (strips, poster/wide cards, chips, shades) - `engine/` — the torrent engine (C): `torrentfs` (piece picking, cache, the streaming read), `peer`, `swarm`, `torrent`, `udp_tracker`, `dht`, `magnet`, `stream` (the mpv `stream_cb` protocols: `torrent://` and `nxhttps://`), diff --git a/CMakeLists.txt b/CMakeLists.txt index dbd140b..ef75c3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,10 @@ set(UPDATE_API_URL "https://api.github.com/repos/${UPDATE_REPO}/releases/latest" # A tag's release (its changelog), the tag appended. Same repo as above. set(UPDATE_TAG_URL "https://api.github.com/repos/${UPDATE_REPO}/releases/tags/" CACHE STRING "Release-by-tag endpoint, the tag is appended") +# The TMDB key a build ships with (a v3 key or a v4 read token), used when the +# user has not entered one in Options. Empty by default: the home screen then +# asks for one. CI can pass it from a secret: -DTMDB_API_KEY=... +set(TMDB_API_KEY "" CACHE STRING "Default TMDB API key / read token") set(PROJECT_ICON ${CMAKE_CURRENT_SOURCE_DIR}/assets/icon.jpg) set(PROJECT_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/library/borealis/resources) @@ -88,7 +92,8 @@ target_compile_options(${PROJECT_NAME} PRIVATE -Wformat -Werror=format) target_compile_definitions(${PROJECT_NAME} PRIVATE APP_VERSION="${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_ALTER}" UPDATE_API_URL="${UPDATE_API_URL}" - UPDATE_TAG_URL="${UPDATE_TAG_URL}") + UPDATE_TAG_URL="${UPDATE_TAG_URL}" + TMDB_API_KEY="${TMDB_API_KEY}") if (PLATFORM_SWITCH) # Bundle borealis' resources into the nro's romfs (borealis reads them from diff --git a/README.md b/README.md index 057c0fa..25f7b1f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Whether you stream through **Stremio** catalogs and addons, search across dozens - 🕒 **Watch History & Resume Playback** — Dedicated **History** tab tracking your recently watched videos across all sources (Local, Jackett, Stremio). Automatically remembers your playback position so you can resume right where you left off. - 📂 **Multi-File Selection & Natural Sorting** — Smart file picker for torrents and magnets containing multiple videos with natural alphanumeric episode sorting (e.g., Episode 1, 2, ..., 10 instead of 1, 10, 2). - ⚡ **Enhanced Engine & Tracker Pooling** — Background announce worker pool supporting concurrent announces across 30+ trackers, asynchronous metadata resolution with cancelable progress dialogs, and robust DHT peer search fallback. +- 🏠 **Home Screen & Title Cards (TMDB)** — A Home tab in the style of Lampa: a hero banner, *Continue watching*, and rows of trending, popular and top-rated films and shows from TMDB in your language; each title opens a card with backdrop, rating, genres, synopsis, cast, seasons and episodes, and finds sources through your Stremio addons or Jackett. Needs a free TMDB API key (**Options → Network → TMDB**); answers are cached on the SD card, so the home screen opens instantly and offline. - 🔗 **Direct Video Links** — Stremio addon streams that are plain `http(s)` URLs (debrid services and the like) now play, with seeking. `https` goes through the app's own curl with certificate checks, since the Switch's FFmpeg has no `https` of its own. - 🖥️ **TorrServer** — Set your TorrServer address in **Options → Network** and every torrent (Local, Jackett, Stremio) is streamed by that server and played on the console as a regular video stream. - 🌐 **Proxy** — An HTTP or SOCKS proxy for catalogs, addons, posters and `https` video links (**Options → Network**); servers on your local network are always reached directly. diff --git a/README.ru.md b/README.ru.md index ec52a0a..7a6f058 100644 --- a/README.ru.md +++ b/README.ru.md @@ -39,6 +39,7 @@ - 🕒 **История просмотров и возобновление** — Вкладка **History** отслеживает просмотренные видео из всех источников (локальные, Jackett, Stremio). Запоминается прогресс и позиция, позволяя продолжить просмотр в один клик. - 📂 **Умный выбор файлов и естественная сортировка** — Удобное окно выбора файлов для раздач с несколькими сериями/файлами. Сортировка учитывает числа в названиях (серии 1, 2, ..., 10 идут по порядку, а не 1, 10, 2). - ⚡ **Улучшенный движок и опрос трекеров** — Фоновый пул потоков для одновременного анонса на более чем 30 трекерах, асинхронное получение метаданных для magnet-ссылок с возможностью отмены, а также фолбэк на DHT для бестрекерных раздач. +- 🏠 **Главная и карточки (TMDB)** — Вкладка «Главная» в духе Lampa: большой баннер, «Продолжить просмотр» и ряды трендов, популярных и лучших фильмов и сериалов из TMDB на вашем языке. Каждое название открывает карточку с фоном, рейтингом, жанрами, описанием, актёрами, сезонами и сериями, а источники ищутся через аддоны Stremio или Jackett. Нужен бесплатный ключ TMDB (**Options → Network → TMDB**). Ответы кэшируются на SD-карте, поэтому главная открывается мгновенно и без сети. - 🔗 **Прямые ссылки на видео** — Источники аддонов Stremio в виде обычных `http(s)`-ссылок (дебрид-сервисы и подобные) теперь воспроизводятся, с перемоткой. `https` идёт через собственный curl приложения с проверкой сертификатов: в FFmpeg для Switch своего `https` нет. - 🖥️ **TorrServer** — Укажите адрес TorrServer в **Options → Network**, и любой торрент (локальный, из Jackett или Stremio) будет качать этот сервер, а консоль получит обычный видеопоток. - 🌐 **Прокси** — HTTP- или SOCKS-прокси для каталогов, аддонов, постеров и `https`-ссылок на видео (**Options → Network**). К серверам в локальной сети подключение всегда напрямую. diff --git a/app/appdata.hpp b/app/appdata.hpp index 3049dae..e47c250 100644 --- a/app/appdata.hpp +++ b/app/appdata.hpp @@ -26,6 +26,9 @@ // resume later, and a single shared temp file made every entry open whichever // torrent was played last. #define APPDATA_JACKETT APPDATA_DIR "/jackett" +// TMDB answers, one .json per request (see tmdb.cpp): fresh copies replace +// the network, stale ones stand in for it when it is down. +#define APPDATA_TMDB APPDATA_DIR "/tmdb" // Watch history and saved playback positions. #define APPDATA_HISTORY APPDATA_DIR "/history.json" diff --git a/app/card.cpp b/app/card.cpp new file mode 100644 index 0000000..da4a2e4 --- /dev/null +++ b/app/card.cpp @@ -0,0 +1,529 @@ +#include "card.hpp" + +#include + +#include +#include + +#include "browse.hpp" // openLibraryItem / openEpisodeById: Stremio sources +#include "config.hpp" +#include "i18n.hpp" +#include "jackett.hpp" // jackett::Category +#include "jackett_tab.hpp" // openJackettSearch +#include "player.hpp" // PlayerArt +#include "stremio.hpp" +#include "theme.hpp" +#include "tmdb.hpp" +#include "widgets.hpp" + +namespace +{ + +// Horizon dark, as in the mock-ups: what the backdrop is darkened towards, so +// the white text on it reads in either theme. +const NVGcolor kInk = nvgRGB(7, 7, 26); + +NVGcolor ink(int alpha) +{ + NVGcolor c = kInk; + c.a = alpha / 255.0f; + return c; +} + +void note(const std::string& msg) +{ + auto* d = new brls::Dialog(msg); + d->addButton("OK", []() {}); + d->open(); +} + +std::string twoDigits(int n) +{ + char b[16]; + std::snprintf(b, sizeof(b), "%02d", n); + return b; +} + +std::string runtimeText(int min) +{ + if (min <= 0) return ""; + if (min < 60) return std::to_string(min) + tr(" min"); + return std::to_string(min / 60) + tr(" h ") + std::to_string(min % 60) + tr(" min"); +} + +std::string ratingText(double r) +{ + char b[16]; + std::snprintf(b, sizeof(b), "TMDB %.1f", r); + return b; +} + +// Where an episode or a film is looked for. Stremio's addons first: they key +// on the IMDb id and answer with ready sources. Jackett otherwise, searched by +// name. Neither set up: say what would make this work. +void openSources(const content::Item& it, int season, int episode) +{ + std::string authKey = stremio::loadAuthKey(); + if (!authKey.empty() && !it.imdbId.empty()) + { + if (!it.isSeries()) + { + stremio::LibItem li; + li.id = it.imdbId; + li.name = it.title; + li.type = "movie"; + li.poster = it.posterUrlLarge; + li.year = it.year; + openLibraryItem(authKey, li); + return; + } + if (season > 0 || episode > 0) + { + PlayerArt art; + art.posterPath = stremio::cachedPosterPath(it.key()); + art.bgId = it.key() + "_bd"; + art.bgUrl = it.backdropUrl; + std::string vid = it.imdbId + ":" + std::to_string(season) + ":" + + std::to_string(episode); + openEpisodeById(authKey, it.imdbId, vid, art); + return; + } + } + + if (!config::get().jackettUrl.empty()) + { + std::string q = it.title; + if (!it.isSeries() && !it.year.empty()) q += " " + it.year; + if (it.isSeries() && season > 0 && episode > 0) + q += " S" + twoDigits(season) + "E" + twoDigits(episode); + openJackettSearch(q, (int)(it.isSeries() ? jackett::Category::SERIES + : jackett::Category::MOVIES)); + return; + } + + note(tr("To find sources, sign in to Stremio or set up Jackett in Options.")); +} + +// The card's state, shared by the callbacks that fill it in. +struct CardState +{ + content::Details d; + bool detailsLoaded = false; + int seasonIdx = 0; // into d.seasons + int seasonReq = 0; // bumps on every switch: late answers are dropped + std::shared_ptr alive = std::make_shared(true); + + brls::Image* backdrop = nullptr; + brls::Box* chips = nullptr; + brls::Label* overview = nullptr; + brls::Label* cast = nullptr; + brls::Button* playBtn = nullptr; + brls::Box* seasonsSection = nullptr; + brls::Box* seasonBar = nullptr; + std::vector seasonBtns; + brls::Box* episodes = nullptr; +}; + +class CardView : public brls::Box +{ + public: + explicit CardView(const content::Item& item) + { + st = std::make_shared(); + st->d.item = item; + alive = st->alive; + + setAxis(brls::Axis::COLUMN); + setGrow(1.0f); + + // The backdrop under everything, then the shade that darkens its left + // side for the text and its bottom for the episode strip. + st->backdrop = new brls::Image(); + st->backdrop->setPositionType(brls::PositionType::ABSOLUTE); + st->backdrop->setPositionTop(0.0f); + st->backdrop->setPositionLeft(0.0f); + st->backdrop->setWidthPercentage(100.0f); + st->backdrop->setHeightPercentage(100.0f); + st->backdrop->setScalingType(brls::ImageScalingType::FILL); + st->backdrop->setClipsToBounds(true); + addView(st->backdrop); + + auto* shadeLeft = new ui::ShadeBox(true, ink(250), ink(60), 0.85f); + absoluteFill(shadeLeft); + addView(shadeLeft); + auto* shadeBottom = new ui::ShadeBox(false, ink(0), ink(235), 1.0f); + absoluteFill(shadeBottom); + addView(shadeBottom); + + auto* scroll = new brls::ScrollingFrame(); + scroll->setGrow(1.0f); + scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + auto* body = new brls::Box(); + body->setAxis(brls::Axis::COLUMN); + body->setPadding(34.0f, 60.0f, 40.0f, 60.0f); + buildTop(body); + buildSeasons(body); + scroll->setContentView(body); + addView(scroll); + + ui::attachArt(st->backdrop, item.key() + "_bd", item.backdropUrl, alive, true); + + // L / R walk the seasons (the mock-up's hint bar). + registerAction(tr("Season"), brls::BUTTON_LB, [this](brls::View*) { + switchSeason(-1); + return true; + }); + registerAction(tr("Season"), brls::BUTTON_RB, [this](brls::View*) { + switchSeason(+1); + return true; + }); + + loadDetails(); + } + + ~CardView() override { *alive = false; } + + private: + std::shared_ptr st; + std::shared_ptr alive; + + static void absoluteFill(brls::View* v) + { + v->setPositionType(brls::PositionType::ABSOLUTE); + v->setPositionTop(0.0f); + v->setPositionLeft(0.0f); + v->setWidthPercentage(100.0f); + v->setHeightPercentage(100.0f); + } + + static brls::Label* whiteLabel(const std::string& text, float size, int alpha) + { + auto* l = new brls::Label(); + l->setText(text); + l->setFontSize(size); + l->setTextColor(nvgRGBA(255, 255, 255, alpha)); + return l; + } + + void buildTop(brls::Box* body) + { + const content::Item& it = st->d.item; + + auto* top = new brls::Box(); + top->setAxis(brls::Axis::ROW); + top->setAlignItems(brls::AlignItems::FLEX_START); + + auto* poster = new brls::Image(); + poster->setDimensions(220.0f, 330.0f); + poster->setScalingType(brls::ImageScalingType::FILL); + poster->setClipsToBounds(true); + poster->setCornerRadius(12.0f); + poster->setMarginRight(40.0f); + poster->setShadowType(brls::ShadowType::GENERIC); + poster->setShadowVisibility(true); + ui::attachArt(poster, it.key(), it.posterUrlLarge, alive); + top->addView(poster); + + auto* info = new brls::Box(); + info->setAxis(brls::Axis::COLUMN); + info->setGrow(1.0f); + info->setShrink(1.0f); + + info->addView(whiteLabel(it.title, 40.0f, 255)); + + st->chips = new brls::Box(); + st->chips->setAxis(brls::Axis::ROW); + st->chips->setMarginTop(16.0f); + info->addView(st->chips); + fillChips(); + + st->overview = whiteLabel(trimmed(it.overview), 19.0f, 225); + st->overview->setMarginTop(18.0f); + info->addView(st->overview); + + st->cast = whiteLabel("", 16.0f, 165); + st->cast->setMarginTop(10.0f); + st->cast->setVisibility(brls::Visibility::GONE); + info->addView(st->cast); + + auto* actions = new brls::Box(); + actions->setAxis(brls::Axis::ROW); + actions->setMarginTop(24.0f); + + auto weak = std::weak_ptr(st); + st->playBtn = ui::actionButton(playLabel(), true, [weak]() { + if (auto s = weak.lock()) watch(*s); + }); + actions->addView(st->playBtn); + + actions->addView(ui::actionButton(tr("Torrents"), false, [weak]() { + auto s = weak.lock(); + if (!s) return; + if (config::get().jackettUrl.empty()) + { + note(tr("Set up Jackett in Options to search torrents.")); + return; + } + content::Item i = s->d.item; + std::string q = i.title; + if (!i.isSeries() && !i.year.empty()) q += " " + i.year; + openJackettSearch(q, (int)(i.isSeries() ? jackett::Category::SERIES + : jackett::Category::MOVIES)); + })); + info->addView(actions); + + top->addView(info); + body->addView(top); + } + + static std::string trimmed(const std::string& s) + { + // A synopsis can run to a page; the card has room for a paragraph. + const size_t kMax = 420; + if (s.size() <= kMax) return s; + size_t cut = s.rfind(' ', kMax); + if (cut == std::string::npos) cut = kMax; + // Don't split a UTF-8 sequence. + while (cut > 0 && ((unsigned char)s[cut] & 0xC0) == 0x80) cut--; + return s.substr(0, cut) + "..."; + } + + std::string playLabel() const + { + const content::Details& d = st->d; + if (d.item.isSeries() && !d.seasons.empty()) + { + const content::Season& s = d.seasons[0]; + return std::string("\xE2\x96\xB6 ") + tr("Watch S") + std::to_string(s.number) + + tr(" E") + "1"; + } + return std::string("\xE2\x96\xB6 ") + tr("Watch"); + } + + void fillChips() + { + st->chips->clearViews(); + const content::Details& d = st->d; + const content::Item& it = d.item; + if (it.rating > 0.05) st->chips->addView(ui::chip(ratingText(it.rating), true)); + if (!it.year.empty()) st->chips->addView(ui::chip(it.year)); + for (size_t i = 0; i < it.genres.size() && i < 2; i++) + st->chips->addView(ui::chip(it.genres[i])); + if (it.isSeries()) + { + int seasons = 0, eps = 0; + for (const auto& s : d.seasons) + if (s.number > 0) { seasons++; eps += s.episodeCount; } + // "Seasons: 1 · Episodes: 8" rather than "1 season": no plural + // forms to get wrong in any language. + if (seasons > 0) + st->chips->addView(ui::chip(tr("Seasons: ") + std::to_string(seasons) + + " \xC2\xB7 " + tr("Episodes: ") + + std::to_string(eps))); + } + else if (it.runtimeMin > 0) + st->chips->addView(ui::chip(runtimeText(it.runtimeMin))); + } + + void buildSeasons(brls::Box* body) + { + st->seasonsSection = new brls::Box(); + st->seasonsSection->setAxis(brls::Axis::COLUMN); + st->seasonsSection->setMarginTop(30.0f); + st->seasonsSection->setVisibility(brls::Visibility::GONE); + + st->seasonBar = new brls::Box(); + st->seasonBar->setAxis(brls::Axis::ROW); + st->seasonBar->setMarginBottom(14.0f); + st->seasonsSection->addView(st->seasonBar); + + st->episodes = new brls::Box(); + // 300 wide at 16:9, plus room for the focus glow. + st->seasonsSection->addView(ui::strip(st->episodes, 169.0f + 24.0f, false)); + body->addView(st->seasonsSection); + } + + void loadDetails() + { + auto weak = std::weak_ptr(st); + const content::Item& it = st->d.item; + tmdb::fetchDetailsAsync(it.kind, it.tmdbId, [this, weak](tmdb::DetailsResult r) { + auto s = weak.lock(); + if (!s || !*s->alive) return; + if (!r.ok) + { + brls::Logger::warning("[card] details: {}", r.error); + return; + } + // Keep what the list had where the details are thinner (a list + // backdrop when the details lack one, say). + content::Item before = s->d.item; + s->d = r.details; + if (s->d.item.backdropUrl.empty()) s->d.item.backdropUrl = before.backdropUrl; + if (s->d.item.overview.empty()) s->d.item.overview = before.overview; + s->detailsLoaded = true; + + fillChips(); + s->overview->setText(trimmed(s->d.item.overview)); + if (!s->d.cast.empty()) + { + std::string c = tr("Starring: "); + for (size_t i = 0; i < s->d.cast.size() && i < 4; i++) + c += (i ? ", " : "") + s->d.cast[i]; + s->cast->setText(c); + s->cast->setVisibility(brls::Visibility::VISIBLE); + } + s->playBtn->setText(playLabel()); + if (s->d.item.isSeries() && !s->d.seasons.empty()) showSeasons(); + }); + } + + void showSeasons() + { + st->seasonsSection->setVisibility(brls::Visibility::VISIBLE); + st->seasonBar->clearViews(); + st->seasonBtns.clear(); + for (size_t i = 0; i < st->d.seasons.size(); i++) + { + const content::Season& s = st->d.seasons[i]; + std::string name = s.number == 0 ? tr("Specials") + : tr("Season ") + std::to_string(s.number); + int idx = (int)i; + auto* b = ui::actionButton(name, false, [this, idx]() { selectSeason(idx); }); + st->seasonBar->addView(b); + st->seasonBtns.push_back(b); + } + selectSeason(0); + } + + void switchSeason(int dir) + { + if (st->seasonBtns.empty()) return; + int n = (int)st->seasonBtns.size(); + int idx = st->seasonIdx + dir; + if (idx < 0 || idx >= n) return; + // The focus may be on an episode about to be freed: move it to the + // new season's button first (a freed focused view crashes borealis). + brls::Application::giveFocus(st->seasonBtns[idx]); + selectSeason(idx); + } + + void selectSeason(int idx) + { + if (idx < 0 || idx >= (int)st->d.seasons.size()) return; + // An episode card can hold the focus when a season button is pressed + // by touch; park it on that button before the strip is emptied. + brls::View* f = brls::Application::getCurrentFocus(); + for (brls::View* v = f; v; v = v->getParent()) + if (v == st->episodes) + { + brls::Application::giveFocus(st->seasonBtns[idx]); + break; + } + + st->seasonIdx = idx; + for (size_t i = 0; i < st->seasonBtns.size(); i++) + st->seasonBtns[i]->setStyle((int)i == idx ? &brls::BUTTONSTYLE_PRIMARY + : &brls::BUTTONSTYLE_BORDERED); + + st->episodes->clearViews(); + auto* wait = whiteLabel(tr("Loading..."), 18.0f, 180); + wait->setMarginLeft(4.0f); + st->episodes->addView(wait); + + int req = ++st->seasonReq; + int number = st->d.seasons[idx].number; + auto weak = std::weak_ptr(st); + tmdb::fetchSeasonAsync(st->d.item.tmdbId, number, + [this, weak, req](tmdb::SeasonResult r) { + auto s = weak.lock(); + if (!s || !*s->alive || req != s->seasonReq) return; + s->episodes->clearViews(); // only the "Loading" label is there + if (!r.ok || r.episodes.empty()) + { + s->episodes->addView(whiteLabel(r.ok ? tr("No episodes") : r.error, 18.0f, 180)); + return; + } + for (const auto& e : r.episodes) + { + std::string title = std::to_string(e.number) + ". " + + (e.title.empty() ? tr("Episode ") + std::to_string(e.number) + : e.title); + std::string id = s->d.item.key() + "_s" + std::to_string(e.season) + + "e" + std::to_string(e.number); + int season = e.season, number = e.number; + auto onClick = [weak, season, number]() { + if (auto c = weak.lock()) openSources(c->d.item, season, number); + }; + s->episodes->addView(ui::wideCard(title, id, e.stillUrl, "", -1.0, + s->alive, onClick, 300.0f)); + } + }); + } + + // The main button: a film's sources, or a series' first episode of the + // first season (the details have to be in: that is where the IMDb id and + // the seasons come from). + static void watch(CardState& s) + { + if (!s.detailsLoaded) + { + note(tr("Still loading, try again in a moment.")); + return; + } + if (!s.d.item.isSeries()) + { + openSources(s.d.item, 0, 0); + return; + } + if (s.d.seasons.empty()) + { + note(tr("No episodes")); + return; + } + openSources(s.d.item, s.d.seasons[0].number, 1); + } +}; + +class CardActivity : public brls::Activity +{ + public: + explicit CardActivity(content::Item item) : item(std::move(item)) {} + + brls::View* createContentView() override + { + auto* frame = new brls::AppletFrame(new CardView(item)); + frame->setTitle(""); + return frame; + } + + private: + content::Item item; +}; + +} // namespace + +void openContentCard(const content::Item& item) +{ + brls::Application::pushActivity(new CardActivity(item)); +} + +void watchContent(const content::Item& item) +{ + if (item.isSeries()) + { + openContentCard(item); + return; + } + if (!item.imdbId.empty()) + { + openSources(item, 0, 0); + return; + } + // A list item has no IMDb id yet: the details (usually cached) carry it. + tmdb::fetchDetailsAsync(item.kind, item.tmdbId, [item](tmdb::DetailsResult r) { + content::Item it = item; + if (r.ok) it.imdbId = r.details.item.imdbId; + openSources(it, 0, 0); + }); +} diff --git a/app/card.hpp b/app/card.hpp new file mode 100644 index 0000000..9e2ba44 --- /dev/null +++ b/app/card.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "content.hpp" + +// The film / series card (roadmap mock-up "Карточка"): the backdrop across the +// screen darkened on the left, the poster, the title, a row of chips (TMDB +// rating, year, genres, length or seasons), the synopsis and the actions; for +// a series a season switcher (L/R) and that season's episodes as stills. +// +// Opens at once with what the list already knew and fills in the rest +// (genres, runtime, seasons, the IMDb id) when the details arrive. +void openContentCard(const content::Item& item); + +// "Watch" without the card: the hero's button on the home screen. A film goes +// straight to its sources; a series opens the card, since an episode has to +// be picked first. +void watchContent(const content::Item& item); diff --git a/app/config.cpp b/app/config.cpp index 2574754..a905c95 100644 --- a/app/config.cpp +++ b/app/config.cpp @@ -242,14 +242,18 @@ void load() while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) body.append(buf, n); std::fclose(f); - std::string sTab = readStr(body, "startupTab", "local"); + std::string sTab = readStr(body, "startupTab", "home"); cfg.startupTab = (sTab == "stremio") ? Tab::STREMIO : (sTab == "jackett") ? Tab::JACKETT : - (sTab == "history") ? Tab::HISTORY : Tab::LOCAL; + (sTab == "history") ? Tab::HISTORY : + (sTab == "home") ? Tab::HOME : Tab::LOCAL; cfg.jackettUrl = readStr(body, "jackettUrl", cfg.jackettUrl); cfg.jackettApiKey = readStr(body, "jackettApiKey", cfg.jackettApiKey); cfg.torrServerUrl = readStr(body, "torrServerUrl", cfg.torrServerUrl); cfg.proxyUrl = readStr(body, "proxyUrl", cfg.proxyUrl); + cfg.tmdbApiKey = readStr(body, "tmdbApiKey", cfg.tmdbApiKey); + cfg.tmdbApiUrl = readStr(body, "tmdbApiUrl", cfg.tmdbApiUrl); + cfg.tmdbImageUrl = readStr(body, "tmdbImageUrl", cfg.tmdbImageUrl); cfg.logging = readBool(body, "logging", cfg.logging); cfg.hide4k = readBool(body, "hide4k", cfg.hide4k); cfg.rateGovernor = readBool(body, "rateGovernor", cfg.rateGovernor); @@ -289,7 +293,8 @@ bool save() } const char* sTabStr = (cfg.startupTab == Tab::STREMIO) ? "stremio" : (cfg.startupTab == Tab::JACKETT) ? "jackett" : - (cfg.startupTab == Tab::HISTORY) ? "history" : "local"; + (cfg.startupTab == Tab::HISTORY) ? "history" : + (cfg.startupTab == Tab::HOME) ? "home" : "local"; std::fprintf(f, "{\n" " \"startupTab\": \"%s\",\n" @@ -297,6 +302,9 @@ bool save() " \"jackettApiKey\": \"%s\",\n" " \"torrServerUrl\": \"%s\",\n" " \"proxyUrl\": \"%s\",\n" + " \"tmdbApiKey\": \"%s\",\n" + " \"tmdbApiUrl\": \"%s\",\n" + " \"tmdbImageUrl\": \"%s\",\n" " \"logging\": %s,\n" " \"hide4k\": %s,\n" " \"rateGovernor\": %s,\n" @@ -319,6 +327,9 @@ bool save() cfg.jackettApiKey.c_str(), cfg.torrServerUrl.c_str(), cfg.proxyUrl.c_str(), + cfg.tmdbApiKey.c_str(), + cfg.tmdbApiUrl.c_str(), + cfg.tmdbImageUrl.c_str(), cfg.logging ? "true" : "false", cfg.hide4k ? "true" : "false", cfg.rateGovernor ? "true" : "false", cfg.ramStream ? "true" : "false", diff --git a/app/config.hpp b/app/config.hpp index babd49c..3337c61 100644 --- a/app/config.hpp +++ b/app/config.hpp @@ -16,6 +16,7 @@ enum class Tab STREMIO = 1, JACKETT = 2, HISTORY = 3, + HOME = 4, // the TMDB catalog (home.cpp) }; // The default UI sizes, named so the Options screen can mark them in the list. @@ -27,7 +28,7 @@ constexpr int kDefaultHandheldUiWidth = 1280; struct Config { // Which category the browser opens on. - Tab startupTab = Tab::LOCAL; + Tab startupTab = Tab::HOME; // Writes the log to APPDATA_LOG. Off by default: it is unbuffered and the // engine dumps a [stats] line every 2s, so it writes to the SD card @@ -130,6 +131,13 @@ struct Config // "http://host:3128", "socks5h://host:1080". Empty: direct. Addresses on // the local network (Jackett, TorrServer) never go through it. std::string proxyUrl = ""; + + // TMDB: the key (v3 key or v4 token; empty = the build's default, if + // any), and optional mirrors for the API and the images (empty = TMDB's + // own). See tmdb.hpp. + std::string tmdbApiKey = ""; + std::string tmdbApiUrl = ""; + std::string tmdbImageUrl = ""; }; // The live settings. Mutate, then call save(). diff --git a/app/content.hpp b/app/content.hpp new file mode 100644 index 0000000..dd36629 --- /dev/null +++ b/app/content.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +// What the catalog screens (home, card) show, independent of where it came +// from. TMDB fills it today; the IMDb id is the bridge to everything that keys +// on it (Stremio addons, Cinemeta). +namespace content +{ + +enum class Kind +{ + Movie, + Series, +}; + +struct Item +{ + Kind kind = Kind::Movie; + int tmdbId = 0; + std::string imdbId; // "tt1234567"; often only known after details + std::string title; // in the UI language when TMDB has it + std::string originalTitle; + std::string year; // "2024"; "" when unknown + std::string date; // "2024-02-27" (release / first air date) + std::string overview; + std::string posterUrl; // small, for lists + std::string posterUrlLarge; // for the card + std::string backdropUrl; // full width, for the card background + double rating = 0.0; // 0..10, 0 = none + std::vector genres; // names; details only + int runtimeMin = 0; // film length or a typical episode + int seasonCount = 0; // series only + + bool isSeries() const { return kind == Kind::Series; } + // "movie" / "series": Stremio's names for the two. + const char* stremioType() const { return isSeries() ? "series" : "movie"; } + // A stable id for caches (posters): "tmdb_movie_123". + std::string key() const + { + return std::string(isSeries() ? "tmdb_tv_" : "tmdb_movie_") + + std::to_string(tmdbId); + } +}; + +struct Season +{ + int number = 0; // 0 = specials + std::string name; + int episodeCount = 0; + std::string airDate; + std::string posterUrl; +}; + +struct Episode +{ + int season = 0; + int number = 0; + std::string title; + std::string overview; + std::string airDate; + std::string stillUrl; + int runtimeMin = 0; + + // Stremio's id for it: "tt1234567:1:3". "" without the show's IMDb id. + std::string stremioId(const std::string& showImdb) const + { + if (showImdb.empty()) return ""; + return showImdb + ":" + std::to_string(season) + ":" + std::to_string(number); + } +}; + +// A film or a show with everything the card needs. +struct Details +{ + Item item; + std::vector seasons; // series only, specials last + std::vector cast; // a few names, top billing first +}; + +} // namespace content diff --git a/app/home.cpp b/app/home.cpp new file mode 100644 index 0000000..38f0600 --- /dev/null +++ b/app/home.cpp @@ -0,0 +1,378 @@ +#include "home.hpp" + +#include +#include +#include + +#include "card.hpp" +#include "history.hpp" +#include "i18n.hpp" +#include "player.hpp" // startPlaybackPrompt, for Continue watching +#include "theme.hpp" +#include "tmdb.hpp" +#include "widgets.hpp" + +namespace +{ + +// Where every row starts, so headings and first cards line up. +constexpr float kInset = 60.0f; +// Card widths. A poster is 2:3, a Continue card 16:9. +constexpr float kPosterW = 190.0f; +constexpr float kWideW = 330.0f; +// The strips are taller than their cards: the focus glow is drawn outside the +// card and the frame clips to itself. +constexpr float kGlow = 24.0f; + +const NVGcolor kInk = nvgRGB(7, 7, 26); + +NVGcolor ink(int alpha) +{ + NVGcolor c = kInk; + c.a = alpha / 255.0f; + return c; +} + +brls::Label* textLabel(const std::string& text, float size, NVGcolor color) +{ + auto* l = new brls::Label(); + l->setText(text); + l->setFontSize(size); + l->setTextColor(color); + return l; +} + +std::string shortText(const std::string& s, size_t max) +{ + if (s.size() <= max) return s; + size_t cut = s.rfind(' ', max); + if (cut == std::string::npos) cut = max; + while (cut > 0 && ((unsigned char)s[cut] & 0xC0) == 0x80) cut--; + return s.substr(0, cut) + "..."; +} + +// "2024 · Film · TMDB 8.1", the hero's meta line. +std::string heroMeta(const content::Item& it) +{ + std::string m = it.year; + auto add = [&m](const std::string& s) { + if (s.empty()) return; + if (!m.empty()) m += " \xC2\xB7 "; + m += s; + }; + add(it.isSeries() ? tr("Show") : tr("Movie")); + if (it.rating > 0.05) + { + char b[16]; + std::snprintf(b, sizeof(b), "TMDB %.1f", it.rating); + add(b); + } + return m; +} + +class HomeRoot : public brls::Box +{ + public: + HomeRoot() + { + setAxis(brls::Axis::COLUMN); + setGrow(1.0f); + + auto* scroll = new brls::ScrollingFrame(); + scroll->setGrow(1.0f); + scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + list = new brls::Box(); + list->setAxis(brls::Axis::COLUMN); + list->setPadding(14.0f, 0.0f, 36.0f, 0.0f); + scroll->setContentView(list); + addView(scroll); + + hero = new brls::Box(); + hero->setVisibility(brls::Visibility::GONE); + list->addView(hero); + + addContinueRow(); + + if (!tmdb::configured()) + { + addSetupNote(); + return; + } + + registerAction(tr("Search"), brls::BUTTON_BACK, [this](brls::View*) { + promptSearch(); + return true; + }); + + // The trending row also feeds the hero. + brls::Box* trending = addRow(tr("Popular now")); + tmdb::fetchRowAsync(tmdb::Row::Trending, [this, trending, a = alive](tmdb::ListResult r) { + if (!*a) return; + fillRow(trending, r); + for (const auto& it : r.items) + if (!it.backdropUrl.empty()) + { + buildHero(it); + break; + } + }); + + const std::vector> rows = { + { "Popular movies", tmdb::Row::PopularMovies }, + { "Popular shows", tmdb::Row::PopularSeries }, + { "In cinemas", tmdb::Row::NowPlaying }, + { "Top rated movies", tmdb::Row::TopMovies }, + { "Top rated shows", tmdb::Row::TopSeries }, + }; + for (const auto& r : rows) + { + brls::Box* row = addRow(tr(r.first)); + tmdb::fetchRowAsync(r.second, [this, row, a = alive](tmdb::ListResult res) { + if (!*a) return; + fillRow(row, res); + }); + } + } + + ~HomeRoot() override { *alive = false; } + + private: + std::shared_ptr alive = std::make_shared(true); + brls::Box* list = nullptr; + brls::Box* hero = nullptr; + + // A titled strip, with a "Loading..." placeholder until fillRow. + brls::Box* addRow(const std::string& title) + { + list->addView(ui::heading(title, kInset)); + auto* row = new brls::Box(); + auto* wait = textLabel(tr("Loading..."), 18.0f, theme::textMuted()); + wait->setMarginLeft(kInset); + row->addView(wait); + list->addView(ui::strip(row, kPosterW * 1.5f + kGlow)); + return row; + } + + void fillRow(brls::Box* row, const tmdb::ListResult& r) + { + // Only the placeholder is in there: nothing focusable is freed. + row->clearViews(); + if (!r.ok || r.items.empty()) + { + auto* l = textLabel(r.ok ? tr("Nothing here") : r.error, 18.0f, + r.ok ? theme::textMuted() : theme::textWarn()); + l->setMarginLeft(kInset); + row->addView(l); + return; + } + for (const auto& it : r.items) + { + auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, alive, + [it]() { openContentCard(it); }, kPosterW); + if (row->getChildren().empty()) card->setMarginLeft(kInset); + row->addView(card); + } + } + + // The mock-up's banner: the backdrop, darkened from the left, with the + // title, a meta line, two lines of synopsis and the two actions. + void buildHero(const content::Item& it) + { + hero->clearViews(); + hero->setVisibility(brls::Visibility::VISIBLE); + hero->setHeight(350.0f); + hero->setMargins(4.0f, kInset, 8.0f, kInset); + hero->setCornerRadius(16.0f); + hero->setAxis(brls::Axis::COLUMN); + hero->setJustifyContent(brls::JustifyContent::FLEX_END); + + auto* bg = new brls::Image(); + bg->setPositionType(brls::PositionType::ABSOLUTE); + bg->setPositionTop(0.0f); + bg->setPositionLeft(0.0f); + bg->setWidthPercentage(100.0f); + bg->setHeightPercentage(100.0f); + bg->setScalingType(brls::ImageScalingType::FILL); + bg->setClipsToBounds(true); + bg->setCornerRadius(16.0f); + ui::attachArt(bg, it.key() + "_bd", it.backdropUrl, alive, true); + hero->addView(bg); + + auto* shade = new ui::ShadeBox(true, ink(245), ink(30), 0.75f); + shade->setCornerRadius(16.0f); + shade->setPositionType(brls::PositionType::ABSOLUTE); + shade->setPositionTop(0.0f); + shade->setPositionLeft(0.0f); + shade->setWidthPercentage(100.0f); + shade->setHeightPercentage(100.0f); + hero->addView(shade); + + auto* text = new brls::Box(); + text->setAxis(brls::Axis::COLUMN); + text->setPadding(0.0f, 36.0f, 30.0f, 36.0f); + text->setMaxWidth(760.0f); + + auto* title = textLabel(it.title, 42.0f, nvgRGBA(255, 255, 255, 255)); + title->setSingleLine(true); + text->addView(title); + auto* meta = textLabel(heroMeta(it), 18.0f, nvgRGBA(255, 255, 255, 200)); + meta->setMarginTop(8.0f); + text->addView(meta); + if (!it.overview.empty()) + { + auto* ov = textLabel(shortText(it.overview, 170), 18.0f, + nvgRGBA(255, 255, 255, 215)); + ov->setMarginTop(12.0f); + text->addView(ov); + } + + auto* actions = new brls::Box(); + actions->setAxis(brls::Axis::ROW); + actions->setMarginTop(18.0f); + actions->addView(ui::actionButton(std::string("\xE2\x96\xB6 ") + tr("Watch"), true, + [it]() { watchContent(it); })); + actions->addView(ui::actionButton(tr("Details"), false, + [it]() { openContentCard(it); })); + text->addView(actions); + hero->addView(text); + } + + // History entries that can be resumed, newest first, as 16:9 cards with + // the progress along the bottom. + void addContinueRow() + { + std::vector items; + for (const auto& it : history::all()) + if (history::hasValidResume(it)) items.push_back(it); + if (items.empty()) return; + if (items.size() > 12) items.resize(12); + + list->addView(ui::heading(tr("Continue watching"), kInset)); + auto* row = new brls::Box(); + for (const auto& it : items) + { + double prog = it.durationSec > 0 ? it.positionSec / it.durationSec : -1.0; + history::Item copy = it; + auto* card = ui::wideCard(it.title, "", "", it.posterPath, prog, alive, + [copy]() { + PlayerArt art; + art.posterPath = copy.posterPath; + WatchInfo w; + w.authKey = copy.stremioAuthKey; + w.itemId = copy.stremioItemId; + w.videoId = copy.stremioVideoId; + w.type = copy.stremioType; + w.displayTitle = copy.title; + startPlaybackPrompt(copy.source, art, copy.title, + copy.fileIndex, w, + copy.sourceType); + }, + kWideW); + if (row->getChildren().empty()) card->setMarginLeft(kInset); + row->addView(card); + } + list->addView(ui::strip(row, kWideW * 9.0f / 16.0f + kGlow)); + } + + void addSetupNote() + { + auto* box = new brls::Box(); + box->setAxis(brls::Axis::COLUMN); + box->setMargins(30.0f, kInset, 0.0f, kInset); + box->setPadding(26.0f, 30.0f, 26.0f, 30.0f); + box->setCornerRadius(14.0f); + box->setBackgroundColor(theme::scrim(20)); + box->addView(textLabel(tr("The catalog needs a TMDB key"), 26.0f, theme::text())); + auto* l = textLabel( + tr("Get a free API key at themoviedb.org (Settings -> API) and enter it in " + "Options -> Network. Where TMDB is blocked, set a proxy or a mirror there " + "too."), + 18.0f, theme::textBody()); + l->setMarginTop(10.0f); + box->addView(l); + list->addView(box); + } + + void promptSearch() + { + brls::Application::getImeManager()->openForText( + [a = alive](std::string q) { + if (!*a || q.empty()) return; + openSearchResults(q); + }, + tr("Search movies & shows..."), "", 128, ""); + } + + // The results on a page of their own: films and series as two strips. + static void openSearchResults(const std::string& query) + { + auto* page = new SearchPage(query); + auto* frame = new brls::AppletFrame(page); + frame->setTitle(tr("Search") + std::string(": ") + query); + brls::Application::pushActivity(new brls::Activity(frame)); + } + + class SearchPage : public brls::Box + { + public: + explicit SearchPage(const std::string& query) + { + setAxis(brls::Axis::COLUMN); + setGrow(1.0f); + auto* scroll = new brls::ScrollingFrame(); + scroll->setGrow(1.0f); + scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + auto* list = new brls::Box(); + list->setAxis(brls::Axis::COLUMN); + list->setPadding(10.0f, 0.0f, 30.0f, 0.0f); + scroll->setContentView(list); + addView(scroll); + + list->addView(ui::heading(tr("Movies"), kInset)); + auto* movies = new brls::Box(); + list->addView(ui::strip(movies, kPosterW * 1.5f + kGlow)); + list->addView(ui::heading(tr("Shows"), kInset)); + auto* shows = new brls::Box(); + list->addView(ui::strip(shows, kPosterW * 1.5f + kGlow)); + for (brls::Box* r : { movies, shows }) + { + auto* w = textLabel(tr("Searching..."), 18.0f, theme::textMuted()); + w->setMarginLeft(kInset); + r->addView(w); + } + + tmdb::searchAsync(query, [movies, shows, a = alive](tmdb::ListResult r) { + if (!*a) return; + movies->clearViews(); + shows->clearViews(); + for (const auto& it : r.items) + { + brls::Box* row = it.isSeries() ? shows : movies; + auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, a, + [it]() { openContentCard(it); }, kPosterW); + if (row->getChildren().empty()) card->setMarginLeft(kInset); + row->addView(card); + } + for (brls::Box* row : { movies, shows }) + if (row->getChildren().empty()) + { + auto* l = textLabel(r.ok ? tr("No results found") : r.error, 18.0f, + theme::textMuted()); + l->setMarginLeft(kInset); + row->addView(l); + } + }); + } + ~SearchPage() override { *alive = false; } + + private: + std::shared_ptr alive = std::make_shared(true); + }; +}; + +} // namespace + +brls::View* buildHomeTab() +{ + return new HomeRoot(); +} diff --git a/app/home.hpp b/app/home.hpp new file mode 100644 index 0000000..9a2341b --- /dev/null +++ b/app/home.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +// The Home tab (roadmap mock-up "Главный экран"): a hero banner for the top +// trending title, "Continue watching" from the local history, then TMDB rows +// (trending, popular and top-rated films and series, in cinemas). A opens a +// title's card, "-" searches TMDB. +// +// Without a TMDB key the rows are replaced by a note saying where to enter +// one; Continue watching works either way. +brls::View* buildHomeTab(); diff --git a/app/i18n_ru.cpp b/app/i18n_ru.cpp index 62d54f9..bcdc8d7 100644 --- a/app/i18n_ru.cpp +++ b/app/i18n_ru.cpp @@ -427,6 +427,61 @@ const Pair kRu[] = { { " – present", " – н. в." }, { "Could not remove it", "Не удалось удалить" }, + // ---- home (TMDB) --------------------------------------------------------- + { "Home", "Главная" }, + { "Popular now", "Популярное сейчас" }, + { "Popular movies", "Популярные фильмы" }, + { "Popular shows", "Популярные сериалы" }, + { "In cinemas", "Сейчас в кино" }, + { "Top rated movies", "Лучшие фильмы" }, + { "Top rated shows", "Лучшие сериалы" }, + { "Watch", "Смотреть" }, + { "Details", "Подробнее" }, + { "The catalog needs a TMDB key", "Для каталога нужен ключ TMDB" }, + { "Get a free API key at themoviedb.org (Settings -> API) and enter it in " + "Options -> Network. Where TMDB is blocked, set a proxy or a mirror there " + "too.", + "Получите бесплатный ключ API на themoviedb.org (Settings -> API) и " + "введите его в Настройки -> Сеть. Если TMDB заблокирован, там же укажите " + "прокси или зеркало." }, + + // ---- card --------------------------------------------------------------- + { "Torrents", "Торренты" }, + { "Season", "Сезон" }, + { "Watch S", "Смотреть С" }, + { " E", " Э" }, + { " min", " мин" }, + { " h ", " ч " }, + { "Seasons: ", "Сезонов: " }, + { "Episodes: ", "Серий: " }, + { "Starring: ", "В ролях: " }, + { "Still loading, try again in a moment.", + "Данные ещё загружаются, попробуйте через секунду." }, + { "Set up Jackett in Options to search torrents.", + "Настройте Jackett в настройках, чтобы искать торренты." }, + { "To find sources, sign in to Stremio or set up Jackett in Options.", + "Чтобы найти источники, войдите в Stremio или настройте Jackett в " + "настройках." }, + + // ---- options: TMDB ------------------------------------------------------- + { "API key", "Ключ API" }, + { "TMDB API key", "Ключ API TMDB" }, + { "API key (v3) or read access token (v4)", + "Ключ API (v3) или токен доступа (v4)" }, + { "API address", "Адрес API" }, + { "TMDB API address", "Адрес API TMDB" }, + { "The catalog on the Home tab comes from TMDB. A free key is issued at " + "themoviedb.org (Settings -> API). Leave the address empty unless you use " + "a mirror. Changes apply when you reopen the Home tab.", + "Каталог на вкладке «Главная» берётся из TMDB. Бесплатный ключ выдают на " + "themoviedb.org (Settings -> API). Адрес оставьте пустым, если не " + "пользуетесь зеркалом. Изменения применятся, когда вы снова откроете " + "«Главную»." }, + { "Enter the TMDB API key first.", "Сначала введите ключ API TMDB." }, + { "empty answer", "пустой ответ" }, + { "Clear TMDB cache", "Очистить кэш TMDB" }, + { "TMDB cache cleared.", "Кэш TMDB очищен." }, + // ---- updater ------------------------------------------------------------ { "unknown install path", "путь установки неизвестен" }, { "no release found", "релиз не найден" }, diff --git a/app/jackett_tab.cpp b/app/jackett_tab.cpp index d2b3a5b..c539998 100644 --- a/app/jackett_tab.cpp +++ b/app/jackett_tab.cpp @@ -293,6 +293,22 @@ class JackettView } } + public: + // Starts on a query of someone else's choosing (the film card's + // "Torrents"): fills the search bar and runs it, so the results are the + // first thing on screen and the bar is still there to refine the query. + void startWith(const std::string& query, jackett::Category cat) + { + if (query.empty()) return; + searchQuery = query; + currentCategory = cat; + barText->setText(query); + barText->setTextColor(theme::text()); + updateCategoryButtonStyles(); + runSearch(); + } + + private: void promptSearch() { brls::Application::getImeManager()->openForText( @@ -850,3 +866,18 @@ brls::View* buildJackettTab() root->controller = std::make_shared(root); return root; } + +void openJackettSearch(const std::string& query, int category) +{ + auto* root = new JackettTabRoot(); + auto view = std::make_shared(root); + root->controller = view; + + auto* frame = new brls::AppletFrame(root); + frame->setTitle("Jackett"); + brls::Application::pushActivity(new brls::Activity(frame)); + // After the push: a search left unconfigured shows its setup prompt + // instead, which is the right answer there too. + if (!config::get().jackettUrl.empty()) + view->startWith(query, (jackett::Category)category); +} diff --git a/app/jackett_tab.hpp b/app/jackett_tab.hpp index 32e0c65..f60342e 100644 --- a/app/jackett_tab.hpp +++ b/app/jackett_tab.hpp @@ -2,5 +2,11 @@ #include +#include + // Builds the Jackett search tab root view. brls::View* buildJackettTab(); + +// Opens Jackett search as its own screen, already searching `query` in +// `category` (a jackett::Category). B returns to the caller. +void openJackettSearch(const std::string& query, int category = 0); diff --git a/app/json.cpp b/app/json.cpp index 85323b2..4b85c80 100644 --- a/app/json.cpp +++ b/app/json.cpp @@ -282,6 +282,95 @@ std::vector objects(const std::string& body, const char* key) return out; } +std::string topLevel(const std::string& obj) +{ + // Copy the text, but inside any nested {...} or [...] keep only the + // brackets themselves: then a lookup can only land on the outer object's + // own keys. String contents are copied as they are (a brace in a title is + // not structure). + std::string out; + out.reserve(obj.size()); + int depth = 0; // 1 = inside the outer object + bool inStr = false; + for (size_t i = 0; i < obj.size(); i++) + { + char c = obj[i]; + bool keep = depth <= 1; + if (inStr) + { + if (keep) out += c; + if (c == '\\' && i + 1 < obj.size()) + { + if (keep) out += obj[i + 1]; + i++; + } + else if (c == '"') + inStr = false; + continue; + } + if (c == '"') + { + inStr = true; + if (keep) out += c; + continue; + } + if (c == '{' || c == '[') + { + if (depth <= 1) out += c; + depth++; + continue; + } + if (c == '}' || c == ']') + { + depth--; + if (depth <= 1) out += c; + continue; + } + if (keep) out += c; + } + return out; +} + +std::string object(const std::string& body, const char* key) +{ + std::string pat = std::string("\"") + key + "\""; + size_t from = 0; + while (from < body.size()) + { + size_t k = body.find(pat, from); + if (k == std::string::npos) return ""; + size_t v = k + pat.size(); + while (v < body.size() && (body[v] == ' ' || body[v] == '\t' || + body[v] == '\r' || body[v] == '\n')) v++; + if (v < body.size() && body[v] == ':') + { + v++; + while (v < body.size() && (body[v] == ' ' || body[v] == '\t' || + body[v] == '\r' || body[v] == '\n')) v++; + if (v >= body.size() || body[v] != '{') return ""; + int depth = 0; + bool inStr = false; + for (size_t i = v; i < body.size(); i++) + { + char c = body[i]; + if (inStr) + { + if (c == '\\') i++; + else if (c == '"') inStr = false; + continue; + } + if (c == '"') inStr = true; + else if (c == '{') depth++; + else if (c == '}' && --depth == 0) + return body.substr(v, i - v + 1); + } + return ""; + } + from = k + 1; + } + return ""; +} + // Collects the plain strings of a JSON array like "types":["movie","series"]. std::vector strings(const std::string& body, const char* key) { diff --git a/app/json.hpp b/app/json.hpp index 27ec578..67b0d77 100644 --- a/app/json.hpp +++ b/app/json.hpp @@ -36,4 +36,12 @@ std::vector objects(const std::string& body, const char* key); // The plain strings of an array like "types":["movie","series"]. std::vector strings(const std::string& body, const char* key); +// `obj` (one JSON object) with the contents of every nested object and array +// emptied, so the lookups above can only find the object's own keys -- TMDB's +// "name" turns up inside "created_by" and "genres" before the show's own. +std::string topLevel(const std::string& obj); + +// The text of the object at `key` ("external_ids": {...}), or "". +std::string object(const std::string& body, const char* key); + } // namespace json diff --git a/app/main.cpp b/app/main.cpp index 4f1f3bf..4f003bb 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -45,6 +45,7 @@ #include "jackett_tab.hpp" #include "history.hpp" #include "history_tab.hpp" +#include "home.hpp" extern "C" { #include "httpsrc.h" @@ -1294,6 +1295,8 @@ brls::View* buildTab(config::Tab tab) return buildJackettTab(); if (tab == config::Tab::HISTORY) return buildHistoryTab(); + if (tab == config::Tab::HOME) + return buildHomeTab(); return buildLocalTab(); } @@ -1327,6 +1330,7 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) // Rebuilt on every switch: TabFrame frees the old tab before making the new // one, and so must we -- two live StremioTabs would each hold their own // library and in-flight requests. + auto* homeBtn = new brls::Button(); auto* localBtn = new brls::Button(); auto* stremioBtn = new brls::Button(); auto* jackettBtn = new brls::Button(); @@ -1339,7 +1343,10 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) // The style carries the accent: a Button copies it out of the theme when a // style is applied and keeps it, so this has to be re-run (not the tab // rebuilt) when the colour changes. - auto restyleTabs = [localBtn, stremioBtn, jackettBtn, historyBtn, curTab]() { + auto restyleTabs = [homeBtn, localBtn, stremioBtn, jackettBtn, historyBtn, curTab]() { + homeBtn->setStyle(*curTab == config::Tab::HOME + ? &brls::BUTTONSTYLE_PRIMARY + : &brls::BUTTONSTYLE_BORDERLESS); localBtn->setStyle(*curTab == config::Tab::LOCAL ? &brls::BUTTONSTYLE_PRIMARY : &brls::BUTTONSTYLE_BORDERLESS); @@ -1354,8 +1361,8 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) : &brls::BUTTONSTYLE_BORDERLESS); }; - auto select = [content, localBtn, stremioBtn, jackettBtn, historyBtn, frame, curTab, - restyleTabs](config::Tab tab) { + auto select = [content, homeBtn, localBtn, stremioBtn, jackettBtn, historyBtn, frame, + curTab, restyleTabs](config::Tab tab) { *curTab = tab; restyleTabs(); // L and R only cycle the Stremio views, so the "View" chip has no @@ -1376,7 +1383,8 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) // sits under two more boxes than the local list) never hands it over. brls::Button* active = tab == config::Tab::STREMIO ? stremioBtn : (tab == config::Tab::JACKETT ? jackettBtn : - (tab == config::Tab::HISTORY ? historyBtn : localBtn)); + (tab == config::Tab::HISTORY ? historyBtn : + (tab == config::Tab::HOME ? homeBtn : localBtn))); v->registerAction( tr("Back"), brls::BUTTON_B, [active](brls::View*) { @@ -1386,9 +1394,18 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) false, false, brls::SOUND_BACK); }; + // Home first: it is where the app opens (the roadmap's main screen). + homeBtn->setText(tr("Home")); + homeBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); + homeBtn->setMarginLeft(8.0f); + homeBtn->registerClickAction([select](brls::View*) { + select(config::Tab::HOME); + return true; + }); + bar->addView(homeBtn); + stremioBtn->setText(tr("Stremio")); stremioBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); - stremioBtn->setMarginLeft(8.0f); stremioBtn->registerClickAction([select](brls::View*) { select(config::Tab::STREMIO); return true; diff --git a/app/settings.cpp b/app/settings.cpp index d12d1cd..6a7c4aa 100644 --- a/app/settings.cpp +++ b/app/settings.cpp @@ -18,6 +18,7 @@ #include "jackett.hpp" #include "http.hpp" #include "torrserver.hpp" +#include "tmdb.hpp" #include "theme.hpp" #include "update.hpp" #include "stremio.hpp" @@ -149,13 +150,16 @@ brls::View* generalPane() auto* startup = new brls::SelectorCell(); int curStartupIdx = (cfg.startupTab == config::Tab::STREMIO) ? 1 : (cfg.startupTab == config::Tab::JACKETT) ? 2 : - (cfg.startupTab == config::Tab::HISTORY) ? 3 : 0; - startup->init(tr("Category on startup"), { tr("Local"), tr("Stremio"), "Jackett", tr("History") }, + (cfg.startupTab == config::Tab::HISTORY) ? 3 : + (cfg.startupTab == config::Tab::HOME) ? 4 : 0; + startup->init(tr("Category on startup"), + { tr("Local"), tr("Stremio"), "Jackett", tr("History"), tr("Home") }, curStartupIdx, [](int sel) { config::get().startupTab = (sel == 1) ? config::Tab::STREMIO : (sel == 2) ? config::Tab::JACKETT : - (sel == 3) ? config::Tab::HISTORY : config::Tab::LOCAL; + (sel == 3) ? config::Tab::HISTORY : + (sel == 4) ? config::Tab::HOME : config::Tab::LOCAL; config::save(); }); list->addView(startup); @@ -600,6 +604,88 @@ brls::View* networkPane() tr("Used for catalogs, addons, posters and https video links. Servers on " "your local network (Jackett, TorrServer) are always reached directly."))); + auto* thdr = new brls::Header(); + thdr->setTitle("TMDB"); + list->addView(thdr); + + auto maskKey = [](const std::string& k) -> std::string { + if (k.empty()) return tr("Not set"); + if (k.size() <= 8) return "********"; + return k.substr(0, 4) + "..." + k.substr(k.size() - 4); + }; + auto* tmdbKey = new brls::DetailCell(); + tmdbKey->setText(tr("API key")); + tmdbKey->setDetailText(maskKey(cfg.tmdbApiKey)); + tmdbKey->registerClickAction([tmdbKey, maskKey](brls::View*) { + brls::Application::getImeManager()->openForText( + [tmdbKey, maskKey](std::string out) { + while (!out.empty() && out.back() == ' ') out.pop_back(); + config::get().tmdbApiKey = out; + config::save(); + tmdbKey->setDetailText(maskKey(out)); + }, + tr("TMDB API key"), tr("API key (v3) or read access token (v4)"), 512, + config::get().tmdbApiKey); + return true; + }); + list->addView(tmdbKey); + + auto* tmdbUrl = new brls::DetailCell(); + tmdbUrl->setText(tr("API address")); + tmdbUrl->setDetailText(cfg.tmdbApiUrl.empty() ? "api.themoviedb.org" : cfg.tmdbApiUrl); + tmdbUrl->registerClickAction([tmdbUrl](brls::View*) { + brls::Application::getImeManager()->openForText( + [tmdbUrl](std::string out) { + while (!out.empty() && (out.back() == ' ' || out.back() == '/')) out.pop_back(); + config::get().tmdbApiUrl = out; + config::save(); + tmdbUrl->setDetailText(out.empty() ? "api.themoviedb.org" : out); + }, + tr("TMDB API address"), "https://api.themoviedb.org/3", 256, + config::get().tmdbApiUrl); + return true; + }); + list->addView(tmdbUrl); + list->addView(caption( + tr("The catalog on the Home tab comes from TMDB. A free key is issued at " + "themoviedb.org (Settings -> API). Leave the address empty unless you use " + "a mirror. Changes apply when you reopen the Home tab."))); + + auto* tmdbTest = new brls::Button(); + tmdbTest->setText(tr("Test connection")); + tmdbTest->setMargins(14.0f, 20.0f, 6.0f, 20.0f); + tmdbTest->registerClickAction([tmdbTest](brls::View*) { + if (!tmdb::configured()) + { + note(tr("Enter the TMDB API key first.")); + return true; + } + tmdbTest->setState(brls::ButtonState::DISABLED); + tmdbTest->setText(tr("Testing...")); + // A cleared cache makes it a real request rather than a stored answer. + tmdb::clearCache(); + tmdb::fetchRowAsync(tmdb::Row::Trending, [tmdbTest](tmdb::ListResult r) { + tmdbTest->setState(brls::ButtonState::ENABLED); + tmdbTest->setText(tr("Test connection")); + if (r.ok && !r.items.empty()) + note(tr("Connection successful!")); + else + note(tr("Connection failed: ") + (r.error.empty() ? tr("empty answer") : r.error)); + }); + return true; + }); + list->addView(tmdbTest); + + auto* tmdbClear = new brls::Button(); + tmdbClear->setText(tr("Clear TMDB cache")); + tmdbClear->setMargins(6.0f, 20.0f, 18.0f, 20.0f); + tmdbClear->registerClickAction([](brls::View*) { + tmdb::clearCache(); + note(tr("TMDB cache cleared.")); + return true; + }); + list->addView(tmdbClear); + return pane; } diff --git a/app/stremio.cpp b/app/stremio.cpp index f42667d..9792948 100644 --- a/app/stremio.cpp +++ b/app/stremio.cpp @@ -38,6 +38,7 @@ #include "http.hpp" #include "json.hpp" #include "theme.hpp" +#include "widgets.hpp" #include "i18n.hpp" namespace @@ -3568,86 +3569,6 @@ NVGcolor tabBgAt(float px, float py) a.b + (b.b - a.b) * t, 1.0f); } -// A strip that fades its cards out at whichever edge still has content beyond -// it, so they dissolve into the background instead of being cut off -- the same -// treatment the episode and addon scrollers get. The scrim is the background -// colour sampled AT the edge, fading to transparent inward; an edge with -// nothing past it is left alone, so the first card at rest is never dimmed. -class FadeStrip : public brls::HScrollingFrame -{ - public: - void setContentView(brls::View* view) - { - content = view; - brls::HScrollingFrame::setContentView(view); - } - - void draw(NVGcontext* vg, float x, float y, float w, float h, - brls::Style style, brls::FrameContext* ctx) override - { - brls::HScrollingFrame::draw(vg, x, y, w, h, style, ctx); - - const float fade = 64.0f; - float offset = getContentOffsetX(); - float rightLimit = content ? content->getWidth() - w : 0.0f; - float midY = y + h * 0.5f; - - if (offset > 2.0f) // scrolled: cards run off the left - { - NVGcolor edge = tabBgAt(x, midY); - NVGcolor gone = edge; - gone.a = 0.0f; - nvgBeginPath(vg); - nvgRect(vg, x, y, fade, h); - nvgFillPaint(vg, nvgLinearGradient(vg, x, y, x + fade, y, edge, gone)); - nvgFill(vg); - } - if (offset < rightLimit - 2.0f) // more cards off the right - { - NVGcolor edge = tabBgAt(x + w, midY); - NVGcolor gone = edge; - gone.a = 0.0f; - nvgBeginPath(vg); - nvgRect(vg, x + w - fade, y, fade, h); - nvgFillPaint(vg, - nvgLinearGradient(vg, x + w - fade, y, x + w, y, gone, edge)); - nvgFill(vg); - } - } - - private: - brls::View* content = nullptr; -}; - -// A poster with only its top corners rounded: it meets the card's footer flush, -// and a rounded bottom edge cut two notches of card background out of the -// artwork. brls::Image rounds all four corners, so this repeats its draw with a -// varying-radius rect -- everything it needs is protected, not private. -class TopRoundedImage : public brls::Image -{ - public: - void draw(NVGcontext* vg, float x, float y, float w, float h, - brls::Style style, brls::FrameContext* ctx) override - { - if (this->texture == 0) return; - - float ix = x + this->imageX; - float iy = y + this->imageY; - this->paint.xform[4] = ix; - this->paint.xform[5] = iy; - - float r = this->getCornerRadius(); - nvgBeginPath(vg); - if (this->getClipsToBounds()) - nvgRoundedRectVarying(vg, x, y, w, h, r, r, 0.0f, 0.0f); - else - nvgRoundedRectVarying(vg, ix, iy, this->imageWidth, - this->imageHeight, r, r, 0.0f, 0.0f); - nvgFillPaint(vg, a(this->paint)); - nvgFill(vg); - } -}; - // The card's panel: the surface the title, the meta line and the progress bar // sit on. It drops its own fill while the row is focused -- the cursor draws a // background of its own behind the whole row, and the two stacked surfaces read diff --git a/app/tmdb.cpp b/app/tmdb.cpp new file mode 100644 index 0000000..5857b6c --- /dev/null +++ b/app/tmdb.cpp @@ -0,0 +1,465 @@ +#include "tmdb.hpp" + +#include +#include +#include +#include + +#include "http.hpp" +#include "json.hpp" + +#ifndef TMDB_NO_UI // the PC tests build the pure half alone +#include +#include + +#include + +#include + +#include "appdata.hpp" +#include "config.hpp" +#include "i18n.hpp" +#endif + +#ifndef TMDB_API_KEY +#define TMDB_API_KEY "" +#endif + +namespace tmdb +{ + +// ============================================================================ +// Pure +// ============================================================================ + +std::string languageFor(const std::string& uiLang) +{ + if (uiLang == "ru") return "ru-RU"; + if (uiLang == "fr") return "fr-FR"; + return "en-US"; +} + +bool isBearerToken(const std::string& key) +{ + // v3 keys are 32 hex digits; v4 tokens are JWTs ("eyJ...", ~200 chars). + return key.size() > 40 || key.rfind("eyJ", 0) == 0; +} + +std::string requestUrl(const Settings& s, const std::string& path, + const std::vector>& params) +{ + std::string url = s.apiBase; + while (!url.empty() && url.back() == '/') url.pop_back(); + url += path; + char sep = '?'; + for (const auto& p : params) + { + url += sep + p.first + "=" + http::urlEncode(p.second); + sep = '&'; + } + url += sep + std::string("language=") + s.language; + if (!s.apiKey.empty() && !isBearerToken(s.apiKey)) + url += "&api_key=" + http::urlEncode(s.apiKey); + return url; +} + +std::string cacheKey(const std::string& url) +{ + std::string u = url; + size_t k = u.find("api_key="); + if (k != std::string::npos) + { + size_t end = u.find('&', k); + // Drop the parameter and the separator in front of it. + size_t from = (k > 0 && (u[k - 1] == '&' || u[k - 1] == '?')) ? k - 1 : k; + u.erase(from, end == std::string::npos ? std::string::npos : end - from); + } + uint64_t h = 1469598103934665603ULL; // FNV-1a + for (unsigned char c : u) + { + h ^= c; + h *= 1099511628211ULL; + } + char out[17]; + std::snprintf(out, sizeof(out), "%016llx", (unsigned long long)h); + return out; +} + +std::string imageUrl(const std::string& imageBase, const char* size, + const std::string& path) +{ + if (path.empty() || path == "null") return ""; + std::string base = imageBase; + while (!base.empty() && base.back() == '/') base.pop_back(); + return base + "/" + size + (path[0] == '/' ? "" : "/") + path; +} + +std::string errorMessage(const std::string& body) +{ + std::string top = json::topLevel(body); + if (json::boolean(top, "success", true)) return ""; + std::string m = json::str(top, "status_message"); + return m.empty() ? "TMDB error" : m; +} + +namespace +{ +std::string yearOf(const std::string& date) +{ + return date.size() >= 4 && std::isdigit((unsigned char)date[0]) ? date.substr(0, 4) + : ""; +} + +// The fields every film/series object shares, read off its top level. +void readCommon(const std::string& top, content::Kind kind, + const std::string& imageBase, content::Item& it) +{ + it.kind = kind; + it.tmdbId = (int)json::integer(top, "id", 0); + bool tv = kind == content::Kind::Series; + it.title = json::str(top, tv ? "name" : "title"); + it.originalTitle = json::str(top, tv ? "original_name" : "original_title"); + if (it.title.empty()) it.title = it.originalTitle; + it.date = json::str(top, tv ? "first_air_date" : "release_date"); + it.year = yearOf(it.date); + it.overview = json::str(top, "overview"); + std::string poster = json::str(top, "poster_path"); + it.posterUrl = imageUrl(imageBase, "w185", poster); + it.posterUrlLarge = imageUrl(imageBase, "w342", poster); + it.backdropUrl = imageUrl(imageBase, "w1280", json::str(top, "backdrop_path")); + it.rating = json::number(top, "vote_average", 0.0); +} +} // namespace + +std::vector parseList(const std::string& body, + const std::string& imageBase, int kind) +{ + std::vector out; + for (const auto& obj : json::objects(body, "results")) + { + std::string top = json::topLevel(obj); + content::Kind k; + if (kind == 0) + k = content::Kind::Movie; + else if (kind == 1) + k = content::Kind::Series; + else + { + std::string mt = json::str(top, "media_type"); + if (mt == "movie") k = content::Kind::Movie; + else if (mt == "tv") k = content::Kind::Series; + else continue; // a person, or something new + } + content::Item it; + readCommon(top, k, imageBase, it); + if (it.tmdbId <= 0 || it.title.empty()) continue; + out.push_back(std::move(it)); + } + return out; +} + +bool parseDetails(const std::string& body, content::Kind kind, + const std::string& imageBase, content::Details& out) +{ + std::string top = json::topLevel(body); + out = content::Details(); + readCommon(top, kind, imageBase, out.item); + if (out.item.tmdbId <= 0) return false; + + // A film carries imdb_id itself; a series only inside external_ids. + out.item.imdbId = json::str(top, "imdb_id"); + if (out.item.imdbId.empty()) + out.item.imdbId = json::str(json::object(body, "external_ids"), "imdb_id"); + + for (const auto& g : json::objects(body, "genres")) + { + std::string n = json::str(g, "name"); + if (!n.empty()) out.item.genres.push_back(n); + } + + if (kind == content::Kind::Movie) + out.item.runtimeMin = (int)json::integer(top, "runtime", 0); + else + { + out.item.seasonCount = (int)json::integer(top, "number_of_seasons", 0); + // "episode_run_time": [45] -- the first figure, when TMDB has one. + size_t k = body.find("\"episode_run_time\""); + if (k != std::string::npos) + { + size_t open = body.find('[', k); + if (open != std::string::npos) + out.item.runtimeMin = std::atoi(body.c_str() + open + 1); + } + std::vector specials; + for (const auto& s : json::objects(body, "seasons")) + { + content::Season se; + se.number = (int)json::integer(s, "season_number", -1); + se.name = json::str(s, "name"); + se.episodeCount = (int)json::integer(s, "episode_count", 0); + se.airDate = json::str(s, "air_date"); + se.posterUrl = imageUrl(imageBase, "w185", json::str(s, "poster_path")); + if (se.number < 0 || se.episodeCount <= 0) continue; + // Specials (season 0) go last: nobody opens a show to watch those. + (se.number == 0 ? specials : out.seasons).push_back(se); + } + for (auto& s : specials) out.seasons.push_back(s); + } + + std::string credits = json::object(body, "credits"); + for (const auto& c : json::objects(credits, "cast")) + { + std::string n = json::str(c, "name"); + if (!n.empty()) out.cast.push_back(n); + if (out.cast.size() >= 6) break; + } + return true; +} + +std::vector parseSeason(const std::string& body, + const std::string& imageBase) +{ + std::vector out; + for (const auto& obj : json::objects(body, "episodes")) + { + // An episode object nests "crew" and "guest_stars", each with "name"s. + std::string top = json::topLevel(obj); + content::Episode e; + e.season = (int)json::integer(top, "season_number", 0); + e.number = (int)json::integer(top, "episode_number", 0); + e.title = json::str(top, "name"); + e.overview = json::str(top, "overview"); + e.airDate = json::str(top, "air_date"); + e.stillUrl = imageUrl(imageBase, "w300", json::str(top, "still_path")); + e.runtimeMin = (int)json::integer(top, "runtime", 0); + if (e.number <= 0) continue; + out.push_back(std::move(e)); + } + return out; +} + +// ============================================================================ +// The app side +// ============================================================================ +#ifndef TMDB_NO_UI + +Settings settings() +{ + const config::Config& c = config::get(); + Settings s; + s.apiKey = c.tmdbApiKey.empty() ? std::string(TMDB_API_KEY) : c.tmdbApiKey; + if (!c.tmdbApiUrl.empty()) s.apiBase = c.tmdbApiUrl; + if (!c.tmdbImageUrl.empty()) s.imageBase = c.tmdbImageUrl; + s.language = languageFor(i18n::lang()); + return s; +} + +bool configured() { return !settings().apiKey.empty(); } + +std::string rowPath(Row row) +{ + switch (row) + { + case Row::Trending: return "/trending/all/week"; + case Row::PopularMovies: return "/movie/popular"; + case Row::PopularSeries: return "/tv/popular"; + case Row::TopMovies: return "/movie/top_rated"; + case Row::TopSeries: return "/tv/top_rated"; + case Row::NowPlaying: return "/movie/now_playing"; + } + return "/trending/all/week"; +} + +int rowKind(Row row) +{ + switch (row) + { + case Row::Trending: return -1; + case Row::PopularSeries: + case Row::TopSeries: return 1; + default: return 0; + } +} + +namespace +{ +std::string cachePath(const std::string& url) +{ + return std::string(APPDATA_TMDB) + "/" + cacheKey(url) + ".json"; +} + +bool readFile(const std::string& path, std::string& out) +{ + FILE* f = std::fopen(path.c_str(), "rb"); + if (!f) return false; + char buf[4096]; + size_t n; + out.clear(); + while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); + std::fclose(f); + return !out.empty(); +} + +// Blocking: the answer for `url`, from the SD card when the copy there is +// younger than `ttlSec`, else from TMDB (and stored). A network failure falls +// back to a copy of any age, so the screen still has something to show. +bool fetchCached(const Settings& s, const std::string& url, long ttlSec, + std::string& body, std::string& err) +{ + std::string path = cachePath(url); + struct stat st; + bool have = stat(path.c_str(), &st) == 0; + if (have && std::time(nullptr) - st.st_mtime < ttlSec && readFile(path, body)) + return true; + + std::string auth; + if (isBearerToken(s.apiKey)) auth = "Authorization: Bearer " + s.apiKey; + std::string resp; + bool ok = http::get(url, resp, err, auth.empty() ? nullptr : auth.c_str(), 20); + if (ok) + { + std::string tmdbErr = errorMessage(resp); + if (!tmdbErr.empty()) + { + err = "TMDB: " + tmdbErr; + ok = false; + } + } + if (ok) + { + mkdir(APPDATA_TMDB, 0777); + std::string tmp = path + ".tmp"; + if (FILE* f = std::fopen(tmp.c_str(), "wb")) + { + std::fwrite(resp.data(), 1, resp.size(), f); + std::fclose(f); + std::remove(path.c_str()); + std::rename(tmp.c_str(), path.c_str()); + } + body = std::move(resp); + return true; + } + if (have && readFile(path, body)) + { + brls::Logger::info("[tmdb] offline, using a cached answer ({})", err); + return true; + } + return false; +} + +constexpr long kListTtl = 6 * 3600; // rows change daily at most +constexpr long kDetailsTtl = 3 * 24 * 3600; // a new season shows up eventually +constexpr long kSearchTtl = 3600; + +void fetchList(const std::string& path, + std::vector> params, int kind, + long ttl, std::function done) +{ + Settings s = settings(); + if (s.apiKey.empty()) + { + ListResult r; + r.error = "no TMDB key"; + done(r); + return; + } + std::string url = requestUrl(s, path, params); + brls::async([s, url, kind, ttl, done]() { + ListResult r; + std::string body; + if (fetchCached(s, url, ttl, body, r.error)) + { + r.items = parseList(body, s.imageBase, kind); + r.ok = true; + } + brls::sync([done, r]() { done(r); }); + }); +} +} // namespace + +void fetchRowAsync(Row row, std::function done) +{ + fetchList(rowPath(row), {}, rowKind(row), kListTtl, std::move(done)); +} + +void searchAsync(const std::string& query, std::function done) +{ + fetchList("/search/multi", { { "query", query }, { "include_adult", "false" } }, + -1, kSearchTtl, std::move(done)); +} + +void fetchDetailsAsync(content::Kind kind, int id, + std::function done) +{ + Settings s = settings(); + std::string path = std::string(kind == content::Kind::Series ? "/tv/" : "/movie/") + + std::to_string(id); + std::string url = requestUrl(s, path, { { "append_to_response", "external_ids,credits" } }); + brls::async([s, url, kind, done]() { + DetailsResult r; + std::string body; + if (s.apiKey.empty()) + r.error = "no TMDB key"; + else if (fetchCached(s, url, kDetailsTtl, body, r.error)) + { + r.ok = parseDetails(body, kind, s.imageBase, r.details); + if (!r.ok) r.error = "unexpected answer from TMDB"; + } + brls::sync([done, r]() { done(r); }); + }); +} + +void fetchSeasonAsync(int tvId, int season, std::function done) +{ + Settings s = settings(); + std::string url = requestUrl(s, "/tv/" + std::to_string(tvId) + "/season/" + + std::to_string(season)); + brls::async([s, url, done]() { + SeasonResult r; + std::string body; + if (s.apiKey.empty()) + r.error = "no TMDB key"; + else if (fetchCached(s, url, kDetailsTtl, body, r.error)) + { + r.episodes = parseSeason(body, s.imageBase); + r.ok = true; + } + brls::sync([done, r]() { done(r); }); + }); +} + +int64_t cacheBytes() +{ + int64_t total = 0; + if (DIR* d = opendir(APPDATA_TMDB)) + { + struct dirent* e; + while ((e = readdir(d))) + { + std::string p = std::string(APPDATA_TMDB) + "/" + e->d_name; + struct stat st; + if (stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode)) total += st.st_size; + } + closedir(d); + } + return total; +} + +void clearCache() +{ + if (DIR* d = opendir(APPDATA_TMDB)) + { + struct dirent* e; + while ((e = readdir(d))) + { + std::string name = e->d_name; + if (name == "." || name == "..") continue; + std::remove((std::string(APPDATA_TMDB) + "/" + name).c_str()); + } + closedir(d); + } +} + +#endif // TMDB_NO_UI + +} // namespace tmdb diff --git a/app/tmdb.hpp b/app/tmdb.hpp new file mode 100644 index 0000000..0ded82a --- /dev/null +++ b/app/tmdb.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "content.hpp" + +// The TMDB (themoviedb.org) catalog: what the home screen's rows and the +// film/show card are built from, in the UI's language. +// +// TMDB needs a key. It comes from Options -> Network, or failing that from the +// build (cmake -DTMDB_API_KEY=...). Both kinds work: a v3 "API key" (32 hex, +// sent as ?api_key=) and a v4 "read access token" (a long JWT, sent as a +// Bearer header). TMDB is blocked in some countries; the API address can point +// at a mirror, and the app's proxy applies. +// +// Every answer is kept on the SD card (APPDATA_TMDB). A fresh copy is used +// without asking the network; when the network fails, any copy at all is -- +// so the home screen opens instantly and works offline. +namespace tmdb +{ + +struct Settings +{ + std::string apiKey; + std::string apiBase = "https://api.themoviedb.org/3"; + std::string imageBase = "https://image.tmdb.org/t/p"; + std::string language = "en-US"; +}; + +// ---- pure: no network, no UI (tested on PC) --------------------------------- + +// The TMDB language for the app's UI language: "ru" -> "ru-RU". +std::string languageFor(const std::string& uiLang); + +// A v4 read access token (Bearer) rather than a v3 key. +bool isBearerToken(const std::string& key); + +// {apiBase}{path}?{params}&language=..[&api_key=..]. Values are URL-encoded. +std::string requestUrl(const Settings& s, const std::string& path, + const std::vector>& params = {}); + +// A cache file name for a request: a hash of the URL without its api_key, so +// changing the key does not throw the cache away. 16 hex digits. +std::string cacheKey(const std::string& url); + +// "https://image.tmdb.org/t/p" + "w185" + "/abc.jpg"; "" when there is no path. +std::string imageUrl(const std::string& imageBase, const char* size, + const std::string& path); + +// TMDB's own error text from an error body ("Invalid API key: ..."), or "". +std::string errorMessage(const std::string& body); + +// Items of a list answer ("results": trending, popular, search...). kind -1 +// reads each item's media_type (people are skipped); 0 = all films, 1 = all +// series. +std::vector parseList(const std::string& json, + const std::string& imageBase, int kind); + +// /movie/{id} or /tv/{id} with append_to_response=external_ids,credits. +bool parseDetails(const std::string& json, content::Kind kind, + const std::string& imageBase, content::Details& out); + +// /tv/{id}/season/{n}. +std::vector parseSeason(const std::string& json, + const std::string& imageBase); + +// ---- the app side ----------------------------------------------------------- + +// From config (and the build's default key). +Settings settings(); +bool configured(); // there is a key to use + +// The home screen's rows. +enum class Row +{ + Trending, // films and series, this week + PopularMovies, + PopularSeries, + TopMovies, + TopSeries, + NowPlaying, // in cinemas +}; +std::string rowPath(Row row); +int rowKind(Row row); // as parseList takes it + +struct ListResult +{ + bool ok = false; + std::vector items; + std::string error; +}; +struct DetailsResult +{ + bool ok = false; + content::Details details; + std::string error; +}; +struct SeasonResult +{ + bool ok = false; + std::vector episodes; + std::string error; +}; + +// All run on a worker thread and call back on the UI thread. +void fetchRowAsync(Row row, std::function done); +void searchAsync(const std::string& query, std::function done); +void fetchDetailsAsync(content::Kind kind, int id, + std::function done); +void fetchSeasonAsync(int tvId, int season, std::function done); + +// Size of the response cache on the SD card, and emptying it. +int64_t cacheBytes(); +void clearCache(); + +} // namespace tmdb diff --git a/app/widgets.cpp b/app/widgets.cpp new file mode 100644 index 0000000..698a225 --- /dev/null +++ b/app/widgets.cpp @@ -0,0 +1,220 @@ +#include "widgets.hpp" + +#include "stremio.hpp" // fetchPosterAsync / fetchHqArtAsync: the shared art cache +#include "theme.hpp" + +namespace ui +{ + +namespace +{ +constexpr float kRadius = 10.0f; + +void makeFocusable(brls::Box* card, std::function onClick) +{ + card->setFocusable(true); + card->setHighlightCornerRadius( + kRadius + brls::Application::getStyle()["brls/highlight/stroke_width"] / 2); + card->registerClickAction([onClick](brls::View*) { + if (onClick) onClick(); + return true; + }); + card->addGestureRecognizer(new brls::TapGestureRecognizer(card)); +} + +// The artwork filling a card, cropped to it. +brls::Image* fillImage(float w, float h) +{ + auto* img = new brls::Image(); + img->setDimensions(w, h); + img->setScalingType(brls::ImageScalingType::FILL); + img->setClipsToBounds(true); + img->setCornerRadius(kRadius); + img->setPositionType(brls::PositionType::ABSOLUTE); + img->setPositionTop(0.0f); + img->setPositionLeft(0.0f); + return img; +} + +// A dark band across the bottom of a card with the title on it, so the title +// reads over any artwork. +brls::Box* titleBand(const std::string& title, float w, float h, float fontSize) +{ + auto* band = new ShadeBox(false, nvgRGBA(0, 0, 0, 0), nvgRGBA(0, 0, 0, 215), 0.7f); + band->setWidth(w); + band->setHeight(h); + band->setCornerRadius(kRadius); + band->setPositionType(brls::PositionType::ABSOLUTE); + band->setPositionBottom(0.0f); + band->setPositionLeft(0.0f); + band->setAxis(brls::Axis::COLUMN); + band->setJustifyContent(brls::JustifyContent::FLEX_END); + band->setPadding(0.0f, 12.0f, 12.0f, 12.0f); + + auto* name = new brls::Label(); + name->setText(title); + name->setFontSize(fontSize); + name->setTextColor(nvgRGBA(255, 255, 255, 245)); + name->setSingleLine(true); + name->setWidth(w - 24.0f); + band->addView(name); + return band; +} +} // namespace + +void attachArt(brls::Image* img, const std::string& id, const std::string& url, + std::shared_ptr alive, bool hq, const std::string& localPath) +{ + if (!localPath.empty()) + { + img->setImageFromFile(localPath); + return; + } + if (id.empty() || url.empty()) return; + auto done = [img, alive](std::string path) { + if (!*alive || path.empty()) return; + img->setImageFromFile(path); + }; + if (hq) + stremio::fetchHqArtAsync(id, url, done); + else + stremio::fetchPosterAsync(id, url, done); +} + +brls::Label* heading(const std::string& text, float inset) +{ + auto* l = new brls::Label(); + l->setText(text); + l->setFontSize(24.0f); + l->setTextColor(theme::text()); + l->setMargins(18.0f, 0.0f, 10.0f, inset); + return l; +} + +brls::View* strip(brls::Box* row, float height, bool fade) +{ + row->setAxis(brls::Axis::ROW); + row->setAlignItems(brls::AlignItems::CENTER); + // CENTERED: card by card, the focused one kept in view (NATURAL hands focus + // to the frame and slides a card half out of sight -- see StremioTab). + if (fade) + { + auto* s = new FadeStrip(); + s->setHeight(height); + s->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + s->setContentView(row); + return s; + } + auto* s = new brls::HScrollingFrame(); + s->setHeight(height); + s->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + s->setContentView(row); + return s; +} + +brls::Box* posterCard(const std::string& title, const std::string& artId, + const std::string& artUrl, std::shared_ptr alive, + std::function onClick, float w, + const std::string& badge) +{ + const float h = w * 1.5f; + auto* card = new brls::Box(); + card->setWidth(w); + card->setHeight(h); + card->setCornerRadius(kRadius); + card->setBackgroundColor(theme::scrim(20)); + card->setMargins(0.0f, 18.0f, 0.0f, 0.0f); + makeFocusable(card, std::move(onClick)); + + auto* img = fillImage(w, h); + attachArt(img, artId, artUrl, alive); + card->addView(img); + card->addView(titleBand(title, w, h * 0.42f, 17.0f)); + + if (!badge.empty()) + { + auto* b = new brls::Box(); + b->setPositionType(brls::PositionType::ABSOLUTE); + b->setPositionTop(8.0f); + b->setPositionLeft(8.0f); + b->setCornerRadius(5.0f); + b->setBackgroundColor(nvgRGBA(0, 0, 0, 160)); + b->setPadding(5.0f, 7.0f, 3.0f, 7.0f); + auto* t = new brls::Label(); + t->setText(badge); + t->setFontSize(13.0f); + t->setTextColor(nvgRGBA(255, 255, 255, 240)); + b->addView(t); + card->addView(b); + } + return card; +} + +brls::Box* wideCard(const std::string& title, const std::string& artId, + const std::string& artUrl, const std::string& localPath, + double progress, std::shared_ptr alive, + std::function onClick, float w) +{ + const float h = w * 9.0f / 16.0f; + auto* card = new brls::Box(); + card->setWidth(w); + card->setHeight(h); + card->setCornerRadius(kRadius); + card->setBackgroundColor(theme::scrim(20)); + card->setMargins(0.0f, 18.0f, 0.0f, 0.0f); + makeFocusable(card, std::move(onClick)); + + auto* img = fillImage(w, h); + attachArt(img, artId, artUrl, alive, false, localPath); + card->addView(img); + card->addView(titleBand(title, w, h * 0.55f, 18.0f)); + + if (progress >= 0.0) + { + auto* track = new brls::Box(); + track->setPositionType(brls::PositionType::ABSOLUTE); + track->setPositionBottom(0.0f); + track->setPositionLeft(0.0f); + track->setWidth(w); + track->setHeight(5.0f); + track->setBackgroundColor(nvgRGBA(255, 255, 255, 50)); + auto* fill = new theme::AccentBox(); + fill->setHeight(5.0f); + fill->setWidth(w * (float)(progress > 1.0 ? 1.0 : progress)); + track->addView(fill); + card->addView(track); + } + return card; +} + +brls::Box* chip(const std::string& text, bool strong) +{ + auto* c = new brls::Box(); + c->setCornerRadius(6.0f); + c->setPadding(6.0f, 10.0f, 3.0f, 10.0f); + c->setMarginRight(10.0f); + c->setBackgroundColor(strong ? nvgRGB(0x2f, 0x7d, 0x22) : nvgRGBA(255, 255, 255, 28)); + auto* l = new brls::Label(); + l->setText(text); + l->setFontSize(17.0f); + l->setTextColor(nvgRGBA(255, 255, 255, strong ? 255 : 225)); + l->setSingleLine(true); + c->addView(l); + return c; +} + +brls::Button* actionButton(const std::string& text, bool primary, + std::function onClick) +{ + auto* b = new brls::Button(); + b->setText(text); + b->setStyle(primary ? &brls::BUTTONSTYLE_PRIMARY : &brls::BUTTONSTYLE_BORDERED); + b->setMarginRight(14.0f); + b->registerClickAction([onClick](brls::View*) { + if (onClick) onClick(); + return true; + }); + return b; +} + +} // namespace ui diff --git a/app/widgets.hpp b/app/widgets.hpp new file mode 100644 index 0000000..78c8efa --- /dev/null +++ b/app/widgets.hpp @@ -0,0 +1,169 @@ +#pragma once + +// Views and builders shared by the browse screens: the Stremio tab and the +// TMDB home / card screens. Drawn in the app's Horizon style: the theme's +// field, the accent for what is active, cards that fade at a strip's edges. + +#include + +#include +#include +#include + +// The tab's background colour at a screen point (defined in stremio.cpp, where +// the field is painted): what a fade has to blend into. +NVGcolor tabBgAt(float px, float py); + +// A strip that fades its cards out at whichever edge still has content beyond +// it, so they dissolve into the background instead of being cut off -- the same +// treatment the episode and addon scrollers get. The scrim is the background +// colour sampled AT the edge, fading to transparent inward; an edge with +// nothing past it is left alone, so the first card at rest is never dimmed. +class FadeStrip : public brls::HScrollingFrame +{ + public: + void setContentView(brls::View* view) + { + content = view; + brls::HScrollingFrame::setContentView(view); + } + + void draw(NVGcontext* vg, float x, float y, float w, float h, + brls::Style style, brls::FrameContext* ctx) override + { + brls::HScrollingFrame::draw(vg, x, y, w, h, style, ctx); + + const float fade = 64.0f; + float offset = getContentOffsetX(); + float rightLimit = content ? content->getWidth() - w : 0.0f; + float midY = y + h * 0.5f; + + if (offset > 2.0f) // scrolled: cards run off the left + { + NVGcolor edge = tabBgAt(x, midY); + NVGcolor gone = edge; + gone.a = 0.0f; + nvgBeginPath(vg); + nvgRect(vg, x, y, fade, h); + nvgFillPaint(vg, nvgLinearGradient(vg, x, y, x + fade, y, edge, gone)); + nvgFill(vg); + } + if (offset < rightLimit - 2.0f) // more cards off the right + { + NVGcolor edge = tabBgAt(x + w, midY); + NVGcolor gone = edge; + gone.a = 0.0f; + nvgBeginPath(vg); + nvgRect(vg, x + w - fade, y, fade, h); + nvgFillPaint(vg, + nvgLinearGradient(vg, x + w - fade, y, x + w, y, gone, edge)); + nvgFill(vg); + } + } + + private: + brls::View* content = nullptr; +}; + +// A poster with only its top corners rounded: it meets the card's footer flush, +// and a rounded bottom edge cut two notches of card background out of the +// artwork. brls::Image rounds all four corners, so this repeats its draw with a +// varying-radius rect -- everything it needs is protected, not private. +class TopRoundedImage : public brls::Image +{ + public: + void draw(NVGcontext* vg, float x, float y, float w, float h, + brls::Style style, brls::FrameContext* ctx) override + { + if (this->texture == 0) return; + + float ix = x + this->imageX; + float iy = y + this->imageY; + this->paint.xform[4] = ix; + this->paint.xform[5] = iy; + + float r = this->getCornerRadius(); + nvgBeginPath(vg); + if (this->getClipsToBounds()) + nvgRoundedRectVarying(vg, x, y, w, h, r, r, 0.0f, 0.0f); + else + nvgRoundedRectVarying(vg, ix, iy, this->imageWidth, + this->imageHeight, r, r, 0.0f, 0.0f); + nvgFillPaint(vg, a(this->paint)); + nvgFill(vg); + } +}; + +namespace ui +{ + +// A box that paints a linear gradient behind its children: `from` at the +// start edge to `to` at `stop` (0..1) of the way across, `to` after that. +// Horizontal runs left to right, vertical top to bottom. Used to darken art so +// text on it stays readable: the card's backdrop, a poster's title band. +class ShadeBox : public brls::Box +{ + public: + ShadeBox(bool horizontal, NVGcolor from, NVGcolor to, float stop = 1.0f) + : horizontal(horizontal), from(from), to(to), stop(stop) + { + } + + void draw(NVGcontext* vg, float x, float y, float w, float h, + brls::Style style, brls::FrameContext* ctx) override + { + float ex = horizontal ? x + w * stop : x; + float ey = horizontal ? y : y + h * stop; + nvgBeginPath(vg); + nvgRoundedRect(vg, x, y, w, h, getCornerRadius()); + nvgFillPaint(vg, nvgLinearGradient(vg, x, y, ex, ey, from, to)); + nvgFill(vg); + brls::Box::draw(vg, x, y, w, h, style, ctx); + } + + private: + bool horizontal; + NVGcolor from, to; + float stop; +}; + +// Fills `img` once the artwork for `id` lands (thumbnail size, or full size +// with `hq`). `alive` guards a fetch that lands after the view is gone. +// `localPath`, when set, is used as is (history keeps posters on the SD card). +void attachArt(brls::Image* img, const std::string& id, const std::string& url, + std::shared_ptr alive, bool hq = false, + const std::string& localPath = ""); + +// A section heading over a strip ("Popular now"), inset to line up with the +// first card. +brls::Label* heading(const std::string& text, float inset); + +// A horizontally scrolling strip holding `row`'s cards. `height` should leave +// room for the focus glow above and below the cards. `fade` dissolves the +// edges into the tab's background -- only right where that background is +// behind the strip (not over a card's backdrop picture). +brls::View* strip(brls::Box* row, float height, bool fade = true); + +// An upright poster card, as on the home screen: the artwork fills it, the +// title sits on a dark band across the bottom. `badge` ("4K") goes top-left. +brls::Box* posterCard(const std::string& title, const std::string& artId, + const std::string& artUrl, std::shared_ptr alive, + std::function onClick, float w = 190.0f, + const std::string& badge = ""); + +// A wide 16:9 card: image, title on a dark band, and a progress bar along the +// bottom edge when `progress` is in 0..1 (Continue watching, episodes). +brls::Box* wideCard(const std::string& title, const std::string& artId, + const std::string& artUrl, const std::string& localPath, + double progress, std::shared_ptr alive, + std::function onClick, float w = 320.0f); + +// A small rounded label: the meta line on the card ("2024", "Drama"). +// `strong` uses the accent-free green the rating chip wants. +brls::Box* chip(const std::string& text, bool strong = false); + +// A pill button in the card's action row. `primary` takes the accent. +brls::Button* actionButton(const std::string& text, bool primary, + std::function onClick); + +} // namespace ui diff --git a/pctest/run_tests.sh b/pctest/run_tests.sh index dc48224..bc25c77 100755 --- a/pctest/run_tests.sh +++ b/pctest/run_tests.sh @@ -3,6 +3,7 @@ # test_suite -- the torrent engine (pctest/test_suite.c) # test_httpsrc -- HTTP range reads for https playback (pctest/test_httpsrc.c) # test_net -- TorrServer URLs and the proxy bypass (pctest/test_net.cpp) +# test_tmdb -- TMDB answers and the JSON helpers (pctest/test_tmdb.cpp) # # Needs a C compiler and libcurl headers (Debian/Ubuntu: build-essential # libcurl4-openssl-dev; MSYS2/Cygwin: gcc libcurl-devel). No mbedtls: SHA-1 @@ -41,8 +42,17 @@ ${CXX:-g++} -O1 -g -Wall -std=c++17 \ pctest/test_net.cpp \ -o "$out/test_net" +${CXX:-g++} -O1 -g -Wall -std=c++17 \ + -DTMDB_NO_UI -Iapp \ + app/tmdb.cpp \ + app/json.cpp \ + pctest/test_tmdb.cpp \ + -o "$out/test_tmdb" + "$out/test_suite" echo "$out/test_httpsrc" echo "$out/test_net" +echo +"$out/test_tmdb" diff --git a/pctest/test_tmdb.cpp b/pctest/test_tmdb.cpp new file mode 100644 index 0000000..bdcb2d6 --- /dev/null +++ b/pctest/test_tmdb.cpp @@ -0,0 +1,234 @@ +// Offline tests for the TMDB client's pure half (app/tmdb.cpp built with +// TMDB_NO_UI) and the JSON helpers it relies on. The fixtures are trimmed +// copies of real TMDB answers, keeping the parts that trip a naive reader: +// "name" inside created_by / genres / last_episode_to_air before the show's +// own, people in a multi search, a series' specials season. + +#include +#include +#include +#include + +#include "http.hpp" +#include "json.hpp" +#include "tmdb.hpp" + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, " FAILED %s:%d: %s\n", __FILE__, \ + __LINE__, #cond); \ + std::exit(1); \ + } \ + } while (0) + +namespace http +{ +bool get(const std::string&, std::string&, std::string& err, const char*, long) +{ + err = "no network in tests"; + return false; +} +std::string urlEncode(const std::string& s) // same as app/http.cpp +{ + static const char* hex = "0123456789ABCDEF"; + std::string out; + for (unsigned char c : s) + { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') + out += (char)c; + else + { + out += '%'; + out += hex[c >> 4]; + out += hex[c & 0xF]; + } + } + return out; +} +} // namespace http + +static const char* IMG = "https://image.tmdb.org/t/p"; + +static const char* kTrending = R"J({"page":1,"results":[ + {"backdrop_path":"/bd1.jpg","id":693134,"title":"Дюна: Часть вторая","original_title":"Dune: Part Two", + "overview":"Пол Атрейдес объединяется с фрименами.","poster_path":"/p1.jpg","media_type":"movie", + "genre_ids":[878,12],"release_date":"2024-02-27","vote_average":8.143}, + {"id":17419,"name":"Kevin Bacon","media_type":"person","known_for":[{"id":1,"title":"x"}]}, + {"backdrop_path":null,"id":106379,"name":"Фоллаут","original_name":"Fallout","overview":"", + "poster_path":"/p2.jpg","media_type":"tv","first_air_date":"2024-04-10","vote_average":8.3, + "origin_country":["US"]} +],"total_pages":500})J"; + +static const char* kTv = R"J({"adult":false,"backdrop_path":"/bd.jpg", + "created_by":[{"id":1,"name":"Жора Крыжовников","profile_path":null}], + "episode_run_time":[52],"first_air_date":"2023-11-09", + "genres":[{"id":18,"name":"драма"},{"id":80,"name":"криминал"}], + "id":220102, + "last_episode_to_air":{"id":9,"name":"Асфальт","episode_number":8}, + "name":"Слово пацана. Кровь на асфальте","number_of_seasons":1, + "original_name":"Слово пацана. Кровь на асфальте","overview":"Казань, конец 1980-х.", + "poster_path":"/poster.jpg", + "seasons":[{"air_date":null,"episode_count":2,"id":5,"name":"Спецматериалы","poster_path":null,"season_number":0}, + {"air_date":"2023-11-09","episode_count":8,"id":6,"name":"Сезон 1","poster_path":"/s1.jpg","season_number":1}], + "vote_average":8.4, + "credits":{"cast":[{"name":"Леон Кемстач","character":"Андрей"},{"name":"Рузиль Минекаев","character":"Марат"}],"crew":[]}, + "external_ids":{"imdb_id":"tt29632826","tvdb_id":null}})J"; + +static const char* kMovie = R"J({"backdrop_path":"/bd.jpg","genres":[{"id":878,"name":"фантастика"}], + "id":693134,"imdb_id":"tt15239678","original_title":"Dune: Part Two","overview":"…", + "poster_path":"/p.jpg","release_date":"2024-02-27","runtime":166,"title":"Дюна: Часть вторая", + "vote_average":8.1,"belongs_to_collection":{"id":726871,"name":"Дюна (Коллекция)"}, + "external_ids":{"imdb_id":"tt15239678"}})J"; + +static const char* kSeason = R"J({"_id":"x","air_date":"2023-11-09","episodes":[ + {"air_date":"2023-11-09","episode_number":1,"name":"Пришиться","overview":"o1","runtime":53, + "season_number":1,"still_path":"/e1.jpg","crew":[{"name":"Режиссёр"}],"guest_stars":[{"name":"Гость"}]}, + {"air_date":"2023-11-09","episode_number":2,"name":"Отшиться","overview":"","runtime":null, + "season_number":1,"still_path":null,"crew":[],"guest_stars":[]} +],"name":"Сезон 1","season_number":1})J"; + +static void test_json_helpers() +{ + std::printf("[1/6] json::topLevel and json::object...\n"); + std::string top = json::topLevel(kTv); + CHECK(json::str(kTv, "name") == "Жора Крыжовников"); // the naive read + CHECK(json::str(top, "name") == "Слово пацана. Кровь на асфальте"); + CHECK(json::str(top, "imdb_id").empty()); // nested only + CHECK(json::str(json::object(kTv, "external_ids"), "imdb_id") == "tt29632826"); + CHECK(json::object(kTv, "missing").empty()); + // Braces and quotes inside strings are not structure. + std::string tricky = R"J({"a":"x{\"y\":[1]}","b":{"c":"d"},"e":"f"})J"; + std::string t2 = json::topLevel(tricky); + CHECK(json::str(t2, "a") == "x{\"y\":[1]}"); + CHECK(json::str(t2, "c").empty()); + CHECK(json::str(t2, "e") == "f"); + std::printf(" PASSED\n"); +} + +static void test_urls() +{ + std::printf("[2/6] Request URLs, keys, languages, cache keys...\n"); + tmdb::Settings s; + s.apiKey = "0123456789abcdef0123456789abcdef"; + s.language = tmdb::languageFor("ru"); + CHECK(s.language == "ru-RU"); + CHECK(tmdb::languageFor("xx") == "en-US"); + std::string u = tmdb::requestUrl(s, "/search/multi", { { "query", "Слово пацана" } }); + CHECK(u.rfind("https://api.themoviedb.org/3/search/multi?query=%D0%A1", 0) == 0); + CHECK(u.find("&language=ru-RU") != std::string::npos); + CHECK(u.find("&api_key=0123456789abcdef0123456789abcdef") != std::string::npos); + + CHECK(!tmdb::isBearerToken(s.apiKey)); + tmdb::Settings b = s; + b.apiKey = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIxMjMifQ.signature"; + CHECK(tmdb::isBearerToken(b.apiKey)); + CHECK(tmdb::requestUrl(b, "/movie/popular").find("api_key") == std::string::npos); + + // The key is not part of the cache key; the rest of the URL is. + tmdb::Settings other = s; + other.apiKey = "ffffffffffffffffffffffffffffffff"; + CHECK(tmdb::cacheKey(tmdb::requestUrl(s, "/tv/1")) == + tmdb::cacheKey(tmdb::requestUrl(other, "/tv/1"))); + CHECK(tmdb::cacheKey(tmdb::requestUrl(s, "/tv/1")) != + tmdb::cacheKey(tmdb::requestUrl(s, "/tv/2"))); + CHECK(tmdb::cacheKey("x").size() == 16); + + // A mirror with a trailing slash. + tmdb::Settings m = s; + m.apiBase = "https://mirror.example/3/"; + CHECK(tmdb::requestUrl(m, "/tv/1").rfind("https://mirror.example/3/tv/1?", 0) == 0); + + CHECK(tmdb::imageUrl(IMG, "w185", "/a.jpg") == "https://image.tmdb.org/t/p/w185/a.jpg"); + CHECK(tmdb::imageUrl(IMG, "w185", "").empty()); + CHECK(tmdb::imageUrl(IMG, "w185", "null").empty()); + std::printf(" PASSED\n"); +} + +static void test_list() +{ + std::printf("[3/6] A trending list: films and series, people skipped...\n"); + auto items = tmdb::parseList(kTrending, IMG, -1); + CHECK(items.size() == 2); + CHECK(items[0].kind == content::Kind::Movie); + CHECK(items[0].tmdbId == 693134); + CHECK(items[0].title == "Дюна: Часть вторая"); + CHECK(items[0].originalTitle == "Dune: Part Two"); + CHECK(items[0].year == "2024"); + CHECK(items[0].posterUrl == "https://image.tmdb.org/t/p/w185/p1.jpg"); + CHECK(items[0].backdropUrl == "https://image.tmdb.org/t/p/w1280/bd1.jpg"); + CHECK(items[0].rating > 8.1 && items[0].rating < 8.2); + CHECK(items[0].key() == "tmdb_movie_693134"); + CHECK(items[1].kind == content::Kind::Series); + CHECK(items[1].title == "Фоллаут"); + CHECK(items[1].backdropUrl.empty()); + CHECK(items[1].key() == "tmdb_tv_106379"); + // A forced kind ignores media_type. + CHECK(tmdb::parseList(R"J({"results":[{"id":5,"name":"S"}]})J", IMG, 1)[0].isSeries()); + std::printf(" PASSED\n"); +} + +static void test_details() +{ + std::printf("[4/6] Details: a series (nested names, specials last) and a film...\n"); + content::Details d; + CHECK(tmdb::parseDetails(kTv, content::Kind::Series, IMG, d)); + CHECK(d.item.title == "Слово пацана. Кровь на асфальте"); + CHECK(d.item.imdbId == "tt29632826"); + CHECK(d.item.seasonCount == 1); + CHECK(d.item.runtimeMin == 52); + CHECK(d.item.genres.size() == 2 && d.item.genres[1] == "криминал"); + CHECK(d.seasons.size() == 2); + CHECK(d.seasons[0].number == 1 && d.seasons[0].episodeCount == 8); + CHECK(d.seasons[1].number == 0); // specials moved to the end + CHECK(d.cast.size() == 2 && d.cast[0] == "Леон Кемстач"); + + content::Details m; + CHECK(tmdb::parseDetails(kMovie, content::Kind::Movie, IMG, m)); + CHECK(m.item.title == "Дюна: Часть вторая"); // not the collection's name + CHECK(m.item.imdbId == "tt15239678"); + CHECK(m.item.runtimeMin == 166); + CHECK(m.item.posterUrlLarge == "https://image.tmdb.org/t/p/w342/p.jpg"); + CHECK(!tmdb::parseDetails("{}", content::Kind::Movie, IMG, m)); + std::printf(" PASSED\n"); +} + +static void test_season() +{ + std::printf("[5/6] A season's episodes...\n"); + auto eps = tmdb::parseSeason(kSeason, IMG); + CHECK(eps.size() == 2); + CHECK(eps[0].title == "Пришиться"); // not the crew's "name" + CHECK(eps[0].number == 1 && eps[0].season == 1); + CHECK(eps[0].runtimeMin == 53); + CHECK(eps[0].stillUrl == "https://image.tmdb.org/t/p/w300/e1.jpg"); + CHECK(eps[1].stillUrl.empty()); + CHECK(eps[1].stremioId("tt29632826") == "tt29632826:1:2"); + CHECK(eps[1].stremioId("").empty()); + std::printf(" PASSED\n"); +} + +static void test_errors() +{ + std::printf("[6/6] TMDB error bodies...\n"); + CHECK(tmdb::errorMessage(R"J({"success":false,"status_code":7,"status_message":"Invalid API key: You must be granted a valid key."})J") + .rfind("Invalid API key", 0) == 0); + CHECK(tmdb::errorMessage(kMovie).empty()); + CHECK(tmdb::errorMessage(R"J({"success":false})J") == "TMDB error"); + std::printf(" PASSED\n"); +} + +int main() +{ + std::printf("=========================================\n"); + std::printf(" NX TORRENT PLAYER: TMDB CLIENT \n"); + std::printf("=========================================\n\n"); + test_json_helpers(); + test_urls(); + test_list(); + test_details(); + test_season(); + test_errors(); + std::printf("\nALL TESTS PASSED\n"); + return 0; +} From 207cd3d30e528056c1e117db9d874b468e0aafcb Mon Sep 17 00:00:00 2001 From: Langegen Date: Thu, 29 Oct 2026 08:02:03 +0300 Subject: [PATCH 05/13] Pick sources per title, add the library, collections and search, PC build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources screen (roadmap phase 3: app/sources.cpp, app/release.cpp) - A film's or an episode's releases from Jackett (localized and original title, the year for a film) and the Stremio account's stream addons (by IMDb id), deduplicated, in one list. - Release names parsed into quality, source, codec, HDR / Dolby Vision, 10-bit, voice-overs (dub, MVO, DVO, AVO, studios), group, and the seasons / episodes held (S01E06, 1x06, "Сезон: 1, Серии: 1-8 из 8", ...). Other seasons / episodes and cam rips are hidden; the rest ranked for a Switch (1080p H.264 over 4K HEVC HDR and remuxes, seeds, size); "best choice". - Filters: quality, voice-over, source, picture (HDR), tracker; X cycles the order. - A season pack opens on the episode's file (release::matchEpisode); the voice and group picked for a show rank first next time. - app/torrent_play.cpp: the Jackett tab's play flow, shared and extended with the episode match, artwork and watch context. Library (roadmap phase 4: app/library.cpp, app/bookmarks.cpp) - Bookmarks: Watching / Planned / Completed / Dropped, from the card or Y on a poster; a Bookmarks tab; export / import of library-export.json. - Progress per film and per episode in library.json, tied to the title, not the file. "Continue watching" on Home resumes in one press (X removes); the card's main button says "Continue S1 E5" / "Watch S1 E6". - "Next episode" at the end of an episode works without Stremio: from the same season pack when the last one came from it, else its sources. Catalog - Layout after the roadmap mock-ups: header Home / Movies / Shows / Bookmarks / History, Stremio / Local / Jackett behind one "Sources" button, "Search" on the right; compact hero; pill buttons; smaller cards; A "Open", Y bookmark, X remove hints. The L/R "View" keys exist only on Stremio's tab. - Collections: every row's heading and its "See all" tile open a paged grid (app/collection.cpp). - Search screen (app/search.cpp): search bar, kind / genre / year / order filters, genre tiles; TMDB discover without text, search with it. - IMDb rating (from Cinemeta) on the card and the hero. - borealis' footer words in the app's language: Platform::APP_LOCALE_DEFAULT, plus i18n/ru|fr/hints.json (upstream only had an unread brls.json). PC build (for checking the UI without a console) - build-pc.bat / run-pc.bat, compat/ headers, CMake and source guards for a desktop (MinGW) build with libmpv from pc_deps/. Tests: pctest/test_release.cpp, test_library.cpp, TMDB queries; 34 offline tests in all. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 6 + CLAUDE.md | 7 +- CMakeLists.txt | 35 +- README.md | 2 + README.ru.md | 2 + app/appdata.hpp | 8 + app/bookmarks.cpp | 166 ++++ app/bookmarks.hpp | 8 + app/browse.cpp | 2 + app/browse.hpp | 5 + app/card.cpp | 236 +++-- app/collection.cpp | 157 ++++ app/collection.hpp | 47 + app/config.cpp | 12 +- app/config.hpp | 3 + app/content.hpp | 1 + app/home.cpp | 499 ++++++---- app/home.hpp | 15 +- app/http.cpp | 8 + app/i18n_ru.cpp | 98 ++ app/jackett_tab.cpp | 236 +---- app/json.cpp | 25 + app/json.hpp | 3 + app/library.cpp | 531 +++++++++++ app/library.hpp | 126 +++ app/main.cpp | 315 +++++-- app/player.cpp | 32 + app/player.hpp | 11 + app/release.cpp | 522 +++++++++++ app/release.hpp | 73 ++ app/search.cpp | 429 +++++++++ app/settings.cpp | 9 +- app/sources.cpp | 887 ++++++++++++++++++ app/sources.hpp | 32 + app/tmdb.cpp | 131 ++- app/tmdb.hpp | 48 + app/torrent_play.cpp | 295 ++++++ app/torrent_play.hpp | 27 + app/update.hpp | 1 + app/widgets.cpp | 94 +- app/widgets.hpp | 16 +- build-pc.bat | 85 ++ compat/arpa/inet.h | 10 + compat/fcntl.h | 46 + compat/mbedtls/sha1.h | 16 + compat/netdb.h | 10 + compat/netinet/in.h | 10 + compat/netinet/tcp.h | 13 + compat/poll.h | 23 + compat/switch.h | 177 ++++ compat/sys/poll.h | 6 + compat/sys/select.h | 10 + compat/sys/socket.h | 172 ++++ compat/sys/stat.h | 16 + compat/time.h | 24 + compat/win32_inet_ntop.h | 6 + engine/dht.c | 7 +- library/borealis/VENDORED.md | 10 + library/borealis/resources/i18n/fr/hints.json | 14 + library/borealis/resources/i18n/ru/hints.json | 14 + pctest/run_tests.sh | 19 + pctest/test_library.cpp | 166 ++++ pctest/test_release.cpp | 189 ++++ pctest/test_tmdb.cpp | 58 +- run-pc.bat | 8 + 65 files changed, 5633 insertions(+), 636 deletions(-) create mode 100644 app/bookmarks.cpp create mode 100644 app/bookmarks.hpp create mode 100644 app/collection.cpp create mode 100644 app/collection.hpp create mode 100644 app/library.cpp create mode 100644 app/library.hpp create mode 100644 app/release.cpp create mode 100644 app/release.hpp create mode 100644 app/search.cpp create mode 100644 app/sources.cpp create mode 100644 app/sources.hpp create mode 100644 app/torrent_play.cpp create mode 100644 app/torrent_play.hpp create mode 100644 build-pc.bat create mode 100644 compat/arpa/inet.h create mode 100644 compat/fcntl.h create mode 100644 compat/mbedtls/sha1.h create mode 100644 compat/netdb.h create mode 100644 compat/netinet/in.h create mode 100644 compat/netinet/tcp.h create mode 100644 compat/poll.h create mode 100644 compat/switch.h create mode 100644 compat/sys/poll.h create mode 100644 compat/sys/select.h create mode 100644 compat/sys/socket.h create mode 100644 compat/sys/stat.h create mode 100644 compat/time.h create mode 100644 compat/win32_inet_ntop.h create mode 100644 library/borealis/resources/i18n/fr/hints.json create mode 100644 library/borealis/resources/i18n/ru/hints.json create mode 100644 pctest/test_library.cpp create mode 100644 pctest/test_release.cpp create mode 100644 run-pc.bat diff --git a/.gitignore b/.gitignore index 422ebc0..b4fd2df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Build output /build/ /build-ninja/ +/build-pc/ +/pc_deps/ /NX-torrent-player.nro /.updatetest/ /NX-torrent-player.elf @@ -15,3 +17,7 @@ /pctest/test_net.exe /pctest/test_tmdb /pctest/test_tmdb.exe +/pctest/test_release +/pctest/test_release.exe +/pctest/test_library +/pctest/test_library.exe diff --git a/CLAUDE.md b/CLAUDE.md index 9988f60..119ea0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,12 @@ rewrites the container paths. `config` (config.json), `http` (curl + proxy), `torrserver`, `i18n` (+ `i18n_ru.cpp`, one table per language), `home` (the TMDB home tab), `card` (film/series card), `tmdb` (client + SD cache), `content` (the model the - catalog screens share), `widgets` (strips, poster/wide cards, chips, shades) + catalog screens share), `widgets` (strips, poster/wide cards, chips, shades), + `sources` (the release list for a film/episode: Jackett + Stremio addons), + `release` (release-name parsing, ranking, episode-in-pack matching), + `torrent_play` (open a Jackett result or magnet and pick the file to play), + `library` (bookmarks + per-title/per-episode progress, library.json), + `bookmarks` (the Bookmarks tab) - `engine/` — the torrent engine (C): `torrentfs` (piece picking, cache, the streaming read), `peer`, `swarm`, `torrent`, `udp_tracker`, `dht`, `magnet`, `stream` (the mpv `stream_cb` protocols: `torrent://` and `nxhttps://`), diff --git a/CMakeLists.txt b/CMakeLists.txt index ef75c3f..3a3c526 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ cmake_dependent_option(USE_SYSTEM_FMT "" OFF "NOT USE_SHARED_LIB" ON) cmake_dependent_option(USE_SYSTEM_TINYXML2 "" OFF "NOT USE_SHARED_LIB" ON) cmake_dependent_option(USE_SYSTEM_TWEENY "" OFF "NOT USE_SHARED_LIB" ON) +set(CMAKE_NO_SYSTEM_FROM_IMPORTED ON) + include(${BOREALIS_LIBRARY}/cmake/toolchain.cmake) project(NX-torrent-player) @@ -51,7 +53,9 @@ find_package(Threads REQUIRED) list(APPEND APP_PLATFORM_LIB ${CMAKE_THREAD_LIBS_INIT}) find_package(PkgConfig REQUIRED) -set(PKG_CONFIG_USE_STATIC_LIBS ON) # Switch links everything statically +if (PLATFORM_SWITCH) + set(PKG_CONFIG_USE_STATIC_LIBS ON) # Switch links everything statically +endif () pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl) # Metahub serves some Stremio posters as WebP, which nanovg's stb_image cannot @@ -69,8 +73,10 @@ set(ENGINE_SRC ${ENGINE_DIR}/udp_tracker.c ${ENGINE_DIR}/dht.c ${ENGINE_DIR}/dhtclient.c ${ENGINE_DIR}/udpdiag.c ${ENGINE_DIR}/stream.c ${ENGINE_DIR}/httpsrc.c ${ENGINE_DIR}/utp.cpp ${ENGINE_DIR}/utp_utils.cpp ${ENGINE_DIR}/utpbridge.cpp) -# libutp needs -DPOSIX for its socket types (SOCKADDR_STORAGE etc). -set_source_files_properties(${ENGINE_SRC} PROPERTIES COMPILE_DEFINITIONS "POSIX") +# libutp needs -DPOSIX for its socket types (SOCKADDR_STORAGE etc) on Switch. +if (PLATFORM_SWITCH) + set_source_files_properties(${ENGINE_SRC} PROPERTIES COMPILE_DEFINITIONS "POSIX") +endif () list(APPEND MAIN_SRC ${ENGINE_SRC}) if (PLATFORM_SWITCH) @@ -132,6 +138,29 @@ if (PLATFORM_SWITCH) endif () +if (NOT PLATFORM_SWITCH) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory ${PROJECT_RESOURCES} ${CMAKE_BINARY_DIR}/resources + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/assets/NX-torrent-player-bg-rounded.png ${CMAKE_BINARY_DIR}/resources/NX-torrent-player-bg-rounded.png + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/assets/video-icon.png ${CMAKE_BINARY_DIR}/resources/video-icon.png + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/assets/magnet-icon.png ${CMAKE_BINARY_DIR}/resources/magnet-icon.png + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/assets/stremio-icon.png ${CMAKE_BINARY_DIR}/resources/stremio-icon.png + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/assets/local-tab-icon.png ${CMAKE_BINARY_DIR}/resources/local-icon.png + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/assets/SpaceGrotesk-Medium.ttf ${CMAKE_BINARY_DIR}/resources/SpaceGrotesk-Medium.ttf + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/assets/cacert.pem ${CMAKE_BINARY_DIR}/resources/cacert.pem + ) + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/pc_deps/mpv/libmpv-2.dll") + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/pc_deps/mpv/libmpv-2.dll ${CMAKE_BINARY_DIR}/libmpv-2.dll + ) + endif () +endif () + +if (NOT PLATFORM_SWITCH) + target_include_directories(${PROJECT_NAME} BEFORE PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/compat) +endif () + target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/app ${CMAKE_CURRENT_SOURCE_DIR}/engine diff --git a/README.md b/README.md index 25f7b1f..c118ff7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Whether you stream through **Stremio** catalogs and addons, search across dozens - 📂 **Multi-File Selection & Natural Sorting** — Smart file picker for torrents and magnets containing multiple videos with natural alphanumeric episode sorting (e.g., Episode 1, 2, ..., 10 instead of 1, 10, 2). - ⚡ **Enhanced Engine & Tracker Pooling** — Background announce worker pool supporting concurrent announces across 30+ trackers, asynchronous metadata resolution with cancelable progress dialogs, and robust DHT peer search fallback. - 🏠 **Home Screen & Title Cards (TMDB)** — A Home tab in the style of Lampa: a hero banner, *Continue watching*, and rows of trending, popular and top-rated films and shows from TMDB in your language; each title opens a card with backdrop, rating, genres, synopsis, cast, seasons and episodes, and finds sources through your Stremio addons or Jackett. Needs a free TMDB API key (**Options → Network → TMDB**); answers are cached on the SD card, so the home screen opens instantly and offline. +- 📚 **Library & Bookmarks** — Bookmark any title as *Watching*, *Planned*, *Completed* or *Dropped* (a **Bookmarks** tab lists them). Progress is kept per film and per episode, tied to the title rather than the file, so it survives picking another release; *Continue watching* on Home resumes in one press, and when an episode ends the next one opens from the same season pack. Export / import the whole library as one file. +- 🎯 **Smart Source Picking** — A title's *Watch*, *Torrents* or an episode opens one list of releases from Jackett (searched by the localized and the original title) and your Stremio addons. Release names are read into quality, source, codec, HDR / 10-bit, voice-overs and season / episode scope; releases for another episode are hidden, and the one that plays best on a Switch (1080p H.264 over 4K HEVC HDR, seeded, sensibly sized) comes first as *best choice*. Quality filters, X cycles the order. Season packs open straight on the right episode, and a show remembers the dub you picked. - 🔗 **Direct Video Links** — Stremio addon streams that are plain `http(s)` URLs (debrid services and the like) now play, with seeking. `https` goes through the app's own curl with certificate checks, since the Switch's FFmpeg has no `https` of its own. - 🖥️ **TorrServer** — Set your TorrServer address in **Options → Network** and every torrent (Local, Jackett, Stremio) is streamed by that server and played on the console as a regular video stream. - 🌐 **Proxy** — An HTTP or SOCKS proxy for catalogs, addons, posters and `https` video links (**Options → Network**); servers on your local network are always reached directly. diff --git a/README.ru.md b/README.ru.md index 7a6f058..c4e5911 100644 --- a/README.ru.md +++ b/README.ru.md @@ -40,6 +40,8 @@ - 📂 **Умный выбор файлов и естественная сортировка** — Удобное окно выбора файлов для раздач с несколькими сериями/файлами. Сортировка учитывает числа в названиях (серии 1, 2, ..., 10 идут по порядку, а не 1, 10, 2). - ⚡ **Улучшенный движок и опрос трекеров** — Фоновый пул потоков для одновременного анонса на более чем 30 трекерах, асинхронное получение метаданных для magnet-ссылок с возможностью отмены, а также фолбэк на DHT для бестрекерных раздач. - 🏠 **Главная и карточки (TMDB)** — Вкладка «Главная» в духе Lampa: большой баннер, «Продолжить просмотр» и ряды трендов, популярных и лучших фильмов и сериалов из TMDB на вашем языке. Каждое название открывает карточку с фоном, рейтингом, жанрами, описанием, актёрами, сезонами и сериями, а источники ищутся через аддоны Stremio или Jackett. Нужен бесплатный ключ TMDB (**Options → Network → TMDB**). Ответы кэшируются на SD-карте, поэтому главная открывается мгновенно и без сети. +- 📚 **Библиотека и закладки** — Любое название можно добавить в закладки со статусом «Смотрю», «Буду смотреть», «Просмотрено» или «Брошено»; все они собраны на вкладке **«Закладки»**. Прогресс хранится для каждого фильма и каждой серии и привязан к названию, а не к файлу, поэтому переживает смену раздачи. «Продолжить просмотр» на главной возвращает к просмотру одним нажатием, а после конца серии следующая открывается из того же сезонного пака. Библиотеку можно экспортировать и импортировать одним файлом. +- 🎯 **Умный подбор раздач** — Кнопки «Смотреть», «Торренты» и нажатие на серию открывают единый список раздач из Jackett (поиск по русскому и оригинальному названию) и ваших аддонов Stremio. Название релиза разбирается на качество, источник, кодек, HDR / 10-bit, озвучку и сезон или серию. Раздачи для других серий скрываются, а первой идёт та, что лучше всего играет на Switch (1080p H.264 выше 4K HEVC HDR, с сидами, разумного размера) — с меткой «лучший выбор». Есть фильтры качества, X меняет порядок. Сезонный пак сразу открывается на нужной серии, а для сериала запоминается выбранная озвучка. - 🔗 **Прямые ссылки на видео** — Источники аддонов Stremio в виде обычных `http(s)`-ссылок (дебрид-сервисы и подобные) теперь воспроизводятся, с перемоткой. `https` идёт через собственный curl приложения с проверкой сертификатов: в FFmpeg для Switch своего `https` нет. - 🖥️ **TorrServer** — Укажите адрес TorrServer в **Options → Network**, и любой торрент (локальный, из Jackett или Stremio) будет качать этот сервер, а консоль получит обычный видеопоток. - 🌐 **Прокси** — HTTP- или SOCKS-прокси для каталогов, аддонов, постеров и `https`-ссылок на видео (**Options → Network**). К серверам в локальной сети подключение всегда напрямую. diff --git a/app/appdata.hpp b/app/appdata.hpp index e47c250..6fac21b 100644 --- a/app/appdata.hpp +++ b/app/appdata.hpp @@ -2,7 +2,13 @@ // All files the app writes live under one folder on the SD card instead of the // root. The directory is created at startup by ensureAppDataDir() in main.cpp. +#ifdef __SWITCH__ #define APPDATA_DIR "sdmc:/switch/NX-torrent-player" +#define APP_RES(p) "romfs:/" p +#else +#define APPDATA_DIR "./appdata" +#define APP_RES(p) "resources/" p +#endif #define APPDATA_LOG APPDATA_DIR "/nx-torrent-player.log" #define APPDATA_CACHE APPDATA_DIR "/cache.bin" // Folder the user drops .torrent files into (scanned for the main menu list). @@ -29,6 +35,8 @@ // TMDB answers, one .json per request (see tmdb.cpp): fresh copies replace // the network, stale ones stand in for it when it is down. #define APPDATA_TMDB APPDATA_DIR "/tmdb" +// The library: bookmarks, per-title / per-episode progress (library.cpp). +#define APPDATA_LIBRARY APPDATA_DIR "/library.json" // Watch history and saved playback positions. #define APPDATA_HISTORY APPDATA_DIR "/history.json" diff --git a/app/bookmarks.cpp b/app/bookmarks.cpp new file mode 100644 index 0000000..c57104a --- /dev/null +++ b/app/bookmarks.cpp @@ -0,0 +1,166 @@ +#include "bookmarks.hpp" + +#include +#include + +#include "appdata.hpp" +#include "card.hpp" +#include "i18n.hpp" +#include "library.hpp" +#include "theme.hpp" +#include "widgets.hpp" + +namespace +{ + +constexpr float kInset = 60.0f; +constexpr float kPosterW = 190.0f; +constexpr float kGlow = 24.0f; + +// Where an export goes and an import is read from: a fixed name, so it is +// easy to find on the SD card and to copy to another console. +const char* kExportPath = APPDATA_DIR "/library-export.json"; + +void note(const std::string& msg) +{ + auto* d = new brls::Dialog(msg); + d->addButton("OK", []() {}); + d->open(); +} + +class BookmarksRoot : public brls::Box +{ + public: + BookmarksRoot() + { + setAxis(brls::Axis::COLUMN); + setGrow(1.0f); + + auto* top = new brls::Box(); + top->setAxis(brls::Axis::ROW); + top->setPadding(6.0f, kInset, 4.0f, kInset); + exportBtn = ui::actionButton(tr("Export"), false, []() { + std::string err; + if (library::exportTo(kExportPath, err)) + note(tr("The library was saved to ") + std::string(kExportPath)); + else + note(tr("Export failed: ") + err); + }); + top->addView(exportBtn); + top->addView(ui::actionButton(tr("Import"), false, []() { + int added = 0; + std::string err; + if (library::importFrom(kExportPath, added, err)) + note(tr("Imported. New titles: ") + std::to_string(added)); + else + note(tr("Import failed: ") + err + "\n" + tr("Expected file: ") + + std::string(kExportPath)); + })); + addView(top); + + auto* scroll = new brls::ScrollingFrame(); + scroll->setGrow(1.0f); + scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + list = new brls::Box(); + list->setAxis(brls::Axis::COLUMN); + list->setPadding(4.0f, 0.0f, 36.0f, 0.0f); + scroll->setContentView(list); + addView(scroll); + + seenGen = library::generation(); + rebuild(); + } + + ~BookmarksRoot() override { *alive = false; } + + // A status changed on a card pushed over this tab: rebuild on return. + void draw(NVGcontext* vg, float x, float y, float w, float h, brls::Style style, + brls::FrameContext* ctx) override + { + uint32_t gen = library::generation(); + if (gen != seenGen) + { + seenGen = gen; + rebuild(); + } + brls::Box::draw(vg, x, y, w, h, style, ctx); + } + + private: + std::shared_ptr alive = std::make_shared(true); + brls::Box* list = nullptr; + brls::Button* exportBtn = nullptr; + uint32_t seenGen = 0; + + void rebuild() + { + // The focus comes back to the card it was on when a pushed card + // closes -- and that card is about to be freed. Park it first. + for (brls::View* v = brls::Application::getCurrentFocus(); v; v = v->getParent()) + if (v == list) + { + brls::Application::giveFocus(exportBtn); + break; + } + *alive = false; + alive = std::make_shared(true); + list->clearViews(); + + const std::pair sections[] = { + { library::Status::Watching, "Watching" }, + { library::Status::Planned, "Planned" }, + { library::Status::Completed, "Completed" }, + { library::Status::Dropped, "Dropped" }, + }; + int shown = 0; + for (const auto& sec : sections) + { + auto entries = library::withStatus(sec.first); + if (entries.empty()) continue; + list->addView(ui::heading(std::string(tr(sec.second)) + " \xC2\xB7 " + + std::to_string(entries.size()), + kInset)); + auto* row = new brls::Box(); + for (const auto& e : entries) + { + content::Item it = e.item; + auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, alive, + [it]() { openContentCard(it); }, kPosterW); + if (row->getChildren().empty()) card->setMarginLeft(kInset); + row->addView(card); + } + list->addView(ui::strip(row, kPosterW * 1.5f + kGlow)); + shown++; + } + + if (shown == 0) + { + auto* box = new brls::Box(); + box->setAxis(brls::Axis::COLUMN); + box->setMargins(30.0f, kInset, 0.0f, kInset); + box->setPadding(26.0f, 30.0f, 26.0f, 30.0f); + box->setCornerRadius(14.0f); + box->setBackgroundColor(theme::scrim(20)); + auto* t = new brls::Label(); + t->setText(tr("No bookmarks yet")); + t->setFontSize(26.0f); + box->addView(t); + auto* l = new brls::Label(); + l->setText(tr("Open a film or a show and press \"Bookmark\" to keep it here, " + "sorted by what you are watching, planning, have finished or " + "dropped.")); + l->setFontSize(18.0f); + l->setTextColor(theme::textBody()); + l->setMarginTop(10.0f); + box->addView(l); + list->addView(box); + } + } +}; + +} // namespace + +brls::View* buildBookmarksTab() +{ + return new BookmarksRoot(); +} diff --git a/app/bookmarks.hpp b/app/bookmarks.hpp new file mode 100644 index 0000000..efc50e4 --- /dev/null +++ b/app/bookmarks.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include + +// The Bookmarks tab: the library by status -- Watching, Planned, Completed, +// Dropped -- as poster strips opening each title's card, plus the export / +// import of the whole library as one file on the SD card. +brls::View* buildBookmarksTab(); diff --git a/app/browse.cpp b/app/browse.cpp index 3ca0484..7c953bf 100644 --- a/app/browse.cpp +++ b/app/browse.cpp @@ -2180,6 +2180,8 @@ class SeriesDetailActivity : public brls::Activity } // namespace +std::string stremioStreamSource(const stremio::Stream& s) { return playableSource(s); } + ListActivity::ListActivity(std::string title, std::string subtitle, std::vector rows, std::function onSelect, std::string iconPath) diff --git a/app/browse.hpp b/app/browse.hpp index 11445bb..0bfb0d0 100644 --- a/app/browse.hpp +++ b/app/browse.hpp @@ -82,6 +82,11 @@ class ListActivity : public brls::Activity // series go through season/episode pickers first. void openLibraryItem(const std::string& authKey, const stremio::LibItem& item); +// What the player is handed for an addon stream: a magnet (public trackers +// attached) for an infoHash, the URL for a direct http(s) link, "" when it +// cannot be played. +std::string stremioStreamSource(const stremio::Stream& s); + // Opens one episode's detail screen (synopsis + source cards) straight from its // id, without going through the series/season pickers. For the player's // end-of-episode card: it only ever knew the next episode's id, so everything diff --git a/app/card.cpp b/app/card.cpp index da4a2e4..41a3a7a 100644 --- a/app/card.cpp +++ b/app/card.cpp @@ -5,12 +5,10 @@ #include #include -#include "browse.hpp" // openLibraryItem / openEpisodeById: Stremio sources #include "config.hpp" #include "i18n.hpp" -#include "jackett.hpp" // jackett::Category -#include "jackett_tab.hpp" // openJackettSearch -#include "player.hpp" // PlayerArt +#include "library.hpp" +#include "sources.hpp" #include "stremio.hpp" #include "theme.hpp" #include "tmdb.hpp" @@ -37,13 +35,6 @@ void note(const std::string& msg) d->open(); } -std::string twoDigits(int n) -{ - char b[16]; - std::snprintf(b, sizeof(b), "%02d", n); - return b; -} - std::string runtimeText(int min) { if (min <= 0) return ""; @@ -58,50 +49,19 @@ std::string ratingText(double r) return b; } -// Where an episode or a film is looked for. Stremio's addons first: they key -// on the IMDb id and answer with ready sources. Jackett otherwise, searched by -// name. Neither set up: say what would make this work. -void openSources(const content::Item& it, int season, int episode) +// The sources screen for a film, a whole show (season 0) or one episode. It +// gathers from Jackett and the Stremio addons both; with neither set up there +// is nothing it could show, so say what would make it work instead. +void openSources(const content::Item& it, int season, int episode, int episodesInSeason = 0) { - std::string authKey = stremio::loadAuthKey(); - if (!authKey.empty() && !it.imdbId.empty()) - { - if (!it.isSeries()) - { - stremio::LibItem li; - li.id = it.imdbId; - li.name = it.title; - li.type = "movie"; - li.poster = it.posterUrlLarge; - li.year = it.year; - openLibraryItem(authKey, li); - return; - } - if (season > 0 || episode > 0) - { - PlayerArt art; - art.posterPath = stremio::cachedPosterPath(it.key()); - art.bgId = it.key() + "_bd"; - art.bgUrl = it.backdropUrl; - std::string vid = it.imdbId + ":" + std::to_string(season) + ":" + - std::to_string(episode); - openEpisodeById(authKey, it.imdbId, vid, art); - return; - } - } - - if (!config::get().jackettUrl.empty()) + bool jackettOn = !config::get().jackettUrl.empty(); + bool stremioOn = !stremio::loadAuthKey().empty() && !it.imdbId.empty(); + if (!jackettOn && !stremioOn) { - std::string q = it.title; - if (!it.isSeries() && !it.year.empty()) q += " " + it.year; - if (it.isSeries() && season > 0 && episode > 0) - q += " S" + twoDigits(season) + "E" + twoDigits(episode); - openJackettSearch(q, (int)(it.isSeries() ? jackett::Category::SERIES - : jackett::Category::MOVIES)); + note(tr("To find sources, sign in to Stremio or set up Jackett in Options.")); return; } - - note(tr("To find sources, sign in to Stremio or set up Jackett in Options.")); + openSourcesScreen(it, season, episode, episodesInSeason); } // The card's state, shared by the callbacks that fill it in. @@ -109,6 +69,7 @@ struct CardState { content::Details d; bool detailsLoaded = false; + std::string imdbRating; // "7.9", from Cinemeta; "" until known int seasonIdx = 0; // into d.seasons int seasonReq = 0; // bumps on every switch: late answers are dropped std::shared_ptr alive = std::make_shared(true); @@ -118,6 +79,7 @@ struct CardState brls::Label* overview = nullptr; brls::Label* cast = nullptr; brls::Button* playBtn = nullptr; + brls::Button* markBtn = nullptr; // the bookmark status brls::Box* seasonsSection = nullptr; brls::Box* seasonBar = nullptr; std::vector seasonBtns; @@ -214,11 +176,11 @@ class CardView : public brls::Box top->setAlignItems(brls::AlignItems::FLEX_START); auto* poster = new brls::Image(); - poster->setDimensions(220.0f, 330.0f); + poster->setDimensions(180.0f, 270.0f); poster->setScalingType(brls::ImageScalingType::FILL); poster->setClipsToBounds(true); poster->setCornerRadius(12.0f); - poster->setMarginRight(40.0f); + poster->setMarginRight(34.0f); poster->setShadowType(brls::ShadowType::GENERIC); poster->setShadowVisibility(true); ui::attachArt(poster, it.key(), it.posterUrlLarge, alive); @@ -229,15 +191,15 @@ class CardView : public brls::Box info->setGrow(1.0f); info->setShrink(1.0f); - info->addView(whiteLabel(it.title, 40.0f, 255)); + info->addView(whiteLabel(it.title, 36.0f, 255)); st->chips = new brls::Box(); st->chips->setAxis(brls::Axis::ROW); - st->chips->setMarginTop(16.0f); + st->chips->setMarginTop(12.0f); info->addView(st->chips); fillChips(); - st->overview = whiteLabel(trimmed(it.overview), 19.0f, 225); + st->overview = whiteLabel(trimmed(it.overview), 17.0f, 225); st->overview->setMarginTop(18.0f); info->addView(st->overview); @@ -248,28 +210,30 @@ class CardView : public brls::Box auto* actions = new brls::Box(); actions->setAxis(brls::Axis::ROW); - actions->setMarginTop(24.0f); + actions->setMarginTop(18.0f); auto weak = std::weak_ptr(st); st->playBtn = ui::actionButton(playLabel(), true, [weak]() { if (auto s = weak.lock()) watch(*s); - }); + }, true); actions->addView(st->playBtn); + // Every release of the film, or of the season on screen (packs and + // single episodes alike). actions->addView(ui::actionButton(tr("Torrents"), false, [weak]() { auto s = weak.lock(); if (!s) return; - if (config::get().jackettUrl.empty()) - { - note(tr("Set up Jackett in Options to search torrents.")); - return; - } - content::Item i = s->d.item; - std::string q = i.title; - if (!i.isSeries() && !i.year.empty()) q += " " + i.year; - openJackettSearch(q, (int)(i.isSeries() ? jackett::Category::SERIES - : jackett::Category::MOVIES)); - })); + int season = 0; + if (s->d.item.isSeries() && s->seasonIdx < (int)s->d.seasons.size()) + season = s->d.seasons[s->seasonIdx].number; + openSources(s->d.item, season, 0); + }, true)); + + // Bookmark: a status from the library (Watching / Planned / ...). + st->markBtn = ui::actionButton(markLabel(), false, [weak]() { + if (auto s = weak.lock()) pickStatus(s); + }, true); + actions->addView(st->markBtn); info->addView(actions); top->addView(info); @@ -279,7 +243,7 @@ class CardView : public brls::Box static std::string trimmed(const std::string& s) { // A synopsis can run to a page; the card has room for a paragraph. - const size_t kMax = 420; + const size_t kMax = 300; if (s.size() <= kMax) return s; size_t cut = s.rfind(' ', kMax); if (cut == std::string::npos) cut = kMax; @@ -288,16 +252,84 @@ class CardView : public brls::Box return s.substr(0, cut) + "..."; } + // The main button says what it will do: continue where the library left + // off ("Continue S1 E5"), the episode after a finished one, or the start. std::string playLabel() const { + const std::string play = "\xE2\x96\xB6 "; const content::Details& d = st->d; + library::Entry en; + if (library::find(d.item.key(), en)) + { + library::Next n = library::nextOf(en); + if (n.kind == library::Next::Kind::Resume) + return play + (d.item.isSeries() + ? tr("Continue S") + std::to_string(n.season) + tr(" E") + + std::to_string(n.episode) + : std::string(tr("Continue"))); + if (n.kind == library::Next::Kind::NextEp) + return play + tr("Watch S") + std::to_string(n.season) + tr(" E") + + std::to_string(n.episode); + } if (d.item.isSeries() && !d.seasons.empty()) + return play + tr("Watch S") + std::to_string(d.seasons[0].number) + tr(" E") + "1"; + return play + tr("Watch"); + } + + std::string markLabel() const + { + library::Entry en; + library::Status s = library::find(st->d.item.key(), en) ? en.status + : library::Status::None; + switch (s) + { + case library::Status::Watching: return tr("Watching"); + case library::Status::Planned: return tr("Planned"); + case library::Status::Completed: return tr("Completed"); + case library::Status::Dropped: return tr("Dropped"); + default: return std::string("+ ") + tr("Bookmark"); + } + } + + // The status list, as borealis' own selector shows one. + static void pickStatus(std::shared_ptr s) + { + static const library::Status kOrder[] = { + library::Status::Watching, library::Status::Planned, library::Status::Completed, + library::Status::Dropped, library::Status::None, + }; + std::vector labels = { tr("Watching"), tr("Planned"), tr("Completed"), + tr("Dropped"), tr("Remove from bookmarks") }; + library::Entry en; + library::Status cur = library::find(s->d.item.key(), en) ? en.status + : library::Status::None; + int sel = 0; + for (int i = 0; i < 5; i++) + if (kOrder[i] == cur) sel = i; + std::weak_ptr weak = s; + auto* dd = new brls::Dropdown(tr("Bookmark"), labels, [weak](int i) { + auto c = weak.lock(); + if (!c || i < 0 || i >= 5) return; + library::setStatus(c->d.item, kOrder[i]); + }, sel); + brls::Application::pushActivity(new brls::Activity(dd)); + } + + // Library changes (a status picked, an episode watched): relabel. Polled: + // the player writes the library from its own screen, on top of this one. + uint32_t seenGen = 0; + + void draw(NVGcontext* vg, float x, float y, float w, float h, brls::Style style, + brls::FrameContext* ctx) override + { + uint32_t gen = library::generation(); + if (gen != seenGen) { - const content::Season& s = d.seasons[0]; - return std::string("\xE2\x96\xB6 ") + tr("Watch S") + std::to_string(s.number) + - tr(" E") + "1"; + seenGen = gen; + if (st->playBtn) st->playBtn->setText(playLabel()); + if (st->markBtn) st->markBtn->setText(markLabel()); } - return std::string("\xE2\x96\xB6 ") + tr("Watch"); + brls::Box::draw(vg, x, y, w, h, style, ctx); } void fillChips() @@ -306,6 +338,9 @@ class CardView : public brls::Box const content::Details& d = st->d; const content::Item& it = d.item; if (it.rating > 0.05) st->chips->addView(ui::chip(ratingText(it.rating), true)); + if (!st->imdbRating.empty()) + st->chips->addView(ui::chip("IMDb " + st->imdbRating, nvgRGB(0xf5, 0xc5, 0x18), + nvgRGB(0x10, 0x10, 0x10))); if (!it.year.empty()) st->chips->addView(ui::chip(it.year)); for (size_t i = 0; i < it.genres.size() && i < 2; i++) st->chips->addView(ui::chip(it.genres[i])); @@ -329,7 +364,7 @@ class CardView : public brls::Box { st->seasonsSection = new brls::Box(); st->seasonsSection->setAxis(brls::Axis::COLUMN); - st->seasonsSection->setMarginTop(30.0f); + st->seasonsSection->setMarginTop(22.0f); st->seasonsSection->setVisibility(brls::Visibility::GONE); st->seasonBar = new brls::Box(); @@ -339,7 +374,7 @@ class CardView : public brls::Box st->episodes = new brls::Box(); // 300 wide at 16:9, plus room for the focus glow. - st->seasonsSection->addView(ui::strip(st->episodes, 169.0f + 24.0f, false)); + st->seasonsSection->addView(ui::strip(st->episodes, 236.0f * 9.0f / 16.0f + 22.0f, false)); body->addView(st->seasonsSection); } @@ -375,6 +410,22 @@ class CardView : public brls::Box } s->playBtn->setText(playLabel()); if (s->d.item.isSeries() && !s->d.seasons.empty()) showSeasons(); + loadImdbRating(); + }); + } + + // TMDB has no IMDb rating; Cinemeta (Stremio's catalog, keyed on the IMDb + // id, no account needed) does. Shown next to TMDB's once it lands. + void loadImdbRating() + { + if (st->d.item.imdbId.empty()) return; + auto weak = std::weak_ptr(st); + stremio::fetchMetaAsync("https://v3-cinemeta.strem.io", st->d.item.stremioType(), + st->d.item.imdbId, [this, weak](stremio::MetaResult r) { + auto s = weak.lock(); + if (!s || !*s->alive || !r.ok || r.imdbRating.empty()) return; + s->imdbRating = r.imdbRating; + fillChips(); }); } @@ -389,7 +440,7 @@ class CardView : public brls::Box std::string name = s.number == 0 ? tr("Specials") : tr("Season ") + std::to_string(s.number); int idx = (int)i; - auto* b = ui::actionButton(name, false, [this, idx]() { selectSeason(idx); }); + auto* b = ui::actionButton(name, false, [this, idx]() { selectSeason(idx); }, true); st->seasonBar->addView(b); st->seasonBtns.push_back(b); } @@ -423,8 +474,7 @@ class CardView : public brls::Box st->seasonIdx = idx; for (size_t i = 0; i < st->seasonBtns.size(); i++) - st->seasonBtns[i]->setStyle((int)i == idx ? &brls::BUTTONSTYLE_PRIMARY - : &brls::BUTTONSTYLE_BORDERED); + ui::setPillActive(st->seasonBtns[i], (int)i == idx, true); st->episodes->clearViews(); auto* wait = whiteLabel(tr("Loading..."), 18.0f, 180); @@ -444,19 +494,27 @@ class CardView : public brls::Box s->episodes->addView(whiteLabel(r.ok ? tr("No episodes") : r.error, 18.0f, 180)); return; } + int count = (int)r.episodes.size(); + library::Entry lib; + bool haveLib = library::find(s->d.item.key(), lib); for (const auto& e : r.episodes) { + // Watched part of it, from the library: a full bar when done. + double prog = -1.0; + if (const library::EpisodeProgress* p = + haveLib ? lib.episode(e.season, e.number) : nullptr) + prog = p->done ? 1.0 : (p->dur > 0 ? p->pos / p->dur : -1.0); std::string title = std::to_string(e.number) + ". " + (e.title.empty() ? tr("Episode ") + std::to_string(e.number) : e.title); std::string id = s->d.item.key() + "_s" + std::to_string(e.season) + "e" + std::to_string(e.number); int season = e.season, number = e.number; - auto onClick = [weak, season, number]() { - if (auto c = weak.lock()) openSources(c->d.item, season, number); + auto onClick = [weak, season, number, count]() { + if (auto c = weak.lock()) openSources(c->d.item, season, number, count); }; - s->episodes->addView(ui::wideCard(title, id, e.stillUrl, "", -1.0, - s->alive, onClick, 300.0f)); + s->episodes->addView(ui::wideCard(title, id, e.stillUrl, "", prog, + s->alive, onClick, 236.0f)); } }); } @@ -471,6 +529,16 @@ class CardView : public brls::Box note(tr("Still loading, try again in a moment.")); return; } + // Something to continue: straight back to it (the file that was + // playing, or the next episode). + library::Entry en; + if (library::find(s.d.item.key(), en) && + library::nextOf(en).kind != library::Next::Kind::None) + { + en.item = s.d.item; // the fresher snapshot (IMDb id, artwork) + continueContent(en); + return; + } if (!s.d.item.isSeries()) { openSources(s.d.item, 0, 0); @@ -481,7 +549,7 @@ class CardView : public brls::Box note(tr("No episodes")); return; } - openSources(s.d.item, s.d.seasons[0].number, 1); + openSources(s.d.item, s.d.seasons[0].number, 1, s.d.seasons[0].episodeCount); } }; diff --git a/app/collection.cpp b/app/collection.cpp new file mode 100644 index 0000000..dbbce8a --- /dev/null +++ b/app/collection.cpp @@ -0,0 +1,157 @@ +#include "collection.hpp" + +#include "card.hpp" +#include "i18n.hpp" +#include "library.hpp" +#include "theme.hpp" +#include "widgets.hpp" + +namespace +{ +constexpr float kInset = 60.0f; +constexpr float kPosterW = 150.0f; +constexpr float kGap = 16.0f; +// How close to the end of the scroll the next page is asked for: about one +// row of posters, so it is there before anyone reaches the bottom. +constexpr float kNearBottom = 400.0f; +} // namespace + +PosterGrid::PosterGrid() +{ + setAxis(brls::Axis::COLUMN); + setPadding(6.0f, 0.0f, 24.0f, 0.0f); + float usable = brls::Application::contentWidth - 2 * kInset + kGap; + columns = std::max(3, (int)(usable / (kPosterW + kGap))); +} + +PosterGrid::~PosterGrid() { *alive = false; } + +void PosterGrid::load(const tmdb::ListQuery& q, brls::ScrollingFrame* frame) +{ + // A card about to be freed may hold the focus (a new search started from + // the keyboard leaves it where it was). + for (brls::View* v = brls::Application::getCurrentFocus(); v; v = v->getParent()) + if (v == this) + { + if (parkTarget) brls::Application::giveFocus(parkTarget); + break; + } + *alive = false; + alive = std::make_shared(true); + clearViews(); + row = nullptr; + status = nullptr; + shown = 0; + page = 0; + totalPages = 1; + loading = false; + query = q; + scroll = frame; + next(); +} + +void PosterGrid::next() +{ + if (loading || page >= totalPages) return; + loading = true; + if (!status) + { + status = new brls::Label(); + status->setFontSize(17.0f); + status->setTextColor(theme::textMuted()); + status->setMargins(14.0f, 0.0f, 0.0f, kInset); + addView(status); + } + status->setText(tr("Loading...")); + status->setVisibility(brls::Visibility::VISIBLE); + + tmdb::fetchQueryAsync(query, page + 1, [this, a = alive](tmdb::ListResult r) { + if (!*a) return; + loading = false; + if (!r.ok) + { + status->setText(r.error); + totalPages = page; // stop asking + return; + } + page = r.page; + totalPages = r.totalPages; + // The status line stays the last child: take it out, add the page, + // put it back. + removeView(status, false); + for (const auto& it : r.items) + if (!filter || filter(it)) add(it); + addView(status); + if (shown == 0 && page >= totalPages) + status->setText(tr("Nothing here")); + else + status->setVisibility(page >= totalPages ? brls::Visibility::GONE + : brls::Visibility::VISIBLE); + // A filter can thin a page to nothing: keep going while the screen + // is not even full. + if (shown < columns * 2 && page < totalPages) next(); + }); +} + +void PosterGrid::add(const content::Item& it) +{ + if (!row || (int)row->getChildren().size() >= columns) + { + row = new brls::Box(); + row->setAxis(brls::Axis::ROW); + row->setPaddingLeft(kInset); + row->setMarginBottom(kGap); + addView(row); + } + content::Item copy = it; + auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, alive, + [copy]() { openContentCard(copy); }, kPosterW); + card->registerAction(tr("Bookmark"), brls::BUTTON_Y, [copy](brls::View*) { + library::Entry e; + bool marked = library::find(copy.key(), e) && e.status != library::Status::None; + library::setStatus(copy, marked ? library::Status::None : library::Status::Planned); + brls::Application::notify(marked ? tr("Removed from bookmarks") + : tr("Added to bookmarks: Planned")); + return true; + }); + row->addView(card); + shown++; +} + +void PosterGrid::draw(NVGcontext* vg, float x, float y, float w, float h, brls::Style style, + brls::FrameContext* ctx) +{ + brls::Box::draw(vg, x, y, w, h, style, ctx); + // borealis' ScrollingFrame says nothing when it scrolls: compare its + // offset with the content each frame (catches a touch flick too). + if (!scroll || loading || page >= totalPages) return; + float visible = scroll->getHeight(); + float content = getHeight(); + if (visible <= 0.0f || content <= 0.0f) return; + if (scroll->getContentOffsetY() + visible >= content - kNearBottom) next(); +} + +void openCollection(const std::string& title, const tmdb::ListQuery& q) +{ + auto* root = new brls::Box(); + root->setAxis(brls::Axis::COLUMN); + root->setGrow(1.0f); + + auto* head = new brls::Label(); + head->setText(title); + head->setFontSize(28.0f); + head->setMargins(16.0f, 0.0f, 6.0f, kInset); + root->addView(head); + + auto* scroll = new brls::ScrollingFrame(); + scroll->setGrow(1.0f); + scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + auto* grid = new PosterGrid(); + scroll->setContentView(grid); + root->addView(scroll); + grid->load(q, scroll); + + auto* frame = new brls::AppletFrame(root); + frame->setTitle(""); + brls::Application::pushActivity(new brls::Activity(frame)); +} diff --git a/app/collection.hpp b/app/collection.hpp new file mode 100644 index 0000000..10c7d63 --- /dev/null +++ b/app/collection.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include +#include +#include + +#include "tmdb.hpp" + +// A wrapping grid of posters fed by a TMDB query, one page at a time: the +// next page is asked for as the scroll nears the bottom. A collection's "See +// all" screen is one; the search screen embeds one for its results. +class PosterGrid : public brls::Box +{ + public: + PosterGrid(); + ~PosterGrid() override; + + // Drops what is shown and starts over on `q` (page 1). `scroll` is the + // frame the grid sits in, watched for the bottom. + void load(const tmdb::ListQuery& q, brls::ScrollingFrame* scroll); + // Applied to each page before it is shown (the search's genre filter). + std::function filter; + // Where the focus goes when the grid is emptied under it. + brls::View* parkTarget = nullptr; + + void draw(NVGcontext* vg, float x, float y, float w, float h, brls::Style style, + brls::FrameContext* ctx) override; + + private: + tmdb::ListQuery query; + brls::ScrollingFrame* scroll = nullptr; + std::shared_ptr alive = std::make_shared(true); + brls::Box* row = nullptr; // the row being filled + brls::Label* status = nullptr; + int columns = 6; + int shown = 0; + int page = 0, totalPages = 1; + bool loading = false; + + void next(); + void add(const content::Item& it); +}; + +// The full screen of one collection ("Top rated movies" and so on). +void openCollection(const std::string& title, const tmdb::ListQuery& q); diff --git a/app/config.cpp b/app/config.cpp index a905c95..71b3bf8 100644 --- a/app/config.cpp +++ b/app/config.cpp @@ -127,6 +127,7 @@ Config& get() { return cfg; } std::string consoleLang() { +#ifdef __SWITCH__ // setGetSystemLanguage packs the code as chars in a u64 ("fr", "en-US"), // NOT as a SetLanguage enum -- the first two bytes are the ISO-639-1 part, // which is all we need. borealis already calls setInitialize(). @@ -136,6 +137,11 @@ std::string consoleLang() std::memcpy(buf, &code, 8); if (!buf[0] || !buf[1]) return "en"; return std::string(buf, 2); +#else + const char* l = std::getenv("LANG"); + if (l && std::strlen(l) >= 2) return std::string(l, 2); + return "ru"; +#endif } std::string mpvLangList(const std::string& code) @@ -246,7 +252,8 @@ void load() cfg.startupTab = (sTab == "stremio") ? Tab::STREMIO : (sTab == "jackett") ? Tab::JACKETT : (sTab == "history") ? Tab::HISTORY : - (sTab == "home") ? Tab::HOME : Tab::LOCAL; + (sTab == "home") ? Tab::HOME : + (sTab == "bookmarks") ? Tab::BOOKMARKS : Tab::LOCAL; cfg.jackettUrl = readStr(body, "jackettUrl", cfg.jackettUrl); cfg.jackettApiKey = readStr(body, "jackettApiKey", cfg.jackettApiKey); cfg.torrServerUrl = readStr(body, "torrServerUrl", cfg.torrServerUrl); @@ -294,7 +301,8 @@ bool save() const char* sTabStr = (cfg.startupTab == Tab::STREMIO) ? "stremio" : (cfg.startupTab == Tab::JACKETT) ? "jackett" : (cfg.startupTab == Tab::HISTORY) ? "history" : - (cfg.startupTab == Tab::HOME) ? "home" : "local"; + (cfg.startupTab == Tab::HOME) ? "home" : + (cfg.startupTab == Tab::BOOKMARKS) ? "bookmarks" : "local"; std::fprintf(f, "{\n" " \"startupTab\": \"%s\",\n" diff --git a/app/config.hpp b/app/config.hpp index 3337c61..f8b3f2c 100644 --- a/app/config.hpp +++ b/app/config.hpp @@ -17,6 +17,9 @@ enum class Tab JACKETT = 2, HISTORY = 3, HOME = 4, // the TMDB catalog (home.cpp) + BOOKMARKS = 5, // the library by status (bookmarks.cpp) + MOVIES = 6, // the catalog's films (home.cpp) + SHOWS = 7, // the catalog's series (home.cpp) }; // The default UI sizes, named so the Options screen can mark them in the list. diff --git a/app/content.hpp b/app/content.hpp index dd36629..e5c374c 100644 --- a/app/content.hpp +++ b/app/content.hpp @@ -30,6 +30,7 @@ struct Item std::string backdropUrl; // full width, for the card background double rating = 0.0; // 0..10, 0 = none std::vector genres; // names; details only + std::vector genreIds; // TMDB genre ids; lists only int runtimeMin = 0; // film length or a typical episode int seasonCount = 0; // series only diff --git a/app/home.cpp b/app/home.cpp index 38f0600..b7846bb 100644 --- a/app/home.cpp +++ b/app/home.cpp @@ -1,28 +1,38 @@ #include "home.hpp" #include +#include #include #include #include "card.hpp" +#include "collection.hpp" #include "history.hpp" #include "i18n.hpp" -#include "player.hpp" // startPlaybackPrompt, for Continue watching +#include "library.hpp" +#include "player.hpp" // startPlaybackPrompt, for files from the history +#include "sources.hpp" // continueContent +#include "stremio.hpp" // Cinemeta, for the IMDb rating #include "theme.hpp" #include "tmdb.hpp" #include "widgets.hpp" +// Laid out after the roadmap's mock-up "Главный экран": a compact hero (title, +// one meta line, two pills), "Continue watching" right under it as 16:9 cards, +// then rows of 2:3 posters -- so the first screen shows all three kinds. + namespace { // Where every row starts, so headings and first cards line up. constexpr float kInset = 60.0f; // Card widths. A poster is 2:3, a Continue card 16:9. -constexpr float kPosterW = 190.0f; -constexpr float kWideW = 330.0f; +constexpr float kPosterW = 150.0f; +constexpr float kWideW = 260.0f; +constexpr float kHeroH = 236.0f; // The strips are taller than their cards: the focus glow is drawn outside the // card and the frame clips to itself. -constexpr float kGlow = 24.0f; +constexpr float kGlow = 22.0f; const NVGcolor kInk = nvgRGB(7, 7, 26); @@ -42,15 +52,6 @@ brls::Label* textLabel(const std::string& text, float size, NVGcolor color) return l; } -std::string shortText(const std::string& s, size_t max) -{ - if (s.size() <= max) return s; - size_t cut = s.rfind(' ', max); - if (cut == std::string::npos) cut = max; - while (cut > 0 && ((unsigned char)s[cut] & 0xC0) == 0x80) cut--; - return s.substr(0, cut) + "..."; -} - // "2024 · Film · TMDB 8.1", the hero's meta line. std::string heroMeta(const content::Item& it) { @@ -70,10 +71,66 @@ std::string heroMeta(const content::Item& it) return m; } +// A backdrop at a card's size: TMDB serves every width off the same path. +std::string smallBackdrop(std::string url) +{ + size_t k = url.find("/w1280/"); + if (k != std::string::npos) url.replace(k, 7, "/w780/"); + return url; +} + +// Y on a poster: in the bookmarks as Planned, or out again. +void toggleBookmark(const content::Item& it) +{ + library::Entry e; + bool marked = library::find(it.key(), e) && e.status != library::Status::None; + library::setStatus(it, marked ? library::Status::None : library::Status::Planned); + brls::Application::notify(marked ? tr("Removed from bookmarks") + : tr("Added to bookmarks: Planned")); +} + +brls::Box* catalogCard(const content::Item& it, std::shared_ptr alive) +{ + auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, alive, + [it]() { openContentCard(it); }, kPosterW); + card->registerAction(tr("Bookmark"), brls::BUTTON_Y, [it](brls::View*) { + toggleBookmark(it); + return true; + }); + return card; +} + +// The rows of each view: what the header says and where the titles come from. +struct RowSpec +{ + const char* title; + tmdb::ListQuery query; // also what its "See all" screen pages through +}; + +RowSpec rowOf(const char* title, tmdb::Row row) { return { title, tmdb::rowQuery(row) }; } + +RowSpec pathOf(const char* title, const char* path, content::Kind kind) +{ + return { title, tmdb::pathQuery(path, kind) }; +} + +RowSpec genreOf(const char* title, content::Kind kind, int genre) +{ + return { title, tmdb::genreQuery(kind, genre) }; +} + + class HomeRoot : public brls::Box { public: - HomeRoot() + enum class View + { + Home = 0, + Movies, + Shows, + }; + + explicit HomeRoot(View v) : view(v) { setAxis(brls::Axis::COLUMN); setGrow(1.0f); @@ -83,83 +140,152 @@ class HomeRoot : public brls::Box scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); list = new brls::Box(); list->setAxis(brls::Axis::COLUMN); - list->setPadding(14.0f, 0.0f, 36.0f, 0.0f); + list->setPadding(14.0f, 0.0f, 30.0f, 0.0f); scroll->setContentView(list); addView(scroll); - hero = new brls::Box(); - hero->setVisibility(brls::Visibility::GONE); - list->addView(hero); + registerAction(tr("Search"), brls::BUTTON_BACK, [](brls::View*) { + openCatalogSearch(); + return true; + }); + + build(); + } - addContinueRow(); + ~HomeRoot() override { *alive = false; } + + // The library and the history change behind this tab (a film watched, an + // episode finished): keep Continue current. Polled, as nothing signals it. + void draw(NVGcontext* vg, float x, float y, float w, float h, brls::Style style, + brls::FrameContext* ctx) override + { + uint32_t gen = library::generation() * 7919u + history::generation(); + if (gen != seenGen) + { + seenGen = gen; + if (continueRow) fillContinue(); + } + brls::Box::draw(vg, x, y, w, h, style, ctx); + } + private: + std::shared_ptr alive = std::make_shared(true); + View view; + brls::Box* list = nullptr; + brls::Box* hero = nullptr; + brls::Button* heroWatch = nullptr; // somewhere safe to park the focus + brls::Box* firstRow = nullptr; // ... or here + brls::View* continueSection[2] = { nullptr, nullptr }; // heading, strip + brls::Box* continueRow = nullptr; + uint32_t seenGen = 0; + + void build() + { + using content::Kind; + if (view == View::Home) + { + hero = new brls::Box(); + hero->setVisibility(brls::Visibility::GONE); + list->addView(hero); + addContinueSection(); + } if (!tmdb::configured()) { addSetupNote(); return; } - registerAction(tr("Search"), brls::BUTTON_BACK, [this](brls::View*) { - promptSearch(); - return true; - }); - - // The trending row also feeds the hero. - brls::Box* trending = addRow(tr("Popular now")); - tmdb::fetchRowAsync(tmdb::Row::Trending, [this, trending, a = alive](tmdb::ListResult r) { - if (!*a) return; - fillRow(trending, r); - for (const auto& it : r.items) - if (!it.backdropUrl.empty()) - { - buildHero(it); - break; - } - }); - - const std::vector> rows = { - { "Popular movies", tmdb::Row::PopularMovies }, - { "Popular shows", tmdb::Row::PopularSeries }, - { "In cinemas", tmdb::Row::NowPlaying }, - { "Top rated movies", tmdb::Row::TopMovies }, - { "Top rated shows", tmdb::Row::TopSeries }, - }; - for (const auto& r : rows) + std::vector rows; + if (view == View::Home) + { + // The trending row also feeds the hero. + RowSpec trend = rowOf("Popular now", tmdb::Row::Trending); + brls::Box* trending = addRow(trend); + tmdb::fetchQueryAsync(trend.query, 1, [this, trending, trend, a = alive](tmdb::ListResult r) { + if (!*a) return; + fillRow(trending, r, trend); + for (const auto& it : r.items) + if (!it.backdropUrl.empty()) + { + buildHero(it); + break; + } + }); + rows = { rowOf("Popular movies", tmdb::Row::PopularMovies), + rowOf("Popular shows", tmdb::Row::PopularSeries), + rowOf("In cinemas", tmdb::Row::NowPlaying), + rowOf("Top rated movies", tmdb::Row::TopMovies), + rowOf("Top rated shows", tmdb::Row::TopSeries) }; + } + else if (view == View::Movies) + rows = { rowOf("Popular movies", tmdb::Row::PopularMovies), + rowOf("In cinemas", tmdb::Row::NowPlaying), + pathOf("Coming soon", "/movie/upcoming", Kind::Movie), + rowOf("Top rated movies", tmdb::Row::TopMovies), + genreOf("Action", Kind::Movie, 28), + genreOf("Comedy", Kind::Movie, 35), + genreOf("Drama", Kind::Movie, 18), + genreOf("Science fiction", Kind::Movie, 878), + genreOf("Thriller", Kind::Movie, 53), + genreOf("Horror", Kind::Movie, 27), + genreOf("Animation", Kind::Movie, 16) }; + else + rows = { rowOf("Popular shows", tmdb::Row::PopularSeries), + pathOf("On the air", "/tv/on_the_air", Kind::Series), + rowOf("Top rated shows", tmdb::Row::TopSeries), + genreOf("Drama", Kind::Series, 18), + genreOf("Comedy", Kind::Series, 35), + genreOf("Crime", Kind::Series, 80), + genreOf("Sci-Fi & Fantasy", Kind::Series, 10765), + genreOf("Animation", Kind::Series, 16), + genreOf("Documentary", Kind::Series, 99) }; + + for (const auto& spec : rows) { - brls::Box* row = addRow(tr(r.first)); - tmdb::fetchRowAsync(r.second, [this, row, a = alive](tmdb::ListResult res) { + brls::Box* row = addRow(spec); + tmdb::fetchQueryAsync(spec.query, 1, [this, row, spec, a = alive](tmdb::ListResult res) { if (!*a) return; - fillRow(row, res); + fillRow(row, res, spec); }); } } - ~HomeRoot() override { *alive = false; } - - private: - std::shared_ptr alive = std::make_shared(true); - brls::Box* list = nullptr; - brls::Box* hero = nullptr; - - // A titled strip, with a "Loading..." placeholder until fillRow. - brls::Box* addRow(const std::string& title) + // A titled strip, with a "Loading..." placeholder until fillRow. The + // heading opens the whole collection on a touch ("See all" at the row's + // end does it from the controller). + brls::Box* addRow(const RowSpec& spec) { - list->addView(ui::heading(title, kInset)); + std::string title = tr(spec.title); + tmdb::ListQuery q = spec.query; + + auto* head = new brls::Box(); + head->setAxis(brls::Axis::ROW); + head->setAlignItems(brls::AlignItems::FLEX_END); + auto* h = ui::heading(title, kInset); + head->addView(h); + auto* more = textLabel(std::string(tr("See all")) + " >", 15.0f, theme::textMuted()); + more->setMargins(0.0f, 0.0f, 6.0f, 14.0f); + head->addView(more); + head->addGestureRecognizer(new brls::TapGestureRecognizer( + head, [title, q]() { openCollection(title, q); })); + list->addView(head); + auto* row = new brls::Box(); - auto* wait = textLabel(tr("Loading..."), 18.0f, theme::textMuted()); + auto* wait = textLabel(tr("Loading..."), 17.0f, theme::textMuted()); wait->setMarginLeft(kInset); row->addView(wait); list->addView(ui::strip(row, kPosterW * 1.5f + kGlow)); + if (!firstRow) firstRow = row; return row; } - void fillRow(brls::Box* row, const tmdb::ListResult& r) + void fillRow(brls::Box* row, const tmdb::ListResult& r, const RowSpec& spec) { // Only the placeholder is in there: nothing focusable is freed. row->clearViews(); if (!r.ok || r.items.empty()) { - auto* l = textLabel(r.ok ? tr("Nothing here") : r.error, 18.0f, + auto* l = textLabel(r.ok ? tr("Nothing here") : r.error, 17.0f, r.ok ? theme::textMuted() : theme::textWarn()); l->setMarginLeft(kInset); row->addView(l); @@ -167,22 +293,26 @@ class HomeRoot : public brls::Box } for (const auto& it : r.items) { - auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, alive, - [it]() { openContentCard(it); }, kPosterW); + auto* card = catalogCard(it, alive); if (row->getChildren().empty()) card->setMarginLeft(kInset); row->addView(card); } + // The rest of the collection, one press past the last poster. + std::string title = tr(spec.title); + tmdb::ListQuery q = spec.query; + row->addView(ui::moreTile(tr("See all"), kPosterW, + [title, q]() { openCollection(title, q); })); } - // The mock-up's banner: the backdrop, darkened from the left, with the - // title, a meta line, two lines of synopsis and the two actions. + // The mock-up's banner: the backdrop darkened from the left, the title, + // one meta line and the two pills. void buildHero(const content::Item& it) { - hero->clearViews(); + if (!hero) return; hero->setVisibility(brls::Visibility::VISIBLE); - hero->setHeight(350.0f); - hero->setMargins(4.0f, kInset, 8.0f, kInset); - hero->setCornerRadius(16.0f); + hero->setHeight(kHeroH); + hero->setMargins(0.0f, kInset, 6.0f, kInset); + hero->setCornerRadius(14.0f); hero->setAxis(brls::Axis::COLUMN); hero->setJustifyContent(brls::JustifyContent::FLEX_END); @@ -194,12 +324,12 @@ class HomeRoot : public brls::Box bg->setHeightPercentage(100.0f); bg->setScalingType(brls::ImageScalingType::FILL); bg->setClipsToBounds(true); - bg->setCornerRadius(16.0f); + bg->setCornerRadius(14.0f); ui::attachArt(bg, it.key() + "_bd", it.backdropUrl, alive, true); hero->addView(bg); - auto* shade = new ui::ShadeBox(true, ink(245), ink(30), 0.75f); - shade->setCornerRadius(16.0f); + auto* shade = new ui::ShadeBox(true, ink(242), ink(40), 0.62f); + shade->setCornerRadius(14.0f); shade->setPositionType(brls::PositionType::ABSOLUTE); shade->setPositionTop(0.0f); shade->setPositionLeft(0.0f); @@ -209,48 +339,115 @@ class HomeRoot : public brls::Box auto* text = new brls::Box(); text->setAxis(brls::Axis::COLUMN); - text->setPadding(0.0f, 36.0f, 30.0f, 36.0f); - text->setMaxWidth(760.0f); + text->setPadding(0.0f, 28.0f, 24.0f, 28.0f); + text->setMaxWidth(620.0f); - auto* title = textLabel(it.title, 42.0f, nvgRGBA(255, 255, 255, 255)); + auto* title = textLabel(it.title, 34.0f, nvgRGBA(255, 255, 255, 255)); title->setSingleLine(true); text->addView(title); - auto* meta = textLabel(heroMeta(it), 18.0f, nvgRGBA(255, 255, 255, 200)); - meta->setMarginTop(8.0f); + auto* meta = textLabel(heroMeta(it), 16.0f, nvgRGBA(255, 255, 255, 200)); + meta->setMarginTop(6.0f); text->addView(meta); - if (!it.overview.empty()) - { - auto* ov = textLabel(shortText(it.overview, 170), 18.0f, - nvgRGBA(255, 255, 255, 215)); - ov->setMarginTop(12.0f); - text->addView(ov); - } + // The IMDb rating joins the line once known: the details give the IMDb + // id (cached), Cinemeta the rating. + std::string line = heroMeta(it); + auto a = alive; + tmdb::fetchDetailsAsync(it.kind, it.tmdbId, [meta, line, a](tmdb::DetailsResult d) { + if (!*a || !d.ok || d.details.item.imdbId.empty()) return; + const content::Item& di = d.details.item; + stremio::fetchMetaAsync("https://v3-cinemeta.strem.io", di.stremioType(), di.imdbId, + [meta, line, a](stremio::MetaResult m) { + if (!*a || !m.ok || m.imdbRating.empty()) return; + meta->setText(line + " \xC2\xB7 IMDb " + m.imdbRating); + }); + }); auto* actions = new brls::Box(); actions->setAxis(brls::Axis::ROW); - actions->setMarginTop(18.0f); - actions->addView(ui::actionButton(std::string("\xE2\x96\xB6 ") + tr("Watch"), true, - [it]() { watchContent(it); })); + actions->setMarginTop(16.0f); + heroWatch = ui::actionButton(std::string("\xE2\x96\xB6 ") + tr("Watch"), true, + [it]() { watchContent(it); }, true); + actions->addView(heroWatch); actions->addView(ui::actionButton(tr("Details"), false, - [it]() { openContentCard(it); })); + [it]() { openContentCard(it); }, true)); text->addView(actions); hero->addView(text); } - // History entries that can be resumed, newest first, as 16:9 cards with - // the progress along the bottom. - void addContinueRow() + // ---- Continue watching --------------------------------------------------- + + void addContinueSection() { - std::vector items; - for (const auto& it : history::all()) - if (history::hasValidResume(it)) items.push_back(it); - if (items.empty()) return; - if (items.size() > 12) items.resize(12); + auto* h = ui::heading(tr("Continue watching"), kInset); + list->addView(h); + continueRow = new brls::Box(); + brls::View* strip = ui::strip(continueRow, kWideW * 9.0f / 16.0f + kGlow); + list->addView(strip); + continueSection[0] = h; + continueSection[1] = strip; + fillContinue(); + } - list->addView(ui::heading(tr("Continue watching"), kInset)); - auto* row = new brls::Box(); - for (const auto& it : items) + bool focusInside(brls::View* root) + { + for (brls::View* v = brls::Application::getCurrentFocus(); v; v = v->getParent()) + if (v == root) return true; + return false; + } + + // Moves the focus off the Continue row before it is rebuilt (a freed + // focused view crashes borealis): the hero, else the first poster row. + void parkFocus() + { + if (!focusInside(continueRow)) return; + if (heroWatch) + brls::Application::giveFocus(heroWatch); + else if (firstRow && !firstRow->getChildren().empty() && + firstRow->getChildren()[0]->isFocusable()) + brls::Application::giveFocus(firstRow->getChildren()[0]); + } + + // The library's titles first (one press back into the film, or on to the + // next episode), then files from the history that are not those titles + // (a local .torrent, a magnet). + void fillContinue() + { + parkFocus(); + continueRow->clearViews(); + + std::set libSources; + int n = 0; + for (const auto& e : library::continueWatching()) { + if (n >= 12) break; + library::Next next = library::nextOf(e); + std::string title = e.item.title; + if (e.item.isSeries()) + title += " \xC2\xB7 S" + std::to_string(next.season) + "E" + + std::to_string(next.episode); + if (!e.source.empty()) libSources.insert(e.source); + library::Entry copy = e; + const content::Item& it = e.item; + std::string art = !it.backdropUrl.empty() ? smallBackdrop(it.backdropUrl) + : it.posterUrlLarge; + auto* card = ui::wideCard(title, it.key() + "_bdw", art, "", + next.kind == library::Next::Kind::Resume ? next.progress + : -1.0, + alive, [copy]() { continueContent(copy); }, kWideW); + std::string key = e.key(); + card->registerAction(tr("Remove"), brls::BUTTON_X, [key](brls::View*) { + library::dismissContinue(key); + return true; + }); + if (continueRow->getChildren().empty()) card->setMarginLeft(kInset); + continueRow->addView(card); + n++; + } + + for (const auto& it : history::all()) + { + if (n >= 12) break; + if (!history::hasValidResume(it) || libSources.count(it.source)) continue; double prog = it.durationSec > 0 ? it.positionSec / it.durationSec : -1.0; history::Item copy = it; auto* card = ui::wideCard(it.title, "", "", it.posterPath, prog, alive, @@ -268,111 +465,43 @@ class HomeRoot : public brls::Box copy.sourceType); }, kWideW); - if (row->getChildren().empty()) card->setMarginLeft(kInset); - row->addView(card); + std::string id = it.id; + card->registerAction(tr("Remove"), brls::BUTTON_X, [id](brls::View*) { + history::remove(id); + return true; + }); + if (continueRow->getChildren().empty()) card->setMarginLeft(kInset); + continueRow->addView(card); + n++; } - list->addView(ui::strip(row, kWideW * 9.0f / 16.0f + kGlow)); + + auto vis = n > 0 ? brls::Visibility::VISIBLE : brls::Visibility::GONE; + for (brls::View* v : continueSection) + if (v) v->setVisibility(vis); } void addSetupNote() { auto* box = new brls::Box(); box->setAxis(brls::Axis::COLUMN); - box->setMargins(30.0f, kInset, 0.0f, kInset); - box->setPadding(26.0f, 30.0f, 26.0f, 30.0f); + box->setMargins(24.0f, kInset, 0.0f, kInset); + box->setPadding(24.0f, 28.0f, 24.0f, 28.0f); box->setCornerRadius(14.0f); box->setBackgroundColor(theme::scrim(20)); - box->addView(textLabel(tr("The catalog needs a TMDB key"), 26.0f, theme::text())); + box->addView(textLabel(tr("The catalog needs a TMDB key"), 24.0f, theme::text())); auto* l = textLabel( tr("Get a free API key at themoviedb.org (Settings -> API) and enter it in " "Options -> Network. Where TMDB is blocked, set a proxy or a mirror there " "too."), - 18.0f, theme::textBody()); + 17.0f, theme::textBody()); l->setMarginTop(10.0f); box->addView(l); list->addView(box); } - - void promptSearch() - { - brls::Application::getImeManager()->openForText( - [a = alive](std::string q) { - if (!*a || q.empty()) return; - openSearchResults(q); - }, - tr("Search movies & shows..."), "", 128, ""); - } - - // The results on a page of their own: films and series as two strips. - static void openSearchResults(const std::string& query) - { - auto* page = new SearchPage(query); - auto* frame = new brls::AppletFrame(page); - frame->setTitle(tr("Search") + std::string(": ") + query); - brls::Application::pushActivity(new brls::Activity(frame)); - } - - class SearchPage : public brls::Box - { - public: - explicit SearchPage(const std::string& query) - { - setAxis(brls::Axis::COLUMN); - setGrow(1.0f); - auto* scroll = new brls::ScrollingFrame(); - scroll->setGrow(1.0f); - scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); - auto* list = new brls::Box(); - list->setAxis(brls::Axis::COLUMN); - list->setPadding(10.0f, 0.0f, 30.0f, 0.0f); - scroll->setContentView(list); - addView(scroll); - - list->addView(ui::heading(tr("Movies"), kInset)); - auto* movies = new brls::Box(); - list->addView(ui::strip(movies, kPosterW * 1.5f + kGlow)); - list->addView(ui::heading(tr("Shows"), kInset)); - auto* shows = new brls::Box(); - list->addView(ui::strip(shows, kPosterW * 1.5f + kGlow)); - for (brls::Box* r : { movies, shows }) - { - auto* w = textLabel(tr("Searching..."), 18.0f, theme::textMuted()); - w->setMarginLeft(kInset); - r->addView(w); - } - - tmdb::searchAsync(query, [movies, shows, a = alive](tmdb::ListResult r) { - if (!*a) return; - movies->clearViews(); - shows->clearViews(); - for (const auto& it : r.items) - { - brls::Box* row = it.isSeries() ? shows : movies; - auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, a, - [it]() { openContentCard(it); }, kPosterW); - if (row->getChildren().empty()) card->setMarginLeft(kInset); - row->addView(card); - } - for (brls::Box* row : { movies, shows }) - if (row->getChildren().empty()) - { - auto* l = textLabel(r.ok ? tr("No results found") : r.error, 18.0f, - theme::textMuted()); - l->setMarginLeft(kInset); - row->addView(l); - } - }); - } - ~SearchPage() override { *alive = false; } - - private: - std::shared_ptr alive = std::make_shared(true); - }; }; - } // namespace -brls::View* buildHomeTab() +brls::View* buildHomeTab(int view) { - return new HomeRoot(); + return new HomeRoot((HomeRoot::View)(view < 0 || view > 2 ? 0 : view)); } diff --git a/app/home.hpp b/app/home.hpp index 9a2341b..96e3348 100644 --- a/app/home.hpp +++ b/app/home.hpp @@ -2,11 +2,16 @@ #include -// The Home tab (roadmap mock-up "Главный экран"): a hero banner for the top -// trending title, "Continue watching" from the local history, then TMDB rows -// (trending, popular and top-rated films and series, in cinemas). A opens a -// title's card, "-" searches TMDB. +// The catalog tabs (roadmap mock-up "Главный экран"). view 0 = Home: a hero +// for the top trending title, "Continue watching" (the library, then files +// from the history), then TMDB rows. 1 = Movies, 2 = Shows: rows of that +// kind, by list and by genre. A opens a card, Y bookmarks a poster, X takes a +// title out of Continue, "-" searches. // // Without a TMDB key the rows are replaced by a note saying where to enter // one; Continue watching works either way. -brls::View* buildHomeTab(); +brls::View* buildHomeTab(int view = 0); + +// The TMDB search: keyboard, then films and shows on a results page. The +// header's "Search" button and "-" on the catalog tabs. +void openCatalogSearch(); diff --git a/app/http.cpp b/app/http.cpp index 86996b5..2c1a38a 100644 --- a/app/http.cpp +++ b/app/http.cpp @@ -9,7 +9,9 @@ #include #include "config.hpp" +#include "appdata.hpp" #include "netutil.hpp" +#include namespace http @@ -61,6 +63,12 @@ static void verifyTls(CURL* curl, const std::string& url) { #ifdef __SWITCH__ curl_easy_setopt(curl, CURLOPT_CAINFO, "romfs:/cacert.pem"); +#else + if (access(APP_RES("cacert.pem"), 0) == 0) + curl_easy_setopt(curl, CURLOPT_CAINFO, APP_RES("cacert.pem")); +#ifdef CURLSSLOPT_NATIVE_CA + curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA); +#endif #endif curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); diff --git a/app/i18n_ru.cpp b/app/i18n_ru.cpp index bcdc8d7..056eb2f 100644 --- a/app/i18n_ru.cpp +++ b/app/i18n_ru.cpp @@ -463,6 +463,104 @@ const Pair kRu[] = { "Чтобы найти источники, войдите в Stremio или настройте Jackett в " "настройках." }, + // ---- sources screen ------------------------------------------------------- + { " releases", " раздач" }, + { " found so far", " найдено" }, + { "Order", "Порядок" }, + { "Order: recommended", "Сортировка: рекомендуемые" }, + { "Order: seeders", "Сортировка: по сидам" }, + { "Order: size", "Сортировка: по размеру" }, + { "Nothing matches this filter.", "Под этот фильтр ничего не подходит." }, + { "best choice", "лучший выбор" }, + { "whole season", "сезон целиком" }, + { "several seasons", "несколько сезонов" }, + { "heavy for Wi-Fi", "тяжёлый для Wi-Fi" }, + { "seeds ", "сиды " }, + + // ---- library / bookmarks --------------------------------------------------- + { "Bookmarks", "Закладки" }, + { "Bookmark", "В закладки" }, + { "Watching", "Смотрю" }, + { "Planned", "Буду смотреть" }, + { "Dropped", "Брошено" }, + { "Remove from bookmarks", "Убрать из закладок" }, + { "Continue S", "Продолжить С" }, + { "Export", "Экспорт" }, + { "Import", "Импорт" }, + { "The library was saved to ", "Библиотека сохранена в " }, + { "Export failed: ", "Не удалось экспортировать: " }, + { "Imported. New titles: ", "Импорт выполнен. Новых названий: " }, + { "Import failed: ", "Не удалось импортировать: " }, + { "Expected file: ", "Ожидаемый файл: " }, + { "No bookmarks yet", "Закладок пока нет" }, + { "Open a film or a show and press \"Bookmark\" to keep it here, sorted by what " + "you are watching, planning, have finished or dropped.", + "Откройте фильм или сериал и нажмите «В закладки» — он появится здесь, " + "разложенный по статусам: смотрю, буду смотреть, просмотрено, брошено." }, + + { "Open", "Открыть" }, + { "Sources", "Источники" }, + { "Added to bookmarks: Planned", "В закладках: буду смотреть" }, + { "Removed from bookmarks", "Убрано из закладок" }, + + // ---- collections, search ----------------------------------------------------- + { "See all", "Все" }, + { "Genres", "Жанры" }, + { "All genres", "Все жанры" }, + { "Type", "Тип" }, + { "Reset", "Сбросить" }, + { "By rating", "По рейтингу" }, + { "Newest", "Новые" }, + { "Any year", "Любой год" }, + { "2010s", "2010-е" }, + { "2000s", "2000-е" }, + { "1990s", "1990-е" }, + { "1980s", "1980-е" }, + { "Before 1980", "До 1980" }, + { "Adventure", "Приключения" }, + { "Family", "Семейные" }, + { "Fantasy", "Фэнтези" }, + { "Historical", "Исторические" }, // the genre; "History" is the tab + { "Music", "Музыка" }, + { "Mystery", "Детективы" }, + { "Romance", "Мелодрамы" }, + { "War", "Военные" }, + { "Western", "Вестерны" }, + { "Action & Adventure", "Боевики и приключения" }, + { "Kids", "Детские" }, + { "Reality", "Реалити" }, + { "War & Politics", "Война и политика" }, + + // ---- release filters ----------------------------------------------------------- + { "Voice-over", "Озвучка" }, + { "Any voice-over", "Любая озвучка" }, + { "Any source", "Любой источник" }, + { "Picture", "Картинка" }, + { "Any picture", "Любая картинка" }, + { "Without HDR", "Без HDR" }, + { "HDR / Dolby Vision", "HDR / Dolby Vision" }, + { "Tracker", "Трекер" }, + { "Any tracker", "Любой трекер" }, + + // ---- home sections (passed through a variable) ------------------------------ + { "Section", "Раздел" }, + { "Coming soon", "Скоро в кино" }, + { "On the air", "Сейчас выходят" }, + { "Action", "Боевики" }, + { "Comedy", "Комедии" }, + { "Drama", "Драмы" }, + { "Science fiction", "Фантастика" }, + { "Thriller", "Триллеры" }, + { "Horror", "Ужасы" }, + { "Animation", "Мультфильмы" }, + { "Crime", "Криминал" }, + { "Sci-Fi & Fantasy", "Фантастика и фэнтези" }, + { "Documentary", "Документальные" }, + + // ---- PC build --------------------------------------------------------------- + { "Clipboard is empty or does not contain text.", + "Буфер обмена пуст или в нём не текст." }, + // ---- options: TMDB ------------------------------------------------------- { "API key", "Ключ API" }, { "TMDB API key", "Ключ API TMDB" }, diff --git a/app/jackett_tab.cpp b/app/jackett_tab.cpp index c539998..a8be266 100644 --- a/app/jackett_tab.cpp +++ b/app/jackett_tab.cpp @@ -15,6 +15,7 @@ #include "i18n.hpp" #include "jackett.hpp" #include "player.hpp" +#include "torrent_play.hpp" #include "theme.hpp" extern "C" { @@ -24,75 +25,6 @@ extern "C" { namespace { -struct VideoFileInfo -{ - int index; - std::string name; - int64_t length; -}; - -bool isVideoExtension(const std::string& path) -{ - static const char* kExt[] = { ".mkv", ".mp4", ".avi", ".m4v", ".mov", - ".webm", ".ts", ".m2ts", ".mpg", ".mpeg", - ".wmv", ".flv", ".ogv" }; - for (const char* e : kExt) - { - size_t n = std::strlen(e); - if (path.size() > n && - strcasecmp(path.c_str() + path.size() - n, e) == 0) - return true; - } - return false; -} - -// The video files of a loaded torrent, with picker labels (unsorted). -std::vector videosOf(const torrent_meta* m) -{ - std::vector idx; - std::vector paths; - for (int i = 0; i < m->file_count; i++) - { - if (!isVideoExtension(m->files[i].path)) continue; - idx.push_back(i); - paths.push_back(m->files[i].path); - } - std::vector names = pickerNames(paths); - std::vector out; - for (size_t k = 0; k < idx.size(); k++) - out.push_back({ idx[k], names[k], m->files[idx[k]].length }); - return out; -} - -std::vector parseTorrentVideos(const std::string& path) -{ - std::vector out; - torrent_meta* m = (torrent_meta*)std::calloc(1, sizeof(torrent_meta)); - if (!m) return out; - char err[128] = { 0 }; - if (torrent_load(m, path.c_str(), err, sizeof(err)) == 0) - { - out = videosOf(m); - torrent_unload(m); - } - std::free(m); - std::sort(out.begin(), out.end(), [](const VideoFileInfo& a, const VideoFileInfo& b) { - return naturalCompare(a.name, b.name); - }); - return out; -} - -class LoadingBox : public brls::Box -{ - public: - std::shared_ptr cancelled; - LoadingBox(std::shared_ptr c) : cancelled(std::move(c)) {} - ~LoadingBox() override - { - if (cancelled) *cancelled = true; - } -}; - class JackettTabRoot : public brls::Box { public: @@ -670,7 +602,9 @@ class JackettView // Action A: Play jackett::Item itemCopy = item; row->registerClickAction([this, itemCopy](brls::View*) { - playItem(itemCopy); + PlayHint hint; + hint.title = itemCopy.title; + playJackettItem(itemCopy, hint); return true; }); @@ -686,168 +620,6 @@ class JackettView return row; } - void playWithVideos(const std::string& source, const std::string& title, - const std::vector& vids) - { - if (vids.empty()) - { - auto* d = new brls::Dialog(tr("This torrent has no video file to stream.")); - d->addButton("OK", []() {}); - d->open(); - return; - } - - if (vids.size() == 1) - { - startPlaybackPrompt(source, {}, vids[0].name, vids[0].index, {}, "Jackett"); - return; - } - - std::vector rows; - for (const auto& v : vids) - { - Row r; - r.label = v.name; - r.detail = jackett::formatSize(v.length); - rows.push_back(r); - } - brls::Application::pushActivity(new ListActivity( - title, tr("Pick a file"), rows, [source, vids](int i) { - startPlaybackPrompt(source, {}, vids[i].name, vids[i].index, {}, "Jackett"); - })); - } - - void resolveAndPlayMagnet(const std::string& magnet, const std::string& title, - brls::Dialog* loading, LoadingBox* box, - std::shared_ptr cancelled) - { - brls::async([magnet, title, loading, box, cancelled, this, live = alive]() { - torrent_meta* m = (torrent_meta*)std::calloc(1, sizeof(torrent_meta)); - char err[128] = { 0 }; - int rc = -1; - std::vector vids; - std::string resolvedName; - if (m) - { - rc = torrent_load_magnet(m, magnet.c_str(), err, sizeof(err)); - if (rc == 0) - { - resolvedName = m->name; - vids = videosOf(m); - std::sort(vids.begin(), vids.end(), [](const VideoFileInfo& a, const VideoFileInfo& b) { - return naturalCompare(a.name, b.name); - }); - torrent_unload(m); - } - std::free(m); - } - std::string e = err; - - brls::sync([this, live, magnet, title, resolvedName, vids, rc, e, loading, box, cancelled]() { - if (*cancelled || !*live) return; - box->cancelled = nullptr; - loading->close([this, magnet, title, resolvedName, vids, rc, e]() { - if (rc != 0) - { - auto* d = new brls::Dialog(tr("Could not resolve torrent metadata: ") + e); - d->addButton("OK", []() {}); - d->open(); - return; - } - std::string playTitle = !resolvedName.empty() ? resolvedName : title; - playWithVideos(magnet, playTitle, vids); - }); - }); - }); - } - - void playItem(const jackett::Item& item) - { - std::string link = item.link; - std::string magnet = (!link.empty() && link.rfind("magnet:", 0) == 0) - ? link - : item.magnetUri; - bool isHttp = !link.empty() && link.rfind("magnet:", 0) != 0; - - if (!isHttp && magnet.empty()) - { - auto* d = new brls::Dialog(tr("No playable link or magnet URI found for this torrent.")); - d->addButton("OK", []() {}); - d->open(); - return; - } - - auto cancelled = std::make_shared(false); - auto* box = new LoadingBox(cancelled); - box->setAxis(brls::Axis::COLUMN); - box->setAlignItems(brls::AlignItems::CENTER); - box->setJustifyContent(brls::JustifyContent::CENTER); - box->setPadding(24.0f); - - auto* spinner = new brls::ProgressSpinner(); - box->addView(spinner); - - auto* lbl = new brls::Label(); - lbl->setText(tr("Preparing torrent...")); - lbl->setFontSize(20.0f); - lbl->setMarginTop(20.0f); - box->addView(lbl); - - auto* loading = new brls::Dialog(box); - loading->addButton(tr("Cancel"), [cancelled]() { *cancelled = true; }); - loading->setCancelable(true); - loading->open(); - - if (isHttp) - { - // One file per link: history entries resume from this path later, - // so it must not be overwritten by the next result played. - std::string tempPath = jackett::cachedTorrentPath(link); - jackett::downloadTorrentAsync(link, tempPath, [this, live = alive, item, tempPath, magnet, loading, box, cancelled]( - bool ok, const std::string& err, const std::string& redirected) { - if (*cancelled || !*live) return; - // The indexer pointed at a magnet instead of a file (common for - // public trackers): resolve that, it is the same torrent. - if (ok && !redirected.empty()) - { - resolveAndPlayMagnet(redirected, item.title, loading, box, cancelled); - return; - } - if (ok) - { - auto vids = parseTorrentVideos(tempPath); - if (!vids.empty()) - { - box->cancelled = nullptr; - loading->close([this, item, tempPath, vids]() { - playWithVideos(tempPath, item.title, vids); - }); - return; - } - } - // If .torrent download failed or had no video, fall back to magnet if available - if (!magnet.empty()) - { - resolveAndPlayMagnet(magnet, item.title, loading, box, cancelled); - } - else - { - box->cancelled = nullptr; - loading->close([err, ok]() { - auto* d = new brls::Dialog(ok ? tr("This torrent has no video file to stream.") - : tr("Could not download torrent: ") + err); - d->addButton("OK", []() {}); - d->open(); - }); - } - }); - } - else - { - resolveAndPlayMagnet(magnet, item.title, loading, box, cancelled); - } - } - void saveItem(const jackett::Item& item) { jackett::saveToLocalAsync(item, [](bool ok, const std::string& msg) { diff --git a/app/json.cpp b/app/json.cpp index 4b85c80..9e247a5 100644 --- a/app/json.cpp +++ b/app/json.cpp @@ -1,5 +1,6 @@ #include "json.hpp" +#include #include // A JSON reader sized to what this app actually receives: flat "key": value @@ -371,6 +372,30 @@ std::string object(const std::string& body, const char* key) return ""; } +std::vector integers(const std::string& body, const char* key) +{ + std::vector out; + std::string pat = std::string("\"") + key + "\""; + size_t k = body.find(pat); + if (k == std::string::npos) return out; + size_t open = body.find_first_not_of(" \t\r\n:", k + pat.size()); + if (open == std::string::npos || body[open] != '[') return out; + const char* p = body.c_str() + open + 1; + while (*p && *p != ']') + { + char* end = nullptr; + long v = std::strtol(p, &end, 10); + if (end == p) + { + p++; // a comma or a space + continue; + } + out.push_back(v); + p = end; + } + return out; +} + // Collects the plain strings of a JSON array like "types":["movie","series"]. std::vector strings(const std::string& body, const char* key) { diff --git a/app/json.hpp b/app/json.hpp index 67b0d77..26178f3 100644 --- a/app/json.hpp +++ b/app/json.hpp @@ -36,6 +36,9 @@ std::vector objects(const std::string& body, const char* key); // The plain strings of an array like "types":["movie","series"]. std::vector strings(const std::string& body, const char* key); +// The numbers of an array like "genre_ids":[28,12]. +std::vector integers(const std::string& body, const char* key); + // `obj` (one JSON object) with the contents of every nested object and array // emptied, so the lookups above can only find the object's own keys -- TMDB's // "name" turns up inside "created_by" and "genres" before the show's own. diff --git a/app/library.cpp b/app/library.cpp new file mode 100644 index 0000000..3545f2a --- /dev/null +++ b/app/library.cpp @@ -0,0 +1,531 @@ +#include "library.hpp" + +#include +#include +#include +#include +#include + +#include "json.hpp" + +#ifndef LIBRARY_NO_UI +#include +#include "appdata.hpp" +#endif + +namespace library +{ + +std::string episodeKey(int season, int episode) +{ + return std::to_string(season) + ":" + std::to_string(episode); +} + +const EpisodeProgress* Entry::episode(int s, int e) const +{ + auto it = episodes.find(episodeKey(s, e)); + return it == episodes.end() ? nullptr : &it->second; +} + +// ============================================================================ +// JSON +// ============================================================================ + +namespace +{ +std::string num(double v) +{ + char b[32]; + std::snprintf(b, sizeof(b), "%.1f", v); + return b; +} + +std::string str(const std::string& s) { return "\"" + json::escape(s) + "\""; } + +// json::escape covers quotes and backslashes; a title can also hold a newline +// or a tab, which a JSON string may not carry raw. +std::string clean(std::string s) +{ + for (char& c : s) + if (c == '\n' || c == '\r' || c == '\t') c = ' '; + return s; +} + +const char* statusName(Status s) +{ + switch (s) + { + case Status::Watching: return "watching"; + case Status::Planned: return "planned"; + case Status::Completed: return "completed"; + case Status::Dropped: return "dropped"; + default: return ""; + } +} + +Status statusOf(const std::string& s) +{ + if (s == "watching") return Status::Watching; + if (s == "planned") return Status::Planned; + if (s == "completed") return Status::Completed; + if (s == "dropped") return Status::Dropped; + return Status::None; +} +} // namespace + +std::string toJson(const std::vector& entries) +{ + std::string o = "{\"version\":1,\"entries\":[\n"; + for (size_t i = 0; i < entries.size(); i++) + { + const Entry& e = entries[i]; + const content::Item& it = e.item; + o += "{\"item\":{"; + o += "\"kind\":" + str(it.isSeries() ? "series" : "movie"); + o += ",\"tmdbId\":" + std::to_string(it.tmdbId); + o += ",\"imdbId\":" + str(clean(it.imdbId)); + o += ",\"title\":" + str(clean(it.title)); + o += ",\"originalTitle\":" + str(clean(it.originalTitle)); + o += ",\"year\":" + str(clean(it.year)); + o += ",\"poster\":" + str(clean(it.posterUrl)); + o += ",\"posterLarge\":" + str(clean(it.posterUrlLarge)); + o += ",\"backdrop\":" + str(clean(it.backdropUrl)); + o += ",\"rating\":" + num(it.rating); + o += "}"; + o += ",\"status\":" + str(statusName(e.status)); + o += ",\"added\":" + std::to_string(e.added); + o += ",\"changed\":" + std::to_string(e.changed); + o += ",\"pos\":" + num(e.pos) + ",\"dur\":" + num(e.dur); + o += ",\"done\":" + std::string(e.done ? "true" : "false"); + o += ",\"lastSeason\":" + std::to_string(e.lastSeason); + o += ",\"lastEpisode\":" + std::to_string(e.lastEpisode); + o += ",\"watchedAt\":" + std::to_string(e.watchedAt); + o += ",\"source\":" + str(clean(e.source)); + o += ",\"fileIndex\":" + std::to_string(e.fileIndex); + o += ",\"sourceType\":" + str(clean(e.sourceType)); + o += ",\"packTitle\":" + str(clean(e.packTitle)); + o += ",\"packLink\":" + str(clean(e.packLink)); + o += ",\"packMagnet\":" + str(clean(e.packMagnet)); + o += ",\"packSeason\":" + std::to_string(e.packSeason); + o += ",\"packSeasonTo\":" + std::to_string(e.packSeasonTo); + o += ",\"episodes\":["; + bool first = true; + for (const auto& kv : e.episodes) + { + size_t c = kv.first.find(':'); + if (c == std::string::npos) continue; + if (!first) o += ","; + first = false; + o += "{\"s\":" + kv.first.substr(0, c) + ",\"e\":" + kv.first.substr(c + 1) + + ",\"pos\":" + num(kv.second.pos) + ",\"dur\":" + num(kv.second.dur) + + ",\"done\":" + (kv.second.done ? "true" : "false") + + ",\"at\":" + std::to_string(kv.second.at) + "}"; + } + o += "]}"; + o += i + 1 < entries.size() ? ",\n" : "\n"; + } + o += "]}\n"; + return o; +} + +std::vector fromJson(const std::string& body) +{ + std::vector out; + for (const auto& obj : json::objects(body, "entries")) + { + // The entry's own keys only: "pos" and "done" also appear in each of + // its episodes, "title" in its item. + std::string top = json::topLevel(obj); + std::string itj = json::object(obj, "item"); + Entry e; + content::Item& it = e.item; + it.kind = json::str(itj, "kind") == "series" ? content::Kind::Series + : content::Kind::Movie; + it.tmdbId = (int)json::integer(itj, "tmdbId", 0); + it.imdbId = json::str(itj, "imdbId"); + it.title = json::str(itj, "title"); + it.originalTitle = json::str(itj, "originalTitle"); + it.year = json::str(itj, "year"); + it.posterUrl = json::str(itj, "poster"); + it.posterUrlLarge = json::str(itj, "posterLarge"); + it.backdropUrl = json::str(itj, "backdrop"); + it.rating = json::number(itj, "rating", 0); + if (it.tmdbId <= 0) continue; + + e.status = statusOf(json::str(top, "status")); + e.added = json::integer(top, "added", 0); + e.changed = json::integer(top, "changed", 0); + e.pos = json::number(top, "pos", 0); + e.dur = json::number(top, "dur", 0); + e.done = json::boolean(top, "done", false); + e.lastSeason = (int)json::integer(top, "lastSeason", 0); + e.lastEpisode = (int)json::integer(top, "lastEpisode", 0); + e.watchedAt = json::integer(top, "watchedAt", 0); + e.source = json::str(top, "source"); + e.fileIndex = (int)json::integer(top, "fileIndex", -1); + e.sourceType = json::str(top, "sourceType"); + e.packTitle = json::str(top, "packTitle"); + e.packLink = json::str(top, "packLink"); + e.packMagnet = json::str(top, "packMagnet"); + e.packSeason = (int)json::integer(top, "packSeason", 0); + e.packSeasonTo = (int)json::integer(top, "packSeasonTo", 0); + for (const auto& ep : json::objects(obj, "episodes")) + { + EpisodeProgress p; + p.pos = json::number(ep, "pos", 0); + p.dur = json::number(ep, "dur", 0); + p.done = json::boolean(ep, "done", false); + p.at = json::integer(ep, "at", 0); + e.episodes[episodeKey((int)json::integer(ep, "s", 0), + (int)json::integer(ep, "e", 0))] = p; + } + out.push_back(std::move(e)); + } + return out; +} + +// ============================================================================ +// Continue +// ============================================================================ + +namespace +{ +// Started (15 s in) and not yet over (95% counts as over: the credits). +bool partWay(double pos, double dur, bool done) +{ + if (done || pos < 15.0) return false; + return dur <= 0.0 || pos < dur * 0.95; +} +} // namespace + +Next nextOf(const Entry& e) +{ + Next n; + if (e.status == Status::Dropped || e.status == Status::Completed) return n; + if (!e.item.isSeries()) + { + if (partWay(e.pos, e.dur, e.done)) + { + n.kind = Next::Kind::Resume; + n.progress = e.dur > 0 ? e.pos / e.dur : -1; + } + return n; + } + if (e.lastEpisode <= 0) return n; + const EpisodeProgress* p = e.episode(e.lastSeason, e.lastEpisode); + if (p && partWay(p->pos, p->dur, p->done)) + { + n.kind = Next::Kind::Resume; + n.season = e.lastSeason; + n.episode = e.lastEpisode; + n.progress = p->dur > 0 ? p->pos / p->dur : -1; + return n; + } + if (p && (p->done || (p->dur > 0 && p->pos >= p->dur * 0.95))) + { + n.kind = Next::Kind::NextEp; + n.season = e.lastSeason; + n.episode = e.lastEpisode + 1; + } + return n; +} + +int merge(std::vector& into, const std::vector& incoming) +{ + int added = 0; + for (const auto& in : incoming) + { + auto it = std::find_if(into.begin(), into.end(), + [&](const Entry& e) { return e.key() == in.key(); }); + if (it == into.end()) + { + into.push_back(in); + added++; + continue; + } + if (in.changed > it->changed) + { + it->status = in.status; + it->changed = in.changed; + } + if (in.watchedAt > it->watchedAt) + { + Status keep = it->status; + int64_t ch = it->changed; + auto eps = it->episodes; + *it = in; + it->status = keep; + it->changed = ch; + for (const auto& kv : eps) + if (!it->episodes.count(kv.first)) it->episodes[kv.first] = kv.second; + } + else + for (const auto& kv : in.episodes) + { + auto mine = it->episodes.find(kv.first); + if (mine == it->episodes.end() || kv.second.at > mine->second.at) + it->episodes[kv.first] = kv.second; + } + } + return added; +} + +// ============================================================================ +// The store +// ============================================================================ +#ifndef LIBRARY_NO_UI + +namespace +{ +std::mutex g_lock; +std::vector g_entries; +uint32_t g_gen = 0; + +int64_t now() { return (int64_t)std::time(nullptr); } + +bool readFile(const std::string& path, std::string& out) +{ + FILE* f = std::fopen(path.c_str(), "rb"); + if (!f) return false; + char buf[8192]; + size_t n; + out.clear(); + while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); + std::fclose(f); + return true; +} + +bool writeFile(const std::string& path, const std::string& data) +{ + std::string tmp = path + ".tmp"; + FILE* f = std::fopen(tmp.c_str(), "wb"); + if (!f) return false; + bool ok = std::fwrite(data.data(), 1, data.size(), f) == data.size(); + ok = std::fclose(f) == 0 && ok; + if (!ok) return false; + std::remove(path.c_str()); + return std::rename(tmp.c_str(), path.c_str()) == 0; +} + +// Off the UI thread: the player records every 15 s, and an SD write in the +// frame is a stutter. Snapshot under the lock, write without it. +void saveAsync() +{ + std::string data; + { + std::lock_guard g(g_lock); + data = toJson(g_entries); + g_gen++; + } + brls::async([data]() { + static std::mutex writeLock; // two saves never interleave on disk + std::lock_guard g(writeLock); + if (!writeFile(APPDATA_LIBRARY, data)) + brls::Logger::warning("[library] could not write {}", APPDATA_LIBRARY); + }); +} + +Entry& ensure(const content::Item& item) +{ + for (auto& e : g_entries) + if (e.key() == item.key()) + { + // Keep the snapshot fresh (an IMDb id learned since, a new poster). + if (!item.title.empty()) e.item.title = item.title; + if (!item.imdbId.empty()) e.item.imdbId = item.imdbId; + if (!item.posterUrl.empty()) e.item.posterUrl = item.posterUrl; + if (!item.posterUrlLarge.empty()) e.item.posterUrlLarge = item.posterUrlLarge; + if (!item.backdropUrl.empty()) e.item.backdropUrl = item.backdropUrl; + return e; + } + Entry e; + e.item = item; + e.added = now(); + g_entries.push_back(e); + return g_entries.back(); +} +} // namespace + +void load() +{ + std::string body; + bool ok = readFile(APPDATA_LIBRARY, body) || + readFile(std::string(APPDATA_LIBRARY) + ".tmp", body); // a cut-off save + std::lock_guard g(g_lock); + g_entries = ok ? fromJson(body) : std::vector(); + brls::Logger::info("[library] {} titles", g_entries.size()); +} + +uint32_t generation() +{ + std::lock_guard g(g_lock); + return g_gen; +} + +bool find(const std::string& key, Entry& out) +{ + std::lock_guard g(g_lock); + for (const auto& e : g_entries) + if (e.key() == key) + { + out = e; + return true; + } + return false; +} + +std::vector all() +{ + std::lock_guard g(g_lock); + return g_entries; +} + +std::vector withStatus(Status s) +{ + std::vector out; + { + std::lock_guard g(g_lock); + for (const auto& e : g_entries) + if (e.status == s) out.push_back(e); + } + std::sort(out.begin(), out.end(), [](const Entry& a, const Entry& b) { + return std::max(a.changed, a.watchedAt) > std::max(b.changed, b.watchedAt); + }); + return out; +} + +std::vector continueWatching() +{ + std::vector out; + { + std::lock_guard g(g_lock); + for (const auto& e : g_entries) + if (nextOf(e).kind != Next::Kind::None) out.push_back(e); + } + std::sort(out.begin(), out.end(), + [](const Entry& a, const Entry& b) { return a.watchedAt > b.watchedAt; }); + return out; +} + +void setStatus(const content::Item& item, Status s) +{ + { + std::lock_guard g(g_lock); + Entry& e = ensure(item); + e.status = s; + e.changed = now(); + } + saveAsync(); +} + +void dismissContinue(const std::string& key) +{ + { + std::lock_guard g(g_lock); + for (auto& e : g_entries) + if (e.key() == key) + { + e.pos = e.dur = 0; + e.lastSeason = e.lastEpisode = 0; + } + } + saveAsync(); +} + +void recordProgress(const content::Item& item, int season, int episode, double pos, + double dur, bool finished, const std::string& source, int fileIndex, + const std::string& sourceType) +{ + { + std::lock_guard g(g_lock); + Entry& e = ensure(item); + e.watchedAt = now(); + e.source = source; + e.fileIndex = fileIndex; + e.sourceType = sourceType; + if (item.isSeries() && episode > 0) + { + EpisodeProgress& p = e.episodes[episodeKey(season, episode)]; + p.dur = dur; + p.pos = finished ? dur : pos; + p.done = p.done || finished; + p.at = e.watchedAt; + e.lastSeason = season; + e.lastEpisode = episode; + } + else + { + e.pos = finished ? dur : pos; + e.dur = dur; + e.done = finished; + } + // Started a planned title: it is being watched now. A film played to + // the end is watched. + if (e.status == Status::Planned || e.status == Status::None) + { + if (!item.isSeries() && finished) + e.status = Status::Completed; + else if (e.status == Status::Planned) + e.status = Status::Watching; + if (e.status != Status::None) e.changed = e.watchedAt; + } + } + saveAsync(); +} + +void rememberPack(const content::Item& item, const std::string& title, + const std::string& link, const std::string& magnet, int seasonFrom, + int seasonTo) +{ + { + std::lock_guard g(g_lock); + Entry& e = ensure(item); + e.packTitle = title; + e.packLink = link; + e.packMagnet = magnet; + e.packSeason = seasonFrom; + e.packSeasonTo = seasonTo < seasonFrom ? seasonFrom : seasonTo; + } + saveAsync(); +} + +bool exportTo(const std::string& path, std::string& err) +{ + std::string data; + { + std::lock_guard g(g_lock); + data = toJson(g_entries); + } + if (!writeFile(path, data)) + { + err = "cannot write " + path; + return false; + } + return true; +} + +bool importFrom(const std::string& path, int& added, std::string& err) +{ + std::string body; + if (!readFile(path, body)) + { + err = "cannot read " + path; + return false; + } + std::vector in = fromJson(body); + if (in.empty() && body.find("\"entries\"") == std::string::npos) + { + err = "not a library file"; + return false; + } + { + std::lock_guard g(g_lock); + added = merge(g_entries, in); + } + saveAsync(); + return true; +} + +#endif // LIBRARY_NO_UI + +} // namespace library diff --git a/app/library.hpp b/app/library.hpp new file mode 100644 index 0000000..5f0cf64 --- /dev/null +++ b/app/library.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include + +#include "content.hpp" + +// The user's library of films and shows (roadmap phase 4), kept on the SD card +// as one JSON file (APPDATA_LIBRARY): +// - a bookmark status: Watching / Planned / Completed / Dropped; +// - watch progress per film and per episode -- tied to the title, not to a +// file, so it survives picking another release; +// - what played last (to resume in one press) and the season pack it came +// from (to open the next episode from the same torrent). +// +// The model and the JSON are pure (tested on PC, pctest/test_library.cpp); +// the store below them is the app's. +namespace library +{ + +enum class Status +{ + None = 0, + Watching, + Planned, + Completed, + Dropped, +}; + +struct EpisodeProgress +{ + double pos = 0, dur = 0; + bool done = false; + int64_t at = 0; // unix seconds +}; + +struct Entry +{ + content::Item item; // snapshot: enough to show it and reopen its card + Status status = Status::None; + int64_t added = 0; // first seen + int64_t changed = 0; // status last set + + // Film progress. + double pos = 0, dur = 0; + bool done = false; + // Series progress: "season:episode" -> progress, and the last one played. + std::map episodes; + int lastSeason = 0, lastEpisode = 0; + int64_t watchedAt = 0; // last playback, for "Continue watching" order + + // What played last: a path / magnet / URL with its file index. + std::string source; + int fileIndex = -1; + std::string sourceType; + + // The season pack the last episode came from (a Jackett result or a + // magnet) and the seasons it holds. + std::string packTitle, packLink, packMagnet; + int packSeason = 0, packSeasonTo = 0; + + std::string key() const { return item.key(); } + const EpisodeProgress* episode(int s, int e) const; +}; + +std::string episodeKey(int season, int episode); + +// ---- pure ------------------------------------------------------------------- + +std::string toJson(const std::vector& entries); +std::vector fromJson(const std::string& json); + +// What "Continue" means for an entry right now. +struct Next +{ + enum class Kind + { + None, // nothing to continue (not started, finished, dropped) + Resume, // the film / episode in `source` is part-way through + NextEp, // the last episode is done: the one after it + } kind = Kind::None; + int season = 0, episode = 0; // for a series + double progress = -1; // 0..1 of what Resume would resume +}; +Next nextOf(const Entry& e); + +// Merges `incoming` into `into` (an import): new titles added, for a title +// both have the newer status and progress win. Returns how many were added. +int merge(std::vector& into, const std::vector& incoming); + +// ---- the store (UI thread; saves run in the background) --------------------- + +void load(); +uint32_t generation(); // bumps on every change, for screens to refresh + +bool find(const std::string& key, Entry& out); +std::vector all(); +std::vector withStatus(Status s); +// Entries with something to continue, most recently watched first. +std::vector continueWatching(); + +void setStatus(const content::Item& item, Status s); + +// Takes a title out of "Continue watching" (X on its card) without touching +// its bookmark or the per-episode marks: the film's position, or which +// episode was last, is forgotten. +void dismissContinue(const std::string& key); + +// Called by the player while a title plays: progress, and what is playing. +// `item` is only used to create the entry if it is not there yet. +void recordProgress(const content::Item& item, int season, int episode, double pos, + double dur, bool finished, const std::string& source, int fileIndex, + const std::string& sourceType); + +// The season pack an episode is being played from. +void rememberPack(const content::Item& item, const std::string& title, + const std::string& link, const std::string& magnet, int seasonFrom, + int seasonTo); + +// One file holding the whole library, for a backup or another console. +bool exportTo(const std::string& path, std::string& err); +bool importFrom(const std::string& path, int& added, std::string& err); + +} // namespace library diff --git a/app/main.cpp b/app/main.cpp index 4f003bb..37e0d9c 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -30,6 +30,15 @@ #include +#ifdef ABSOLUTE +#undef ABSOLUTE +#endif +#ifdef RELATIVE +#undef RELATIVE +#endif + +#include + #include // Thread/Mutex/CondVar for the async log sink #include "appdata.hpp" @@ -45,7 +54,9 @@ #include "jackett_tab.hpp" #include "history.hpp" #include "history_tab.hpp" +#include "bookmarks.hpp" #include "home.hpp" +#include "library.hpp" extern "C" { #include "httpsrc.h" @@ -58,6 +69,7 @@ extern "C" { namespace { +#ifdef __SWITCH__ // --- Async log sink --------------------------------------------------------- // The log used to be an UNBUFFERED FILE handed to the Logger: every line // (each mpv "v" message, the 2 s [stats] line) was a synchronous fs IPC from @@ -142,12 +154,15 @@ int logRingWrite(void* cookie, const char* p, size_t n) condvarWakeOne(&r->cv); return (int)n; // claim everything: a short write would make stdio error out } +#endif // Everything the app writes (log, piece cache) lives here so it doesn't clutter // the SD card root. Created at startup. Paths in appdata.hpp. void ensureAppDataDir() { +#ifdef __SWITCH__ mkdir("sdmc:/switch", 0777); // usually already there +#endif mkdir(APPDATA_DIR, 0777); // ignore EEXIST mkdir(APPDATA_TORRENTS, 0777); // where the user drops .torrent files mkdir(APPDATA_POSTERS, 0777); // cached Stremio artwork @@ -864,6 +879,7 @@ void removeMagnetFromFile(const std::string& magnet) // list -- it does NOT auto-play. std::string promptMagnetInput() { +#ifdef __SWITCH__ SwkbdConfig kbd; if (R_FAILED(swkbdCreate(&kbd, 0))) return ""; @@ -876,6 +892,22 @@ std::string promptMagnetInput() swkbdClose(&kbd); if (R_FAILED(rc) || !raw[0]) return ""; +#else + char raw[2048] = { 0 }; + const char* clip = glfwGetClipboardString(nullptr); + if (clip) + { + snprintf(raw, sizeof(raw), "%s", clip); + } + if (!raw[0]) + { + auto* d = new brls::Dialog( + tr("Clipboard is empty or does not contain text.")); + d->addButton("OK", []() {}); + d->open(); + return ""; + } +#endif std::string magnet = normalizeMagnet(raw); if (magnet.empty()) @@ -936,6 +968,7 @@ brls::View* buildEmptyState() return box; } +#ifdef __SWITCH__ // Shown on the libnx text console (NOT borealis) when the app was launched in // applet mode -- that mode caps the heap far below what mpv and the RAM // streaming window need. Done before borealis/GL is ever set up: bringing the @@ -965,6 +998,7 @@ static void appletModeBlock() } consoleExit(NULL); } +#endif // Resolve magnets in the background, ONE AT A TIME (no full download starts): // (magnet, resolved name, whole-torrent size). Name "" / size 0 mean the attempt @@ -1271,7 +1305,7 @@ void applyTabIdentity(brls::AppletFrame* frame, config::Tab tab) // Local keeps its white PNG. Stremio's mark is drawn by BrowserFrame instead, // so no texture is loaded for it at all -- romfs:/stremio-icon.png is a fixed // violet that cannot follow the accent. - if (!stremio) frame->setIcon("romfs:/local-icon.png"); + if (!stremio) frame->setIcon(APP_RES("local-icon.png")); // The header icon is sized "auto", i.e. from the texture, and these two are // not the same pixel size -- pin both to the same box. @@ -1297,9 +1331,51 @@ brls::View* buildTab(config::Tab tab) return buildHistoryTab(); if (tab == config::Tab::HOME) return buildHomeTab(); + if (tab == config::Tab::BOOKMARKS) + return buildBookmarksTab(); + if (tab == config::Tab::MOVIES) + return buildHomeTab(1); + if (tab == config::Tab::SHOWS) + return buildHomeTab(2); return buildLocalTab(); } +// R/L cycle the Stremio view. On the frame (not the tab) so they work with +// focus on the header tab bar too. Registered only while the Stremio tab is +// up: an action merely made unavailable still shows its (greyed) chip in the +// footer of every other tab. One chip "L R View": L is the auto icon, R a +// glyph in the hint text; the R action is hidden so it adds no second chip. +void setStremioViewKeys(brls::AppletFrame* frame, bool on) +{ + static brls::AppletFrame* owner = nullptr; + static brls::ActionIdentifier lb = -1, rb = -1; + if (owner && owner != frame) lb = rb = -1; // a new frame: nothing on it yet + owner = frame; + bool registered = lb >= 0; + if (on == registered) return; + if (on) + { + lb = frame->registerAction(std::string(" ") + tr("View"), brls::BUTTON_LB, + [](brls::View*) { + stremio::cycleActiveView(-1); + return true; + }, + false, false, brls::SOUND_NONE); + rb = frame->registerAction(tr("View"), brls::BUTTON_RB, + [](brls::View*) { + stremio::cycleActiveView(+1); + return true; + }, + true, false, brls::SOUND_NONE); + } + else + { + frame->unregisterAction(lb); + frame->unregisterAction(rb); + lb = rb = -1; + } +} + // A Label that renders with the Material icon font as its PRIMARY face. Reached // through the regular font's fallback, a Material glyph is positioned on the // text baseline and sits visibly too high; as the primary face, nanovg centres @@ -1330,46 +1406,63 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) // Rebuilt on every switch: TabFrame frees the old tab before making the new // one, and so must we -- two live StremioTabs would each hold their own // library and in-flight requests. + // + // The roadmap mock-up's header: Home, Movies, Shows, Bookmarks, History. + // Stremio, Local and Jackett are where titles come from rather than what to + // watch, so they share one button -- labelled with the source on screen -- + // which also leaves the middle free for the Stremio view bar. auto* homeBtn = new brls::Button(); - auto* localBtn = new brls::Button(); - auto* stremioBtn = new brls::Button(); - auto* jackettBtn = new brls::Button(); + auto* moviesBtn = new brls::Button(); + auto* showsBtn = new brls::Button(); + auto* marksBtn = new brls::Button(); auto* historyBtn = new brls::Button(); + auto* sourceBtn = new brls::Button(); + // The routes further down were written for three buttons; they are one now. + brls::Button* stremioBtn = sourceBtn; + brls::Button* localBtn = sourceBtn; + brls::Button* jackettBtn = sourceBtn; + + struct TabButton + { + brls::Button* btn; + config::Tab tab; + }; + std::vector tabButtons = { + { homeBtn, config::Tab::HOME }, { moviesBtn, config::Tab::MOVIES }, + { showsBtn, config::Tab::SHOWS }, { marksBtn, config::Tab::BOOKMARKS }, + { historyBtn, config::Tab::HISTORY }, + }; // Which tab is up, held in a shared cell rather than a capture so the // repaint hook below can restyle without knowing how we got here. auto curTab = std::make_shared(config::get().startupTab); + auto isSource = [](config::Tab t) { + return t == config::Tab::STREMIO || t == config::Tab::LOCAL || + t == config::Tab::JACKETT; + }; + // The style carries the accent: a Button copies it out of the theme when a // style is applied and keeps it, so this has to be re-run (not the tab // rebuilt) when the colour changes. - auto restyleTabs = [homeBtn, localBtn, stremioBtn, jackettBtn, historyBtn, curTab]() { - homeBtn->setStyle(*curTab == config::Tab::HOME - ? &brls::BUTTONSTYLE_PRIMARY - : &brls::BUTTONSTYLE_BORDERLESS); - localBtn->setStyle(*curTab == config::Tab::LOCAL - ? &brls::BUTTONSTYLE_PRIMARY - : &brls::BUTTONSTYLE_BORDERLESS); - stremioBtn->setStyle(*curTab == config::Tab::STREMIO - ? &brls::BUTTONSTYLE_PRIMARY - : &brls::BUTTONSTYLE_BORDERLESS); - jackettBtn->setStyle(*curTab == config::Tab::JACKETT - ? &brls::BUTTONSTYLE_PRIMARY - : &brls::BUTTONSTYLE_BORDERLESS); - historyBtn->setStyle(*curTab == config::Tab::HISTORY - ? &brls::BUTTONSTYLE_PRIMARY - : &brls::BUTTONSTYLE_BORDERLESS); + auto restyleTabs = [tabButtons, sourceBtn, curTab, isSource]() { + for (const auto& t : tabButtons) + t.btn->setStyle(*curTab == t.tab ? &brls::BUTTONSTYLE_PRIMARY + : &brls::BUTTONSTYLE_BORDERLESS); + bool src = isSource(*curTab); + sourceBtn->setStyle(src ? &brls::BUTTONSTYLE_PRIMARY : &brls::BUTTONSTYLE_BORDERLESS); + sourceBtn->setText(*curTab == config::Tab::STREMIO ? std::string("Stremio") + : *curTab == config::Tab::LOCAL ? std::string(tr("Local")) + : *curTab == config::Tab::JACKETT ? std::string("Jackett") + : std::string(tr("Sources"))); }; - auto select = [content, homeBtn, localBtn, stremioBtn, jackettBtn, historyBtn, frame, - curTab, restyleTabs](config::Tab tab) { + auto select = [content, tabButtons, sourceBtn, frame, curTab, restyleTabs, + isSource](config::Tab tab) { *curTab = tab; restyleTabs(); - // L and R only cycle the Stremio views, so the "View" chip has no - // business in the footer on the Local tab. - bool stremioUp = tab == config::Tab::STREMIO; - frame->setActionAvailable(brls::BUTTON_LB, stremioUp); - frame->setActionAvailable(brls::BUTTON_RB, stremioUp); + // L and R cycle the Stremio views: their footer chip exists only there. + setStremioViewKeys(frame, tab == config::Tab::STREMIO); content->clearViews(); // deletes the previous tab brls::View* v = buildTab(tab); v->setGrow(1.0f); @@ -1381,10 +1474,9 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) // does not do it: ScrollingFrame::getNextFocus keeps the focus inside // while the list can still scroll up, so a deep tab (Stremio's library // sits under two more boxes than the local list) never hands it over. - brls::Button* active = tab == config::Tab::STREMIO ? stremioBtn : - (tab == config::Tab::JACKETT ? jackettBtn : - (tab == config::Tab::HISTORY ? historyBtn : - (tab == config::Tab::HOME ? homeBtn : localBtn))); + brls::Button* active = sourceBtn; + for (const auto& t : tabButtons) + if (t.tab == tab) active = t.btn; v->registerAction( tr("Back"), brls::BUTTON_B, [active](brls::View*) { @@ -1394,48 +1486,45 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) false, false, brls::SOUND_BACK); }; - // Home first: it is where the app opens (the roadmap's main screen). - homeBtn->setText(tr("Home")); - homeBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); - homeBtn->setMarginLeft(8.0f); - homeBtn->registerClickAction([select](brls::View*) { - select(config::Tab::HOME); - return true; - }); - bar->addView(homeBtn); - - stremioBtn->setText(tr("Stremio")); - stremioBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); - stremioBtn->registerClickAction([select](brls::View*) { - select(config::Tab::STREMIO); - return true; - }); - bar->addView(stremioBtn); - - - localBtn->setText(tr("Local")); - localBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); - localBtn->registerClickAction([select](brls::View*) { - select(config::Tab::LOCAL); - return true; - }); - bar->addView(localBtn); - - jackettBtn->setText("Jackett"); - jackettBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); - jackettBtn->registerClickAction([select](brls::View*) { - select(config::Tab::JACKETT); - return true; - }); - bar->addView(jackettBtn); + const char* tabNames[] = { "Home", "Movies", "Shows", "Bookmarks", "History" }; + for (size_t i = 0; i < tabButtons.size(); i++) + { + brls::Button* b = tabButtons[i].btn; + config::Tab tab = tabButtons[i].tab; + b->setText(tr(tabNames[i])); + b->setStyle(&brls::BUTTONSTYLE_BORDERLESS); + // Narrower than borealis' stock padding: six tabs and the view bar + // share a handheld-width header. + b->setPadding(12.0f, 16.0f, 12.0f, 16.0f); + if (i == 0) b->setMarginLeft(8.0f); + b->registerClickAction([select, tab](brls::View*) { + select(tab); + return true; + }); + bar->addView(b); + } - historyBtn->setText(tr("History")); - historyBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); - historyBtn->registerClickAction([select](brls::View*) { - select(config::Tab::HISTORY); + sourceBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); + sourceBtn->setPadding(12.0f, 16.0f, 12.0f, 16.0f); + sourceBtn->setMarginLeft(10.0f); + sourceBtn->registerClickAction([select, curTab](brls::View*) { + const config::Tab tabs[] = { config::Tab::STREMIO, config::Tab::LOCAL, + config::Tab::JACKETT }; + int sel = 0; + for (int i = 0; i < 3; i++) + if (*curTab == tabs[i]) sel = i; + auto* dd = new brls::Dropdown( + tr("Sources"), { "Stremio", tr("Local"), "Jackett" }, + [select](int i) { + const config::Tab t[] = { config::Tab::STREMIO, config::Tab::LOCAL, + config::Tab::JACKETT }; + if (i >= 0 && i < 3) select(t[i]); + }, + sel); + brls::Application::pushActivity(new brls::Activity(dd)); return true; }); - bar->addView(historyBtn); + bar->addView(sourceBtn); // The Stremio sub-view switcher, centred in the header: one button per view // (Continue / Movies / Shows / Library). It says where you are and clicking @@ -1552,6 +1641,29 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) }); rightBar->addView(profileBtn); + // "Search" for the catalog, as the mock-up's header has it: the magnifier + // of Stremio's own search plus a word. The Stremio tab shows its own search + // instead, so this one folds away there (see applyViewBar). + auto* catSearchBtn = new brls::Button(); + { + const std::string& full = viewLabels[searchIdx]; + size_t sp = full.find(' '); + catSearchBtn->setText(tr("Search")); + catSearchBtn->setStyle(&brls::BUTTONSTYLE_BORDERLESS); + catSearchBtn->setPadding(12.0f, 16.0f, 12.0f, 16.0f); + auto* ic = new IconLabel(); + ic->setText(sp == std::string::npos ? full : full.substr(0, sp)); + sizeIcon(ic, btnFont, true); + ic->setMarginRight(6.0f); + ic->setTextColor(brls::Application::getTheme()["brls/button/default_enabled_text"]); + catSearchBtn->addView(ic, 0); + catSearchBtn->registerClickAction([](brls::View*) { + openCatalogSearch(); + return true; + }); + rightBar->addView(catSearchBtn); + } + // Material "settings". The only header button that belongs to BOTH tabs -- // it is X in the footer as well, but that hint is easy to miss. auto* settingsBtn = makeIconButton(""); @@ -1560,6 +1672,8 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) return true; }); rightBar->addView(settingsBtn); + catSearchBtn->setCustomNavigationRoute(brls::FocusDirection::RIGHT, settingsBtn); + catSearchBtn->setCustomNavigationRoute(brls::FocusDirection::LEFT, sourceBtn); // active >= 0: show the bar and mark that view PRIMARY. active < 0: fold it // away and take its buttons out of the focus ring (a GONE box keeps its @@ -1599,10 +1713,22 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) auto curView = std::make_shared(-1); auto applyViewBar = [viewBar, viewBtns, viewIcons, stremioBtn, localBtn, jackettBtn, - profileBtn, settingsBtn, searchIdx, curView, - insetViewBar](int active) { + profileBtn, settingsBtn, searchIdx, curView, catSearchBtn, + tabButtons, insetViewBar](int active) { *curView = active; bool show = active >= 0; + // Stremio's view bar takes the middle of the header: the catalog tabs + // other than Home fold away meanwhile (they would run into it on a + // handheld screen), and so does the catalog's Search -- Stremio's own + // stands in its place. + for (size_t i = 1; i < tabButtons.size(); i++) + { + tabButtons[i].btn->setVisibility(show ? brls::Visibility::GONE + : brls::Visibility::VISIBLE); + tabButtons[i].btn->setFocusable(!show); + } + catSearchBtn->setVisibility(show ? brls::Visibility::GONE : brls::Visibility::VISIBLE); + catSearchBtn->setFocusable(!show); // Also here, not only on the window event: changing the UI size in // Options resizes the logical space without firing that event (see // applyUiScale), and this runs on the way back into a view. @@ -1638,10 +1764,10 @@ void attachTopTabBar(brls::AppletFrame* frame, brls::Box* content) // Jackett: the rightmost button in the category bar. jackettBtn->setCustomNavigationRoute(brls::FocusDirection::RIGHT, show ? (brls::View*)viewBtns[0] - : (brls::View*)settingsBtn); + : (brls::View*)catSearchBtn); settingsBtn->setCustomNavigationRoute(brls::FocusDirection::LEFT, show ? (brls::View*)profileBtn - : (brls::View*)jackettBtn); + : (brls::View*)catSearchBtn); // Up from the Stremio list lands on the ACTIVE view button (Continue / // Movies / ...), not the "Stremio" category tab. reportView runs before // the list is (re)built, so finishList picks this up. Falls back to the @@ -1720,27 +1846,8 @@ brls::View* buildBrowser() // No X shortcut for Options: the header carries a gear now, and a chip in // the footer for something already on screen is noise. - // R/L cycle the Stremio view. On the frame (not the tab) so they work with - // focus on the header tab bar too; a no-op when the Stremio tab is not live. - // One chip "L R View": L is the auto icon, R is a glyph in the hint text. - // (The text glyph renders a touch smaller than the icon -- a borealis hint - // quirk -- but this keeps them side by side; the R action is hidden so it - // does not add a second chip.) - frame->registerAction(std::string(" ") + tr("View"), brls::BUTTON_LB, [](brls::View*) { - stremio::cycleActiveView(-1); - return true; - }, false, false, brls::SOUND_NONE); - frame->registerAction(tr("View"), brls::BUTTON_RB, [](brls::View*) { - stremio::cycleActiveView(+1); - return true; - }, true, false, brls::SOUND_NONE); - - // select() keeps these in step from here on, but its first call ran inside - // attachTopTabBar -- i.e. before the actions existed, where setActionAvailable - // finds nothing to set. Seed them for the startup tab. - bool stremioUp = config::get().startupTab == config::Tab::STREMIO; - frame->setActionAvailable(brls::BUTTON_LB, stremioUp); - frame->setActionAvailable(brls::BUTTON_RB, stremioUp); + // The Stremio L/R "View" keys come and go with the tab: setStremioViewKeys, + // called by select() (already for the startup tab, in attachTopTabBar). return frame; } @@ -1749,6 +1856,7 @@ brls::View* buildBrowser() int main(int argc, char* argv[]) { +#ifdef __SWITCH__ // Applet mode caps the heap far below what mpv + the RAM streaming window // need. Bail before anything is initialised (a full-RAM launch runs as an // Application or SystemApplication; everything else is applet mode) -- and @@ -1761,15 +1869,22 @@ int main(int argc, char* argv[]) appletModeBlock(); return EXIT_SUCCESS; } +#endif ensureAppDataDir(); +#ifdef __SWITCH__ // https:// playback verifies against the same bundle as the app's own // requests (app/http.cpp): the console mounts no CA store. httpsrc_set_ca("romfs:/cacert.pem"); +#else + if (access("resources/cacert.pem", F_OK) == 0) + httpsrc_set_ca("resources/cacert.pem"); +#endif config::load(); http::applyProxy(); history::load(); + library::load(); // Now, while nothing can be playing: a file fetched for the result being // played is only referenced once the player records it. jackett::pruneCache(); @@ -1795,6 +1910,7 @@ int main(int argc, char* argv[]) { if (FILE* lf = std::fopen(APPDATA_LOG, "w+")) { +#ifdef __SWITCH__ // Async sink (see LogRing above): loggers memcpy into a RAM ring, // a dedicated thread does the actual SD writes. Priority 0x3B // (well below everything): losing a beat only delays the file, @@ -1825,6 +1941,10 @@ int main(int argc, char* argv[]) std::setvbuf(lf, nullptr, _IONBF, 0); brls::Logger::setLogOutput(lf); } +#else + std::setvbuf(lf, nullptr, _IONBF, 0); + brls::Logger::setLogOutput(lf); +#endif } } @@ -1838,6 +1958,11 @@ int main(int argc, char* argv[]) // font (which has it) rather than mixing two typefaces in every label. brls::SwitchFontLoader::useBundledFont = !i18n::needsSystemFont(); #endif + // borealis' own words (the footer's "Back", "Exit", Options' "On"/"Off") + // follow the console language unless told otherwise: tell it the app's, + // so a Russian UI on an English console does not end in "Back". + brls::Platform::APP_LOCALE_DEFAULT = + i18n::lang() == "ru" ? "ru" : i18n::lang() == "fr" ? "fr" : "en-US"; if (!brls::Application::init()) { diff --git a/app/player.cpp b/app/player.cpp index efd95c0..6adb2c4 100644 --- a/app/player.cpp +++ b/app/player.cpp @@ -10,6 +10,13 @@ #include #include +#ifdef ABSOLUTE +#undef ABSOLUTE +#endif +#ifdef RELATIVE +#undef RELATIVE +#endif + #include #include #include @@ -24,6 +31,8 @@ #include "browse.hpp" // openEpisodeById, for the next-episode card #include "config.hpp" #include "history.hpp" +#include "library.hpp" +#include "sources.hpp" // openNextEpisode #include "http.hpp" #include "torrserver.hpp" #include "stremio.hpp" // cached artwork -> blurred background @@ -1155,6 +1164,22 @@ void MpvView::goToNextEpisode() std::string next = watch.nextVideoId; PlayerArt showArt = art; int pops = watch.endPop; + if (watch.content.tmdbId > 0) pops = watch.nextPop; + + // A catalog title: the next episode comes from the library -- the same + // season pack when the last one came from one, else its sources screen. + if (watch.content.tmdbId > 0) + { + content::Item item = watch.content; + int season = watch.season, episode = watch.episode + 1; + brls::Logger::info("[player] next episode -> S{}E{} (pop {})", season, episode, pops); + brls::sync([pops, item, season, episode]() { + popActivitiesThen(pops, [item, season, episode]() { + openNextEpisode(item, season, episode); + }); + }); + return; + } brls::Logger::info("[player] next episode -> {} (pop {})", next, pops); // Deferred: we are inside draw()'s update pass (or a tap handler), and @@ -2965,6 +2990,13 @@ void MpvView::maybeUpdateHistory(bool force) // Only the final write (the player closing) waits for the SD card; the // periodic one is handed to a background thread so it cannot drop a frame. history::record(it, force); + + // A catalog title: progress against the film / the episode, whatever file + // it came from (library.cpp writes in the background). + if (watch.content.tmdbId > 0) + library::recordProgress(watch.content, watch.season, watch.episode, lastPosSec, + lastDurSec, finished, currentSource, currentFileIndex, + currentSourceType); } // Enter scrub mode: pause and pin the cursor to where playback currently is. diff --git a/app/player.hpp b/app/player.hpp index f0bf638..d6c2ccf 100644 --- a/app/player.hpp +++ b/app/player.hpp @@ -9,6 +9,7 @@ #include #include +#include "content.hpp" // content::Item, a catalog title in WatchInfo #include "stremio.hpp" // stremio::Subtitle, for the addon subtitle picker struct mpv_handle; @@ -69,6 +70,16 @@ struct WatchInfo // just the player (local). A Stremio play sits under the addon + source // lists, so 3 returns to the library (film) or the episode list (series). int endPop = 1; + + // A catalog title (TMDB card -> sources): progress goes to the library + // (library.hpp) against the title and the episode, and "next episode" + // opens from the library instead of through Stremio. tmdbId 0 = not one. + content::Item content; + int season = 0; + int episode = 0; + // Activities to pop before opening the next episode: the player, plus the + // sources screen it was started from (the next episode brings its own). + int nextPop = 1; }; // Formats seconds into "M:SS" or "H:MM:SS" diff --git a/app/release.cpp b/app/release.cpp new file mode 100644 index 0000000..b5bd613 --- /dev/null +++ b/app/release.cpp @@ -0,0 +1,522 @@ +#include "release.hpp" + +#include +#include +#include + +namespace release +{ + +namespace +{ + +// ASCII and Cyrillic to lower case (UTF-8): "СЕЗОН" and "Сезон" read the same. +std::string lower(const std::string& s) +{ + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) + { + unsigned char c = (unsigned char)s[i]; + if (c < 0x80) + { + out += (char)std::tolower(c); + continue; + } + if (c == 0xD0 && i + 1 < s.size()) + { + unsigned char d = (unsigned char)s[i + 1]; + if (d >= 0x90 && d <= 0x9F) // А..П -> а..п + { + out += (char)0xD0; + out += (char)(d + 0x20); + i++; + continue; + } + if (d >= 0xA0 && d <= 0xAF) // Р..Я -> р..я + { + out += (char)0xD1; + out += (char)(d - 0x20); + i++; + continue; + } + if (d == 0x81) // Ё -> ё + { + out += (char)0xD1; + out += (char)0x91; + i++; + continue; + } + } + out += (char)c; + } + return out; +} + +// Separators turned into spaces, padded: word lookups become " word ". +std::string words(const std::string& low) +{ + std::string out = " "; + for (char c : low) + { + if (std::strchr("._-[](){}|/\\,+:;!?\"'*", c) || c == ' ' || c == '\t') + { + if (out.back() != ' ') out += ' '; + } + else + out += c; + } + if (out.back() != ' ') out += ' '; + return out; +} + +bool hasWord(const std::string& w, const char* word) +{ + return w.find(std::string(" ") + word + " ") != std::string::npos; +} + +bool has(const std::string& s, const char* sub) { return s.find(sub) != std::string::npos; } + +// Digits at s[i..], at most `max` of them; -1 when none. Advances i. +int readNum(const std::string& s, size_t& i, int max = 3) +{ + int n = 0, k = 0; + while (i < s.size() && k < max && std::isdigit((unsigned char)s[i])) + { + n = n * 10 + (s[i] - '0'); + i++; + k++; + } + if (k == 0) return -1; + // More digits follow: a year or a resolution, not a season number. + if (i < s.size() && std::isdigit((unsigned char)s[i])) return -1; + return n; +} + +void skipSpaces(const std::string& s, size_t& i) +{ + while (i < s.size() && (s[i] == ' ' || s[i] == ':' || s[i] == '.' || s[i] == '_')) + i++; +} + +// "1-8", "1–8" (an en dash), "1 - 8": the end of a range after a number. +int readRangeEnd(const std::string& s, size_t& i) +{ + size_t j = i; + while (j < s.size() && s[j] == ' ') j++; + if (j < s.size() && s[j] == '-') + j++; + else if (s.compare(j, 3, "\xE2\x80\x93") == 0) // – + j += 3; + else + return -1; + while (j < s.size() && s[j] == ' ') j++; + if (j < s.size() && (s[j] == 'e' || s[j] == 's')) j++; // "E01-E08", "S01-S03" + int n = readNum(s, j); + if (n >= 0) i = j; + return n; +} + +// The number just before `pos` ("1 сезон"), or -1. +int numberBefore(const std::string& s, size_t pos) +{ + size_t j = pos; + while (j > 0 && s[j - 1] == ' ') j--; + size_t end = j; + while (j > 0 && std::isdigit((unsigned char)s[j - 1])) j--; + if (j == end || end - j > 2) return -1; + return std::atoi(s.substr(j, end - j).c_str()); +} + +bool isLetterAt(const std::string& s, size_t i) +{ + return i < s.size() && + (std::isalpha((unsigned char)s[i]) || (unsigned char)s[i] >= 0xD0); +} + +// The word stem at s[k..k+len), as a whole word: no letter before it, at most +// one ending letter after it ("сезоны", "серии", "seasons"), then no more. +// `end` is set past the ending. +bool wordAt(const std::string& s, size_t k, size_t len, size_t& end) +{ + if (k > 0 && (std::isalpha((unsigned char)s[k - 1]) || (unsigned char)s[k - 1] >= 0x80)) + return false; + size_t j = k + len; + if (j < s.size() && std::isalpha((unsigned char)s[j])) + j++; // "s" + else if (j + 1 < s.size() && (unsigned char)s[j] >= 0xD0) + j += 2; // one Cyrillic letter + if (isLetterAt(s, j)) return false; + end = j; + return true; +} + +// Seasons and episodes, in the spellings trackers use. +void readScope(const std::string& low, Info& in) +{ + // SxxEyy, SxxEyy-Ezz, Sxx-Syy, Sxx + for (size_t i = 0; i + 1 < low.size() && in.season < 0; i++) + { + if (low[i] != 's' || !std::isdigit((unsigned char)low[i + 1])) continue; + if (i > 0 && std::isalnum((unsigned char)low[i - 1])) continue; + size_t j = i + 1; + int s = readNum(low, j, 2); + if (s < 0) continue; + in.season = in.seasonTo = s; + if (j < low.size() && low[j] == 'e' && j + 1 < low.size() && + std::isdigit((unsigned char)low[j + 1])) + { + j++; + int e = readNum(low, j); + in.episode = in.episodeTo = e; + int to = readRangeEnd(low, j); + if (to >= 0) in.episodeTo = to; + } + else + { + int to = readRangeEnd(low, j); + if (to >= 0) in.seasonTo = to; + } + } + + // 1x06 + if (in.season < 0) + for (size_t i = 1; i + 2 < low.size(); i++) + { + if (low[i] != 'x' || !std::isdigit((unsigned char)low[i - 1]) || + !std::isdigit((unsigned char)low[i + 1])) + continue; + size_t a = i; + while (a > 0 && std::isdigit((unsigned char)low[a - 1])) a--; + if (i - a > 2 || (a > 0 && std::isalnum((unsigned char)low[a - 1]))) continue; + size_t j = i + 1; + int e = readNum(low, j, 3); + if (e < 0 || j - (i + 1) < 2) continue; // "2x2" is not an episode + in.season = in.seasonTo = std::atoi(low.substr(a, i - a).c_str()); + in.episode = in.episodeTo = e; + break; + } + + // "сезон 1", "1 сезон", "сезоны 1-3", "season 2", "seasons 1-3" + // "сезон 1", "1 сезон", "сезоны 1-3", "season 2", "seasons 1-3" + for (const char* word : { "\xD1\x81\xD0\xB5\xD0\xB7\xD0\xBE\xD0\xBD", // сезон + "season" }) + { + for (size_t k = low.find(word); k != std::string::npos && in.season < 0; + k = low.find(word, k + 1)) + { + size_t j; + if (!wordAt(low, k, std::strlen(word), j)) continue; + skipSpaces(low, j); + int s = readNum(low, j, 2); + if (s < 0) s = numberBefore(low, k); + if (s < 0) continue; + in.season = in.seasonTo = s; + int to = readRangeEnd(low, j); + if (to >= 0) in.seasonTo = to; + } + } + + // "серии 1-8 из 8", "серия 6", "6 серия", "episode 6", "episodes 1-8" + const char* seria = "\xD1\x81\xD0\xB5\xD1\x80\xD0\xB8"; // сери(я|и|й) + for (const char* word : { seria, "episode" }) + { + for (size_t k = low.find(word); k != std::string::npos && in.episode < 0; + k = low.find(word, k + 1)) + { + size_t j; + // "сериал" is not "серия". + if (!wordAt(low, k, std::strlen(word), j)) continue; + skipSpaces(low, j); + int e = readNum(low, j); + if (e < 0) e = numberBefore(low, k); + if (e < 0) continue; + in.episode = in.episodeTo = e; + int to = readRangeEnd(low, j); + if (to >= 0) in.episodeTo = to; + // "из 8" / "of 8": the whole season when the range reaches it. + size_t of = low.find("\xD0\xB8\xD0\xB7 ", j); // "из " + if (of == std::string::npos) of = low.find("of ", j); + if (of != std::string::npos && of < j + 6) + { + size_t t = low.find(' ', of) + 1; + int total = readNum(low, t); + if (total > 0 && in.episode <= 1 && in.episodeTo >= total) + in.fullSeason = true; + } + } + } + + if (in.season >= 0 && in.episode < 0) in.fullSeason = true; + if (hasWord(words(low), "complete") && in.episode < 0) in.fullSeason = true; +} + +struct Voice +{ + const char* match; // lower-case substring + const char* label; +}; + +const Voice kVoices[] = { + { "\xD0\xB4\xD1\x83\xD0\xB1\xD0\xBB\xD1\x8F\xD0\xB6", "\xD0\x94\xD1\x83\xD0\xB1\xD0\xBB\xD1\x8F\xD0\xB6" }, // дубляж + { "\xD0\xB4\xD1\x83\xD0\xB1\xD0\xBB\xD0\xB8\xD1\x80", "\xD0\x94\xD1\x83\xD0\xB1\xD0\xBB\xD1\x8F\xD0\xB6" }, // дублир(ованный) + { " dub ", "\xD0\x94\xD1\x83\xD0\xB1\xD0\xBB\xD1\x8F\xD0\xB6" }, + { " mvo ", "MVO" }, + { "\xD0\xBC\xD0\xBD\xD0\xBE\xD0\xB3\xD0\xBE\xD0\xB3\xD0\xBE\xD0\xBB\xD0\xBE\xD1\x81", "MVO" }, // многоголос + { " dvo ", "DVO" }, + { "\xD0\xB4\xD0\xB2\xD1\x83\xD1\x85\xD0\xB3\xD0\xBE\xD0\xBB\xD0\xBE\xD1\x81", "DVO" }, // двухголос + { " avo ", "AVO" }, + { "\xD0\xB0\xD0\xB2\xD1\x82\xD0\xBE\xD1\x80\xD1\x81\xD0\xBA", "AVO" }, // авторск + { "lostfilm", "LostFilm" }, + { "hdrezka", "HDRezka" }, + { "newstudio", "NewStudio" }, + { "kubik", "\xD0\x9A\xD1\x83\xD0\xB1\xD0\xB8\xD0\xBA \xD0\xB2 \xD0\x9A\xD1\x83\xD0\xB1\xD0\xB5" }, + { "\xD0\xBA\xD1\x83\xD0\xB1\xD0\xB8\xD0\xBA \xD0\xB2 \xD0\xBA\xD1\x83\xD0\xB1\xD0\xB5", + "\xD0\x9A\xD1\x83\xD0\xB1\xD0\xB8\xD0\xBA \xD0\xB2 \xD0\x9A\xD1\x83\xD0\xB1\xD0\xB5" }, // Кубик в Кубе + { "alexfilm", "AlexFilm" }, + { "jaskier", "Jaskier" }, + { "tvshows", "TVShows" }, + { "baibako", "BaibaKo" }, + { "amedia", "Amedia" }, + { "coldfilm", "ColdFilm" }, + { "red head sound", "RHS" }, + { "anilibria", "AniLibria" }, + { "animevost", "AnimeVost" }, +}; + +void readVoices(const std::string& low, const std::string& w, Info& in) +{ + for (const Voice& v : kVoices) + { + bool hit = v.match[0] == ' ' ? w.find(v.match) != std::string::npos + : low.find(v.match) != std::string::npos; + if (!hit) continue; + bool dup = false; + for (const auto& x : in.voices) dup = dup || x == v.label; + if (!dup) in.voices.push_back(v.label); + } +} + +// "...H264-NTb.mkv" -> "NTb"; "[HDRezka]" at the end -> "HDRezka". +std::string readGroup(const std::string& title) +{ + std::string t = title; + size_t dot = t.find_last_of('.'); + if (dot != std::string::npos && t.size() - dot <= 5) t = t.substr(0, dot); + while (!t.empty() && t.back() == ' ') t.pop_back(); + if (!t.empty() && t.back() == ']') + { + size_t open = t.rfind('['); + if (open != std::string::npos && t.size() - open - 2 <= 24) + return t.substr(open + 1, t.size() - open - 2); + } + size_t dash = t.rfind('-'); + if (dash == std::string::npos || dash + 1 >= t.size()) return ""; + std::string g = t.substr(dash + 1); + if (g.size() > 16) return ""; + for (char c : g) + if (!std::isalnum((unsigned char)c)) return ""; + // "...-2024" or "-1080p" is not a group. + bool digits = true; + for (char c : g) digits = digits && std::isdigit((unsigned char)c); + if (digits || g == "DL" || g == "dl") return ""; + return g; +} + +} // namespace + +Info parse(const std::string& title) +{ + Info in; + std::string low = lower(title); + std::string w = words(low); + + if (has(low, "2160p") || hasWord(w, "4k") || hasWord(w, "uhd") || has(low, "3840x")) + in.height = 2160; + else if (has(low, "1080p") || has(low, "1080i") || has(low, "1920x")) + in.height = 1080; + else if (has(low, "720p") || has(low, "1280x")) + in.height = 720; + else if (has(low, "480p") || has(low, "576p") || hasWord(w, "dvdrip")) + in.height = 480; + + if (has(low, "remux")) + in.source = "Remux"; + else if (has(low, "bdrip") || has(low, "bd-rip") || has(low, "brrip")) + in.source = "BDRip"; + else if (has(low, "bluray") || has(low, "blu-ray")) + in.source = "BluRay"; + else if (has(low, "web-dl") || has(low, "webdl") || has(low, "web dl")) + in.source = "WEB-DL"; + else if (has(low, "webrip") || has(low, "web-rip")) + in.source = "WEBRip"; + else if (has(low, "hdtv")) + in.source = "HDTV"; + else if (has(low, "dvdrip") || hasWord(w, "dvd5") || hasWord(w, "dvd9")) + in.source = "DVDRip"; + if (has(low, "camrip") || hasWord(w, "cam") || hasWord(w, "ts") || has(low, "telesync") || + hasWord(w, "hdts") || hasWord(w, "tc")) + { + in.source = "CAMRip"; + in.cam = true; + } + + if (has(low, "hevc") || has(low, "x265") || has(low, "h265") || has(low, "h.265")) + in.codec = "HEVC"; + else if (has(low, "x264") || has(low, "h264") || has(low, "h.264") || hasWord(w, "avc")) + in.codec = "H.264"; + else if (hasWord(w, "av1")) + in.codec = "AV1"; + else if (has(low, "xvid") || has(low, "divx")) + in.codec = "XviD"; + + in.dolbyVision = has(low, "dolby vision") || hasWord(w, "dovi") || hasWord(w, "dv"); + // As words: "hdr" is also the start of "HDRezka". + in.hdr = hasWord(w, "hdr") || hasWord(w, "hdr10") || hasWord(w, "hdr10plus") || + in.dolbyVision; + in.tenBit = has(low, "10bit") || has(low, "10-bit") || has(low, "10 bit") || + has(low, "hi10"); + + readScope(low, in); + readVoices(low, w, in); + in.group = readGroup(title); + return in; +} + +std::vector tags(const Info& in) +{ + std::vector t; + if (!in.source.empty()) t.push_back(in.source); + if (!in.codec.empty()) t.push_back(in.codec + (in.tenBit ? " 10-bit" : "")); + if (in.dolbyVision) + t.push_back("Dolby Vision"); + else if (in.hdr) + t.push_back("HDR"); + for (const auto& v : in.voices) t.push_back(v); + return t; +} + +int score(const Info& in, int seeders, int64_t size, const Want& want) +{ + // What it holds first: the wrong season or episode is not a choice at all. + if (want.series && want.season > 0 && in.season >= 0 && + (want.season < in.season || want.season > in.seasonTo)) + return -1000; + if (want.series && want.episode > 0 && in.episode >= 0 && !in.fullSeason && + (want.episode < in.episode || want.episode > in.episodeTo)) + return -1000; + if (in.cam) return -500; + + int s = 0; + // Quality for a Switch: 1080p plays everywhere; 4K is downscaled by the + // dock anyway (it outputs 1080p) and costs four times the bandwidth. + switch (in.height) + { + case 1080: s += 40; break; + case 720: s += 28; break; + case 2160: s += 18; break; + case 480: s += 10; break; + default: s += 16; break; + } + // H.264 decodes in hardware everywhere; HEVC too, but 10-bit and HDR + // releases are the heavy ones; AV1 has no hardware decoder on the X1. + if (in.codec == "H.264") s += 10; + else if (in.codec == "HEVC") s += 5; + else if (in.codec == "AV1") s -= 20; + if (in.tenBit) s -= 4; + if (in.dolbyVision) s -= 20; // profile 5 comes out green and purple + else if (in.hdr) s -= 8; // washed out without a tone-map + + if (in.source == "WEB-DL") s += 8; + else if (in.source == "BluRay" || in.source == "BDRip") s += 6; + else if (in.source == "WEBRip") s += 5; + else if (in.source == "Remux") s -= 15; // 40-80 GB over Wi-Fi + + if (seeders == 0) s -= 30; + else if (seeders > 0) s += (int)std::fmin(30.0, 8.0 * std::log2(1.0 + seeders)); + + const double gb = size / (1024.0 * 1024.0 * 1024.0); + if (gb > 80) s -= 20; + else if (gb > 40) s -= 10; + + if (want.series && want.episode > 0) + { + if (in.episode == want.episode && in.episodeTo == want.episode) s += 4; + else if (in.fullSeason) s += 2; + } + + if (!want.voice.empty()) + for (const auto& v : in.voices) + if (v == want.voice) s += 15; + if (!want.group.empty() && in.group == want.group) s += 10; + if (want.preferRussian && !in.voices.empty()) s += 8; + return s; +} + +namespace +{ +// Season and episode of one file: from its name, then its folders. +void fileScope(const std::string& path, int& season, int& episode) +{ + size_t slash = path.find_last_of("/\\"); + std::string base = slash == std::string::npos ? path : path.substr(slash + 1); + std::string dir = slash == std::string::npos ? "" : path.substr(0, slash); + + Info b = parse(base); + season = b.season; + episode = b.episode; + std::string low = lower(base); + if (episode < 0) + { + // "E06", "Ep06", "Ep.06" with no season in front. + for (size_t i = 0; i + 1 < low.size(); i++) + { + if (low[i] != 'e' || (i > 0 && std::isalpha((unsigned char)low[i - 1]))) continue; + size_t j = i + 1; + if (j < low.size() && low[j] == 'p') j++; + if (j < low.size() && low[j] == '.') j++; + int e = readNum(low, j); + if (e >= 0) { episode = e; break; } + } + } + if (episode < 0) + { + // A leading number: "06. Title.mkv", "06 - Title.mkv", "06.mkv". + size_t j = 0; + int e = readNum(low, j); + if (e >= 0 && j < low.size() && std::strchr(" ._-", low[j])) episode = e; + } + if (season < 0 && !dir.empty()) + { + Info d = parse(dir.substr(dir.find_last_of("/\\") == std::string::npos + ? 0 + : dir.find_last_of("/\\") + 1)); + season = d.season; + } +} +} // namespace + +int matchEpisode(const std::vector& paths, int season, int episode) +{ + int best = -1, bestRank = 0; + bool tie = false; + for (size_t i = 0; i < paths.size(); i++) + { + int s = -1, e = -1; + fileScope(paths[i], s, e); + if (e != episode) continue; + if (s >= 0 && s != season) continue; + int rank = s == season ? 2 : 1; // a stated season beats an unstated one + if (rank > bestRank) + { + best = (int)i; + bestRank = rank; + tie = false; + } + else if (rank == bestRank) + tie = true; + } + return tie ? -1 : best; +} + +} // namespace release diff --git a/app/release.hpp b/app/release.hpp new file mode 100644 index 0000000..746e53f --- /dev/null +++ b/app/release.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +// Release names, read the way a person scanning a tracker reads them: +// "Slovo.patsana.S01.1080p.WEB-DL.H264" is a whole first season in 1080p from +// a web source; "Дюна: Часть вторая / Dune: Part Two (2024) BDRip 1080p | D" +// is a film with Russian dubbing. The sources screen shows this as tags and +// sorts on it, so the release that will simply play on a Switch comes first. +// +// Pure functions: tested on PC (pctest/test_release.cpp). +namespace release +{ + +struct Info +{ + int height = 0; // 2160, 1080, 720, 480; 0 = not said + std::string source; // "WEB-DL", "WEBRip", "BluRay", "BDRip", "Remux", + // "HDTV", "DVDRip", "CAMRip"; "" = not said + std::string codec; // "HEVC", "H.264", "AV1", "XviD"; "" = not said + bool hdr = false; + bool dolbyVision = false; + bool tenBit = false; + bool cam = false; // a cinema recording + + // What part of a series it holds. -1 = not said. + int season = -1; + int seasonTo = -1; // last season of a range ("S01-S03"); = season otherwise + int episode = -1; + int episodeTo = -1; // last episode of a range ("E01-E08", "серии 1-8") + // A whole season (or several) rather than single episodes: a season + // named with no episode, or an episode range that covers the season + // ("серии 1-8 из 8"). + bool fullSeason = false; + + // Voice-overs it names: "Дубляж", "MVO", "DVO", "AVO", "LostFilm"... + std::vector voices; + // The release group: "-NTb" at the end of a scene name, "[HDRezka]". "". + std::string group; +}; + +Info parse(const std::string& title); + +// Short labels for the UI, most telling first: source, codec, HDR / DV, +// 10-bit, "season" scope, voices. +std::vector tags(const Info& info); + +// What the user is looking for. +struct Want +{ + bool series = false; + int season = 0; // 0 = any (a film, or a whole show) + int episode = 0; // 0 = any + // A voice / group picked last time for this show: favoured. + std::string voice; + std::string group; + bool preferRussian = false; // the UI is in Russian +}; + +// How good a choice this is for a Switch, higher is better. A release that +// cannot hold what is wanted (another season, another episode, a cam) comes +// back negative, and the screen hides it behind "All". `seeders` < 0 = not +// known; `size` 0 = not known. +int score(const Info& info, int seeders, int64_t size, const Want& want); + +// Which of a torrent's files is season `season`, episode `episode`, by name +// (S01E06, 1x06, "Серия 6", a leading "06." ...). Paths may carry folders +// ("Season 1/..."). -1 when none, or when two files match equally. +int matchEpisode(const std::vector& paths, int season, int episode); + +} // namespace release diff --git a/app/search.cpp b/app/search.cpp new file mode 100644 index 0000000..84b96a5 --- /dev/null +++ b/app/search.cpp @@ -0,0 +1,429 @@ +#include "home.hpp" // openCatalogSearch + +#include +#include +#include + +#include "collection.hpp" // PosterGrid +#include "i18n.hpp" +#include "theme.hpp" +#include "tmdb.hpp" +#include "widgets.hpp" + +// The catalog search screen: a search bar on top, the filters under it (kind, +// genre, year, order), and below either the genre tiles -- where it opens -- +// or the results as a growing grid. +// +// No text: TMDB's discover, which takes every filter. With text: TMDB's +// search for that kind, which takes a single year only, so a decade and the +// genre are applied to its results here. + +namespace +{ + +constexpr float kInset = 60.0f; +constexpr float kTileW = 230.0f; +constexpr float kTileH = 96.0f; +constexpr float kGap = 16.0f; + +// The year choices: "any", recent years one by one, then decades. +struct YearChoice +{ + const char* label; // shown as is (digits), or translated when a word + int from, to; // 0 = no bound + bool word; +}; +const std::vector& yearChoices() +{ + static const std::vector v = { + { "Any year", 0, 0, true }, { "2026", 2026, 2026, false }, + { "2025", 2025, 2025, false }, { "2024", 2024, 2024, false }, + { "2023", 2023, 2023, false }, { "2022", 2022, 2022, false }, + { "2021", 2021, 2021, false }, { "2020", 2020, 2020, false }, + { "2010s", 2010, 2019, true }, { "2000s", 2000, 2009, true }, + { "1990s", 1990, 1999, true }, { "1980s", 1980, 1989, true }, + { "Before 1980", 0, 1979, true }, + }; + return v; +} + +// One colour per genre tile, from a fixed set: the tiles read as a mosaic. +NVGcolor tileColor(int i) +{ + static const NVGcolor c[] = { + nvgRGB(0x45, 0x2a, 0x86), nvgRGB(0x1d, 0x5b, 0x8c), nvgRGB(0x8a, 0x2e, 0x46), + nvgRGB(0x2f, 0x6d, 0x4f), nvgRGB(0x8a, 0x5a, 0x1f), nvgRGB(0x3a, 0x3f, 0x8f), + nvgRGB(0x6b, 0x2a, 0x6e), nvgRGB(0x1f, 0x6e, 0x75), + }; + return c[i % (sizeof(c) / sizeof(c[0]))]; +} + +class SearchScreen : public brls::Box +{ + public: + SearchScreen() + { + setAxis(brls::Axis::COLUMN); + setGrow(1.0f); + + // The search bar. + bar = new brls::Box(); + bar->setAxis(brls::Axis::ROW); + bar->setAlignItems(brls::AlignItems::CENTER); + bar->setHeight(58.0f); + bar->setMargins(14.0f, kInset, 0.0f, kInset); + bar->setPadding(0.0f, 22.0f, 0.0f, 22.0f); + bar->setCornerRadius(10.0f); + bar->setBackgroundColor(theme::scrim(22)); + bar->setFocusable(true); + bar->setHighlightCornerRadius(10.0f); + barText = new brls::Label(); + barText->setFontSize(20.0f); + barText->setSingleLine(true); + bar->addView(barText); + bar->registerAction(tr("Search"), brls::BUTTON_A, [this](brls::View*) { + prompt(); + return true; + }, false, false, brls::SOUND_CLICK); + bar->addGestureRecognizer(new brls::TapGestureRecognizer(bar)); + addView(bar); + + // The filters. + auto* filters = new brls::Box(); + filters->setAxis(brls::Axis::ROW); + filters->setMargins(12.0f, kInset, 6.0f, kInset); + kindBtn = ui::actionButton("", false, [this]() { pickKind(); }); + genreBtn = ui::actionButton("", false, [this]() { pickGenre(); }); + yearBtn = ui::actionButton("", false, [this]() { pickYear(); }); + sortBtn = ui::actionButton("", false, [this]() { pickSort(); }); + filters->addView(kindBtn); + filters->addView(genreBtn); + filters->addView(yearBtn); + filters->addView(sortBtn); + filters->addView(ui::actionButton(tr("Reset"), false, [this]() { reset(); })); + addView(filters); + + scroll = new brls::ScrollingFrame(); + scroll->setGrow(1.0f); + scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + body = new brls::Box(); + body->setAxis(brls::Axis::COLUMN); + scroll->setContentView(body); + addView(scroll); + + grid = new PosterGrid(); + grid->parkTarget = bar; + grid->filter = [this](const content::Item& it) { return passes(it); }; + + refreshLabels(); + showTiles(); + } + + void openKeyboard() { prompt(); } + + private: + std::string query; + content::Kind kind = content::Kind::Movie; + int genre = 0; + int yearIdx = 0; + tmdb::Discover::Sort sort = tmdb::Discover::Sort::Popular; + + brls::Box* bar = nullptr; + brls::Label* barText = nullptr; + brls::Button *kindBtn = nullptr, *genreBtn = nullptr, *yearBtn = nullptr, + *sortBtn = nullptr; + brls::ScrollingFrame* scroll = nullptr; + brls::Box* body = nullptr; + brls::Box* tiles = nullptr; + PosterGrid* grid = nullptr; + bool gridShown = false; + + std::string genreName() const + { + for (const auto& g : tmdb::genres(kind)) + if (g.first == genre) return tr(g.second); + return tr("All genres"); + } + + void refreshLabels() + { + if (query.empty()) + { + barText->setText(tr("Search movies & shows...")); + barText->setTextColor(theme::textMuted()); + } + else + { + barText->setText(query); + barText->setTextColor(theme::text()); + } + kindBtn->setText(kind == content::Kind::Series ? tr("Shows") : tr("Movies")); + genreBtn->setText(genreName()); + const YearChoice& y = yearChoices()[yearIdx]; + yearBtn->setText(y.word ? std::string(tr(y.label)) : std::string(y.label)); + sortBtn->setText(sort == tmdb::Discover::Sort::Rating ? tr("By rating") + : sort == tmdb::Discover::Sort::Newest ? tr("Newest") + : tr("Popular")); + // The order only applies to browsing; a search comes ranked by TMDB. + sortBtn->setVisibility(query.empty() ? brls::Visibility::VISIBLE + : brls::Visibility::GONE); + sortBtn->setFocusable(query.empty()); + ui::setPillActive(genreBtn, genre != 0); + ui::setPillActive(yearBtn, yearIdx != 0); + } + + // The focus may be on something about to go: give it to the search bar. + void parkFocus() + { + for (brls::View* v = brls::Application::getCurrentFocus(); v; v = v->getParent()) + if (v == body) + { + brls::Application::giveFocus(bar); + return; + } + } + + void showTiles() + { + parkFocus(); + if (gridShown) + { + body->removeView(grid, false); // kept for the next search + gridShown = false; + } + if (tiles) + { + body->removeView(tiles); + tiles = nullptr; + } + tiles = new brls::Box(); + tiles->setAxis(brls::Axis::COLUMN); + tiles->setPadding(8.0f, 0.0f, 24.0f, 0.0f); + auto* head = new brls::Label(); + head->setText(tr("Genres")); + head->setFontSize(21.0f); + head->setMargins(6.0f, 0.0f, 10.0f, kInset); + tiles->addView(head); + + float usable = brls::Application::contentWidth - 2 * kInset + kGap; + int cols = std::max(2, (int)(usable / (kTileW + kGap))); + brls::Box* row = nullptr; + int i = 0; + for (const auto& g : tmdb::genres(kind)) + { + if (!row || (int)row->getChildren().size() >= cols) + { + row = new brls::Box(); + row->setAxis(brls::Axis::ROW); + row->setPaddingLeft(kInset); + row->setMarginBottom(kGap); + tiles->addView(row); + } + int id = g.first; + row->addView(genreTile(tr(g.second), tileColor(i++), [this, id]() { + genre = id; + refreshLabels(); + run(); + })); + } + body->addView(tiles); + } + + brls::Box* genreTile(const std::string& name, NVGcolor color, std::function onClick) + { + NVGcolor dark = color; + dark.r *= 0.55f; + dark.g *= 0.55f; + dark.b *= 0.55f; + auto* t = new ui::ShadeBox(true, color, dark, 1.0f); + t->setWidth(kTileW); + t->setHeight(kTileH); + t->setCornerRadius(10.0f); + t->setMarginRight(kGap); + t->setAxis(brls::Axis::COLUMN); + t->setJustifyContent(brls::JustifyContent::FLEX_END); + t->setPadding(0.0f, 16.0f, 14.0f, 16.0f); + t->setFocusable(true); + t->setHighlightCornerRadius(10.0f); + t->registerAction(tr("Open"), brls::BUTTON_A, [onClick](brls::View*) { + onClick(); + return true; + }, false, false, brls::SOUND_CLICK); + t->addGestureRecognizer(new brls::TapGestureRecognizer(t)); + auto* l = new brls::Label(); + l->setText(name); + l->setFontSize(20.0f); + l->setTextColor(nvgRGB(255, 255, 255)); + l->setSingleLine(true); + t->addView(l); + return t; + } + + // Whether a search result passes the filters TMDB's search does not + // take: the genre, and a year range (a single year it does take). + bool passes(const content::Item& it) const + { + if (query.empty()) return true; // discover filtered it already + if (genre) + { + bool has = false; + for (int g : it.genreIds) has = has || g == genre; + if (!has) return false; + } + const YearChoice& y = yearChoices()[yearIdx]; + if (y.from || y.to) + { + int year = it.year.empty() ? 0 : std::atoi(it.year.c_str()); + if (!year) return false; + if (y.from && year < y.from) return false; + if (y.to && year > y.to) return false; + } + return true; + } + + void run() + { + if (query.empty() && genre == 0 && yearIdx == 0) + { + showTiles(); + return; + } + parkFocus(); + if (tiles) + { + body->removeView(tiles); + tiles = nullptr; + } + if (!gridShown) + { + body->addView(grid); + gridShown = true; + } + const YearChoice& y = yearChoices()[yearIdx]; + tmdb::ListQuery q; + if (query.empty()) + { + tmdb::Discover d; + d.kind = kind; + d.genre = genre; + d.yearFrom = y.from; + d.yearTo = y.to; + d.sort = sort; + q = tmdb::discoverQuery(d); + } + else + { + int single = (y.from && y.from == y.to) ? y.from : 0; + q = tmdb::searchQuery(query, kind == content::Kind::Series ? 1 : 0, single); + } + grid->load(q, scroll); + } + + void prompt() + { + brls::Application::getImeManager()->openForText( + [this](std::string text) { + while (!text.empty() && text.back() == ' ') text.pop_back(); + query = text; + refreshLabels(); + run(); + }, + tr("Search movies & shows..."), "", 128, query); + } + + void dropdown(const std::string& title, const std::vector& values, + int selected, std::function picked) + { + auto* dd = new brls::Dropdown(title, values, [picked](int i) { + if (i >= 0) picked(i); + }, selected); + brls::Application::pushActivity(new brls::Activity(dd)); + } + + void pickKind() + { + dropdown(tr("Type"), { tr("Movies"), tr("Shows") }, + kind == content::Kind::Series ? 1 : 0, [this](int i) { + content::Kind k = i == 1 ? content::Kind::Series : content::Kind::Movie; + if (k == kind) return; + kind = k; + genre = 0; // the two kinds have different genre lists + refreshLabels(); + run(); + }); + } + + void pickGenre() + { + std::vector names = { tr("All genres") }; + std::vector ids = { 0 }; + int sel = 0; + for (const auto& g : tmdb::genres(kind)) + { + if (g.first == genre) sel = (int)ids.size(); + names.push_back(tr(g.second)); + ids.push_back(g.first); + } + dropdown(tr("Genre"), names, sel, [this, ids](int i) { + genre = ids[i]; + refreshLabels(); + run(); + }); + } + + void pickYear() + { + std::vector names; + for (const auto& y : yearChoices()) + names.push_back(y.word ? std::string(tr(y.label)) : std::string(y.label)); + dropdown(tr("Year"), names, yearIdx, [this](int i) { + yearIdx = i; + refreshLabels(); + run(); + }); + } + + void pickSort() + { + dropdown(tr("Order"), { tr("Popular"), tr("By rating"), tr("Newest") }, + (int)sort, [this](int i) { + sort = (tmdb::Discover::Sort)i; + refreshLabels(); + run(); + }); + } + + void reset() + { + query.clear(); + genre = 0; + yearIdx = 0; + sort = tmdb::Discover::Sort::Popular; + refreshLabels(); + showTiles(); + } + + public: + ~SearchScreen() override + { + // Not in the tree while the tiles are up: free it here then. + if (!gridShown) delete grid; + } +}; + +} // namespace + +void openCatalogSearch() +{ + if (!tmdb::configured()) + { + auto* d = new brls::Dialog(tr("The catalog needs a TMDB key")); + d->addButton("OK", []() {}); + d->open(); + return; + } + auto* screen = new SearchScreen(); + auto* frame = new brls::AppletFrame(screen); + frame->setTitle(tr("Search")); + brls::Application::pushActivity(new brls::Activity(frame)); +} diff --git a/app/settings.cpp b/app/settings.cpp index 6a7c4aa..ba10577 100644 --- a/app/settings.cpp +++ b/app/settings.cpp @@ -151,15 +151,18 @@ brls::View* generalPane() int curStartupIdx = (cfg.startupTab == config::Tab::STREMIO) ? 1 : (cfg.startupTab == config::Tab::JACKETT) ? 2 : (cfg.startupTab == config::Tab::HISTORY) ? 3 : - (cfg.startupTab == config::Tab::HOME) ? 4 : 0; + (cfg.startupTab == config::Tab::HOME) ? 4 : + (cfg.startupTab == config::Tab::BOOKMARKS) ? 5 : 0; startup->init(tr("Category on startup"), - { tr("Local"), tr("Stremio"), "Jackett", tr("History"), tr("Home") }, + { tr("Local"), tr("Stremio"), "Jackett", tr("History"), tr("Home"), + tr("Bookmarks") }, curStartupIdx, [](int sel) { config::get().startupTab = (sel == 1) ? config::Tab::STREMIO : (sel == 2) ? config::Tab::JACKETT : (sel == 3) ? config::Tab::HISTORY : - (sel == 4) ? config::Tab::HOME : config::Tab::LOCAL; + (sel == 4) ? config::Tab::HOME : + (sel == 5) ? config::Tab::BOOKMARKS : config::Tab::LOCAL; config::save(); }); list->addView(startup); diff --git a/app/sources.cpp b/app/sources.cpp new file mode 100644 index 0000000..a33c2e2 --- /dev/null +++ b/app/sources.cpp @@ -0,0 +1,887 @@ +#include "sources.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "appdata.hpp" +#include "browse.hpp" // stremioStreamSource +#include "config.hpp" +#include "i18n.hpp" +#include "jackett.hpp" +#include "library.hpp" +#include "player.hpp" // startPlaybackPrompt +#include "release.hpp" +#include "stremio.hpp" +#include "theme.hpp" +#include "torrent_play.hpp" +#include "widgets.hpp" + +namespace +{ + +// --------------------------------------------------------------------------- +// The rows +// --------------------------------------------------------------------------- + +struct Source +{ + enum class Kind + { + Jackett, + StremioTorrent, // an addon's infoHash, played as a magnet + StremioUrl, // an addon's direct link + } kind = Kind::Jackett; + + std::string title; // the release name + std::string tracker; // tracker or addon + int64_t size = 0; + int seeders = -1; + release::Info info; + int score = 0; + + jackett::Item jitem; // Kind::Jackett + std::string src; // a magnet or a URL (Stremio) + int fileIdx = -1; // the addon's file index (season packs) +}; + +// "1.4 GB" / "850 MB" after the marker, or 0. +int64_t sizeAfter(const std::string& s, const char* marker) +{ + size_t k = s.find(marker); + if (k == std::string::npos) return 0; + const char* p = s.c_str() + k + std::strlen(marker); + while (*p == ' ') p++; + char* end = nullptr; + double v = std::strtod(p, &end); + if (end == p) return 0; + while (*end == ' ') end++; + double mul = 1; + if (*end == 'G' || *end == 'g') mul = 1024.0 * 1024 * 1024; + else if (*end == 'M' || *end == 'm') mul = 1024.0 * 1024; + else if (*end == 'T' || *end == 't') mul = 1024.0 * 1024 * 1024 * 1024; + return (int64_t)(v * mul); +} + +int numberAfter(const std::string& s, const char* marker) +{ + size_t k = s.find(marker); + if (k == std::string::npos) return -1; + const char* p = s.c_str() + k + std::strlen(marker); + while (*p == ' ') p++; + if (*p < '0' || *p > '9') return -1; + return std::atoi(p); +} + +std::string restOfLineAfter(const std::string& s, const char* marker) +{ + size_t k = s.find(marker); + if (k == std::string::npos) return ""; + size_t b = k + std::strlen(marker); + while (b < s.size() && s[b] == ' ') b++; + size_t e = s.find('\n', b); + std::string r = s.substr(b, e == std::string::npos ? std::string::npos : e - b); + while (!r.empty() && r.back() == ' ') r.pop_back(); + return r; +} + +// An addon stream as a row. Torrentio and its kin write the details into the +// title: "Release.Name\n👤 23 💾 1.4 GB ⚙️ RuTracker". +Source fromStream(const stremio::Stream& s, const std::string& addon) +{ + static const char* kSeeds = "\xF0\x9F\x91\xA4"; // 👤 + static const char* kSize = "\xF0\x9F\x92\xBE"; // 💾 + static const char* kTracker = "\xE2\x9A\x99\xEF\xB8\x8F"; // ⚙️ + + Source r; + r.kind = s.infoHash.empty() ? Source::Kind::StremioUrl : Source::Kind::StremioTorrent; + r.src = stremioStreamSource(s); + r.fileIdx = s.fileIdx; + std::string firstLine = s.title.substr(0, s.title.find('\n')); + r.title = firstLine.empty() ? s.name : firstLine; + for (char& c : r.title) + if (c == '\n') c = ' '; + r.seeders = numberAfter(s.title, kSeeds); + r.size = sizeAfter(s.title, kSize); + r.tracker = restOfLineAfter(s.title, kTracker); + if (r.tracker.empty()) r.tracker = addon; + // The quality is often only in the name ("Torrentio\n1080p"). + r.info = release::parse(s.name + " " + s.title); + return r; +} + +Source fromJackett(const jackett::Item& it) +{ + Source r; + r.kind = Source::Kind::Jackett; + r.jitem = it; + r.title = it.title; + r.tracker = it.tracker; + r.size = it.size; + r.seeders = it.seeders; + r.info = release::parse(it.title); + return r; +} + +// --------------------------------------------------------------------------- +// What was picked for a show last time +// --------------------------------------------------------------------------- + +const char* kChoices = APPDATA_DIR "/choices.tsv"; // key \t voice \t group + +void loadChoice(const std::string& key, std::string& voice, std::string& group) +{ + FILE* f = std::fopen(kChoices, "rb"); + if (!f) return; + char line[512]; + while (std::fgets(line, sizeof(line), f)) + { + std::string l = line; + while (!l.empty() && (l.back() == '\n' || l.back() == '\r')) l.pop_back(); + size_t a = l.find('\t'); + if (a == std::string::npos || l.substr(0, a) != key) continue; + size_t b = l.find('\t', a + 1); + voice = l.substr(a + 1, b == std::string::npos ? std::string::npos : b - a - 1); + group = b == std::string::npos ? "" : l.substr(b + 1); + } + std::fclose(f); +} + +void saveChoice(const std::string& key, const std::string& voice, const std::string& group) +{ + if (voice.empty() && group.empty()) return; + std::vector lines; + if (FILE* f = std::fopen(kChoices, "rb")) + { + char line[512]; + while (std::fgets(line, sizeof(line), f)) + { + std::string l = line; + while (!l.empty() && (l.back() == '\n' || l.back() == '\r')) l.pop_back(); + if (!l.empty() && l.compare(0, key.size() + 1, key + "\t") != 0) lines.push_back(l); + } + std::fclose(f); + } + lines.push_back(key + "\t" + voice + "\t" + group); + // A few hundred shows at most; the oldest go first. + if (lines.size() > 300) lines.erase(lines.begin(), lines.end() - 300); + if (FILE* f = std::fopen(kChoices, "wb")) + { + for (const auto& l : lines) std::fprintf(f, "%s\n", l.c_str()); + std::fclose(f); + } +} + +// --------------------------------------------------------------------------- +// What the player is told about a catalog title +// --------------------------------------------------------------------------- + +// Title, artwork and the watch context for `item` (an episode of it when +// season/episode are set): progress to the library, and to the Stremio account +// when signed in; the next episode when there is one. `nextPop`: activities to +// close before the next episode opens (see WatchInfo). +PlayHint hintFor(const content::Item& item, int season, int episode, int episodesInSeason, + int nextPop) +{ + PlayHint hint; + bool ep = item.isSeries() && season > 0 && episode > 0; + hint.title = item.title; + hint.season = season; + hint.episode = episode; + if (ep) hint.title += " \xC2\xB7 S" + std::to_string(season) + "E" + std::to_string(episode); + hint.art.posterPath = stremio::cachedPosterPath(item.key()); + hint.art.bgId = item.key() + "_bd"; + hint.art.bgUrl = item.backdropUrl; + + hint.watch.content = item; + hint.watch.season = ep ? season : 0; + hint.watch.episode = ep ? episode : 0; + hint.watch.nextPop = nextPop; + bool hasNext = ep && (episodesInSeason <= 0 || episode < episodesInSeason); + // The id only labels the "next episode" card here ("Season 1 · Episode 4"); + // the library opens it. + if (hasNext) + hint.watch.nextVideoId = item.key() + ":" + std::to_string(season) + ":" + + std::to_string(episode + 1); + + // Signed in: the position goes to the account too, so Stremio's "Continue + // watching" follows a Jackett playback as well. + std::string authKey = stremio::loadAuthKey(); + if (!authKey.empty() && !item.imdbId.empty()) + { + hint.watch.authKey = authKey; + hint.watch.itemId = item.imdbId; + hint.watch.type = item.stremioType(); + hint.watch.videoId = item.imdbId; + if (ep) + { + hint.watch.videoId = item.imdbId + ":" + std::to_string(season) + ":" + + std::to_string(episode); + if (hasNext) + hint.watch.nextVideoId = item.imdbId + ":" + std::to_string(season) + ":" + + std::to_string(episode + 1); + } + } + hint.watch.displayTitle = hint.title; + return hint; +} + +// --------------------------------------------------------------------------- +// The screen +// --------------------------------------------------------------------------- + +enum class Order +{ + Recommended, + Seeders, + Size, +}; + +std::string qualityText(int h) +{ + return h > 0 ? std::to_string(h) + "p" : "\xE2\x80\x94"; // — +} + +std::string sizeText(int64_t b) { return b > 0 ? jackett::formatSize(b) : ""; } + +class SourcesView : public brls::Box +{ + public: + SourcesView(const content::Item& item, int season, int episode, int episodesInSeason) + : item(item), season(season), episode(episode), episodesInSeason(episodesInSeason) + { + setAxis(brls::Axis::COLUMN); + setGrow(1.0f); + setPadding(20.0f, 60.0f, 10.0f, 60.0f); + + want.series = item.isSeries(); + want.season = season; + want.episode = episode; + want.preferRussian = i18n::lang() == "ru"; + if (item.isSeries()) loadChoice(item.key(), want.voice, want.group); + + // Head: "Torrents" and what for. + auto* head = new brls::Box(); + head->setAxis(brls::Axis::ROW); + head->setAlignItems(brls::AlignItems::FLEX_END); + auto* h = new brls::Label(); + h->setText(tr("Torrents")); + h->setFontSize(30.0f); + head->addView(h); + subtitle = new brls::Label(); + subtitle->setFontSize(18.0f); + subtitle->setTextColor(theme::textMuted()); + subtitle->setMarginLeft(20.0f); + subtitle->setSingleLine(true); + head->addView(subtitle); + addView(head); + updateSubtitle(); + + // Filters: quality, then the order. + auto* filters = new brls::Box(); + filters->setAxis(brls::Axis::ROW); + filters->setMargins(14.0f, 0.0f, 10.0f, 0.0f); + const std::pair qualities[] = { + { "All", 0 }, { "2160p", 2160 }, { "1080p", 1080 }, { "720p", 720 } + }; + for (const auto& q : qualities) + { + int height = q.second; + auto* b = ui::actionButton(q.second ? q.first : tr(q.first), false, [this, height]() { + filterHeight = height; + restyleFilters(); + rebuild(); + }); + filterBtns.push_back({ b, height }); + filters->addView(b); + } + orderBtn = ui::actionButton("", false, [this]() { cycleOrder(); }); + filters->addView(orderBtn); + addView(filters); + + // The second row: what the releases found actually differ in. Each + // opens a list of the values present in the results. + auto* more = new brls::Box(); + more->setAxis(brls::Axis::ROW); + more->setMargins(0.0f, 0.0f, 10.0f, 0.0f); + voiceBtn = ui::actionButton("", false, [this]() { pickFacet(Facet::Voice); }); + sourceBtn = ui::actionButton("", false, [this]() { pickFacet(Facet::Source); }); + hdrBtn = ui::actionButton("", false, [this]() { pickFacet(Facet::Hdr); }); + trackerBtn = ui::actionButton("", false, [this]() { pickFacet(Facet::Tracker); }); + more->addView(voiceBtn); + more->addView(sourceBtn); + more->addView(hdrBtn); + more->addView(trackerBtn); + addView(more); + restyleFilters(); + + status = new brls::Label(); + status->setFontSize(20.0f); + status->setTextColor(theme::textMuted()); + status->setMarginTop(20.0f); + status->setText(tr("Searching...")); + addView(status); + + auto* scroll = new brls::ScrollingFrame(); + scroll->setGrow(1.0f); + scroll->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); + list = new brls::Box(); + list->setAxis(brls::Axis::COLUMN); + list->setPadding(6.0f, 4.0f, 20.0f, 4.0f); + scroll->setContentView(list); + addView(scroll); + + registerAction(tr("Order"), brls::BUTTON_X, [this](brls::View*) { + cycleOrder(); + return true; + }); + + search(); + } + + ~SourcesView() override { *alive = false; } + + private: + content::Item item; + int season, episode, episodesInSeason; + release::Want want; + std::shared_ptr alive = std::make_shared(true); + + std::vector found; + std::set seen; // dedup keys + int pending = 0; // requests not answered yet + std::vector errors; + + int filterHeight = 0; + + // The second filter row. "" = any. + enum class Facet + { + Voice, + Source, + Hdr, + Tracker, + }; + std::string filterVoice, filterSource, filterTracker; + int filterHdr = 0; // 0 any, 1 without HDR, 2 HDR / Dolby Vision only + brls::Button *voiceBtn = nullptr, *sourceBtn = nullptr, *hdrBtn = nullptr, + *trackerBtn = nullptr; + + // The values of a facet among the releases shown (another season's are + // not offered), most common first. + std::vector facetValues(Facet f) const + { + std::map count; + for (const auto& r : found) + { + if (r.score <= -500) continue; + if (f == Facet::Voice) + for (const auto& v : r.info.voices) count[v]++; + else if (f == Facet::Source && !r.info.source.empty()) + count[r.info.source]++; + else if (f == Facet::Tracker && !r.tracker.empty()) + count[r.tracker]++; + } + std::vector> v(count.begin(), count.end()); + std::stable_sort(v.begin(), v.end(), + [](const auto& a, const auto& b) { return a.second > b.second; }); + std::vector out; + for (const auto& p : v) out.push_back(p.first); + return out; + } + + void pickFacet(Facet f) + { + if (pending > 0) return; // the values are not all in yet + std::vector labels, values; + if (f == Facet::Hdr) + { + labels = { tr("Any picture"), tr("Without HDR"), tr("HDR / Dolby Vision") }; + values = { "0", "1", "2" }; + } + else + { + labels.push_back(f == Facet::Voice ? tr("Any voice-over") + : f == Facet::Source ? tr("Any source") + : tr("Any tracker")); + values.push_back(""); + for (const auto& v : facetValues(f)) + { + labels.push_back(v); + values.push_back(v); + } + } + const std::string& cur = f == Facet::Voice ? filterVoice + : f == Facet::Source ? filterSource + : f == Facet::Tracker ? filterTracker + : std::string(); + int sel = 0; + for (size_t i = 0; i < values.size(); i++) + if (f == Facet::Hdr ? (int)i == filterHdr : values[i] == cur) sel = (int)i; + const char* title = f == Facet::Voice ? "Voice-over" + : f == Facet::Source ? "Source" + : f == Facet::Hdr ? "Picture" + : "Tracker"; + auto* dd = new brls::Dropdown(tr(title), labels, [this, f, values](int i) { + if (i < 0 || i >= (int)values.size()) return; + if (f == Facet::Voice) filterVoice = values[i]; + else if (f == Facet::Source) filterSource = values[i]; + else if (f == Facet::Tracker) filterTracker = values[i]; + else filterHdr = i; + restyleFilters(); + rebuild(); + }, sel); + brls::Application::pushActivity(new brls::Activity(dd)); + } + + bool facetsPass(const Source& r) const + { + if (!filterVoice.empty() && + std::find(r.info.voices.begin(), r.info.voices.end(), filterVoice) == + r.info.voices.end()) + return false; + if (!filterSource.empty() && r.info.source != filterSource) return false; + if (!filterTracker.empty() && r.tracker != filterTracker) return false; + if (filterHdr == 1 && r.info.hdr) return false; + if (filterHdr == 2 && !r.info.hdr) return false; + return true; + } + Order order = Order::Recommended; + + brls::Label* subtitle = nullptr; + brls::Label* status = nullptr; + brls::Box* list = nullptr; + brls::Button* orderBtn = nullptr; + std::vector> filterBtns; + + void updateSubtitle() + { + std::string s = item.title; + if (item.isSeries() && season > 0) + { + s += std::string(" \xC2\xB7 ") + tr("Season ") + std::to_string(season); + if (episode > 0) + s += std::string(" \xC2\xB7 ") + tr("Episode ") + std::to_string(episode); + } + else if (!item.year.empty()) + s += " (" + item.year + ")"; + if (pending == 0) + { + int n = 0; + for (const auto& r : found) + if (r.score > -500) n++; + s += " \xC2\xB7 " + std::to_string(n) + tr(" releases"); + } + subtitle->setText(s); + } + + void restyleFilters() + { + for (auto& f : filterBtns) + ui::setPillActive(f.first, f.second == filterHeight); + const char* o = order == Order::Recommended ? "Order: recommended" + : order == Order::Seeders ? "Order: seeders" + : "Order: size"; + orderBtn->setText(tr(o)); + + // The second row says what it filters on, or the value picked. + voiceBtn->setText(filterVoice.empty() ? std::string(tr("Voice-over")) : filterVoice); + sourceBtn->setText(filterSource.empty() ? std::string(tr("Source")) : filterSource); + hdrBtn->setText(filterHdr == 1 ? tr("Without HDR") + : filterHdr == 2 ? tr("HDR / Dolby Vision") + : tr("Picture")); + trackerBtn->setText(filterTracker.empty() ? std::string(tr("Tracker")) : filterTracker); + ui::setPillActive(voiceBtn, !filterVoice.empty()); + ui::setPillActive(sourceBtn, !filterSource.empty()); + ui::setPillActive(hdrBtn, filterHdr != 0); + ui::setPillActive(trackerBtn, !filterTracker.empty()); + } + + void cycleOrder() + { + order = order == Order::Recommended ? Order::Seeders + : order == Order::Seeders ? Order::Size + : Order::Recommended; + restyleFilters(); + rebuild(); + } + + // ---- gathering ---------------------------------------------------------- + + bool addSource(Source s) + { + // The same release from two indexers, or from Jackett and an addon. + std::string key = s.title + "|" + std::to_string(s.size / (1024 * 1024)); + if (!seen.insert(key).second) return false; + s.score = release::score(s.info, s.seeders, s.size, want); + found.push_back(std::move(s)); + return true; + } + + void done(const std::string& err = "") + { + if (!err.empty()) errors.push_back(err); + if (--pending > 0) + { + status->setText(tr("Searching...") + std::string(" ") + + std::to_string(found.size()) + tr(" found so far")); + return; + } + updateSubtitle(); + rebuild(); + } + + void search() + { + bool jackettOn = !config::get().jackettUrl.empty(); + std::string authKey = stremio::loadAuthKey(); + bool stremioOn = !authKey.empty() && !item.imdbId.empty(); + + if (!jackettOn && !stremioOn) + { + status->setText(tr("To find sources, sign in to Stremio or set up Jackett in " + "Options.")); + return; + } + + // Jackett: the title in the UI language and the original one -- a + // Russian tracker files "Дюна", an English one "Dune". A film gets + // its year, which keeps remakes and namesakes out. + std::vector queries; + if (jackettOn) + { + auto q = [&](const std::string& t) { + if (t.empty()) return; + std::string s = t; + if (!item.isSeries() && !item.year.empty()) s += " " + item.year; + if (std::find(queries.begin(), queries.end(), s) == queries.end()) + queries.push_back(s); + }; + q(item.title); + q(item.originalTitle); + } + pending = (int)queries.size() + (stremioOn ? 1 : 0); + + jackett::Category cat = + item.isSeries() ? jackett::Category::SERIES : jackett::Category::MOVIES; + for (const auto& q : queries) + { + brls::Logger::info("[sources] jackett: {}", q); + jackett::searchAsync(q, cat, [this, a = alive](jackett::SearchResult r) { + if (!*a) return; + if (r.ok) + for (const auto& it : r.items) addSource(fromJackett(it)); + done(r.ok ? "" : "Jackett: " + r.error); + }); + } + + if (stremioOn) + { + std::string type = item.stremioType(); + std::string id = item.imdbId; + if (item.isSeries() && season > 0 && episode > 0) + id += ":" + std::to_string(season) + ":" + std::to_string(episode); + stremio::fetchAddonsAsync(authKey, [this, a = alive, type, id](stremio::AddonsResult r) { + if (!*a) return; + std::vector addons; + if (r.ok) + for (const auto& ad : r.addons) + if (ad.hasStream && ad.supportsType(type) && + !stremio::isStreamAddonHidden(ad.name)) + addons.push_back(ad); + // One pending for the account, swapped for one per addon. + pending += (int)addons.size(); + for (const auto& ad : addons) + { + std::string name = ad.name; + stremio::fetchStreamsAsync(ad.base, type, id, + [this, a, name](stremio::StreamsResult s) { + if (!*a) return; + if (s.ok) + for (const auto& st : s.streams) + { + Source src = fromStream(st, name); + if (!src.src.empty()) addSource(std::move(src)); + } + done(s.ok ? "" : name + ": " + s.error); + }); + } + done(r.ok ? "" : "Stremio: " + r.error); + }); + } + } + + // ---- the list ------------------------------------------------------------- + + std::vector visible() const + { + std::vector v; + for (const auto& r : found) + { + if (r.score <= -500) continue; // another season / episode, a cam + if (filterHeight && r.info.height != filterHeight) continue; + if (!facetsPass(r)) continue; + v.push_back(&r); + } + std::stable_sort(v.begin(), v.end(), [this](const Source* a, const Source* b) { + switch (order) + { + case Order::Seeders: return a->seeders > b->seeders; + case Order::Size: return a->size > b->size; + default: return a->score > b->score; + } + }); + return v; + } + + void rebuild() + { + // The focus may sit on a row about to be freed (X pressed on it): + // hand it to the filter row first. + for (brls::View* v = brls::Application::getCurrentFocus(); v; v = v->getParent()) + if (v == list) + { + brls::Application::giveFocus(filterBtns[0].first); + break; + } + list->clearViews(); + if (pending > 0) return; + + auto rows = visible(); + if (rows.empty()) + { + std::string msg = found.empty() ? tr("No results found") + : tr("Nothing matches this filter."); + if (found.empty() && !errors.empty()) msg += "\n" + errors[0]; + status->setText(msg); + status->setVisibility(brls::Visibility::VISIBLE); + return; + } + status->setVisibility(brls::Visibility::GONE); + + // "Best choice": the top of the recommended order, whatever is shown. + const Source* best = nullptr; + for (const auto& r : found) + if (r.score > -500 && (!best || r.score > best->score)) best = &r; + + for (const Source* r : rows) list->addView(buildRow(*r, r == best)); + } + + brls::Box* buildRow(const Source& s, bool best) + { + auto* row = new brls::Box(); + row->setAxis(brls::Axis::ROW); + row->setAlignItems(brls::AlignItems::CENTER); + row->setPadding(12.0f, 18.0f, 12.0f, 18.0f); + row->setMarginBottom(10.0f); + row->setCornerRadius(10.0f); + row->setBackgroundColor(theme::scrim(14)); + row->setFocusable(true); + row->setHighlightCornerRadius(10.0f); + + auto* q = new brls::Label(); + q->setText(qualityText(s.info.height)); + q->setFontSize(24.0f); + q->setWidth(100.0f); + q->setSingleLine(true); + row->addView(q); + + auto* mid = new brls::Box(); + mid->setAxis(brls::Axis::COLUMN); + mid->setGrow(1.0f); + mid->setShrink(1.0f); + mid->setMarginRight(16.0f); + auto* t = new brls::Label(); + t->setText(s.title); + t->setFontSize(19.0f); + t->setSingleLine(true); + mid->addView(t); + + auto* tags = new brls::Box(); + tags->setAxis(brls::Axis::ROW); + tags->setMarginTop(8.0f); + auto tag = [tags](const std::string& text, NVGcolor color) { + auto* b = new brls::Box(); + b->setCornerRadius(5.0f); + b->setPadding(4.0f, 8.0f, 2.0f, 8.0f); + b->setMarginRight(8.0f); + b->setBorderColor(color); + b->setBorderThickness(1.0f); + auto* l = new brls::Label(); + l->setText(text); + l->setFontSize(14.0f); + l->setTextColor(color); + l->setSingleLine(true); + b->addView(l); + tags->addView(b); + }; + if (best) tag(tr("best choice"), nvgRGB(0x9b, 0xe3, 0x8b)); + for (const auto& x : release::tags(s.info)) tag(x, theme::textDim()); + if (item.isSeries() && s.info.fullSeason) + tag(s.info.seasonTo > s.info.season ? tr("several seasons") : tr("whole season"), + theme::textDim()); + if (s.info.height == 2160 || s.info.tenBit) + tag(tr("heavy for Wi-Fi"), theme::textWarn()); + if (!s.tracker.empty()) tag(s.tracker, theme::textMuted()); + mid->addView(tags); + row->addView(mid); + + auto* right = new brls::Box(); + right->setAxis(brls::Axis::COLUMN); + right->setAlignItems(brls::AlignItems::FLEX_END); + right->setWidth(130.0f); + auto* sz = new brls::Label(); + sz->setText(sizeText(s.size)); + sz->setFontSize(17.0f); + sz->setHorizontalAlign(brls::HorizontalAlign::RIGHT); + right->addView(sz); + if (s.seeders >= 0) + { + auto* sd = new brls::Label(); + sd->setText(tr("seeds ") + std::to_string(s.seeders)); + sd->setFontSize(15.0f); + sd->setTextColor(s.seeders > 0 ? theme::textMuted() : theme::textWarn()); + sd->setHorizontalAlign(brls::HorizontalAlign::RIGHT); + sd->setMarginTop(6.0f); + right->addView(sd); + } + row->addView(right); + + Source copy = s; + row->registerClickAction([this, copy](brls::View*) { + play(copy); + return true; + }); + row->addGestureRecognizer(new brls::TapGestureRecognizer(row)); + if (s.kind == Source::Kind::Jackett) + { + jackett::Item ji = s.jitem; + row->registerAction(tr("Save to Local"), brls::BUTTON_Y, [ji](brls::View*) { + jackett::saveToLocalAsync(ji, [](bool ok, const std::string& msg) { + auto* d = new brls::Dialog(ok ? tr("Saved to Local library") : msg); + d->addButton("OK", []() {}); + d->open(); + }); + return true; + }); + } + return row; + } + + // ---- playing -------------------------------------------------------------- + + void play(const Source& s) + { + if (item.isSeries()) + { + saveChoice(item.key(), s.info.voices.empty() ? "" : s.info.voices[0], s.info.group); + // A season pack: the next episode can open straight from it. + if (season > 0 && s.info.fullSeason && s.kind != Source::Kind::StremioUrl) + { + bool jk = s.kind == Source::Kind::Jackett; + library::rememberPack(item, s.title, jk ? s.jitem.link : "", + jk ? s.jitem.magnetUri : s.src, s.info.season, + s.info.seasonTo); + } + } + + // From this screen: the next episode replaces it (pop it and the player). + PlayHint hint = hintFor(item, season, episode, episodesInSeason, 2); + + switch (s.kind) + { + case Source::Kind::Jackett: + hint.sourceType = "Jackett"; + playJackettItem(s.jitem, hint); + break; + case Source::Kind::StremioTorrent: + hint.sourceType = "Stremio"; + // The addon said which file: no need to open the torrent here. + if (s.fileIdx >= 0 || !item.isSeries() || episode <= 0) + startPlaybackPrompt(s.src, hint.art, hint.title, s.fileIdx, hint.watch, + hint.sourceType); + else + playMagnet(s.src, hint); + break; + case Source::Kind::StremioUrl: + hint.sourceType = "Stremio"; + startPlaybackPrompt(s.src, hint.art, hint.title, -1, hint.watch, hint.sourceType); + break; + } + } +}; + +} // namespace + +void openSourcesScreen(const content::Item& item, int season, int episode, + int episodesInSeason) +{ + auto* frame = new brls::AppletFrame(new SourcesView(item, season, episode, episodesInSeason)); + frame->setTitle(""); + brls::Application::pushActivity(new brls::Activity(frame)); +} + +void openNextEpisode(const content::Item& item, int season, int episode) +{ + library::Entry en; + bool have = library::find(item.key(), en); + content::Item it = item; + if (have && it.imdbId.empty()) it.imdbId = en.item.imdbId; + + // The last episode came from a season pack that holds this one too: open + // the same torrent and let torrent_play find the episode's file. + bool packHasIt = have && en.packSeason > 0 && season >= en.packSeason && + season <= en.packSeasonTo && + (!en.packLink.empty() || !en.packMagnet.empty()); + if (packHasIt) + { + brls::Logger::info("[sources] next episode S{}E{} from the same pack", season, episode); + PlayHint hint = hintFor(it, season, episode, 0, 1); + bool httpLink = !en.packLink.empty() && en.packLink.rfind("magnet:", 0) != 0; + if (httpLink) + { + jackett::Item ji; + ji.title = en.packTitle; + ji.link = en.packLink; + ji.magnetUri = en.packMagnet; + hint.sourceType = "Jackett"; + playJackettItem(ji, hint); + } + else + { + hint.sourceType = "Jackett"; + playMagnet(en.packMagnet.empty() ? en.packLink : en.packMagnet, hint); + } + return; + } + openSourcesScreen(it, season, episode, 0); +} + +void continueContent(const library::Entry& en) +{ + library::Next n = library::nextOf(en); + if (n.kind == library::Next::Kind::NextEp) + { + openNextEpisode(en.item, n.season, n.episode); + return; + } + if (n.kind == library::Next::Kind::Resume && !en.source.empty()) + { + // Straight back into what was playing: the player's own prompt offers + // the saved position (it is keyed on this source). + PlayHint hint = hintFor(en.item, n.season, n.episode, 0, 1); + startPlaybackPrompt(en.source, hint.art, hint.title, en.fileIndex, hint.watch, + en.sourceType); + return; + } + // Nothing to resume from: the sources for it. + openSourcesScreen(en.item, n.season, n.episode, 0); +} diff --git a/app/sources.hpp b/app/sources.hpp new file mode 100644 index 0000000..3d67c69 --- /dev/null +++ b/app/sources.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "content.hpp" +#include "library.hpp" + +// The sources screen (roadmap mock-up "Выбор раздачи"): every release found +// for a film or an episode, from Jackett (searched by the title and by the +// original title, with the year for a film) and from the Stremio account's +// stream addons (by IMDb id), in one list. +// +// Each release name is read (release.hpp) into quality, source, codec, +// HDR/10-bit, voices and what part of a series it holds; releases for another +// season or episode are dropped, the rest sorted so the one that will simply +// play on a Switch comes first, marked "best choice". Filters: quality chips; +// X cycles the order (recommended / seeders / size). A plays, Y saves a +// Jackett result to the Local library. +// +// For a series the voice and the group picked are remembered, so the next +// episode's list puts the same dub first. +// +// season / episode: 0 / 0 for a film, or for a whole series ("Torrents" on a +// show's card). +void openSourcesScreen(const content::Item& item, int season, int episode, + int episodesInSeason = 0); + +// The next episode of a show: from the same season pack when the last episode +// came from one that holds it (no list to pick from), else its sources screen. +void openNextEpisode(const content::Item& item, int season, int episode); + +// "Continue" on a library entry (home row, card): back into the file that was +// playing, or the next episode when the last one is done. +void continueContent(const library::Entry& entry); diff --git a/app/tmdb.cpp b/app/tmdb.cpp index 5857b6c..9054a66 100644 --- a/app/tmdb.cpp +++ b/app/tmdb.cpp @@ -152,6 +152,7 @@ std::vector parseList(const std::string& body, } content::Item it; readCommon(top, k, imageBase, it); + for (long g : json::integers(obj, "genre_ids")) it.genreIds.push_back((int)g); if (it.tmdbId <= 0 || it.title.empty()) continue; out.push_back(std::move(it)); } @@ -238,6 +239,75 @@ std::vector parseSeason(const std::string& body, return out; } +ListQuery discoverQuery(const Discover& d) +{ + bool tv = d.kind == content::Kind::Series; + ListQuery q; + q.path = tv ? "/discover/tv" : "/discover/movie"; + q.kind = tv ? 1 : 0; + const char* date = tv ? "first_air_date" : "primary_release_date"; + if (d.genre > 0) q.params.push_back({ "with_genres", std::to_string(d.genre) }); + if (d.yearFrom > 0) + q.params.push_back({ std::string(date) + ".gte", std::to_string(d.yearFrom) + "-01-01" }); + if (d.yearTo > 0) + q.params.push_back({ std::string(date) + ".lte", std::to_string(d.yearTo) + "-12-31" }); + switch (d.sort) + { + case Discover::Sort::Rating: + q.params.push_back({ "sort_by", "vote_average.desc" }); + // Rated by enough people that the top is not a page of unknowns. + q.params.push_back({ "vote_count.gte", "300" }); + break; + case Discover::Sort::Newest: + q.params.push_back({ "sort_by", std::string(date) + ".desc" }); + q.params.push_back({ "vote_count.gte", "20" }); + break; + default: + q.params.push_back({ "sort_by", "popularity.desc" }); + break; + } + return q; +} + +ListQuery searchQuery(const std::string& text, int kind, int year) +{ + ListQuery q; + q.kind = kind; + if (kind < 0) + { + q.path = "/search/multi"; + q.params = { { "query", text }, { "include_adult", "false" } }; + return q; + } + bool tv = kind == 1; + q.path = tv ? "/search/tv" : "/search/movie"; + q.params = { { "query", text }, { "include_adult", "false" } }; + if (year > 0) + q.params.push_back({ tv ? "first_air_date_year" : "primary_release_year", + std::to_string(year) }); + return q; +} + +const std::vector>& genres(content::Kind kind) +{ + static const std::vector> movie = { + { 28, "Action" }, { 12, "Adventure" }, { 16, "Animation" }, + { 35, "Comedy" }, { 80, "Crime" }, { 99, "Documentary" }, + { 18, "Drama" }, { 10751, "Family" }, { 14, "Fantasy" }, + { 36, "Historical" }, { 27, "Horror" }, { 10402, "Music" }, + { 9648, "Mystery" }, { 10749, "Romance" }, { 878, "Science fiction" }, + { 53, "Thriller" }, { 10752, "War" }, { 37, "Western" }, + }; + static const std::vector> tv = { + { 10759, "Action & Adventure" }, { 16, "Animation" }, { 35, "Comedy" }, + { 80, "Crime" }, { 99, "Documentary" }, { 18, "Drama" }, + { 10751, "Family" }, { 10762, "Kids" }, { 9648, "Mystery" }, + { 10764, "Reality" }, { 10765, "Sci-Fi & Fantasy" }, + { 10768, "War & Politics" }, { 37, "Western" }, + }; + return kind == content::Kind::Series ? tv : movie; +} + // ============================================================================ // The app side // ============================================================================ @@ -369,19 +439,76 @@ void fetchList(const std::string& path, std::string body; if (fetchCached(s, url, ttl, body, r.error)) { - r.items = parseList(body, s.imageBase, kind); - r.ok = true; + r.items = parseList(body, s.imageBase, kind); + r.ok = true; + r.page = (int)json::integer(body, "page", 1); + r.totalPages = (int)json::integer(body, "total_pages", 1); + // TMDB serves at most 500 pages, whatever it says the total is. + if (r.totalPages > 500) r.totalPages = 500; } brls::sync([done, r]() { done(r); }); }); } } // namespace +ListQuery rowQuery(Row row) +{ + ListQuery q; + q.path = rowPath(row); + q.kind = rowKind(row); + return q; +} + +ListQuery pathQuery(const std::string& path, content::Kind kind) +{ + ListQuery q; + q.path = path; + q.kind = kind == content::Kind::Series ? 1 : 0; + return q; +} + +ListQuery genreQuery(content::Kind kind, int genreId) +{ + Discover d; + d.kind = kind; + d.genre = genreId; + ListQuery q = discoverQuery(d); + // Enough votes that the row is not a page of unknowns. + q.params.push_back({ "vote_count.gte", "150" }); + return q; +} + +void fetchQueryAsync(const ListQuery& q, int page, std::function done) +{ + auto params = q.params; + if (page > 1) params.push_back({ "page", std::to_string(page) }); + bool search = q.path.rfind("/search/", 0) == 0; + fetchList(q.path, params, q.kind, search ? kSearchTtl : kListTtl, std::move(done)); +} + void fetchRowAsync(Row row, std::function done) { fetchList(rowPath(row), {}, rowKind(row), kListTtl, std::move(done)); } +void fetchPathAsync(const std::string& path, + const std::vector>& params, + content::Kind kind, std::function done) +{ + fetchList(path, params, kind == content::Kind::Series ? 1 : 0, kListTtl, std::move(done)); +} + +void fetchGenreAsync(content::Kind kind, int genreId, std::function done) +{ + bool tv = kind == content::Kind::Series; + fetchList(tv ? "/discover/tv" : "/discover/movie", + { { "with_genres", std::to_string(genreId) }, + { "sort_by", "popularity.desc" }, + // Enough votes that the row is not a page of unknowns. + { "vote_count.gte", "150" } }, + tv ? 1 : 0, kListTtl, std::move(done)); +} + void searchAsync(const std::string& query, std::function done) { fetchList("/search/multi", { { "query", query }, { "include_adult", "false" } }, diff --git a/app/tmdb.hpp b/app/tmdb.hpp index 0ded82a..5fb05d4 100644 --- a/app/tmdb.hpp +++ b/app/tmdb.hpp @@ -68,6 +68,38 @@ bool parseDetails(const std::string& json, content::Kind kind, std::vector parseSeason(const std::string& json, const std::string& imageBase); +// A list request that can be asked for page after page: a collection's +// screen, the search's results. kind as parseList takes it. +struct ListQuery +{ + std::string path; + std::vector> params; + int kind = -1; +}; + +// Discover: the catalog filtered and sorted. +struct Discover +{ + content::Kind kind = content::Kind::Movie; + int genre = 0; // TMDB genre id, 0 = any + int yearFrom = 0; // 0 = no bound + int yearTo = 0; + enum class Sort + { + Popular, + Rating, + Newest, + } sort = Sort::Popular; +}; +ListQuery discoverQuery(const Discover& d); + +// A text search. kind -1 = films and shows (TMDB's multi search, which takes +// no year); 0 / 1 = one kind, optionally of one year. +ListQuery searchQuery(const std::string& text, int kind = -1, int year = 0); + +// TMDB's genres for films / shows: id and English name (translate with tr()). +const std::vector>& genres(content::Kind kind); + // ---- the app side ----------------------------------------------------------- // From config (and the build's default key). @@ -92,7 +124,17 @@ struct ListResult bool ok = false; std::vector items; std::string error; + int page = 1; + int totalPages = 1; }; + +// The query behind one of the home rows (so its "See all" page can page on). +ListQuery rowQuery(Row row); +ListQuery pathQuery(const std::string& path, content::Kind kind); +ListQuery genreQuery(content::Kind kind, int genreId); + +// One page (1-based) of a query. +void fetchQueryAsync(const ListQuery& q, int page, std::function done); struct DetailsResult { bool ok = false; @@ -108,6 +150,12 @@ struct SeasonResult // All run on a worker thread and call back on the UI thread. void fetchRowAsync(Row row, std::function done); +// Any list endpoint of one kind ("/movie/upcoming", "/tv/on_the_air"). +void fetchPathAsync(const std::string& path, + const std::vector>& params, + content::Kind kind, std::function done); +// The most popular titles of a TMDB genre id (28 action, 18 drama...). +void fetchGenreAsync(content::Kind kind, int genreId, std::function done); void searchAsync(const std::string& query, std::function done); void fetchDetailsAsync(content::Kind kind, int id, std::function done); diff --git a/app/torrent_play.cpp b/app/torrent_play.cpp new file mode 100644 index 0000000..2a8292c --- /dev/null +++ b/app/torrent_play.cpp @@ -0,0 +1,295 @@ +#include "torrent_play.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "browse.hpp" // ListActivity, Row, naturalCompare, pickerNames +#include "i18n.hpp" +#include "release.hpp" // matchEpisode + +extern "C" { +#include "torrent.h" +} + +namespace +{ + +struct VideoFileInfo +{ + int index; + std::string name; // picker label (folder added when names repeat) + std::string path; // the path inside the torrent + int64_t length; +}; + +bool isVideoExtension(const std::string& path) +{ + static const char* kExt[] = { ".mkv", ".mp4", ".avi", ".m4v", ".mov", + ".webm", ".ts", ".m2ts", ".mpg", ".mpeg", + ".wmv", ".flv", ".ogv" }; + for (const char* e : kExt) + { + size_t n = std::strlen(e); + if (path.size() > n && + strcasecmp(path.c_str() + path.size() - n, e) == 0) + return true; + } + return false; +} + +// The video files of a loaded torrent, sorted naturally by their labels. +std::vector videosOf(const torrent_meta* m) +{ + std::vector idx; + std::vector paths; + for (int i = 0; i < m->file_count; i++) + { + if (!isVideoExtension(m->files[i].path)) continue; + idx.push_back(i); + paths.push_back(m->files[i].path); + } + std::vector names = pickerNames(paths); + std::vector out; + for (size_t k = 0; k < idx.size(); k++) + out.push_back({ idx[k], names[k], paths[k], m->files[idx[k]].length }); + std::sort(out.begin(), out.end(), [](const VideoFileInfo& a, const VideoFileInfo& b) { + return naturalCompare(a.name, b.name); + }); + return out; +} + +std::vector parseTorrentVideos(const std::string& path) +{ + std::vector out; + torrent_meta* m = (torrent_meta*)std::calloc(1, sizeof(torrent_meta)); + if (!m) return out; + char err[128] = { 0 }; + if (torrent_load(m, path.c_str(), err, sizeof(err)) == 0) + { + out = videosOf(m); + torrent_unload(m); + } + std::free(m); + return out; +} + +// The loading dialog's body. Its destruction (the dialog closed by B or by +// Cancel) marks the job cancelled, so a late result is dropped. +class LoadingBox : public brls::Box +{ + public: + std::shared_ptr cancelled; + explicit LoadingBox(std::shared_ptr c) : cancelled(std::move(c)) {} + ~LoadingBox() override + { + if (cancelled) *cancelled = true; + } +}; + +struct Job +{ + PlayHint hint; + brls::Dialog* loading = nullptr; + LoadingBox* box = nullptr; + std::shared_ptr cancelled; +}; + +void errorDialog(const std::string& msg) +{ + auto* d = new brls::Dialog(msg); + d->addButton("OK", []() {}); + d->open(); +} + +std::shared_ptr openJob(const PlayHint& hint) +{ + auto job = std::make_shared(); + job->hint = hint; + job->cancelled = std::make_shared(false); + + job->box = new LoadingBox(job->cancelled); + job->box->setAxis(brls::Axis::COLUMN); + job->box->setAlignItems(brls::AlignItems::CENTER); + job->box->setJustifyContent(brls::JustifyContent::CENTER); + job->box->setPadding(24.0f); + job->box->addView(new brls::ProgressSpinner()); + auto* lbl = new brls::Label(); + lbl->setText(tr("Preparing torrent...")); + lbl->setFontSize(20.0f); + lbl->setMarginTop(20.0f); + job->box->addView(lbl); + + job->loading = new brls::Dialog(job->box); + auto cancelled = job->cancelled; + job->loading->addButton(tr("Cancel"), [cancelled]() { *cancelled = true; }); + job->loading->setCancelable(true); + job->loading->open(); + return job; +} + +// Closes the dialog, then runs `then` (dialogs animate out before the next +// screen can be pushed). +void finish(const std::shared_ptr& job, std::function then) +{ + job->box->cancelled = nullptr; // closing is not cancelling + job->loading->close(std::move(then)); +} + +void start(const PlayHint& h, const std::string& source, const VideoFileInfo& v) +{ + WatchInfo w = h.watch; + if (w.displayTitle.empty()) w.displayTitle = h.title; + startPlaybackPrompt(source, h.art, h.title.empty() ? v.name : h.title, v.index, w, + h.sourceType); +} + +void playWithVideos(const PlayHint& h, const std::string& source, + const std::vector& vids) +{ + if (vids.empty()) + { + errorDialog(tr("This torrent has no video file to stream.")); + return; + } + if (vids.size() == 1) + { + start(h, source, vids[0]); + return; + } + // A season pack opened for one episode: find its file. + if (h.season > 0 && h.episode > 0) + { + std::vector paths; + for (const auto& v : vids) paths.push_back(v.path); + int i = release::matchEpisode(paths, h.season, h.episode); + if (i >= 0) + { + brls::Logger::info("[play] episode S{}E{} -> file {}", h.season, h.episode, + vids[i].index); + start(h, source, vids[i]); + return; + } + } + + std::vector rows; + for (const auto& v : vids) + { + Row r; + r.label = v.name; + r.detail = jackett::formatSize(v.length); + rows.push_back(r); + } + PlayHint hint = h; + brls::Application::pushActivity(new ListActivity( + h.title, tr("Pick a file"), rows, [hint, source, vids](int i) { + // A file picked by hand: its own name is the better title. + PlayHint picked = hint; + if (hint.episode <= 0) picked.title = vids[i].name; + start(picked, source, vids[i]); + })); +} + +void resolveMagnet(const std::shared_ptr& job, const std::string& magnet) +{ + brls::async([job, magnet]() { + torrent_meta* m = (torrent_meta*)std::calloc(1, sizeof(torrent_meta)); + char err[128] = { 0 }; + int rc = -1; + std::vector vids; + if (m) + { + rc = torrent_load_magnet(m, magnet.c_str(), err, sizeof(err)); + if (rc == 0) + { + vids = videosOf(m); + torrent_unload(m); + } + std::free(m); + } + std::string e = err; + brls::sync([job, magnet, vids, rc, e]() { + if (*job->cancelled) return; + finish(job, [job, magnet, vids, rc, e]() { + if (rc != 0) + { + errorDialog(tr("Could not resolve torrent metadata: ") + e); + return; + } + playWithVideos(job->hint, magnet, vids); + }); + }); + }); +} + +} // namespace + +void playMagnet(const std::string& magnet, const PlayHint& hint) +{ + resolveMagnet(openJob(hint), magnet); +} + +void playJackettItem(const jackett::Item& item, const PlayHint& hint) +{ + std::string link = item.link; + std::string magnet = (!link.empty() && link.rfind("magnet:", 0) == 0) ? link + : item.magnetUri; + bool isHttp = !link.empty() && link.rfind("magnet:", 0) != 0; + + if (!isHttp && magnet.empty()) + { + errorDialog(tr("No playable link or magnet URI found for this torrent.")); + return; + } + + PlayHint h = hint; + if (h.title.empty()) h.title = item.title; + auto job = openJob(h); + if (!isHttp) + { + resolveMagnet(job, magnet); + return; + } + + // One file per link: history entries resume from this path later, so it + // must not be overwritten by the next result played. + std::string tempPath = jackett::cachedTorrentPath(link); + jackett::downloadTorrentAsync(link, tempPath, [job, tempPath, magnet]( + bool ok, const std::string& err, + const std::string& redirected) { + if (*job->cancelled) return; + // The indexer pointed at a magnet instead of a file (common for public + // trackers): resolve that, it is the same torrent. + if (ok && !redirected.empty()) + { + resolveMagnet(job, redirected); + return; + } + if (ok) + { + auto vids = parseTorrentVideos(tempPath); + if (!vids.empty()) + { + finish(job, [job, tempPath, vids]() { + playWithVideos(job->hint, tempPath, vids); + }); + return; + } + } + // The .torrent failed or held no video: the magnet, if there is one. + if (!magnet.empty()) + { + resolveMagnet(job, magnet); + return; + } + finish(job, [err, ok]() { + errorDialog(ok ? tr("This torrent has no video file to stream.") + : tr("Could not download torrent: ") + err); + }); + }); +} diff --git a/app/torrent_play.hpp b/app/torrent_play.hpp new file mode 100644 index 0000000..0556be4 --- /dev/null +++ b/app/torrent_play.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "jackett.hpp" +#include "player.hpp" // PlayerArt, WatchInfo + +// Playing a torrent that is not open yet: a Jackett result (a link to a +// .torrent, or a magnet) or a bare magnet. A "Preparing torrent..." dialog +// (cancellable) covers the download / metadata fetch; then the file to play +// is picked: +// - the hint names an episode and one file matches it -> that file, no +// question asked (a season pack opened from the card's episode); +// - one video only -> that one; +// - otherwise the file picker. +struct PlayHint +{ + std::string title; // shown while loading, and in the picker + int season = 0; // > 0 with episode: pick that file in a pack + int episode = 0; + PlayerArt art; + WatchInfo watch; + std::string sourceType = "Jackett"; +}; + +void playJackettItem(const jackett::Item& item, const PlayHint& hint); +void playMagnet(const std::string& magnet, const PlayHint& hint); diff --git a/app/update.hpp b/app/update.hpp index 9c792f8..cd396dd 100644 --- a/app/update.hpp +++ b/app/update.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include diff --git a/app/widgets.cpp b/app/widgets.cpp index 698a225..e49c129 100644 --- a/app/widgets.cpp +++ b/app/widgets.cpp @@ -1,5 +1,6 @@ #include "widgets.hpp" +#include "i18n.hpp" #include "stremio.hpp" // fetchPosterAsync / fetchHqArtAsync: the shared art cache #include "theme.hpp" @@ -8,17 +9,22 @@ namespace ui namespace { -constexpr float kRadius = 10.0f; +constexpr float kRadius = 8.0f; void makeFocusable(brls::Box* card, std::function onClick) { card->setFocusable(true); card->setHighlightCornerRadius( kRadius + brls::Application::getStyle()["brls/highlight/stroke_width"] / 2); - card->registerClickAction([onClick](brls::View*) { - if (onClick) onClick(); - return true; - }); + // "A Open" in the hint bar, as the mock-ups have it (the stock click + // action says "OK"). A tap fires any A action, so touch still works. + card->registerAction( + tr("Open"), brls::BUTTON_A, + [onClick](brls::View*) { + if (onClick) onClick(); + return true; + }, + false, false, brls::SOUND_CLICK); card->addGestureRecognizer(new brls::TapGestureRecognizer(card)); } @@ -85,9 +91,9 @@ brls::Label* heading(const std::string& text, float inset) { auto* l = new brls::Label(); l->setText(text); - l->setFontSize(24.0f); + l->setFontSize(21.0f); l->setTextColor(theme::text()); - l->setMargins(18.0f, 0.0f, 10.0f, inset); + l->setMargins(14.0f, 0.0f, 4.0f, inset); return l; } @@ -123,13 +129,13 @@ brls::Box* posterCard(const std::string& title, const std::string& artId, card->setHeight(h); card->setCornerRadius(kRadius); card->setBackgroundColor(theme::scrim(20)); - card->setMargins(0.0f, 18.0f, 0.0f, 0.0f); + card->setMargins(0.0f, 16.0f, 0.0f, 0.0f); makeFocusable(card, std::move(onClick)); auto* img = fillImage(w, h); attachArt(img, artId, artUrl, alive); card->addView(img); - card->addView(titleBand(title, w, h * 0.42f, 17.0f)); + card->addView(titleBand(title, w, h * 0.42f, w < 170.0f ? 15.0f : 17.0f)); if (!badge.empty()) { @@ -161,13 +167,13 @@ brls::Box* wideCard(const std::string& title, const std::string& artId, card->setHeight(h); card->setCornerRadius(kRadius); card->setBackgroundColor(theme::scrim(20)); - card->setMargins(0.0f, 18.0f, 0.0f, 0.0f); + card->setMargins(0.0f, 16.0f, 0.0f, 0.0f); makeFocusable(card, std::move(onClick)); auto* img = fillImage(w, h); attachArt(img, artId, artUrl, alive, false, localPath); card->addView(img); - card->addView(titleBand(title, w, h * 0.55f, 18.0f)); + card->addView(titleBand(title, w, h * 0.55f, w < 280.0f ? 16.0f : 18.0f)); if (progress >= 0.0) { @@ -187,6 +193,50 @@ brls::Box* wideCard(const std::string& title, const std::string& artId, return card; } +brls::Box* moreTile(const std::string& text, float w, std::function onClick) +{ + auto* card = new brls::Box(); + card->setWidth(w); + card->setHeight(w * 1.5f); + card->setCornerRadius(kRadius); + card->setBackgroundColor(theme::scrim(22)); + card->setMargins(0.0f, 16.0f, 0.0f, 0.0f); + card->setAxis(brls::Axis::COLUMN); + card->setJustifyContent(brls::JustifyContent::CENTER); + card->setAlignItems(brls::AlignItems::CENTER); + makeFocusable(card, std::move(onClick)); + + auto* arrow = new brls::Label(); + arrow->setText(">"); + arrow->setFontSize(34.0f); + arrow->setTextColor(theme::textDim()); + card->addView(arrow); + auto* l = new brls::Label(); + l->setText(text); + l->setFontSize(17.0f); + l->setTextColor(theme::text()); + l->setMarginTop(8.0f); + l->setHorizontalAlign(brls::HorizontalAlign::CENTER); + card->addView(l); + return card; +} + +brls::Box* chip(const std::string& text, NVGcolor bg, NVGcolor fg) +{ + auto* c = new brls::Box(); + c->setCornerRadius(6.0f); + c->setPadding(6.0f, 10.0f, 3.0f, 10.0f); + c->setMarginRight(10.0f); + c->setBackgroundColor(bg); + auto* l = new brls::Label(); + l->setText(text); + l->setFontSize(17.0f); + l->setTextColor(fg); + l->setSingleLine(true); + c->addView(l); + return c; +} + brls::Box* chip(const std::string& text, bool strong) { auto* c = new brls::Box(); @@ -204,12 +254,21 @@ brls::Box* chip(const std::string& text, bool strong) } brls::Button* actionButton(const std::string& text, bool primary, - std::function onClick) + std::function onClick, bool onImage) { auto* b = new brls::Button(); b->setText(text); - b->setStyle(primary ? &brls::BUTTONSTYLE_PRIMARY : &brls::BUTTONSTYLE_BORDERED); - b->setMarginRight(14.0f); + // The mock-ups' pills: smaller than borealis' stock button, the main one + // in the accent, the others a translucent fill rather than an outline. + b->setStyle(primary ? &brls::BUTTONSTYLE_PRIMARY : &brls::BUTTONSTYLE_BORDERLESS); + if (!primary) + b->setBackgroundColor(onImage ? nvgRGBA(255, 255, 255, 34) : theme::scrim(26)); + b->setFontSize(17.0f); + b->setPadding(9.0f, 20.0f, 9.0f, 20.0f); + b->setCornerRadius(8.0f); + b->setHighlightCornerRadius(8.0f + + brls::Application::getStyle()["brls/highlight/stroke_width"] / 2); + b->setMarginRight(12.0f); b->registerClickAction([onClick](brls::View*) { if (onClick) onClick(); return true; @@ -217,4 +276,11 @@ brls::Button* actionButton(const std::string& text, bool primary, return b; } +void setPillActive(brls::Button* b, bool active, bool onImage) +{ + b->setStyle(active ? &brls::BUTTONSTYLE_PRIMARY : &brls::BUTTONSTYLE_BORDERLESS); + b->setBackgroundColor(active ? nvgRGBA(0, 0, 0, 0) + : (onImage ? nvgRGBA(255, 255, 255, 34) : theme::scrim(26))); +} + } // namespace ui diff --git a/app/widgets.hpp b/app/widgets.hpp index 78c8efa..547bb57 100644 --- a/app/widgets.hpp +++ b/app/widgets.hpp @@ -158,12 +158,24 @@ brls::Box* wideCard(const std::string& title, const std::string& artId, double progress, std::shared_ptr alive, std::function onClick, float w = 320.0f); +// The tile at the end of a row that opens its whole collection: a poster's +// size, the label centred on the plain card fill. +brls::Box* moreTile(const std::string& text, float w, std::function onClick); + // A small rounded label: the meta line on the card ("2024", "Drama"). // `strong` uses the accent-free green the rating chip wants. brls::Box* chip(const std::string& text, bool strong = false); +// The same with its own colours (IMDb's yellow with dark text). +brls::Box* chip(const std::string& text, NVGcolor bg, NVGcolor fg); -// A pill button in the card's action row. `primary` takes the accent. +// A pill button (the mock-ups' "▶ Watch", "Details", season chips). `primary` +// takes the accent; the others are a translucent fill -- white when they sit on +// a picture (`onImage`), the theme's scrim otherwise. brls::Button* actionButton(const std::string& text, bool primary, - std::function onClick); + std::function onClick, bool onImage = false); + +// Switches a pill between the accent (selected) and the plain fill: season +// chips, filters, the Home / Movies / Shows switch. +void setPillActive(brls::Button* b, bool active, bool onImage = false); } // namespace ui diff --git a/build-pc.bat b/build-pc.bat new file mode 100644 index 0000000..c09aec0 --- /dev/null +++ b/build-pc.bat @@ -0,0 +1,85 @@ +@echo off +setlocal +cd /d "%~dp0" + +set "BASH_EXE=C:\devkitPro\msys2\usr\bin\bash.exe" +set "MINGW_BIN=C:\devkitPro\msys2\mingw64\bin" + +if not exist "%BASH_EXE%" ( + echo [ERROR] devkitPro MSYS2 bash not found at %BASH_EXE%! + pause + exit /b 1 +) + +echo ======================================================== +echo NX-torrent-player: Building PC Desktop Version +echo ======================================================== + +if not exist build-pc mkdir build-pc + +echo. +echo [1/3] Configuring CMake... +"%BASH_EXE%" -lc "export PATH=/usr/bin:/mingw64/bin:\$PATH && export PKG_CONFIG_PATH=/d/NX-torrent-player/pc_deps/mpv:/mingw64/lib/pkgconfig && cd /d/NX-torrent-player && cmake -B build-pc -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPLATFORM_DESKTOP=ON -DCMAKE_C_COMPILER=/mingw64/bin/x86_64-w64-mingw32-gcc.exe -DCMAKE_CXX_COMPILER=/mingw64/bin/x86_64-w64-mingw32-g++.exe -DCMAKE_SYSTEM_NAME=Windows -DPKG_CONFIG_EXECUTABLE=/usr/bin/pkg-config.exe" + +if errorlevel 1 ( + echo [ERROR] CMake configure failed. + pause + exit /b %errorlevel% +) + +echo. +echo [2/3] Compiling NX-torrent-player.exe with Ninja... +"%BASH_EXE%" -lc "export PATH=/usr/bin:/mingw64/bin:\$PATH && ninja -C /d/NX-torrent-player/build-pc" + +if errorlevel 1 ( + echo [ERROR] Compilation failed. + pause + exit /b %errorlevel% +) + +echo. +echo [3/3] Syncing runtime DLLs and assets... +for %%F in ( + libbrotlicommon.dll + libbrotlidec.dll + libcrypto-3-x64.dll + libcurl-4.dll + libgcc_s_seh-1.dll + libiconv-2.dll + libidn2-0.dll + libintl-8.dll + libmbedcrypto-16.dll + libnghttp2-14.dll + libnghttp3-9.dll + libngtcp2-16.dll + libngtcp2_crypto_ossl-0.dll + libpsl-5.dll + libsharpyuv-0.dll + libssh2-1.dll + libssl-3-x64.dll + libstdc++-6.dll + libunistring-5.dll + libwebp-7.dll + libwinpthread-1.dll + libzstd.dll + zlib1.dll +) do ( + if exist "%MINGW_BIN%\%%F" ( + copy /y "%MINGW_BIN%\%%F" build-pc\ >nul + ) +) + +if exist pc_deps\mpv\libmpv-2.dll ( + copy /y pc_deps\mpv\libmpv-2.dll build-pc\ >nul +) + +if exist resources ( + xcopy /e /i /y resources build-pc\resources >nul +) + +echo. +echo ======================================================== +echo [SUCCESS] PC version built successfully! +echo Executable: build-pc\NX-torrent-player.exe +echo Run "run-pc.bat" to start testing on PC. +echo ======================================================== diff --git a/compat/arpa/inet.h b/compat/arpa/inet.h new file mode 100644 index 0000000..1e3dfcc --- /dev/null +++ b/compat/arpa/inet.h @@ -0,0 +1,10 @@ +#ifndef PC_ARPA_INET_H +#define PC_ARPA_INET_H + +#ifdef _WIN32 +#include "sys/socket.h" +#else +#include_next +#endif + +#endif diff --git a/compat/fcntl.h b/compat/fcntl.h new file mode 100644 index 0000000..0c378c4 --- /dev/null +++ b/compat/fcntl.h @@ -0,0 +1,46 @@ +#ifndef PC_FCNTL_H +#define PC_FCNTL_H + +#include_next + +#ifdef _WIN32 +#include "sys/socket.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef F_GETFL +#define F_GETFL 3 +#endif +#ifndef F_SETFL +#define F_SETFL 4 +#endif +#ifndef O_NONBLOCK +#define O_NONBLOCK 0x4000 +#endif + +static inline int pc_fcntl(int fd, int cmd, ...) { + if (cmd == F_GETFL) { + return 0; + } + if (cmd == F_SETFL) { + va_list args; + va_start(args, cmd); + int arg = va_arg(args, int); + va_end(args); + u_long mode = (arg & O_NONBLOCK) ? 1 : 0; + return ioctlsocket((SOCKET)fd, FIONBIO, &mode); + } + return -1; +} +#define fcntl pc_fcntl + +#ifdef __cplusplus +} +#endif + +#endif + +#endif diff --git a/compat/mbedtls/sha1.h b/compat/mbedtls/sha1.h new file mode 100644 index 0000000..3b3a02d --- /dev/null +++ b/compat/mbedtls/sha1.h @@ -0,0 +1,16 @@ +#ifndef COMPAT_MBEDTLS_SHA1_H +#define COMPAT_MBEDTLS_SHA1_H + +#include_next + +#ifndef mbedtls_sha1_starts_ret +#define mbedtls_sha1_starts_ret mbedtls_sha1_starts +#endif +#ifndef mbedtls_sha1_update_ret +#define mbedtls_sha1_update_ret mbedtls_sha1_update +#endif +#ifndef mbedtls_sha1_finish_ret +#define mbedtls_sha1_finish_ret mbedtls_sha1_finish +#endif + +#endif diff --git a/compat/netdb.h b/compat/netdb.h new file mode 100644 index 0000000..c58f35a --- /dev/null +++ b/compat/netdb.h @@ -0,0 +1,10 @@ +#ifndef PC_NETDB_H +#define PC_NETDB_H + +#ifdef _WIN32 +#include "sys/socket.h" +#else +#include_next +#endif + +#endif diff --git a/compat/netinet/in.h b/compat/netinet/in.h new file mode 100644 index 0000000..9dcccf2 --- /dev/null +++ b/compat/netinet/in.h @@ -0,0 +1,10 @@ +#ifndef PC_NETINET_IN_H +#define PC_NETINET_IN_H + +#ifdef _WIN32 +#include "sys/socket.h" +#else +#include_next +#endif + +#endif diff --git a/compat/netinet/tcp.h b/compat/netinet/tcp.h new file mode 100644 index 0000000..a9bf42a --- /dev/null +++ b/compat/netinet/tcp.h @@ -0,0 +1,13 @@ +#ifndef PC_NETINET_TCP_H +#define PC_NETINET_TCP_H + +#ifdef _WIN32 +#include "sys/socket.h" +#ifndef TCP_NODELAY +#define TCP_NODELAY 0x0001 +#endif +#else +#include_next +#endif + +#endif diff --git a/compat/poll.h b/compat/poll.h new file mode 100644 index 0000000..806c3d5 --- /dev/null +++ b/compat/poll.h @@ -0,0 +1,23 @@ +#ifndef PC_POLL_H +#define PC_POLL_H + +#ifdef _WIN32 +#include "sys/socket.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef unsigned long nfds_t; + +#define poll(fds, nfds, timeout) WSAPoll((LPWSAPOLLFD)(fds), (ULONG)(nfds), (INT)(timeout)) + +#ifdef __cplusplus +} +#endif + +#else +#include_next +#endif + +#endif diff --git a/compat/switch.h b/compat/switch.h new file mode 100644 index 0000000..6675a21 --- /dev/null +++ b/compat/switch.h @@ -0,0 +1,177 @@ +#ifndef PC_SWITCH_COMPAT_H +#define PC_SWITCH_COMPAT_H + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; +typedef int8_t s8; +typedef int16_t s16; +typedef int32_t s32; +typedef int64_t s64; +typedef u32 Result; + +#ifndef R_FAILED +#define R_FAILED(res) ((res) != 0) +#endif +#ifndef R_SUCCEEDED +#define R_SUCCEEDED(res) ((res) == 0) +#endif + +static inline void consoleInit(void* c) { (void)c; } +static inline void consoleExit(void* c) { (void)c; } +static inline void consoleUpdate(void* c) { (void)c; } + +// Windows headers define ABSOLUTE and RELATIVE which conflict with borealis enum +#ifdef ABSOLUTE +#undef ABSOLUTE +#endif +#ifdef RELATIVE +#undef RELATIVE +#endif + +// Mutex +typedef struct { pthread_mutex_t m; int init; } Mutex; + +static inline void mutexInit(Mutex *mtx) { + pthread_mutex_init(&mtx->m, NULL); + mtx->init = 1; +} +static inline void mutexLock(Mutex *mtx) { + if (!mtx->init) mutexInit(mtx); + pthread_mutex_lock(&mtx->m); +} +static inline void mutexUnlock(Mutex *mtx) { + pthread_mutex_unlock(&mtx->m); +} + +// Condition variable +typedef struct { pthread_cond_t c; int init; } CondVar; + +static inline void condvarInit(CondVar *cv) { + pthread_cond_init(&cv->c, NULL); + cv->init = 1; +} +static inline void condvarWakeAll(CondVar *cv) { + if (!cv->init) condvarInit(cv); + pthread_cond_broadcast(&cv->c); +} +static inline void condvarWakeOne(CondVar *cv) { + if (!cv->init) condvarInit(cv); + pthread_cond_signal(&cv->c); +} +static inline Result condvarWaitTimeout(CondVar *cv, Mutex *mtx, u64 timeout_ns) { + if (!cv->init) condvarInit(cv); + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_sec += (time_t)(timeout_ns / 1000000000ULL); + ts.tv_nsec += (long)(timeout_ns % 1000000000ULL); + if (ts.tv_nsec >= 1000000000L) { ts.tv_sec++; ts.tv_nsec -= 1000000000L; } + return (Result)pthread_cond_timedwait(&cv->c, &mtx->m, &ts); +} + +// Thread +typedef void (*ThreadFunc)(void *); +typedef struct { + pthread_t pt; + ThreadFunc entry; + void *arg; + u32 handle; +} Thread; + +static inline void *pc_thread_trampoline(void *p) { + Thread *t = (Thread *)p; + t->entry(t->arg); + return NULL; +} + +static inline Result threadCreate(Thread *t, ThreadFunc entry, void *arg, + void *stack, size_t stack_sz, int prio, int cpu) { + (void)stack; (void)stack_sz; (void)prio; (void)cpu; + t->entry = entry; + t->arg = arg; + t->handle = 1; + return 0; +} + +static inline Result threadStart(Thread *t) { + return (Result)pthread_create(&t->pt, NULL, pc_thread_trampoline, t); +} + +static inline Result threadWaitForExit(Thread *t) { + pthread_join(t->pt, NULL); + return 0; +} + +static inline Result threadClose(Thread *t) { + t->handle = 0; + return 0; +} + +// Timing +static inline u64 armGetSystemTick(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (u64)ts.tv_sec * 1000000000ULL + (u64)ts.tv_nsec; +} +static inline u64 armGetSystemTickFreq(void) { return 1000000000ULL; } +static inline u64 armNsToTicks(u64 ns) { return ns; } +static inline u64 armTicksToNs(u64 t) { return t; } + +static inline void svcSleepThread(u64 ns) { + struct timespec ts = { (time_t)(ns / 1000000000ULL), + (long)(ns % 1000000000ULL) }; + nanosleep(&ts, NULL); +} + +static inline u32 svcGetCurrentProcessorNumber(void) { return 0; } + +static inline void randomGet(void *buf, size_t len) { + static int seeded = 0; + if (!seeded) { srand((unsigned)(armGetSystemTick() ^ (u64)(uintptr_t)&seeded)); seeded = 1; } + u8 *b = (u8 *)buf; + for (size_t i = 0; i < len; i++) b[i] = (u8)(rand() & 0xFF); +} + +// Applet & libnx stubs +typedef enum { + AppletType_None = 0, + AppletType_Application = 1, + AppletType_SystemApplication = 2, +} AppletType; + +typedef enum { + AppletOperationMode_Handheld = 0, + AppletOperationMode_Console = 1, +} AppletOperationMode; + +typedef enum { + AppletFocusState_InFocus = 1, + AppletFocusState_OutOfFocus = 2, +} AppletFocusState; + +static inline AppletType appletGetAppletType(void) { return AppletType_Application; } +static inline AppletOperationMode appletGetOperationMode(void) { return AppletOperationMode_Console; } +static inline AppletFocusState appletGetFocusState(void) { return AppletFocusState_InFocus; } +static inline void appletSetMediaPlaybackState(bool state) { (void)state; } +static inline void romfsExit(void) {} +static inline void envSetNextLoad(const char *nro, const char *arg) { (void)nro; (void)arg; } +static inline bool envHasNextLoad(void) { return false; } + +#ifdef __cplusplus +} +#endif + +#endif // PC_SWITCH_COMPAT_H diff --git a/compat/sys/poll.h b/compat/sys/poll.h new file mode 100644 index 0000000..d5af8ac --- /dev/null +++ b/compat/sys/poll.h @@ -0,0 +1,6 @@ +#ifndef PC_SYS_POLL_H +#define PC_SYS_POLL_H + +#include "poll.h" + +#endif diff --git a/compat/sys/select.h b/compat/sys/select.h new file mode 100644 index 0000000..a95f08c --- /dev/null +++ b/compat/sys/select.h @@ -0,0 +1,10 @@ +#ifndef PC_SYS_SELECT_H +#define PC_SYS_SELECT_H + +#ifdef _WIN32 +#include "sys/socket.h" +#else +#include_next +#endif + +#endif diff --git a/compat/sys/socket.h b/compat/sys/socket.h new file mode 100644 index 0000000..93e94da --- /dev/null +++ b/compat/sys/socket.h @@ -0,0 +1,172 @@ +#ifndef PC_SYS_SOCKET_H +#define PC_SYS_SOCKET_H + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef SHUT_RDWR +#define SHUT_RDWR SD_BOTH +#endif +#ifndef SHUT_RD +#define SHUT_RD SD_RECEIVE +#endif +#ifndef SHUT_WR +#define SHUT_WR SD_SEND +#endif + +#ifndef EWOULDBLOCK +#define EWOULDBLOCK WSAEWOULDBLOCK +#endif +#ifndef EINPROGRESS +#define EINPROGRESS WSAEINPROGRESS +#endif + +static inline void pc_winsock_init(void) { + static int init = 0; + if (!init) { + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); + init = 1; + } +} + +static inline int pc_close(int fd) { + if (fd < 0) return -1; + int optval = 0; + int optlen = sizeof(optval); + if (getsockopt((SOCKET)fd, SOL_SOCKET, SO_TYPE, (char*)&optval, &optlen) == 0) { + return closesocket((SOCKET)fd); + } + return _close(fd); +} +#define close(fd) pc_close(fd) + +static inline int pc_socket(int af, int type, int protocol) { + pc_winsock_init(); + SOCKET s = socket(af, type, protocol); + if (s == INVALID_SOCKET) { + errno = WSAGetLastError(); + return -1; + } + return (int)s; +} +#define socket pc_socket + +static inline int pc_connect(int s, const struct sockaddr *name, int namelen) { + int r = connect((SOCKET)s, name, namelen); + if (r < 0) { + int err = WSAGetLastError(); + if (err == WSAEWOULDBLOCK || err == WSAEINPROGRESS) { + errno = EINPROGRESS; + } else { + errno = err; + } + } + return r; +} +#define connect pc_connect + +static inline int pc_bind(int s, const struct sockaddr *name, int namelen) { + int r = bind((SOCKET)s, name, namelen); + if (r < 0) errno = WSAGetLastError(); + return r; +} +#define bind pc_bind + +static inline int pc_listen(int s, int backlog) { + int r = listen((SOCKET)s, backlog); + if (r < 0) errno = WSAGetLastError(); + return r; +} +#define listen pc_listen + +static inline int pc_accept(int s, struct sockaddr *addr, int *addrlen) { + SOCKET res = accept((SOCKET)s, addr, addrlen); + if (res == INVALID_SOCKET) { + int err = WSAGetLastError(); + if (err == WSAEWOULDBLOCK) errno = EWOULDBLOCK; + else errno = err; + return -1; + } + return (int)res; +} +#define accept pc_accept + +static inline int pc_recv(int s, void *buf, int len, int flags) { + int r = recv((SOCKET)s, (char*)buf, len, flags); + if (r < 0) { + int err = WSAGetLastError(); + if (err == WSAEWOULDBLOCK) errno = EWOULDBLOCK; + else if (err == WSAEINTR) errno = EINTR; + else errno = err; + } + return r; +} +#define recv pc_recv + +static inline int pc_send(int s, const void *buf, int len, int flags) { + int r = send((SOCKET)s, (const char*)buf, len, flags); + if (r < 0) { + int err = WSAGetLastError(); + if (err == WSAEWOULDBLOCK) errno = EWOULDBLOCK; + else if (err == WSAEINTR) errno = EINTR; + else errno = err; + } + return r; +} +#define send pc_send + +static inline int pc_recvfrom(int s, void *buf, int len, int flags, struct sockaddr *from, int *fromlen) { + int r = recvfrom((SOCKET)s, (char*)buf, len, flags, from, fromlen); + if (r < 0) { + int err = WSAGetLastError(); + if (err == WSAEWOULDBLOCK) errno = EWOULDBLOCK; + else if (err == WSAEINTR) errno = EINTR; + else errno = err; + } + return r; +} +#define recvfrom(s, buf, len, flags, from, fromlen) pc_recvfrom((int)(s), (void*)(buf), (int)(len), (int)(flags), (struct sockaddr*)(from), (int*)(fromlen)) + +static inline int pc_sendto(int s, const void *buf, int len, int flags, const struct sockaddr *to, int tolen) { + int r = sendto((SOCKET)s, (const char*)buf, len, flags, to, tolen); + if (r < 0) { + int err = WSAGetLastError(); + if (err == WSAEWOULDBLOCK) errno = EWOULDBLOCK; + else if (err == WSAEINTR) errno = EINTR; + else errno = err; + } + return r; +} +#define sendto(s, buf, len, flags, to, tolen) pc_sendto((int)(s), (const void*)(buf), (int)(len), (int)(flags), (const struct sockaddr*)(to), (int)(tolen)) + +static inline int pc_setsockopt(int s, int level, int optname, const void *optval, int optlen) { + return setsockopt((SOCKET)s, level, optname, (const char*)optval, optlen); +} +#define setsockopt(s, level, optname, optval, optlen) pc_setsockopt((int)(s), (int)(level), (int)(optname), (const void*)(optval), (int)(optlen)) + +static inline int pc_getsockopt(int s, int level, int optname, void *optval, int *optlen) { + return getsockopt((SOCKET)s, level, optname, (char*)optval, optlen); +} +#define getsockopt(s, level, optname, optval, optlen) pc_getsockopt((int)(s), (int)(level), (int)(optname), (void*)(optval), (int*)(optlen)) + +#ifdef __cplusplus +} +#endif + +#else +#include_next +#endif + +#endif // PC_SYS_SOCKET_H diff --git a/compat/sys/stat.h b/compat/sys/stat.h new file mode 100644 index 0000000..05b63ea --- /dev/null +++ b/compat/sys/stat.h @@ -0,0 +1,16 @@ +#ifndef PC_SYS_STAT_H +#define PC_SYS_STAT_H + +#include_next + +#ifdef _WIN32 +#include +#include + +#ifndef mkdir +#define mkdir(path, mode) _mkdir(path) +#endif + +#endif + +#endif diff --git a/compat/time.h b/compat/time.h new file mode 100644 index 0000000..d98b1d1 --- /dev/null +++ b/compat/time.h @@ -0,0 +1,24 @@ +#ifndef COMPAT_TIME_H +#define COMPAT_TIME_H + +#include_next + +#ifdef _WIN32 +#ifndef gmtime_r +static inline struct tm* pc_gmtime_r(const time_t *t, struct tm *r) { + gmtime_s(r, t); + return r; +} +#define gmtime_r pc_gmtime_r +#endif + +#ifndef localtime_r +static inline struct tm* pc_localtime_r(const time_t *t, struct tm *r) { + localtime_s(r, t); + return r; +} +#define localtime_r pc_localtime_r +#endif +#endif + +#endif diff --git a/compat/win32_inet_ntop.h b/compat/win32_inet_ntop.h new file mode 100644 index 0000000..039699b --- /dev/null +++ b/compat/win32_inet_ntop.h @@ -0,0 +1,6 @@ +#ifndef WIN32_INET_NTOP_H +#define WIN32_INET_NTOP_H + +#include + +#endif diff --git a/engine/dht.c b/engine/dht.c index 61522bf..c95819e 100644 --- a/engine/dht.c +++ b/engine/dht.c @@ -46,8 +46,9 @@ THE SOFTWARE. #include #include #else -#include -#define WINVER WindowsXP +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif #include #endif @@ -83,7 +84,9 @@ random(void) { return rand(); } +#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0600 extern const char *inet_ntop(int, const void *, char *, socklen_t); +#endif #else diff --git a/library/borealis/VENDORED.md b/library/borealis/VENDORED.md index 64d308c..ddf933b 100644 --- a/library/borealis/VENDORED.md +++ b/library/borealis/VENDORED.md @@ -112,6 +112,16 @@ invisible however they are sized, since they then match what they travel over. Both patches are marked with a `LOCAL PATCH` comment in the source. +### 6. `resources/i18n/ru/hints.json`, `resources/i18n/fr/hints.json` — added + +The footer's own words ("Back", "Exit", "OK") are looked up as `hints/back` +etc. -- i.e. in a `hints.json` (the key's first part is the file name). +Upstream's `ru/` and `fr/` only carry an older `brls.json` (keys under +`brls/hints/...`), which nothing looks up, so a Russian UI fell back to English. +These two files are the same keys as `en-US/hints.json`, translated. The app +picks the locale itself (`Platform::APP_LOCALE_DEFAULT`, set in `main()` from +its own language setting). + ## Updating Re-cloning upstream **drops the patch** — re-apply it, and check this file's diff --git a/library/borealis/resources/i18n/fr/hints.json b/library/borealis/resources/i18n/fr/hints.json new file mode 100644 index 0000000..723d3c0 --- /dev/null +++ b/library/borealis/resources/i18n/fr/hints.json @@ -0,0 +1,14 @@ +{ + "ok": "OK", + "cancel": "Annuler", + "back": "Retour", + "exit": "Quitter", + "exit_hint": "L'application va se fermer", + "open": "Ouvrir", + "on": "Activé", + "off": "Désactivé", + "delete": "Supprimer", + "save": "Enregistrer", + "input": "Saisissez...", + "submit": "Valider" +} diff --git a/library/borealis/resources/i18n/ru/hints.json b/library/borealis/resources/i18n/ru/hints.json new file mode 100644 index 0000000..9ceabd0 --- /dev/null +++ b/library/borealis/resources/i18n/ru/hints.json @@ -0,0 +1,14 @@ +{ + "ok": "OK", + "cancel": "Отмена", + "back": "Назад", + "exit": "Выход", + "exit_hint": "Приложение будет закрыто", + "open": "Открыть", + "on": "Вкл.", + "off": "Выкл.", + "delete": "Удалить", + "save": "Сохранить", + "input": "Введите...", + "submit": "Готово" +} diff --git a/pctest/run_tests.sh b/pctest/run_tests.sh index bc25c77..6d60693 100755 --- a/pctest/run_tests.sh +++ b/pctest/run_tests.sh @@ -4,6 +4,8 @@ # test_httpsrc -- HTTP range reads for https playback (pctest/test_httpsrc.c) # test_net -- TorrServer URLs and the proxy bypass (pctest/test_net.cpp) # test_tmdb -- TMDB answers and the JSON helpers (pctest/test_tmdb.cpp) +# test_release -- release names, ranking, episode files (pctest/test_release.cpp) +# test_library -- the library's JSON, Continue, import merge (pctest/test_library.cpp) # # Needs a C compiler and libcurl headers (Debian/Ubuntu: build-essential # libcurl4-openssl-dev; MSYS2/Cygwin: gcc libcurl-devel). No mbedtls: SHA-1 @@ -49,6 +51,19 @@ ${CXX:-g++} -O1 -g -Wall -std=c++17 \ pctest/test_tmdb.cpp \ -o "$out/test_tmdb" +${CXX:-g++} -O1 -g -Wall -std=c++17 \ + -Iapp \ + app/release.cpp \ + pctest/test_release.cpp \ + -o "$out/test_release" + +${CXX:-g++} -O1 -g -Wall -std=c++17 \ + -DLIBRARY_NO_UI -Iapp \ + app/library.cpp \ + app/json.cpp \ + pctest/test_library.cpp \ + -o "$out/test_library" + "$out/test_suite" echo "$out/test_httpsrc" @@ -56,3 +71,7 @@ echo "$out/test_net" echo "$out/test_tmdb" +echo +"$out/test_release" +echo +"$out/test_library" diff --git a/pctest/test_library.cpp b/pctest/test_library.cpp new file mode 100644 index 0000000..47cebdd --- /dev/null +++ b/pctest/test_library.cpp @@ -0,0 +1,166 @@ +// Offline tests for the library model (app/library.cpp built with +// LIBRARY_NO_UI): the JSON round trip, what "Continue" offers, and the merge +// an import does. + +#include +#include +#include + +#include "library.hpp" + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, " FAILED %s:%d: %s\n", __FILE__, \ + __LINE__, #cond); \ + std::exit(1); \ + } \ + } while (0) + +using library::Entry; +using library::Next; +using library::Status; + +static Entry film(int id, const std::string& title) +{ + Entry e; + e.item.kind = content::Kind::Movie; + e.item.tmdbId = id; + e.item.title = title; + return e; +} + +static Entry show(int id, const std::string& title) +{ + Entry e = film(id, title); + e.item.kind = content::Kind::Series; + return e; +} + +static void test_roundtrip() +{ + std::printf("[1/4] JSON round trip, with awkward titles...\n"); + Entry a = film(693134, "Дюна: Часть \"вторая\"\tx"); + a.item.imdbId = "tt15239678"; + a.item.year = "2024"; + a.item.posterUrl = "https://image.tmdb.org/t/p/w185/p.jpg"; + a.item.rating = 8.1; + a.status = Status::Planned; + a.changed = 1700000000; + a.pos = 1234.5; + a.dur = 9960; + a.source = "sdmc:/switch/NX-torrent-player/jackett/0123.torrent"; + a.fileIndex = 2; + a.sourceType = "Jackett"; + + Entry b = show(220102, "Слово пацана"); + b.status = Status::Watching; + b.lastSeason = 1; + b.lastEpisode = 5; + b.episodes[library::episodeKey(1, 4)] = { 3100, 3100, true, 1700000100 }; + b.episodes[library::episodeKey(1, 5)] = { 900, 3200, false, 1700000200 }; + b.packTitle = "Slovo.patsana.S01.1080p"; + b.packMagnet = "magnet:?xt=urn:btih:abc"; + b.packSeason = 1; + b.packSeasonTo = 1; + + auto back = library::fromJson(library::toJson({ a, b })); + CHECK(back.size() == 2); + const Entry& x = back[0]; + CHECK(x.item.title == "Дюна: Часть \"вторая\" x"); // the tab became a space + CHECK(x.item.imdbId == "tt15239678" && x.item.year == "2024"); + CHECK(x.item.posterUrl == a.item.posterUrl); + CHECK(x.item.rating > 8.0 && x.item.rating < 8.2); + CHECK(!x.item.isSeries()); + CHECK(x.status == Status::Planned && x.changed == 1700000000); + CHECK(x.pos > 1234 && x.pos < 1235 && x.dur > 9959); + CHECK(x.source == a.source && x.fileIndex == 2 && x.sourceType == "Jackett"); + CHECK(!x.done); // not confused by the episodes' "done" + + const Entry& y = back[1]; + CHECK(y.item.isSeries() && y.status == Status::Watching); + CHECK(y.lastSeason == 1 && y.lastEpisode == 5); + CHECK(y.episodes.size() == 2); + CHECK(y.episode(1, 4) && y.episode(1, 4)->done); + CHECK(y.episode(1, 5) && !y.episode(1, 5)->done && y.episode(1, 5)->pos > 899); + CHECK(y.packMagnet == b.packMagnet && y.packSeason == 1); + CHECK(library::fromJson("not json").empty()); + std::printf(" PASSED\n"); +} + +static void test_next() +{ + std::printf("[2/4] What Continue offers...\n"); + Entry f = film(1, "F"); + CHECK(library::nextOf(f).kind == Next::Kind::None); // never played + f.pos = 600; + f.dur = 6000; + Next n = library::nextOf(f); + CHECK(n.kind == Next::Kind::Resume && n.progress > 0.09 && n.progress < 0.11); + f.pos = 5800; // in the credits + CHECK(library::nextOf(f).kind == Next::Kind::None); + f.pos = 600; + f.status = Status::Dropped; + CHECK(library::nextOf(f).kind == Next::Kind::None); + + Entry s = show(2, "S"); + s.lastSeason = 2; + s.lastEpisode = 3; + s.episodes[library::episodeKey(2, 3)] = { 1200, 2400, false, 1 }; + n = library::nextOf(s); + CHECK(n.kind == Next::Kind::Resume && n.season == 2 && n.episode == 3); + s.episodes[library::episodeKey(2, 3)] = { 2400, 2400, true, 2 }; + n = library::nextOf(s); + CHECK(n.kind == Next::Kind::NextEp && n.season == 2 && n.episode == 4); + s.status = Status::Completed; + CHECK(library::nextOf(s).kind == Next::Kind::None); + std::printf(" PASSED\n"); +} + +static void test_merge() +{ + std::printf("[3/4] Import merge: new titles added, newer state wins...\n"); + Entry mine = show(10, "Mine"); + mine.status = Status::Watching; + mine.changed = 100; + mine.watchedAt = 500; + mine.episodes[library::episodeKey(1, 1)] = { 10, 20, true, 500 }; + + Entry theirs = show(10, "Mine"); + theirs.status = Status::Completed; + theirs.changed = 200; // newer status + theirs.watchedAt = 400; // older progress + theirs.episodes[library::episodeKey(1, 2)] = { 5, 20, false, 400 }; + + std::vector into = { mine }; + int added = library::merge(into, { theirs, film(11, "New") }); + CHECK(added == 1); + CHECK(into.size() == 2); + CHECK(into[0].status == Status::Completed); // newer status + CHECK(into[0].watchedAt == 500); // my newer progress kept + CHECK(into[0].episodes.size() == 2); // episodes from both + CHECK(into[1].item.title == "New"); + std::printf(" PASSED\n"); +} + +static void test_keys() +{ + std::printf("[4/4] Keys...\n"); + CHECK(film(5, "x").key() == "tmdb_movie_5"); + CHECK(show(5, "x").key() == "tmdb_tv_5"); + CHECK(library::episodeKey(1, 12) == "1:12"); + std::printf(" PASSED\n"); +} + +int main() +{ + std::printf("=========================================\n"); + std::printf(" NX TORRENT PLAYER: LIBRARY \n"); + std::printf("=========================================\n\n"); + test_roundtrip(); + test_next(); + test_merge(); + test_keys(); + std::printf("\nALL TESTS PASSED\n"); + return 0; +} diff --git a/pctest/test_release.cpp b/pctest/test_release.cpp new file mode 100644 index 0000000..fedc89e --- /dev/null +++ b/pctest/test_release.cpp @@ -0,0 +1,189 @@ +// Offline tests for app/release.cpp: release names as Russian and English +// trackers actually write them, the recommended order, and finding an episode +// inside a season pack. + +#include +#include +#include +#include + +#include "release.hpp" + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, " FAILED %s:%d: %s\n", __FILE__, \ + __LINE__, #cond); \ + std::exit(1); \ + } \ + } while (0) + +static bool hasVoice(const release::Info& in, const std::string& v) +{ + for (const auto& x : in.voices) + if (x == v) return true; + return false; +} + +static void test_scene_names() +{ + std::printf("[1/6] Scene names: quality, source, codec, HDR, scope, group...\n"); + auto a = release::parse("Slovo.patsana.S01.1080p.WEB-DL.H264-NTb"); + CHECK(a.height == 1080 && a.source == "WEB-DL" && a.codec == "H.264"); + CHECK(a.season == 1 && a.episode == -1 && a.fullSeason); + CHECK(a.group == "NTb"); + + auto b = release::parse("Slovo.patsana.S01E06.720p.WEBRip.x264"); + CHECK(b.height == 720 && b.source == "WEBRip" && b.codec == "H.264"); + CHECK(b.season == 1 && b.episode == 6 && b.episodeTo == 6 && !b.fullSeason); + + auto c = release::parse("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD-FGT"); + CHECK(c.height == 2160 && c.source == "Remux" && c.codec == "HEVC"); + CHECK(c.dolbyVision && c.hdr); + CHECK(c.season == -1 && c.episode == -1); // 2024 is a year, not a season + + auto d = release::parse("The.Show.S02E01-E08.1080p.10bit.x265"); + CHECK(d.season == 2 && d.episode == 1 && d.episodeTo == 8 && d.tenBit); + + auto e = release::parse("Show.S01-S03.Complete.720p"); + CHECK(e.season == 1 && e.seasonTo == 3 && e.fullSeason); + + auto f = release::parse("show.1x06.hdtv.xvid"); + CHECK(f.season == 1 && f.episode == 6 && f.source == "HDTV" && f.codec == "XviD"); + + auto g = release::parse("Movie.2023.1920x1080.WEB-DL"); + CHECK(g.height == 1080 && g.season == -1 && g.episode == -1); + std::printf(" PASSED\n"); +} + +static void test_russian_names() +{ + std::printf("[2/6] Russian tracker titles: seasons, episodes, voices...\n"); + auto a = release::parse("Слово пацана. Кровь на асфальте [01x01-08 из 08] (2023) " + "WEB-DL 1080p | Оригинал"); + CHECK(a.height == 1080 && a.source == "WEB-DL"); + CHECK(a.season == 1); + + auto b = release::parse("Фоллаут / Fallout [Сезон: 1, Серии: 1-8 из 8] (2024) WEB-DL " + "1080p | LostFilm, HDRezka Studio"); + CHECK(b.season == 1 && b.episode == 1 && b.episodeTo == 8 && b.fullSeason); + CHECK(hasVoice(b, "LostFilm") && hasVoice(b, "HDRezka")); + CHECK(!b.hdr); // "HDRezka" is not HDR + + auto c = release::parse("Дюна: Часть вторая / Dune: Part Two (2024) BDRip 1080p | " + "Дублированный"); + CHECK(c.height == 1080 && c.source == "BDRip"); + CHECK(hasVoice(c, "Дубляж")); + CHECK(c.season == -1); + + auto d = release::parse("Сериал (3 сезон, 5 серия) WEBRip 720p | MVO (Кубик в Кубе)"); + CHECK(d.season == 3 && d.episode == 5 && !d.fullSeason); + CHECK(hasVoice(d, "MVO") && hasVoice(d, "Кубик в Кубе")); + + auto e = release::parse("ШОУ (СЕЗОНЫ 1-2) HDTVRip | Профессиональный (многоголосый)"); + CHECK(e.season == 1 && e.seasonTo == 2 && e.fullSeason && hasVoice(e, "MVO")); + + auto f = release::parse("Новый фильм (2024) CAMRip"); + CHECK(f.cam); + std::printf(" PASSED\n"); +} + +static void test_tags() +{ + std::printf("[3/6] Tags for the UI...\n"); + auto t = release::tags(release::parse("Show.S01.2160p.WEB-DL.HDR.HEVC.10bit | LostFilm")); + CHECK(t.size() == 4); + CHECK(t[0] == "WEB-DL" && t[1] == "HEVC 10-bit" && t[2] == "HDR" && t[3] == "LostFilm"); + std::printf(" PASSED\n"); +} + +static void test_order() +{ + std::printf("[4/6] Recommended order: 1080p H.264 over 4K HEVC HDR, seeds, scope...\n"); + release::Want film; + int hd = release::score(release::parse("Film.2024.1080p.WEB-DL.H264"), 200, 6LL << 30, film); + int uhd = release::score(release::parse("Film.2024.2160p.WEB-DL.HDR.HEVC.10bit"), 200, + 20LL << 30, film); + int remux = release::score(release::parse("Film.2024.1080p.BluRay.REMUX.AVC"), 200, + 35LL << 30, film); + int dead = release::score(release::parse("Film.2024.1080p.WEB-DL.H264"), 0, 6LL << 30, film); + int cam = release::score(release::parse("Film.2024.CAMRip"), 900, 1LL << 30, film); + std::printf(" 1080p %d, 4K HDR %d, remux %d, dead %d, cam %d\n", hd, uhd, remux, dead, + cam); + CHECK(hd > uhd); + CHECK(hd > remux); + CHECK(hd > dead); + CHECK(cam < 0); + + release::Want ep; + ep.series = true; + ep.season = 1; + ep.episode = 6; + CHECK(release::score(release::parse("Show.S02.1080p"), 50, 0, ep) < 0); // other season + CHECK(release::score(release::parse("Show.S01E05.1080p"), 50, 0, ep) < 0); // other episode + CHECK(release::score(release::parse("Show.S01E01-E08.1080p"), 50, 0, ep) > 0); + CHECK(release::score(release::parse("Show.S01.1080p"), 50, 0, ep) > 0); // the season + CHECK(release::score(release::parse("Show.S01-S03.1080p"), 50, 0, ep) > 0); + // The voice picked last time wins over an otherwise equal release. + release::Want again = ep; + again.voice = "LostFilm"; + CHECK(release::score(release::parse("Show.S01.1080p | LostFilm"), 50, 0, again) > + release::score(release::parse("Show.S01.1080p | HDRezka"), 50, 0, again)); + std::printf(" PASSED\n"); +} + +static void test_match_episode() +{ + std::printf("[5/6] Finding an episode in a season pack...\n"); + std::vector pack = { + "Slovo.patsana.S01.1080p/Slovo.patsana.S01E01.1080p.mkv", + "Slovo.patsana.S01.1080p/Slovo.patsana.S01E02.1080p.mkv", + "Slovo.patsana.S01.1080p/Slovo.patsana.S01E06.1080p.mkv", + "Slovo.patsana.S01.1080p/Sample/sample.mkv", + }; + CHECK(release::matchEpisode(pack, 1, 6) == 2); + CHECK(release::matchEpisode(pack, 1, 3) == -1); + CHECK(release::matchEpisode(pack, 2, 6) == -1); + + std::vector numbered = { "Сериал/01. Пилот.mkv", "Сериал/02. Вторая.mkv", + "Сериал/10. Десятая.mkv" }; + CHECK(release::matchEpisode(numbered, 1, 2) == 1); + CHECK(release::matchEpisode(numbered, 1, 10) == 2); + + std::vector seasons = { "Season 1/E05.mkv", "Season 2/E05.mkv" }; + CHECK(release::matchEpisode(seasons, 2, 5) == 1); + + std::vector russian = { "Сезон 1/Серия 1.avi", "Сезон 1/Серия 2.avi" }; + CHECK(release::matchEpisode(russian, 1, 2) == 1); + + std::vector ambiguous = { "a/E05.mkv", "b/E05.mkv" }; + CHECK(release::matchEpisode(ambiguous, 1, 5) == -1); + std::printf(" PASSED\n"); +} + +static void test_edges() +{ + std::printf("[6/6] Edge cases: empty, years, resolutions...\n"); + auto a = release::parse(""); + CHECK(a.height == 0 && a.season == -1 && a.voices.empty()); + auto b = release::parse("Blade Runner 2049 (2017) 1080p"); + CHECK(b.season == -1 && b.episode == -1 && b.height == 1080); + auto c = release::parse("S1E1"); + CHECK(c.season == 1 && c.episode == 1); + std::printf(" PASSED\n"); +} + +int main() +{ + std::printf("=========================================\n"); + std::printf(" NX TORRENT PLAYER: RELEASE NAMES \n"); + std::printf("=========================================\n\n"); + test_scene_names(); + test_russian_names(); + test_tags(); + test_order(); + test_match_episode(); + test_edges(); + std::printf("\nALL TESTS PASSED\n"); + return 0; +} diff --git a/pctest/test_tmdb.cpp b/pctest/test_tmdb.cpp index bdcb2d6..b76a4a4 100644 --- a/pctest/test_tmdb.cpp +++ b/pctest/test_tmdb.cpp @@ -90,7 +90,7 @@ static const char* kSeason = R"J({"_id":"x","air_date":"2023-11-09","episodes":[ static void test_json_helpers() { - std::printf("[1/6] json::topLevel and json::object...\n"); + std::printf("[1/7] json::topLevel and json::object...\n"); std::string top = json::topLevel(kTv); CHECK(json::str(kTv, "name") == "Жора Крыжовников"); // the naive read CHECK(json::str(top, "name") == "Слово пацана. Кровь на асфальте"); @@ -108,7 +108,7 @@ static void test_json_helpers() static void test_urls() { - std::printf("[2/6] Request URLs, keys, languages, cache keys...\n"); + std::printf("[2/7] Request URLs, keys, languages, cache keys...\n"); tmdb::Settings s; s.apiKey = "0123456789abcdef0123456789abcdef"; s.language = tmdb::languageFor("ru"); @@ -147,7 +147,7 @@ static void test_urls() static void test_list() { - std::printf("[3/6] A trending list: films and series, people skipped...\n"); + std::printf("[3/7] A trending list: films and series, people skipped...\n"); auto items = tmdb::parseList(kTrending, IMG, -1); CHECK(items.size() == 2); CHECK(items[0].kind == content::Kind::Movie); @@ -170,7 +170,7 @@ static void test_list() static void test_details() { - std::printf("[4/6] Details: a series (nested names, specials last) and a film...\n"); + std::printf("[4/7] Details: a series (nested names, specials last) and a film...\n"); content::Details d; CHECK(tmdb::parseDetails(kTv, content::Kind::Series, IMG, d)); CHECK(d.item.title == "Слово пацана. Кровь на асфальте"); @@ -195,7 +195,7 @@ static void test_details() static void test_season() { - std::printf("[5/6] A season's episodes...\n"); + std::printf("[5/7] A season's episodes...\n"); auto eps = tmdb::parseSeason(kSeason, IMG); CHECK(eps.size() == 2); CHECK(eps[0].title == "Пришиться"); // not the crew's "name" @@ -208,9 +208,54 @@ static void test_season() std::printf(" PASSED\n"); } +static bool hasParam(const tmdb::ListQuery& q, const std::string& k, const std::string& v) +{ + for (const auto& p : q.params) + if (p.first == k && p.second == v) return true; + return false; +} + +static void test_queries() +{ + std::printf("[7/7] Discover and search queries, genre ids...\n"); + tmdb::Discover d; + d.kind = content::Kind::Series; + d.genre = 18; + d.yearFrom = 2010; + d.yearTo = 2019; + d.sort = tmdb::Discover::Sort::Rating; + auto q = tmdb::discoverQuery(d); + CHECK(q.path == "/discover/tv" && q.kind == 1); + CHECK(hasParam(q, "with_genres", "18")); + CHECK(hasParam(q, "first_air_date.gte", "2010-01-01")); + CHECK(hasParam(q, "first_air_date.lte", "2019-12-31")); + CHECK(hasParam(q, "sort_by", "vote_average.desc")); + CHECK(hasParam(q, "vote_count.gte", "300")); + + tmdb::Discover m; // a film, any genre, newest + m.sort = tmdb::Discover::Sort::Newest; + auto mq = tmdb::discoverQuery(m); + CHECK(mq.path == "/discover/movie" && mq.kind == 0); + CHECK(hasParam(mq, "sort_by", "primary_release_date.desc")); + for (const auto& p : mq.params) CHECK(p.first != "with_genres"); + + auto s = tmdb::searchQuery("Дюна", 0, 2024); + CHECK(s.path == "/search/movie" && hasParam(s, "primary_release_year", "2024")); + auto t = tmdb::searchQuery("Слово пацана", 1, 0); + CHECK(t.path == "/search/tv"); + for (const auto& p : t.params) CHECK(p.first != "first_air_date_year"); + CHECK(tmdb::searchQuery("x").path == "/search/multi"); + + auto items = tmdb::parseList(kTrending, IMG, -1); + CHECK(items[0].genreIds.size() == 2 && items[0].genreIds[0] == 878); + CHECK(json::integers(R"J({"a":[ 1, 22 ,333]})J", "a").size() == 3); + CHECK(tmdb::genres(content::Kind::Movie).size() > 10); + std::printf(" PASSED\n"); +} + static void test_errors() { - std::printf("[6/6] TMDB error bodies...\n"); + std::printf("[6/7] TMDB error bodies...\n"); CHECK(tmdb::errorMessage(R"J({"success":false,"status_code":7,"status_message":"Invalid API key: You must be granted a valid key."})J") .rfind("Invalid API key", 0) == 0); CHECK(tmdb::errorMessage(kMovie).empty()); @@ -229,6 +274,7 @@ int main() test_details(); test_season(); test_errors(); + test_queries(); std::printf("\nALL TESTS PASSED\n"); return 0; } diff --git a/run-pc.bat b/run-pc.bat new file mode 100644 index 0000000..b3cbe6f --- /dev/null +++ b/run-pc.bat @@ -0,0 +1,8 @@ +@echo off +cd /d "%~dp0build-pc" +if not exist "NX-torrent-player.exe" ( + echo [ERROR] NX-torrent-player.exe not found! Please run build-pc.bat first. + pause + exit /b 1 +) +start "" "NX-torrent-player.exe" From ddd8c08dc75c1f8c2f3536c70b817f37d1560df4 Mon Sep 17 00:00:00 2001 From: Langegen Date: Thu, 29 Oct 2026 08:13:09 +0300 Subject: [PATCH 06/13] Release 0.6.0: "More like this" on the card, leftover translations - Card: a "More like this" row of TMDB recommendations under the seasons (fetched with the details, no extra request; A opens that card, Y bookmarks). The poster with those two actions is shared now (catalogPoster) by the home rows, collections and the card. - Player: when the history has a file as finished but the Stremio account has a position, ask "Resume playback?" with it instead of seeking there silently; with nothing to offer, start from the beginning. - Translated: the loading screen's metadata / peers / pieces / speed line, the "unsupported" and "link" source labels. The ZR panel stays English on purpose (it is diagnostics for bug reports). - The "no sources" hint names Jackett first. - Version 0.6.0. Tests: a film's recommendations parsed without leaking into its own fields. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 4 ++-- app/browse.cpp | 6 +++--- app/card.cpp | 50 +++++++++++++++++++++++++++++++++++++++++++- app/card.hpp | 12 +++++++++++ app/collection.cpp | 13 +----------- app/content.hpp | 1 + app/home.cpp | 18 +--------------- app/i18n_ru.cpp | 13 +++++++++--- app/player.cpp | 37 ++++++++++++++++---------------- app/sources.cpp | 4 ++-- app/tmdb.cpp | 11 +++++++++- app/tmdb.hpp | 3 ++- pctest/test_tmdb.cpp | 13 +++++++++++- 13 files changed, 124 insertions(+), 61 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a3c526..f2baa1a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,8 +20,8 @@ include(${BOREALIS_LIBRARY}/cmake/toolchain.cmake) project(NX-torrent-player) set(VERSION_MAJOR "0") -set(VERSION_MINOR "5") -set(VERSION_ALTER "1") +set(VERSION_MINOR "6") +set(VERSION_ALTER "0") set(PROJECT_AUTHOR "shodowlo") # On-device app name shown in the Homebrew Menu. set(APP_TITLE "NX Torrent Player") diff --git a/app/browse.cpp b/app/browse.cpp index 7c953bf..d258609 100644 --- a/app/browse.cpp +++ b/app/browse.cpp @@ -582,9 +582,9 @@ void showStreams(const stremio::Addon& addon, const std::string& type, // YouTube id, an external page) does not -- say so up front // rather than failing after the tap. if (playableSource(s).empty()) - row.detail = "unsupported"; + row.detail = tr("unsupported"); else if (s.infoHash.empty()) - row.detail = "link"; + row.detail = tr("link"); rows.push_back(row); } @@ -1107,7 +1107,7 @@ class AddonSourcePicker : public brls::Activity if (playableSource(s).empty()) { auto* u = new brls::Label(); - u->setText("unsupported"); + u->setText(tr("unsupported")); u->setFontSize(15.0f); u->setTextColor(theme::textWarn()); u->setMarginTop(8.0f); diff --git a/app/card.cpp b/app/card.cpp index 41a3a7a..81f4af7 100644 --- a/app/card.cpp +++ b/app/card.cpp @@ -58,7 +58,7 @@ void openSources(const content::Item& it, int season, int episode, int episodesI bool stremioOn = !stremio::loadAuthKey().empty() && !it.imdbId.empty(); if (!jackettOn && !stremioOn) { - note(tr("To find sources, sign in to Stremio or set up Jackett in Options.")); + note(tr("To find sources, set up Jackett in Options (or sign in to Stremio).")); return; } openSourcesScreen(it, season, episode, episodesInSeason); @@ -84,6 +84,8 @@ struct CardState brls::Box* seasonBar = nullptr; std::vector seasonBtns; brls::Box* episodes = nullptr; + brls::Box* similarSection = nullptr; // "More like this" + brls::Box* similarRow = nullptr; }; class CardView : public brls::Box @@ -125,6 +127,7 @@ class CardView : public brls::Box body->setPadding(34.0f, 60.0f, 40.0f, 60.0f); buildTop(body); buildSeasons(body); + buildSimilar(body); scroll->setContentView(body); addView(scroll); @@ -378,6 +381,35 @@ class CardView : public brls::Box body->addView(st->seasonsSection); } + // TMDB's recommendations under everything else, as on the home rows: A + // opens that title's card on top of this one, Y bookmarks it. + void buildSimilar(brls::Box* body) + { + st->similarSection = new brls::Box(); + st->similarSection->setAxis(brls::Axis::COLUMN); + st->similarSection->setMarginTop(26.0f); + st->similarSection->setVisibility(brls::Visibility::GONE); + + auto* h = whiteLabel(tr("More like this"), 20.0f, 235); + h->setMarginBottom(10.0f); + st->similarSection->addView(h); + + st->similarRow = new brls::Box(); + st->similarSection->addView(ui::strip(st->similarRow, kSimilarW * 1.5f + 22.0f, false)); + body->addView(st->similarSection); + } + + static constexpr float kSimilarW = 140.0f; + + void showSimilar() + { + if (st->d.similar.empty()) return; + st->similarRow->clearViews(); // never focused: only filled once + for (const auto& it : st->d.similar) + st->similarRow->addView(catalogPoster(it, alive, kSimilarW)); + st->similarSection->setVisibility(brls::Visibility::VISIBLE); + } + void loadDetails() { auto weak = std::weak_ptr(st); @@ -410,6 +442,7 @@ class CardView : public brls::Box } s->playBtn->setText(playLabel()); if (s->d.item.isSeries() && !s->d.seasons.empty()) showSeasons(); + showSimilar(); loadImdbRating(); }); } @@ -576,6 +609,21 @@ void openContentCard(const content::Item& item) brls::Application::pushActivity(new CardActivity(item)); } +brls::Box* catalogPoster(const content::Item& item, std::shared_ptr alive, float w) +{ + auto* card = ui::posterCard(item.title, item.key(), item.posterUrl, alive, + [item]() { openContentCard(item); }, w); + card->registerAction(tr("Bookmark"), brls::BUTTON_Y, [item](brls::View*) { + library::Entry e; + bool marked = library::find(item.key(), e) && e.status != library::Status::None; + library::setStatus(item, marked ? library::Status::None : library::Status::Planned); + brls::Application::notify(marked ? tr("Removed from bookmarks") + : tr("Added to bookmarks: Planned")); + return true; + }); + return card; +} + void watchContent(const content::Item& item) { if (item.isSeries()) diff --git a/app/card.hpp b/app/card.hpp index 9e2ba44..db0cd37 100644 --- a/app/card.hpp +++ b/app/card.hpp @@ -1,7 +1,14 @@ #pragma once +#include + #include "content.hpp" +namespace brls +{ +class Box; +} + // The film / series card (roadmap mock-up "Карточка"): the backdrop across the // screen darkened on the left, the poster, the title, a row of chips (TMDB // rating, year, genres, length or seasons), the synopsis and the actions; for @@ -15,3 +22,8 @@ void openContentCard(const content::Item& item); // straight to its sources; a series opens the card, since an episode has to // be picked first. void watchContent(const content::Item& item); + +// A catalog poster: A opens the title's card, Y puts it in the bookmarks as +// Planned (or takes it out). The home rows, collections and the card's +// "More like this". +brls::Box* catalogPoster(const content::Item& item, std::shared_ptr alive, float w); diff --git a/app/collection.cpp b/app/collection.cpp index dbbce8a..491d95f 100644 --- a/app/collection.cpp +++ b/app/collection.cpp @@ -103,18 +103,7 @@ void PosterGrid::add(const content::Item& it) row->setMarginBottom(kGap); addView(row); } - content::Item copy = it; - auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, alive, - [copy]() { openContentCard(copy); }, kPosterW); - card->registerAction(tr("Bookmark"), brls::BUTTON_Y, [copy](brls::View*) { - library::Entry e; - bool marked = library::find(copy.key(), e) && e.status != library::Status::None; - library::setStatus(copy, marked ? library::Status::None : library::Status::Planned); - brls::Application::notify(marked ? tr("Removed from bookmarks") - : tr("Added to bookmarks: Planned")); - return true; - }); - row->addView(card); + row->addView(catalogPoster(it, alive, kPosterW)); shown++; } diff --git a/app/content.hpp b/app/content.hpp index e5c374c..cc0b989 100644 --- a/app/content.hpp +++ b/app/content.hpp @@ -78,6 +78,7 @@ struct Details Item item; std::vector seasons; // series only, specials last std::vector cast; // a few names, top billing first + std::vector similar; // TMDB's recommendations, same kind }; } // namespace content diff --git a/app/home.cpp b/app/home.cpp index b7846bb..8342725 100644 --- a/app/home.cpp +++ b/app/home.cpp @@ -79,25 +79,9 @@ std::string smallBackdrop(std::string url) return url; } -// Y on a poster: in the bookmarks as Planned, or out again. -void toggleBookmark(const content::Item& it) -{ - library::Entry e; - bool marked = library::find(it.key(), e) && e.status != library::Status::None; - library::setStatus(it, marked ? library::Status::None : library::Status::Planned); - brls::Application::notify(marked ? tr("Removed from bookmarks") - : tr("Added to bookmarks: Planned")); -} - brls::Box* catalogCard(const content::Item& it, std::shared_ptr alive) { - auto* card = ui::posterCard(it.title, it.key(), it.posterUrl, alive, - [it]() { openContentCard(it); }, kPosterW); - card->registerAction(tr("Bookmark"), brls::BUTTON_Y, [it](brls::View*) { - toggleBookmark(it); - return true; - }); - return card; + return catalogPoster(it, alive, kPosterW); } // The rows of each view: what the header says and where the titles come from. diff --git a/app/i18n_ru.cpp b/app/i18n_ru.cpp index 056eb2f..5a239d4 100644 --- a/app/i18n_ru.cpp +++ b/app/i18n_ru.cpp @@ -32,6 +32,8 @@ const Pair kRu[] = { { "This addon only offers 4K sources, which are hidden in Options.", "Этот аддон предлагает только 4K-источники, а они скрыты в настройках." }, { "Source", "Источник" }, + { "unsupported", "не поддерживается" }, + { "link", "ссылка" }, { "Pick a source", "Выберите источник" }, { "Unsupported source: only torrents and direct video links can be played.", "Источник не поддерживается: воспроизводятся только торренты и прямые " @@ -203,6 +205,10 @@ const Pair kRu[] = { { "Searching DHT for peers...", "Поиск пиров в DHT..." }, { "Announcing to trackers...", "Опрос трекеров..." }, { "Downloading header...", "Загрузка заголовка..." }, + { "Metadata: peer ", "Метаданные: пир " }, + { "Peers: ", "Пиры: " }, + { "Pieces: ", "Части: " }, + { " MB/s", " МБ/с" }, { "NEEDED", "НУЖЕН" }, { "INFLIGHT", "В ПУТИ" }, { "DONE", "ГОТОВ" }, @@ -455,13 +461,14 @@ const Pair kRu[] = { { "Seasons: ", "Сезонов: " }, { "Episodes: ", "Серий: " }, { "Starring: ", "В ролях: " }, + { "More like this", "Похожие" }, { "Still loading, try again in a moment.", "Данные ещё загружаются, попробуйте через секунду." }, { "Set up Jackett in Options to search torrents.", "Настройте Jackett в настройках, чтобы искать торренты." }, - { "To find sources, sign in to Stremio or set up Jackett in Options.", - "Чтобы найти источники, войдите в Stremio или настройте Jackett в " - "настройках." }, + { "To find sources, set up Jackett in Options (or sign in to Stremio).", + "Чтобы найти источники, настройте Jackett в настройках (или войдите в " + "Stremio)." }, // ---- sources screen ------------------------------------------------------- { " releases", " раздач" }, diff --git a/app/player.cpp b/app/player.cpp index 6adb2c4..eaeaf04 100644 --- a/app/player.cpp +++ b/app/player.cpp @@ -2321,12 +2321,8 @@ void MpvView::updateLoadingOverlay() else if (torrent_meta_state == META_ANNOUNCE) statusLabel->setText(tr("Announcing to trackers...")); else if (total > 0) - { - char b[96]; - std::snprintf(b, sizeof(b), "Metadata: peer %d / %d", tried, - total); - statusLabel->setText(b); - } + statusLabel->setText(std::string(tr("Metadata: peer ")) + + std::to_string(tried) + " / " + std::to_string(total)); } return; } @@ -2373,9 +2369,9 @@ void MpvView::updateLoadingOverlay() lastSample = now; statusLabel->setText(fileLoaded ? tr("Buffering...") : tr("Connecting...")); char sbuf[192]; - std::snprintf(sbuf, sizeof(sbuf), "%s \xC2\xB7 %.1f MB/s", + std::snprintf(sbuf, sizeof(sbuf), "%s \xC2\xB7 %.1f%s", urlHost(playUrl).c_str(), - obsCacheSpeed / (1024.0 * 1024.0)); + obsCacheSpeed / (1024.0 * 1024.0), tr(" MB/s")); statsLabel->setText(sbuf); } else if (dt >= 0.5) @@ -2402,10 +2398,11 @@ void MpvView::updateLoadingOverlay() char sbuf[192]; double spd = speedBps / (1024.0 * 1024.0); + // "Peers: 12" rather than "12 peers": no plural forms to translate. std::snprintf(sbuf, sizeof(sbuf), - "%d peer%s · %.1f MB/s · %lld / %lld pieces", - peers, peers == 1 ? "" : "s", spd, (long long)piecesDone, - (long long)piecesTotal); + "%s%d \xC2\xB7 %.1f%s \xC2\xB7 %s%lld / %lld", + tr("Peers: "), peers, spd, tr(" MB/s"), tr("Pieces: "), + (long long)piecesDone, (long long)piecesTotal); statsLabel->setText(sbuf); } } @@ -3285,19 +3282,23 @@ void startPlaybackPrompt(const std::string& source, const PlayerArt& art, double resumeDur = 0.0; std::string itemTitle = title; - if (known) + if (known && history::hasValidResume(item)) { - if (history::hasValidResume(item)) - { - resumePos = item.positionSec; - resumeDur = item.durationSec; - if (itemTitle.empty()) itemTitle = item.title; - } + resumePos = item.positionSec; + resumeDur = item.durationSec; + if (itemTitle.empty()) itemTitle = item.title; } else if (watch.resumeSec > 15.0) { + // The account's position: for a file never played here, or one the + // history has as finished (watched to the end here, then further on + // another device). Asked about either way -- it used to be applied + // without a word when the history entry was finished. resumePos = watch.resumeSec; + if (known && itemTitle.empty()) itemTitle = item.title; } + // Nothing to offer: from the start, whatever a caller passed in. + if (resumePos < 15.0) watch.resumeSec = 0.0; if (resumePos >= 15.0) { diff --git a/app/sources.cpp b/app/sources.cpp index a33c2e2..b4ce019 100644 --- a/app/sources.cpp +++ b/app/sources.cpp @@ -547,8 +547,8 @@ class SourcesView : public brls::Box if (!jackettOn && !stremioOn) { - status->setText(tr("To find sources, sign in to Stremio or set up Jackett in " - "Options.")); + status->setText(tr("To find sources, set up Jackett in Options (or sign in to " + "Stremio).")); return; } diff --git a/app/tmdb.cpp b/app/tmdb.cpp index 9054a66..dc79562 100644 --- a/app/tmdb.cpp +++ b/app/tmdb.cpp @@ -214,6 +214,15 @@ bool parseDetails(const std::string& body, content::Kind kind, if (!n.empty()) out.cast.push_back(n); if (out.cast.size() >= 6) break; } + + // "recommendations": a list answer of its own, nested. Its titles are of + // the same kind (TMDB recommends films for a film, shows for a show). + std::string rec = json::object(body, "recommendations"); + if (!rec.empty()) + { + out.similar = parseList(rec, imageBase, kind == content::Kind::Series ? 1 : 0); + if (out.similar.size() > 20) out.similar.resize(20); + } return true; } @@ -521,7 +530,7 @@ void fetchDetailsAsync(content::Kind kind, int id, Settings s = settings(); std::string path = std::string(kind == content::Kind::Series ? "/tv/" : "/movie/") + std::to_string(id); - std::string url = requestUrl(s, path, { { "append_to_response", "external_ids,credits" } }); + std::string url = requestUrl(s, path, { { "append_to_response", "external_ids,credits,recommendations" } }); brls::async([s, url, kind, done]() { DetailsResult r; std::string body; diff --git a/app/tmdb.hpp b/app/tmdb.hpp index 5fb05d4..71a1404 100644 --- a/app/tmdb.hpp +++ b/app/tmdb.hpp @@ -60,7 +60,8 @@ std::string errorMessage(const std::string& body); std::vector parseList(const std::string& json, const std::string& imageBase, int kind); -// /movie/{id} or /tv/{id} with append_to_response=external_ids,credits. +// /movie/{id} or /tv/{id} with append_to_response=external_ids,credits, +// recommendations (the card's "More like this" row). bool parseDetails(const std::string& json, content::Kind kind, const std::string& imageBase, content::Details& out); diff --git a/pctest/test_tmdb.cpp b/pctest/test_tmdb.cpp index b76a4a4..ff1e59a 100644 --- a/pctest/test_tmdb.cpp +++ b/pctest/test_tmdb.cpp @@ -79,7 +79,11 @@ static const char* kMovie = R"J({"backdrop_path":"/bd.jpg","genres":[{"id":878," "id":693134,"imdb_id":"tt15239678","original_title":"Dune: Part Two","overview":"…", "poster_path":"/p.jpg","release_date":"2024-02-27","runtime":166,"title":"Дюна: Часть вторая", "vote_average":8.1,"belongs_to_collection":{"id":726871,"name":"Дюна (Коллекция)"}, - "external_ids":{"imdb_id":"tt15239678"}})J"; + "external_ids":{"imdb_id":"tt15239678"}, + "recommendations":{"page":1,"results":[ + {"id":438631,"title":"Дюна","original_title":"Dune","media_type":"movie","poster_path":"/r1.jpg", + "release_date":"2021-09-15","genre_ids":[878,12],"vote_average":7.8}, + {"id":0,"title":"broken"}],"total_pages":2}})J"; static const char* kSeason = R"J({"_id":"x","air_date":"2023-11-09","episodes":[ {"air_date":"2023-11-09","episode_number":1,"name":"Пришиться","overview":"o1","runtime":53, @@ -189,6 +193,13 @@ static void test_details() CHECK(m.item.imdbId == "tt15239678"); CHECK(m.item.runtimeMin == 166); CHECK(m.item.posterUrlLarge == "https://image.tmdb.org/t/p/w342/p.jpg"); + // Recommendations: their ids and titles don't leak into the film's own. + CHECK(m.item.tmdbId == 693134); + CHECK(m.similar.size() == 1); + CHECK(m.similar[0].tmdbId == 438631 && m.similar[0].title == "Дюна"); + CHECK(m.similar[0].kind == content::Kind::Movie); + CHECK(m.similar[0].posterUrl == "https://image.tmdb.org/t/p/w185/r1.jpg"); + CHECK(d.similar.empty()); CHECK(!tmdb::parseDetails("{}", content::Kind::Movie, IMG, m)); std::printf(" PASSED\n"); } From 949f4621e57fe51afbb3042086d9c921502683de Mon Sep 17 00:00:00 2001 From: Langegen Date: Thu, 29 Oct 2026 08:47:04 +0300 Subject: [PATCH 07/13] Sources: list Jackett's results without waiting for the Stremio addons Jackett is the app's main source, but the sources screen waited for every request -- each Stremio addon included -- before it listed anything, so one slow addon held back results Jackett had already returned. - The list is built as soon as all Jackett queries are in; the addons' results are folded in as each one answers ("searching addons..." in the subtitle meanwhile). Without Jackett it waits for the addons, as before. - A rebuild keeps the focus on the same release (by its dedup key) instead of dropping it on the filter row -- X on a row no longer loses the place either. - Results that land while the player or a dialog is on top wait until the screen is on top again: freeing the row that opened it would leave a dangling pointer in borealis' focus stack (the History crash). Co-Authored-By: Claude Opus 5.5 --- app/i18n_ru.cpp | 1 + app/sources.cpp | 139 +++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 120 insertions(+), 20 deletions(-) diff --git a/app/i18n_ru.cpp b/app/i18n_ru.cpp index 5a239d4..52478b7 100644 --- a/app/i18n_ru.cpp +++ b/app/i18n_ru.cpp @@ -35,6 +35,7 @@ const Pair kRu[] = { { "unsupported", "не поддерживается" }, { "link", "ссылка" }, { "Pick a source", "Выберите источник" }, + { "searching addons...", "ищем в аддонах..." }, { "Unsupported source: only torrents and direct video links can be played.", "Источник не поддерживается: воспроизводятся только торренты и прямые " "ссылки на видео." }, diff --git a/app/sources.cpp b/app/sources.cpp index b4ce019..da62e02 100644 --- a/app/sources.cpp +++ b/app/sources.cpp @@ -357,9 +357,64 @@ class SourcesView : public brls::Box std::vector found; std::set seen; // dedup keys - int pending = 0; // requests not answered yet + int pending = 0; // requests not answered yet, all sources + // Jackett is the main source: its answers are shown as soon as they are + // all in, without waiting for the Stremio addons (one slow addon held the + // whole list back). Addon results are folded in as they land. + bool jackettAsked = false; + int pendingJackett = 0; + bool shown = false; // the list has been built at least once + bool dirty = false; // new results while another screen was on top + bool rebuildQueued = false; + std::vector> rowKeys; // row -> dedupKey std::vector errors; + static std::string dedupKey(const Source& s) + { + return s.title + "|" + std::to_string(s.size / (1024 * 1024)); + } + + // What can be listed: Jackett's answers once they are all in; without + // Jackett, everything (the addons only). + bool listReady() const { return jackettAsked ? pendingJackett == 0 : pending == 0; } + + bool isOnTop() + { + auto stack = brls::Application::getActivitiesStack(); + return !stack.empty() && stack.back() == getParentActivity(); + } + + // Late results (an addon answering while the player or a dialog is open) + // wait until this screen is on top again: borealis' focus stack keeps a + // raw pointer to the row that opened the screen above, and freeing that + // row would crash when it closes and hands the focus back. + void refresh() + { + if (isOnTop()) + { + dirty = false; + rebuild(); + } + else + dirty = true; + } + + void draw(NVGcontext* vg, float x, float y, float w, float h, brls::Style style, + brls::FrameContext* ctx) override + { + brls::Box::draw(vg, x, y, w, h, style, ctx); + if (dirty && !rebuildQueued && isOnTop()) + { + rebuildQueued = true; + auto live = alive; + brls::sync([this, live]() { + if (!*live) return; + rebuildQueued = false; + if (dirty) refresh(); // still not on top: draw() retries + }); + } + } + int filterHeight = 0; // The second filter row. "" = any. @@ -400,7 +455,7 @@ class SourcesView : public brls::Box void pickFacet(Facet f) { - if (pending > 0) return; // the values are not all in yet + if (!listReady()) return; // nothing listed yet to pick from std::vector labels, values; if (f == Facet::Hdr) { @@ -473,12 +528,14 @@ class SourcesView : public brls::Box } else if (!item.year.empty()) s += " (" + item.year + ")"; - if (pending == 0) + if (listReady()) { int n = 0; for (const auto& r : found) if (r.score > -500) n++; s += " \xC2\xB7 " + std::to_string(n) + tr(" releases"); + // Listed already, the addons still answering. + if (pending > 0) s += std::string(" \xC2\xB7 ") + tr("searching addons..."); } subtitle->setText(s); } @@ -519,24 +576,29 @@ class SourcesView : public brls::Box bool addSource(Source s) { // The same release from two indexers, or from Jackett and an addon. - std::string key = s.title + "|" + std::to_string(s.size / (1024 * 1024)); - if (!seen.insert(key).second) return false; + if (!seen.insert(dedupKey(s)).second) return false; s.score = release::score(s.info, s.seeders, s.size, want); found.push_back(std::move(s)); return true; } - void done(const std::string& err = "") + // One request answered. `jackett`: it was one of Jackett's; `added`: it + // brought releases not seen yet. + void done(const std::string& err, bool jackett, bool added) { if (!err.empty()) errors.push_back(err); - if (--pending > 0) + pending--; + if (jackett) pendingJackett--; + updateSubtitle(); + if (!listReady()) { status->setText(tr("Searching...") + std::string(" ") + std::to_string(found.size()) + tr(" found so far")); return; } - updateSubtitle(); - rebuild(); + // The first listing, new releases, or the last answer (which turns + // "searching" into "no results" when nothing came). + if (!shown || added || pending == 0) refresh(); } void search() @@ -568,7 +630,9 @@ class SourcesView : public brls::Box q(item.title); q(item.originalTitle); } - pending = (int)queries.size() + (stremioOn ? 1 : 0); + pending = (int)queries.size() + (stremioOn ? 1 : 0); + pendingJackett = (int)queries.size(); + jackettAsked = !queries.empty(); jackett::Category cat = item.isSeries() ? jackett::Category::SERIES : jackett::Category::MOVIES; @@ -577,9 +641,10 @@ class SourcesView : public brls::Box brls::Logger::info("[sources] jackett: {}", q); jackett::searchAsync(q, cat, [this, a = alive](jackett::SearchResult r) { if (!*a) return; + bool added = false; if (r.ok) - for (const auto& it : r.items) addSource(fromJackett(it)); - done(r.ok ? "" : "Jackett: " + r.error); + for (const auto& it : r.items) added |= addSource(fromJackett(it)); + done(r.ok ? "" : "Jackett: " + r.error, true, added); }); } @@ -605,16 +670,17 @@ class SourcesView : public brls::Box stremio::fetchStreamsAsync(ad.base, type, id, [this, a, name](stremio::StreamsResult s) { if (!*a) return; + bool added = false; if (s.ok) for (const auto& st : s.streams) { Source src = fromStream(st, name); - if (!src.src.empty()) addSource(std::move(src)); + if (!src.src.empty()) added |= addSource(std::move(src)); } - done(s.ok ? "" : name + ": " + s.error); + done(s.ok ? "" : name + ": " + s.error, false, added); }); } - done(r.ok ? "" : "Stremio: " + r.error); + done(r.ok ? "" : "Stremio: " + r.error, false, false); }); } } @@ -644,18 +710,42 @@ class SourcesView : public brls::Box void rebuild() { - // The focus may sit on a row about to be freed (X pressed on it): - // hand it to the filter row first. + // The focus may sit on a row about to be freed (X pressed on it, or + // an addon's results folded in under it): park it on the filter row, + // then hand it to the same release in the new list. + bool focusInList = false; + std::string focusKey; + brls::View* child = nullptr; for (brls::View* v = brls::Application::getCurrentFocus(); v; v = v->getParent()) + { if (v == list) { - brls::Application::giveFocus(filterBtns[0].first); + focusInList = true; break; } + child = v; + } + if (focusInList) + { + for (const auto& rk : rowKeys) + if (rk.first == child) focusKey = rk.second; + brls::Application::giveFocus(filterBtns[0].first); + } list->clearViews(); - if (pending > 0) return; + rowKeys.clear(); + if (!listReady()) return; + shown = true; + updateSubtitle(); auto rows = visible(); + if (rows.empty() && pending > 0) + { + // Jackett found nothing (yet) that fits; the addons may. + status->setText(tr("Searching...") + std::string(" ") + + std::to_string(found.size()) + tr(" found so far")); + status->setVisibility(brls::Visibility::VISIBLE); + return; + } if (rows.empty()) { std::string msg = found.empty() ? tr("No results found") @@ -672,7 +762,16 @@ class SourcesView : public brls::Box for (const auto& r : found) if (r.score > -500 && (!best || r.score > best->score)) best = &r; - for (const Source* r : rows) list->addView(buildRow(*r, r == best)); + brls::View* refocus = nullptr; + for (const Source* r : rows) + { + brls::Box* row = buildRow(*r, r == best); + list->addView(row); + std::string key = dedupKey(*r); + if (focusInList && !refocus && key == focusKey) refocus = row; + rowKeys.push_back({ row, key }); + } + if (focusInList) brls::Application::giveFocus(refocus ? refocus : rowKeys[0].first); } brls::Box* buildRow(const Source& s, bool best) From dfae6b360b067608e4a943c48965a7e99d0b95b9 Mon Sep 17 00:00:00 2001 From: Langegen Date: Thu, 29 Oct 2026 08:58:50 +0300 Subject: [PATCH 08/13] Jackett first-run setup, and errors that say what to fix Setup (app/setup.cpp) - On a start with no Jackett address, once: a screen with the server URL, the API key and "Check and save", which also counts the indexers; "Skip" or B is an answer too (config.json: jackettSetupAsked). Later changes are in Options -> Jackett as before. TMDB (the key ships in the build) and the proxy (optional) are not asked. Errors (app/jackett_diag.cpp, pure, tested) - curl's and Jackett's failures sorted into what to do about them: a bad URL, a name that does not resolve, nothing listening (Jackett not running, wrong port / network), a timeout, a certificate, a rejected API key (401 / 403 / "Invalid API key"), something that is not Jackett's API (a login page, a 404), no indexers, every indexer failing. Each is one sentence with the next step, in the UI language; the sources screen, the Jackett tab and the connection test all show it. - The connection test now reports "Jackett works. Indexers: N" and how many failed, instead of a bare "Connection successful!". - A typed address is made usable: "192.168.1.5" becomes http://192.168.1.5:9117; spaces and a trailing "/" go. - http::get can report the HTTP status (optional argument). Tests: pctest/test_jackett.cpp (4 groups); 7 offline suites in all. Co-Authored-By: Claude Opus 5.5 --- app/config.cpp | 3 + app/config.hpp | 3 + app/http.cpp | 4 +- app/http.hpp | 6 +- app/i18n_ru.cpp | 47 ++++++++++ app/jackett.cpp | 193 ++++++++++++++++++++------------------ app/jackett.hpp | 12 ++- app/jackett_diag.cpp | 144 ++++++++++++++++++++++++++++ app/jackett_diag.hpp | 49 ++++++++++ app/main.cpp | 3 + app/settings.cpp | 7 +- app/setup.cpp | 203 ++++++++++++++++++++++++++++++++++++++++ app/setup.hpp | 11 +++ pctest/run_tests.sh | 10 ++ pctest/test_jackett.cpp | 113 ++++++++++++++++++++++ pctest/test_net.cpp | 2 +- pctest/test_tmdb.cpp | 2 +- 17 files changed, 712 insertions(+), 100 deletions(-) create mode 100644 app/jackett_diag.cpp create mode 100644 app/jackett_diag.hpp create mode 100644 app/setup.cpp create mode 100644 app/setup.hpp create mode 100644 pctest/test_jackett.cpp diff --git a/app/config.cpp b/app/config.cpp index 71b3bf8..4ccf80c 100644 --- a/app/config.cpp +++ b/app/config.cpp @@ -256,6 +256,7 @@ void load() (sTab == "bookmarks") ? Tab::BOOKMARKS : Tab::LOCAL; cfg.jackettUrl = readStr(body, "jackettUrl", cfg.jackettUrl); cfg.jackettApiKey = readStr(body, "jackettApiKey", cfg.jackettApiKey); + cfg.jackettSetupAsked = readBool(body, "jackettSetupAsked", cfg.jackettSetupAsked); cfg.torrServerUrl = readStr(body, "torrServerUrl", cfg.torrServerUrl); cfg.proxyUrl = readStr(body, "proxyUrl", cfg.proxyUrl); cfg.tmdbApiKey = readStr(body, "tmdbApiKey", cfg.tmdbApiKey); @@ -308,6 +309,7 @@ bool save() " \"startupTab\": \"%s\",\n" " \"jackettUrl\": \"%s\",\n" " \"jackettApiKey\": \"%s\",\n" + " \"jackettSetupAsked\": %s,\n" " \"torrServerUrl\": \"%s\",\n" " \"proxyUrl\": \"%s\",\n" " \"tmdbApiKey\": \"%s\",\n" @@ -333,6 +335,7 @@ bool save() sTabStr, cfg.jackettUrl.c_str(), cfg.jackettApiKey.c_str(), + cfg.jackettSetupAsked ? "true" : "false", cfg.torrServerUrl.c_str(), cfg.proxyUrl.c_str(), cfg.tmdbApiKey.c_str(), diff --git a/app/config.hpp b/app/config.hpp index f8b3f2c..60cfd29 100644 --- a/app/config.hpp +++ b/app/config.hpp @@ -124,6 +124,9 @@ struct Config // Jackett server configuration std::string jackettUrl = ""; std::string jackettApiKey = ""; + // The first-run Jackett setup has been shown (finished or skipped): it is + // offered once, then only Options -> Jackett. + bool jackettSetupAsked = false; // TorrServer ("http://192.168.1.10:8090"). Set: torrents are streamed by // that server and played here as plain HTTP, instead of by the on-device diff --git a/app/http.cpp b/app/http.cpp index 2c1a38a..beedc6a 100644 --- a/app/http.cpp +++ b/app/http.cpp @@ -136,8 +136,9 @@ bool postJson(const char* url, const std::string& body, std::string& resp, // to answer with WebP -- which stb_image (nanovg's decoder) cannot read, so the // poster silently never appeared. bool get(const std::string& url, std::string& resp, std::string& err, - const char* accept, long timeoutSec) + const char* accept, long timeoutSec, long* status) { + if (status) *status = 0; CURL* curl = curl_easy_init(); if (!curl) { @@ -161,6 +162,7 @@ bool get(const std::string& url, std::string& resp, std::string& err, curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); verifyTls(curl, url); CURLcode rc = curl_easy_perform(curl); + if (status) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, status); if (hdrs) curl_slist_free_all(hdrs); curl_easy_cleanup(curl); if (rc != CURLE_OK) diff --git a/app/http.hpp b/app/http.hpp index 00e4158..6617daa 100644 --- a/app/http.hpp +++ b/app/http.hpp @@ -18,9 +18,11 @@ namespace http // GET `url` into `resp`. False on failure, with a readable reason in `err`. // `accept` is an optional full header line ("Accept: image/jpeg"). // `timeoutSec` bounds the whole request (TorrServer answers only once it has -// a torrent's metadata, which can take longer than the default). +// a torrent's metadata, which can take longer than the default). `status`, +// when given, receives the HTTP status code (0 when no answer came); an error +// status still returns true with its body, as it always has. bool get(const std::string& url, std::string& resp, std::string& err, - const char* accept = nullptr, long timeoutSec = 25); + const char* accept = nullptr, long timeoutSec = 25, long* status = nullptr); // POST `body` as application/json. bool postJson(const char* url, const std::string& body, std::string& resp, diff --git a/app/i18n_ru.cpp b/app/i18n_ru.cpp index 52478b7..df7e94d 100644 --- a/app/i18n_ru.cpp +++ b/app/i18n_ru.cpp @@ -86,6 +86,53 @@ const Pair kRu[] = { { "Jackett is not configured. Set URL in Options.", "Jackett не настроен. Укажите адрес в настройках." }, { "Invalid response from Jackett server", "Некорректный ответ сервера Jackett" }, + { "Jackett works. Indexers: ", "Jackett работает. Индексаторов: " }, + { ", with errors: ", ", с ошибками: " }, + { "The Jackett address is not a valid URL. Enter it like http://192.168.1.100:9117.", + "Адрес Jackett — не URL. Введите его в виде http://192.168.1.100:9117." }, + { "The Jackett address does not resolve. Check its spelling, or use the computer's IP " + "address (like http://192.168.1.100:9117).", + "Адрес Jackett не найден. Проверьте написание или укажите IP-адрес компьютера " + "(например, http://192.168.1.100:9117)." }, + { "Nothing answers at ", "По адресу " }, + { ". Check that Jackett is running, that the console is on the same network, and the " + "port (9117 by default).", + " никто не отвечает. Проверьте, что Jackett запущен, консоль в той же сети, и порт " + "(по умолчанию 9117)." }, + { "Jackett did not answer in time. Indexers can be slow: try again, and if it keeps " + "happening, check the network.", + "Jackett не ответил вовремя. Индексаторы бывают медленными: попробуйте ещё раз, а " + "если повторяется — проверьте сеть." }, + { "Secure connection to Jackett failed: ", "Не удалось защищённое соединение с Jackett: " }, + { "Jackett rejected the API key. Copy it from the top right corner of the Jackett web " + "page into Options, Jackett.", + "Jackett не принял ключ API. Скопируйте его из правого верхнего угла веб-страницы " + "Jackett в Настройки → Jackett." }, + { "Something answers at this address, but not Jackett's API. Check the port (9117 by " + "default) and the API key.", + "По этому адресу отвечает не API Jackett. Проверьте порт (по умолчанию 9117) и ключ " + "API." }, + { "Jackett works, but has no indexers. Add trackers with \"Add indexer\" on the Jackett " + "web page.", + "Jackett работает, но в нём нет индексаторов. Добавьте трекеры кнопкой «Add indexer» " + "на веб-странице Jackett." }, + { "Every Jackett indexer returned an error. Check them on the Jackett web page.", + "Все индексаторы Jackett вернули ошибку. Проверьте их на веб-странице Jackett." }, + + // ---- first-run setup (app/setup.cpp) ---------------------------------------- + { "Set up Jackett", "Настройка Jackett" }, + { "The app finds releases through Jackett: a server on your computer or NAS that " + "searches the torrent trackers. Enter its address and API key, then check the " + "connection.", + "Приложение ищет раздачи через Jackett — сервер на вашем компьютере или NAS, который " + "опрашивает торрент-трекеры. Укажите его адрес и ключ API, затем проверьте " + "соединение." }, + { "Check and save", "Проверить и сохранить" }, + { "Skip", "Пропустить" }, + { "Done", "Готово" }, + { "Asking Jackett and its indexers...", "Опрашиваем Jackett и его индексаторы..." }, + { "You can change this later in Options, Jackett.", + "Это можно изменить позже в Настройки → Jackett." }, { "Invalid response from server. Check URL and API key.", "Некорректный ответ сервера. Проверьте адрес и ключ API." }, { "Saved magnet link to Local library", "Магнет сохранён в локальную библиотеку" }, diff --git a/app/jackett.cpp b/app/jackett.cpp index b1070c4..a613d65 100644 --- a/app/jackett.cpp +++ b/app/jackett.cpp @@ -49,6 +49,30 @@ std::string sanitizeFilename(const std::string& name) return out; } +// The releases of a search answer, most seeded first. +std::vector parseResults(const std::string& resp) +{ + std::vector items; + for (const auto& obj : json::objects(resp, "Results")) + { + Item it; + it.title = json::str(obj, "Title"); + if (it.title.empty()) continue; + it.tracker = json::str(obj, "Tracker"); + if (it.tracker.empty()) it.tracker = json::str(obj, "TrackerId"); + it.size = json::integer(obj, "Size"); + it.seeders = (int)json::integer(obj, "Seeders"); + it.peers = (int)json::integer(obj, "Peers"); + it.publishDate = json::str(obj, "PublishDate"); + it.magnetUri = json::str(obj, "MagnetUri"); + it.link = json::str(obj, "Link"); + it.categoryDesc = json::str(obj, "CategoryDesc"); + items.push_back(std::move(it)); + } + sortItems(items, SortBy::SEEDERS); + return items; +} + bool appendMagnetToFile(const std::string& path, const std::string& magnet) { FILE* f = std::fopen(path.c_str(), "a+"); @@ -168,79 +192,37 @@ void searchAsync(const std::string& query, Category category, // on the SD card that people attach to bug reports. brls::Logger::info("[jackett] querying {} for \"{}\"", base, query); - brls::async([url, cb]() { + brls::async([url, cb, base]() { std::string resp, err; - bool ok = http::get(url, resp, err); - if (!ok) + long status = 0; + Diagnosis d; + std::vector items; + if (!http::get(url, resp, err, nullptr, 25, &status)) { brls::Logger::error("[jackett] http request failed: {}", err); - brls::sync([cb, err]() { - SearchResult r; - r.ok = false; - r.error = err; - cb(std::move(r)); - }); - return; + d = diagnoseTransport(err); } - - std::vector rawObjects = json::objects(resp, "Results"); - if (rawObjects.empty()) - { - std::string apiErr = json::str(resp, "error"); - if (apiErr.empty()) - apiErr = json::str(resp, "message"); - if (!apiErr.empty()) - { - brls::Logger::error("[jackett] api error: {}", apiErr); - brls::sync([cb, apiErr]() { - SearchResult r; - r.ok = false; - r.error = apiErr; - cb(std::move(r)); - }); - return; - } - - if (resp.find("\"Results\"") == std::string::npos && - resp.find("\"Indexers\"") == std::string::npos) - { - brls::Logger::error("[jackett] invalid response: {}", resp.substr(0, 120)); - brls::sync([cb]() { - SearchResult r; - r.ok = false; - r.error = tr("Invalid response from Jackett server"); - cb(std::move(r)); - }); - return; - } - } - - std::vector items; - items.reserve(rawObjects.size()); - for (const auto& obj : rawObjects) + else { - Item it; - it.title = json::str(obj, "Title"); - if (it.title.empty()) continue; - it.tracker = json::str(obj, "Tracker"); - if (it.tracker.empty()) it.tracker = json::str(obj, "TrackerId"); - it.size = json::integer(obj, "Size"); - it.seeders = (int)json::integer(obj, "Seeders"); - it.peers = (int)json::integer(obj, "Peers"); - it.publishDate = json::str(obj, "PublishDate"); - it.magnetUri = json::str(obj, "MagnetUri"); - it.link = json::str(obj, "Link"); - it.categoryDesc = json::str(obj, "CategoryDesc"); - items.push_back(std::move(it)); + d = diagnoseAnswer(status, resp, true); + if (d.problem != Problem::None) + brls::Logger::error("[jackett] bad answer (HTTP {}): {}", status, + resp.substr(0, 160)); + else + items = parseResults(resp); + if (d.failed > 0) + brls::Logger::warning("[jackett] {} of {} indexers failed, first: {}", + d.failed, d.indexers, d.detail); } - brls::Logger::info("[jackett] search returned {} items", items.size()); - sortItems(items, SortBy::SEEDERS); - brls::sync([cb, items = std::move(items)]() mutable { + brls::sync([cb, d, base, items = std::move(items)]() mutable { SearchResult r; - r.ok = true; - r.items = std::move(items); + r.ok = d.problem == Problem::None; + if (r.ok) + r.items = std::move(items); + else + r.error = explain(d, base); cb(std::move(r)); }); }); @@ -259,35 +241,68 @@ void testConnectionAsync(const std::string& url, const std::string& apiKey, brls::Logger::info("[jackett] testing connection to {}", base); - brls::async([testUrl, cb]() { + brls::async([testUrl, cb, base]() { std::string resp, err; - bool ok = http::get(testUrl, resp, err); - if (!ok) - { - brls::sync([cb, err]() { cb(false, err); }); - return; - } - - std::string apiErr = json::str(resp, "error"); - if (apiErr.empty()) - apiErr = json::str(resp, "message"); - if (!apiErr.empty()) - { - brls::sync([cb, apiErr]() { cb(false, apiErr); }); - return; - } - - if (resp.find("\"Results\"") == std::string::npos && - resp.find("\"Indexers\"") == std::string::npos) - { - brls::sync([cb]() { cb(false, tr("Invalid response from server. Check URL and API key.")); }); - return; - } - - brls::sync([cb]() { cb(true, ""); }); + long status = 0; + Diagnosis d = http::get(testUrl, resp, err, nullptr, 25, &status) + ? diagnoseAnswer(status, resp, false) + : diagnoseTransport(err); + brls::sync([cb, d, base]() { + if (d.problem != Problem::None) + { + cb(false, explain(d, base)); + return; + } + // Working: say how many indexers it has, and how many of them + // failed this time (a tracker down, a login expired). + std::string msg = tr("Jackett works. Indexers: ") + std::to_string(d.indexers); + if (d.failed > 0) + msg += tr(", with errors: ") + std::to_string(d.failed) + "\n" + d.detail; + cb(true, msg); + }); }); } +std::string explain(const Diagnosis& d, const std::string& base) +{ + switch (d.problem) + { + case Problem::None: + return ""; + case Problem::BadUrl: + return tr("The Jackett address is not a valid URL. Enter it like " + "http://192.168.1.100:9117."); + case Problem::NoHost: + return tr("The Jackett address does not resolve. Check its spelling, or use " + "the computer's IP address (like http://192.168.1.100:9117)."); + case Problem::Unreachable: + return tr("Nothing answers at ") + base + + tr(". Check that Jackett is running, that the console is on the same " + "network, and the port (9117 by default)."); + case Problem::Timeout: + return tr("Jackett did not answer in time. Indexers can be slow: try again, " + "and if it keeps happening, check the network."); + case Problem::Certificate: + return tr("Secure connection to Jackett failed: ") + d.detail; + case Problem::BadKey: + return tr("Jackett rejected the API key. Copy it from the top right corner of " + "the Jackett web page into Options, Jackett."); + case Problem::NotJackett: + return tr("Something answers at this address, but not Jackett's API. Check " + "the port (9117 by default) and the API key."); + case Problem::NoIndexers: + return tr("Jackett works, but has no indexers. Add trackers with \"Add " + "indexer\" on the Jackett web page."); + case Problem::IndexersFailed: + return tr("Every Jackett indexer returned an error. Check them on the Jackett " + "web page.") + std::string("\n") + d.detail; + case Problem::Other: + break; + } + return d.detail.empty() ? std::string(tr("Invalid response from Jackett server")) + : d.detail; +} + void downloadTorrentAsync(const std::string& link, const std::string& destPath, std::function cb) diff --git a/app/jackett.hpp b/app/jackett.hpp index e236242..befbc8c 100644 --- a/app/jackett.hpp +++ b/app/jackett.hpp @@ -5,6 +5,8 @@ #include #include +#include "jackett_diag.hpp" + namespace jackett { @@ -58,8 +60,14 @@ void searchAsync(const std::string& query, Category category, // Sorts items in-place according to SortBy. void sortItems(std::vector& items, SortBy sort); -// Tests connectivity with the specified URL and API key. -// Callback is invoked on the UI thread (via brls::sync). +// What a Diagnosis means and what to do about it, in the UI language, for +// the server at `base`. "" for Problem::None. +std::string explain(const Diagnosis& d, const std::string& base); + +// Tests connectivity with the specified URL and API key: a search, so the +// indexers are checked too. Callback is invoked on the UI thread (via +// brls::sync); on success `msg` says how many indexers answered ("Jackett +// works. Indexers: 12"), on failure what is wrong and what to do. void testConnectionAsync(const std::string& url, const std::string& apiKey, std::function cb); diff --git a/app/jackett_diag.cpp b/app/jackett_diag.cpp new file mode 100644 index 0000000..8d1db5d --- /dev/null +++ b/app/jackett_diag.cpp @@ -0,0 +1,144 @@ +#include "jackett_diag.hpp" + +#include +#include + +#include "json.hpp" + +namespace jackett +{ + +namespace +{ + +std::string lower(std::string s) +{ + for (auto& c : s) c = (char)std::tolower((unsigned char)c); + return s; +} + +bool has(const std::string& hay, const char* needle) +{ + return hay.find(needle) != std::string::npos; +} + +bool mentionsKey(const std::string& low) +{ + return has(low, "api key") || has(low, "apikey") || has(low, "api_key") || + has(low, "unauthorized"); +} + +} // namespace + +Diagnosis diagnoseTransport(const std::string& err) +{ + Diagnosis d; + d.detail = err; + std::string low = lower(err); + if (has(low, "resolve host") || has(low, "resolve proxy")) + d.problem = Problem::NoHost; + else if (has(low, "timed out") || has(low, "timeout")) + d.problem = Problem::Timeout; + else if (has(low, "certificate") || has(low, "ssl") || has(low, "tls")) + d.problem = Problem::Certificate; + else if (has(low, "connect") || has(low, "refused") || has(low, "no route") || + has(low, "unreachable") || has(low, "reset by peer") || + has(low, "empty reply")) + d.problem = Problem::Unreachable; + else if (has(low, "url") || has(low, "protocol") || has(low, "malformed")) + d.problem = Problem::BadUrl; + else + d.problem = Problem::Other; + return d; +} + +Diagnosis diagnoseAnswer(long status, const std::string& body, bool query) +{ + Diagnosis d; + std::string low = lower(body); + + if (status == 401 || status == 403) + { + d.problem = Problem::BadKey; + return d; + } + + // Jackett's own error object: {"error": "..."} or {"message": "..."}. + std::string apiErr = json::str(body, "error"); + if (apiErr.empty()) apiErr = json::str(body, "message"); + + bool isApi = has(body, "\"Results\"") || has(body, "\"Indexers\""); + if (!isApi) + { + if (!apiErr.empty()) + { + d.problem = mentionsKey(lower(apiErr)) ? Problem::BadKey : Problem::Other; + d.detail = apiErr; + } + else if (status >= 500) + { + d.problem = Problem::Other; + d.detail = "HTTP " + std::to_string(status); + } + else + // A login page, a router's page, a 404: not the API. + d.problem = Problem::NotJackett; + return d; + } + + // {"Results":[...],"Indexers":[{"Name":..,"Error":null|"..."}]} + for (const auto& ix : json::objects(body, "Indexers")) + { + d.indexers++; + std::string e = json::str(ix, "Error"); + if (!e.empty()) + { + d.failed++; + if (d.detail.empty()) + { + std::string name = json::str(ix, "Name"); + d.detail = name.empty() ? e : name + ": " + e; + } + } + } + bool anyResult = !json::objects(body, "Results").empty(); + if (d.indexers == 0 && !query) + d.problem = Problem::NoIndexers; + else if (d.indexers > 0 && d.failed == d.indexers && !anyResult) + d.problem = Problem::IndexersFailed; + return d; +} + +std::string normalizeUrl(const std::string& typed) +{ + std::string s = typed; + auto space = [](char c) { return std::isspace((unsigned char)c) != 0; }; + s.erase(s.begin(), std::find_if_not(s.begin(), s.end(), space)); + while (!s.empty() && space(s.back())) s.pop_back(); + while (!s.empty() && s.back() == '/') s.pop_back(); + if (s.empty()) return s; + + bool scheme = s.find("://") != std::string::npos; + if (!scheme) + { + // A port after the host ("nas:9117", "[::1]:9117")? The host ends at + // the first '/', and a port is a ':' after any IPv6 bracket. + std::string host = s.substr(0, s.find('/')); + size_t bracket = host.rfind(']'); + size_t colon = host.rfind(':'); + bool port = colon != std::string::npos && + (bracket == std::string::npos || colon > bracket); + if (!port) + { + size_t slash = s.find('/'); + if (slash == std::string::npos) + s += ":9117"; + else + s.insert(slash, ":9117"); + } + s = "http://" + s; + } + return s; +} + +} // namespace jackett diff --git a/app/jackett_diag.hpp b/app/jackett_diag.hpp new file mode 100644 index 0000000..23ea87b --- /dev/null +++ b/app/jackett_diag.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include + +// What went wrong talking to Jackett, in terms a person can act on: curl's +// "Couldn't connect to server" or an empty 401 told nobody whether to check +// the address, the port, the key or Jackett itself. Pure (no UI, no network): +// jackett.cpp turns a Diagnosis into a translated sentence, the PC tests feed +// it canned answers. +namespace jackett +{ + +enum class Problem +{ + None, + BadUrl, // not a URL curl can use ("htp://", spaces) + NoHost, // the name does not resolve + Unreachable, // nothing listens there: Jackett not running, wrong port, wrong IP + Timeout, // asked, never answered in time + Certificate, // https with a chain that does not verify (often the clock) + BadKey, // 401 / 403 / "Invalid API key" + NotJackett, // something answered, but not Jackett's API (a web page, a 404) + NoIndexers, // Jackett works, but has no indexers to search + IndexersFailed, // every indexer returned an error + Other, // anything else; `detail` has the raw text +}; + +struct Diagnosis +{ + Problem problem = Problem::None; + std::string detail; // the raw error, or the first indexer's error + int indexers = 0; // in the answer's "Indexers" list + int failed = 0; // of those, how many reported an error +}; + +// curl gave up before an answer arrived: `err` is its message. +Diagnosis diagnoseTransport(const std::string& err); + +// An answer arrived: its HTTP status (0 = unknown) and body. `query` is true +// for a search: an empty indexer list is then not an error by itself (a +// search can legitimately find nothing), but every indexer failing is. +Diagnosis diagnoseAnswer(long status, const std::string& body, bool query); + +// The address as typed, made usable: spaces trimmed, "http://" added when no +// scheme was given, a trailing "/" dropped, and Jackett's default port 9117 +// added when neither a scheme nor a port was typed ("192.168.1.5"). +std::string normalizeUrl(const std::string& typed); + +} // namespace jackett diff --git a/app/main.cpp b/app/main.cpp index 37e0d9c..de3b2ee 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -52,6 +52,7 @@ #include "http.hpp" #include "jackett.hpp" #include "jackett_tab.hpp" +#include "setup.hpp" #include "history.hpp" #include "history_tab.hpp" #include "bookmarks.hpp" @@ -1993,6 +1994,8 @@ int main(int argc, char* argv[]) brls::Application::getWindowSizeChangedEvent()->subscribe(applyUiScale); brls::Application::pushActivity(new brls::Activity(buildBrowser())); + // First run without Jackett: its setup on top, once. + maybeOfferJackettSetup(); // A quiet check: it only ever surfaces when there IS a newer release, so a // missing network or an unreachable GitHub costs nothing but a log line. diff --git a/app/settings.cpp b/app/settings.cpp index ba10577..3381289 100644 --- a/app/settings.cpp +++ b/app/settings.cpp @@ -463,6 +463,7 @@ brls::View* jackettPane() urlCell->registerClickAction([urlCell](brls::View*) { brls::Application::getImeManager()->openForText( [urlCell](std::string out) { + out = jackett::normalizeUrl(out); // "192.168.1.5" -> http://...:9117 config::get().jackettUrl = out; config::save(); urlCell->setDetailText(out.empty() ? tr("Not set") : out); @@ -514,10 +515,8 @@ brls::View* jackettPane() [testBtn](bool ok, const std::string& msg) { testBtn->setState(brls::ButtonState::ENABLED); testBtn->setText(tr("Test connection")); - if (ok) - note(tr("Connection successful!")); - else - note(tr("Connection failed: ") + msg); + // Either way the message says what was found or what to fix. + note(ok ? msg : tr("Connection failed: ") + msg); }); return true; }); diff --git a/app/setup.cpp b/app/setup.cpp new file mode 100644 index 0000000..6f36708 --- /dev/null +++ b/app/setup.cpp @@ -0,0 +1,203 @@ +#include "setup.hpp" + +#include + +#include + +#include "config.hpp" +#include "i18n.hpp" +#include "jackett.hpp" +#include "theme.hpp" +#include "widgets.hpp" + +namespace +{ + +std::string maskKey(const std::string& k) +{ + if (k.empty()) return tr("None"); + if (k.size() <= 8) return "********"; + return k.substr(0, 4) + "..." + k.substr(k.size() - 4); +} + +class JackettSetupView : public brls::Box +{ + public: + JackettSetupView() + { + setAxis(brls::Axis::COLUMN); + setGrow(1.0f); + setPadding(30.0f, 80.0f, 20.0f, 80.0f); + + auto* title = new brls::Label(); + title->setText(tr("Set up Jackett")); + title->setFontSize(30.0f); + addView(title); + + auto* intro = new brls::Label(); + intro->setText(tr("The app finds releases through Jackett: a server on your computer " + "or NAS that searches the torrent trackers. Enter its address and " + "API key, then check the connection.")); + intro->setFontSize(18.0f); + intro->setLineHeight(1.4f); + intro->setTextColor(theme::textMuted()); + intro->setMargins(12.0f, 0.0f, 20.0f, 0.0f); + addView(intro); + + const config::Config& cfg = config::get(); + + urlCell = new brls::DetailCell(); + urlCell->setText(tr("Server URL")); + urlCell->setDetailText(cfg.jackettUrl.empty() ? tr("Not set") : cfg.jackettUrl); + urlCell->registerClickAction([this](brls::View*) { + brls::Application::getImeManager()->openForText( + [this](std::string out) { + out = jackett::normalizeUrl(out); + config::get().jackettUrl = out; + config::save(); + urlCell->setDetailText(out.empty() ? tr("Not set") : out); + resetCheck(); + }, + tr("Jackett Server URL"), tr("e.g. http://192.168.1.100:9117"), 256, + config::get().jackettUrl); + return true; + }); + addView(urlCell); + + keyCell = new brls::DetailCell(); + keyCell->setText(tr("API Key")); + keyCell->setDetailText(maskKey(cfg.jackettApiKey)); + keyCell->registerClickAction([this](brls::View*) { + brls::Application::getImeManager()->openForText( + [this](std::string out) { + // Pasted keys pick up spaces at either end. + while (!out.empty() && out.back() == ' ') out.pop_back(); + while (!out.empty() && out.front() == ' ') out.erase(0, 1); + config::get().jackettApiKey = out; + config::save(); + keyCell->setDetailText(maskKey(out)); + resetCheck(); + }, + tr("Jackett API Key (optional)"), tr("Leave empty if disabled in Jackett"), + 128, config::get().jackettApiKey); + return true; + }); + addView(keyCell); + + auto* hint = new brls::Label(); + hint->setText(tr("The API Key shown in the top right corner of the Jackett web " + "interface (leave empty if disabled).")); + hint->setFontSize(15.0f); + hint->setTextColor(theme::textMuted()); + hint->setMargins(10.0f, 20.0f, 0.0f, 20.0f); + addView(hint); + + status = new brls::Label(); + status->setFontSize(18.0f); + status->setLineHeight(1.4f); + status->setMargins(20.0f, 20.0f, 0.0f, 20.0f); + status->setVisibility(brls::Visibility::GONE); + addView(status); + + auto* buttons = new brls::Box(); + buttons->setAxis(brls::Axis::ROW); + buttons->setMarginTop(24.0f); + checkBtn = ui::actionButton(tr("Check and save"), true, [this]() { onCheck(); }); + buttons->addView(checkBtn); + skipBtn = ui::actionButton(tr("Skip"), false, []() { + brls::Application::popActivity(); + }); + buttons->addView(skipBtn); + addView(buttons); + + auto* later = new brls::Label(); + later->setText(tr("You can change this later in Options, Jackett.")); + later->setFontSize(15.0f); + later->setTextColor(theme::textMuted()); + later->setMargins(14.0f, 4.0f, 0.0f, 0.0f); + addView(later); + } + + ~JackettSetupView() override { *alive = false; } + + private: + std::shared_ptr alive = std::make_shared(true); + brls::DetailCell* urlCell = nullptr; + brls::DetailCell* keyCell = nullptr; + brls::Label* status = nullptr; + brls::Button* checkBtn = nullptr; + brls::Button* skipBtn = nullptr; + bool checking = false; + bool working = false; // the last check passed: the button now closes + + void say(const std::string& text, NVGcolor color) + { + status->setText(text); + status->setTextColor(color); + status->setVisibility(brls::Visibility::VISIBLE); + } + + // The address or the key changed: the last result no longer holds. + void resetCheck() + { + working = false; + checkBtn->setText(tr("Check and save")); + status->setVisibility(brls::Visibility::GONE); + } + + void onCheck() + { + if (working) + { + brls::Application::popActivity(); + return; + } + if (checking) return; + const config::Config& cfg = config::get(); + if (cfg.jackettUrl.empty()) + { + say(tr("Please set Server URL first."), theme::textWarn()); + return; + } + checking = true; + checkBtn->setText(tr("Testing...")); + say(tr("Asking Jackett and its indexers..."), theme::textMuted()); + auto live = alive; + jackett::testConnectionAsync(cfg.jackettUrl, cfg.jackettApiKey, + [this, live](bool ok, const std::string& msg) { + if (!*live) return; + checking = false; + working = ok; + checkBtn->setText(ok ? tr("Done") : tr("Check and save")); + say(msg, ok ? nvgRGB(0x9b, 0xe3, 0x8b) : theme::textWarn()); + }); + } +}; + +class JackettSetupActivity : public brls::Activity +{ + public: + brls::View* createContentView() override + { + auto* frame = new brls::AppletFrame(new JackettSetupView()); + frame->setTitle("Jackett"); + return frame; + } +}; + +} // namespace + +void openJackettSetup() +{ + brls::Application::pushActivity(new JackettSetupActivity()); +} + +void maybeOfferJackettSetup() +{ + config::Config& cfg = config::get(); + if (cfg.jackettSetupAsked || !cfg.jackettUrl.empty()) return; + // Once: skipping it (or B) is an answer too. + cfg.jackettSetupAsked = true; + config::save(); + openJackettSetup(); +} diff --git a/app/setup.hpp b/app/setup.hpp new file mode 100644 index 0000000..deac348 --- /dev/null +++ b/app/setup.hpp @@ -0,0 +1,11 @@ +#pragma once + +// The first-run setup: Jackett, the app's main source of releases. A screen +// with the server address, the API key and a check that also counts the +// indexers. Offered once, at startup, while no Jackett address is set; after +// that (finished or skipped) it lives in Options -> Jackett. The TMDB key +// ships in the build and the proxy is optional, so neither is asked here. +void maybeOfferJackettSetup(); + +// The same screen on demand. +void openJackettSetup(); diff --git a/pctest/run_tests.sh b/pctest/run_tests.sh index 6d60693..701e680 100755 --- a/pctest/run_tests.sh +++ b/pctest/run_tests.sh @@ -6,6 +6,7 @@ # test_tmdb -- TMDB answers and the JSON helpers (pctest/test_tmdb.cpp) # test_release -- release names, ranking, episode files (pctest/test_release.cpp) # test_library -- the library's JSON, Continue, import merge (pctest/test_library.cpp) +# test_jackett -- Jackett errors explained, typed addresses (pctest/test_jackett.cpp) # # Needs a C compiler and libcurl headers (Debian/Ubuntu: build-essential # libcurl4-openssl-dev; MSYS2/Cygwin: gcc libcurl-devel). No mbedtls: SHA-1 @@ -64,6 +65,13 @@ ${CXX:-g++} -O1 -g -Wall -std=c++17 \ pctest/test_library.cpp \ -o "$out/test_library" +${CXX:-g++} -O1 -g -Wall -std=c++17 \ + -Iapp \ + app/jackett_diag.cpp \ + app/json.cpp \ + pctest/test_jackett.cpp \ + -o "$out/test_jackett" + "$out/test_suite" echo "$out/test_httpsrc" @@ -75,3 +83,5 @@ echo "$out/test_release" echo "$out/test_library" +echo +"$out/test_jackett" diff --git a/pctest/test_jackett.cpp b/pctest/test_jackett.cpp new file mode 100644 index 0000000..d370acf --- /dev/null +++ b/pctest/test_jackett.cpp @@ -0,0 +1,113 @@ +// Offline tests for app/jackett_diag.cpp: what the app tells people when +// Jackett does not answer the way it should, and how a typed address is +// cleaned up. + +#include +#include +#include + +#include "jackett_diag.hpp" + +#define CHECK(cond) \ + do \ + { \ + if (!(cond)) \ + { \ + std::printf(" FAILED: %s (line %d)\n", #cond, __LINE__); \ + std::exit(1); \ + } \ + } while (0) + +using jackett::Problem; + +static void test_transport() +{ + std::printf("[1/4] curl errors, by what to do about them...\n"); + using jackett::diagnoseTransport; + CHECK(diagnoseTransport("Couldn't connect to server").problem == Problem::Unreachable); + CHECK(diagnoseTransport("Failed to connect to 192.168.1.5 port 9117: Connection refused") + .problem == Problem::Unreachable); + CHECK(diagnoseTransport("Could not resolve host: jacket.local").problem == Problem::NoHost); + CHECK(diagnoseTransport("Timeout was reached").problem == Problem::Timeout); + CHECK(diagnoseTransport("Operation timed out after 25001 milliseconds").problem == + Problem::Timeout); + CHECK(diagnoseTransport("could not verify the site's certificate -- check the console's " + "date and time") + .problem == Problem::Certificate); + CHECK(diagnoseTransport("URL using bad/illegal format or missing URL").problem == + Problem::BadUrl); + CHECK(diagnoseTransport("Unsupported protocol").problem == Problem::BadUrl); + jackett::Diagnosis o = jackett::diagnoseTransport("Something odd"); + CHECK(o.problem == Problem::Other && o.detail == "Something odd"); + std::printf(" PASSED\n"); +} + +static void test_answers() +{ + std::printf("[2/4] Answers: key, not Jackett, server errors...\n"); + using jackett::diagnoseAnswer; + CHECK(diagnoseAnswer(401, "", false).problem == Problem::BadKey); + CHECK(diagnoseAnswer(403, "{}", true).problem == Problem::BadKey); + CHECK(diagnoseAnswer(400, R"({"error":"Invalid API Key"})", false).problem == + Problem::BadKey); + CHECK(diagnoseAnswer(200, "Login", false).problem == + Problem::NotJackett); + CHECK(diagnoseAnswer(404, "Not Found", true).problem == Problem::NotJackett); + jackett::Diagnosis e = diagnoseAnswer(500, "oops", true); + CHECK(e.problem == Problem::Other && e.detail == "HTTP 500"); + jackett::Diagnosis m = diagnoseAnswer(400, R"({"message":"Indexer is not configured"})", true); + CHECK(m.problem == Problem::Other && m.detail == "Indexer is not configured"); + std::printf(" PASSED\n"); +} + +static void test_indexers() +{ + std::printf("[3/4] Indexers: none, all failing, some fine...\n"); + using jackett::diagnoseAnswer; + const char* none = R"({"Results":[],"Indexers":[]})"; + CHECK(diagnoseAnswer(200, none, false).problem == Problem::NoIndexers); + // A search that finds nothing is just that. + CHECK(diagnoseAnswer(200, none, true).problem == Problem::None); + + const char* allBad = R"({"Results":[],"Indexers":[ + {"ID":"rutor","Name":"Rutor","Status":1,"Results":0,"Error":"Request timed out"}, + {"ID":"nnm","Name":"NNM-Club","Status":1,"Results":0,"Error":"Login failed"}]})"; + jackett::Diagnosis b = diagnoseAnswer(200, allBad, true); + CHECK(b.problem == Problem::IndexersFailed); + CHECK(b.indexers == 2 && b.failed == 2); + CHECK(b.detail == "Rutor: Request timed out"); + + const char* someOk = R"({"Results":[{"Title":"Dune.2021.1080p","Size":1}],"Indexers":[ + {"ID":"rutor","Name":"Rutor","Status":2,"Results":1,"Error":null}, + {"ID":"nnm","Name":"NNM-Club","Status":1,"Results":0,"Error":"Login failed"}]})"; + jackett::Diagnosis s = diagnoseAnswer(200, someOk, true); + CHECK(s.problem == Problem::None); + CHECK(s.indexers == 2 && s.failed == 1); + std::printf(" PASSED\n"); +} + +static void test_urls() +{ + std::printf("[4/4] Typed addresses...\n"); + using jackett::normalizeUrl; + CHECK(normalizeUrl(" http://192.168.1.5:9117/ ") == "http://192.168.1.5:9117"); + CHECK(normalizeUrl("192.168.1.5") == "http://192.168.1.5:9117"); + CHECK(normalizeUrl("192.168.1.5:8080") == "http://192.168.1.5:8080"); + CHECK(normalizeUrl("nas.local/jackett") == "http://nas.local:9117/jackett"); + // A scheme typed: taken as is, no port guessed (a reverse proxy). + CHECK(normalizeUrl("https://jackett.example.com") == "https://jackett.example.com"); + CHECK(normalizeUrl("[::1]") == "http://[::1]:9117"); + CHECK(normalizeUrl("[::1]:9000") == "http://[::1]:9000"); + CHECK(normalizeUrl(" ").empty()); + std::printf(" PASSED\n"); +} + +int main() +{ + test_transport(); + test_answers(); + test_indexers(); + test_urls(); + std::printf("ALL TESTS PASSED\n"); + return 0; +} diff --git a/pctest/test_net.cpp b/pctest/test_net.cpp index 58c6eff..192a96d 100644 --- a/pctest/test_net.cpp +++ b/pctest/test_net.cpp @@ -27,7 +27,7 @@ static int g_gets; namespace http { bool get(const std::string& url, std::string& resp, std::string& err, - const char*, long timeoutSec) + const char*, long timeoutSec, long*) { g_gets++; g_lastUrl = url; diff --git a/pctest/test_tmdb.cpp b/pctest/test_tmdb.cpp index ff1e59a..9d0db3b 100644 --- a/pctest/test_tmdb.cpp +++ b/pctest/test_tmdb.cpp @@ -24,7 +24,7 @@ namespace http { -bool get(const std::string&, std::string&, std::string& err, const char*, long) +bool get(const std::string&, std::string&, std::string& err, const char*, long, long*) { err = "no network in tests"; return false; From 5a12e70c6e614fe425f78d25ea59d51cde5790ed Mon Sep 17 00:00:00 2001 From: Langegen Date: Thu, 29 Oct 2026 09:55:27 +0300 Subject: [PATCH 09/13] PC pass over the UI: scripted checks, release matching, anime, fixes A scripted run of the PC build (app/autotest.cpp, NX_AUTOTEST=script): the app presses buttons, opens screens, plays a link and saves screenshots and view-tree dumps itself; input goes straight into borealis, never through the desktop. A frame-end hook in borealis (VENDORED.md #7) is where the frame is read back. Not compiled for the console. What it found, fixed - Sources: a search for a show also finds other titles ("Fallout" -> Mission.Impossible.Fallout.2018 ranked "best choice"). release::titleMatches (the name or one of its " / " parts starts with the local or original title) and release::yearsIn hide other titles, a film's other years and, for a show, same-named films from before it began -- only once some release does match, so an odd spelling never empties the list. - Ranking: "H 264" / "x 265" with spaces read as codecs; Ukrainian-only audio ranks below the rest in a Russian UI. The first listing puts the focus on the best choice, so A plays it. - Sizes over 2 GB wrapped (json::integer was `long`, 32-bit on Windows) and went missing: 64-bit now. - Selected pills (quality filters, seasons, search filters) never showed as selected: setPillActive painted over the accent. - Card: the title from TMDB's details (it kept whatever opened it); poster and backdrop filled in when the opener had none; when the details fail, say so and let Watch / Torrents search by title (it said "Still loading" forever); L/R season hints only for a show. - Focus: empty strips no longer take it (a 0x0 frame held it on the card); a collection opens with the first poster focused; a genre tile in search hands it to the first result. - No empty title bar over the card, sources, collection and setup screens; Options values and list pickers in the accent instead of borealis' cyan; accent text readable on the dark field (accentText). - Sources menu: Jackett first, nothing marked when none is open. - Jacred mirrors (ru.jac.black): no "Indexers" list is a working server, not "no indexers"; a typed public name keeps port 80; nginx's own 403 is "the server refused", not "wrong key". The setup says Jacred works too. - TMDB: one retry on a failed request. - Engine errors translated in the metadata dialog; more of them in ru. Anime - "Anime" in the header next to Movies and Shows: popular / new / top anime series, anime films, and genre rows (TMDB discover: animation, original language ja). - Search: Movies / Shows / Anime; anime text search keeps anime results only. Tests: release title matching, years, codecs, language order; json 64-bit sizes; Jacred answers. 7 offline suites pass; .nro and PC build clean. Co-Authored-By: Claude Opus 5.5 --- app/autotest.cpp | 365 ++++++++++++++++++ app/autotest.hpp | 32 ++ app/bookmarks.cpp | 2 +- app/card.cpp | 53 ++- app/collection.cpp | 27 +- app/collection.hpp | 5 + app/config.hpp | 1 + app/content.hpp | 1 + app/history_tab.cpp | 4 +- app/home.cpp | 28 +- app/i18n_ru.cpp | 26 ++ app/jackett.cpp | 9 + app/jackett_diag.cpp | 42 +- app/jackett_diag.hpp | 6 +- app/jackett_tab.cpp | 2 +- app/json.cpp | 4 +- app/json.hpp | 4 +- app/main.cpp | 27 +- app/player.cpp | 2 +- app/release.cpp | 101 ++++- app/release.hpp | 16 + app/search.cpp | 29 +- app/setup.cpp | 17 +- app/sources.cpp | 61 ++- app/theme.cpp | 15 + app/theme.hpp | 4 +- app/tmdb.cpp | 34 +- app/tmdb.hpp | 8 + app/torrent_play.cpp | 4 +- app/widgets.cpp | 21 +- library/borealis/VENDORED.md | 8 + .../include/borealis/core/application.hpp | 6 + .../borealis/library/lib/core/application.cpp | 10 + pctest/test_jackett.cpp | 20 + pctest/test_release.cpp | 49 +++ 35 files changed, 977 insertions(+), 66 deletions(-) create mode 100644 app/autotest.cpp create mode 100644 app/autotest.hpp diff --git a/app/autotest.cpp b/app/autotest.cpp new file mode 100644 index 0000000..ea8bbf9 --- /dev/null +++ b/app/autotest.cpp @@ -0,0 +1,365 @@ +#include "autotest.hpp" + +#ifdef __SWITCH__ + +namespace autotest +{ +bool init() { return false; } +void tick() {} +} // namespace autotest + +#else + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#define STB_IMAGE_WRITE_STATIC +#include "../library/borealis/library/lib/extern/glfw/deps/stb_image_write.h" + +#include "card.hpp" +#include "content.hpp" +#include "home.hpp" +#include "player.hpp" +#include "setup.hpp" + +namespace autotest +{ + +namespace +{ + +using Clock = std::chrono::steady_clock; + +std::vector lines; +size_t pc = 0; +Clock::time_point resumeAt; +std::string outDir; +std::string pendingShot; // taken by the frame-end hook +FILE* logf = nullptr; + +void say(const std::string& s) +{ + if (!logf) return; + std::fprintf(logf, "%s\n", s.c_str()); + std::fflush(logf); +} + +const std::map& buttons() +{ + static const std::map m = { + { "A", brls::BUTTON_A }, { "B", brls::BUTTON_B }, + { "X", brls::BUTTON_X }, { "Y", brls::BUTTON_Y }, + { "LB", brls::BUTTON_LB }, { "RB", brls::BUTTON_RB }, + { "LT", brls::BUTTON_LT }, { "RT", brls::BUTTON_RT }, + { "START", brls::BUTTON_START }, { "BACK", brls::BUTTON_BACK }, + { "UP", brls::BUTTON_NAV_UP }, { "DOWN", brls::BUTTON_NAV_DOWN }, + { "LEFT", brls::BUTTON_NAV_LEFT }, { "RIGHT", brls::BUTTON_NAV_RIGHT }, + // The D-pad itself (a real press sends both): the player's seek and + // other actions bound to the pad rather than to navigation. + { "DUP", brls::BUTTON_UP }, { "DDOWN", brls::BUTTON_DOWN }, + { "DLEFT", brls::BUTTON_LEFT }, { "DRIGHT", brls::BUTTON_RIGHT }, + }; + return m; +} + +// The frame, read back before the swap and written top row first. +void takeShot() +{ + GLint vp[4] = { 0, 0, 0, 0 }; + glGetIntegerv(GL_VIEWPORT, vp); + int w = vp[2], h = vp[3]; + if (w <= 0 || h <= 0) return; + std::vector px((size_t)w * h * 4); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, px.data()); + std::vector flip((size_t)w * h * 3); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + const unsigned char* s = &px[((size_t)(h - 1 - y) * w + x) * 4]; + unsigned char* d = &flip[((size_t)y * w + x) * 3]; + d[0] = s[0]; + d[1] = s[1]; + d[2] = s[2]; + } + std::string path = outDir + "/" + pendingShot + ".png"; + stbi_write_png(path.c_str(), w, h, 3, flip.data(), w * 3); + say("shot " + path + " " + std::to_string(w) + "x" + std::to_string(h)); +} + +std::string viewName(brls::View* v) +{ + std::string n = typeid(*v).name(); + // MinGW mangles: "N4brls5LabelE" -> keep it, it reads well enough; strip + // the common prefixes. + return n; +} + +void dumpView(std::ostream& o, brls::View* v, int depth, brls::View* focus) +{ + if (!v) return; + bool gone = v->getVisibility() == brls::Visibility::GONE; + o << std::string(depth * 2, ' ') << viewName(v); + char geo[96]; + std::snprintf(geo, sizeof(geo), " [%.0f,%.0f %.0fx%.0f]", v->getX(), v->getY(), + v->getWidth(), v->getHeight()); + o << geo; + if (gone) o << " GONE"; + if (v->getVisibility() == brls::Visibility::INVISIBLE) o << " INVISIBLE"; + if (v->isFocusable()) o << " focusable"; + if (v == focus) o << " <>"; + if (auto* l = dynamic_cast(v)) + { + std::string t = l->getFullText(); + for (auto& c : t) + if (c == '\n') c = '|'; + o << " \"" << t << "\""; + } + o << "\n"; + if (gone) return; + if (auto* b = dynamic_cast(v)) + for (brls::View* c : b->getChildren()) dumpView(o, c, depth + 1, focus); +} + +void dump(const std::string& name) +{ + auto stack = brls::Application::getActivitiesStack(); + std::ofstream o(outDir + "/" + name + ".txt"); + o << "activities: " << stack.size() << "\n"; + brls::View* focus = brls::Application::getCurrentFocus(); + o << "focus: " << (focus ? viewName(focus) : std::string("none")); + if (focus) + { + char geo[96]; + std::snprintf(geo, sizeof(geo), " [%.0f,%.0f %.0fx%.0f]", focus->getX(), + focus->getY(), focus->getWidth(), focus->getHeight()); + o << geo; + } + o << "\n"; + if (!stack.empty()) dumpView(o, stack.back()->getContentView(), 0, focus); + say("dump " + name); +} + +content::Item itemFor(const std::string& kind, int id) +{ + content::Item it; + it.kind = kind == "tv" ? content::Kind::Series : content::Kind::Movie; + it.tmdbId = id; + it.title = "#" + std::to_string(id); + return it; +} + +// One command; false to stop for now (a wait). +bool run(const std::string& line) +{ + std::istringstream in(line); + std::string cmd; + in >> cmd; + if (cmd.empty() || cmd[0] == '#') return true; + say("> " + line); + if (cmd == "wait") + { + int ms = 0; + in >> ms; + resumeAt = Clock::now() + std::chrono::milliseconds(ms); + return false; + } + if (cmd == "press") + { + std::string b; + int n = 1; + in >> b >> n; + if (n < 1) n = 1; + auto it = buttons().find(b); + if (it == buttons().end()) + { + say("unknown button " + b); + return true; + } + brls::Application::onControllerButtonPressed(it->second, false); + // The rest one by one, each after the pause below, as a hand would. + if (n > 1) + lines.insert(lines.begin() + (long)pc, "press " + b + " " + std::to_string(n - 1)); + // Let the press land (and any animation start) before the next line. + resumeAt = Clock::now() + std::chrono::milliseconds(250); + return false; + } + if (cmd == "shot") + { + in >> pendingShot; + return false; // the hook takes it at the end of the next frame + } + if (cmd == "focuson") + { + // The first focusable view of the top screen whose own text (its + // first label, as `focus` prints it) contains the words given. + std::string want; + std::getline(in, want); + while (!want.empty() && want[0] == ' ') want.erase(0, 1); + auto stack = brls::Application::getActivitiesStack(); + brls::View* hit = nullptr; + std::vector todo; + if (!stack.empty()) todo.push_back(stack.back()->getContentView()); + while (!todo.empty() && !hit) + { + brls::View* v = todo.front(); + todo.erase(todo.begin()); + if (!v || v->getVisibility() == brls::Visibility::GONE) continue; + if (v->isFocusable()) + { + std::vector sub = { v }; + while (!sub.empty()) + { + brls::View* s = sub.front(); + sub.erase(sub.begin()); + if (auto* l = dynamic_cast(s)) + if (!l->getFullText().empty()) + { + if (l->getFullText().find(want) != std::string::npos) hit = v; + break; + } + if (auto* b = dynamic_cast(s)) + for (brls::View* c : b->getChildren()) sub.push_back(c); + } + if (hit) break; + } + if (auto* b = dynamic_cast(v)) + for (brls::View* c : b->getChildren()) todo.push_back(c); + } + if (hit) + brls::Application::giveFocus(hit); + else + say("focuson: nothing says \"" + want + "\""); + resumeAt = Clock::now() + std::chrono::milliseconds(200); + return false; + } + if (cmd == "focus") + { + brls::View* f = brls::Application::getCurrentFocus(); + if (!f) + { + say("focus: none"); + return true; + } + // The first text under it says what it is ("Фильмы", a title). + std::string text; + std::vector todo = { f }; + while (!todo.empty() && text.empty()) + { + brls::View* v = todo.front(); + todo.erase(todo.begin()); + if (auto* l = dynamic_cast(v)) text = l->getFullText(); + if (auto* b = dynamic_cast(v)) + for (brls::View* c : b->getChildren()) todo.push_back(c); + } + char geo[96]; + std::snprintf(geo, sizeof(geo), " [%.0f,%.0f %.0fx%.0f] ", f->getX(), f->getY(), + f->getWidth(), f->getHeight()); + say("focus: " + viewName(f) + geo + "\"" + text + "\""); + return true; + } + if (cmd == "dump") + { + std::string n; + in >> n; + dump(n); + return true; + } + if (cmd == "card") + { + std::string k; + int id = 0; + in >> k >> id; + openContentCard(itemFor(k, id)); + return true; + } + if (cmd == "play") + { + // A direct link, as an addon's would be: the player without the engine. + std::string url, title; + in >> url; + std::getline(in, title); + while (!title.empty() && title[0] == ' ') title.erase(0, 1); + startPlaybackPrompt(url, {}, title.empty() ? url : title); + return true; + } + if (cmd == "setup") + { + openJackettSetup(); + return true; + } + if (cmd == "search") + { + openCatalogSearch(); + return true; + } + if (cmd == "log") + { + std::string rest; + std::getline(in, rest); + say(rest); + return true; + } + if (cmd == "quit") + { + say("quit"); + brls::Application::quit(); + pc = lines.size(); + return false; + } + say("unknown command " + cmd); + return true; +} + +} // namespace + +bool init() +{ + const char* script = std::getenv("NX_AUTOTEST"); + if (!script || !*script) return false; + const char* out = std::getenv("NX_AUTOTEST_OUT"); + outDir = out && *out ? out : "./autotest"; + mkdir(outDir.c_str(), 0755); + logf = std::fopen((outDir + "/run.log").c_str(), "w"); + + std::ifstream f(script); + std::string l; + while (std::getline(f, l)) + { + if (!l.empty() && l.back() == '\r') l.pop_back(); + lines.push_back(l); + } + say("script " + std::string(script) + ": " + std::to_string(lines.size()) + " lines"); + resumeAt = Clock::now(); + + brls::Application::setFrameEndHook([]() { + if (pendingShot.empty()) return; + takeShot(); + pendingShot.clear(); + }); + return true; +} + +void tick() +{ + if (pc >= lines.size() || !pendingShot.empty()) return; + if (Clock::now() < resumeAt) return; + while (pc < lines.size()) + if (!run(lines[pc++])) break; +} + +} // namespace autotest + +#endif diff --git a/app/autotest.hpp b/app/autotest.hpp new file mode 100644 index 0000000..ee44c6c --- /dev/null +++ b/app/autotest.hpp @@ -0,0 +1,32 @@ +#pragma once + +// A scripted run of the UI for the PC build: NX_AUTOTEST=