diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..975cb7e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,88 @@ +# 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 + + # 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 + 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)" + + # 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..b4fd2df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,23 @@ # Build output /build/ /build-ninja/ +/build-pc/ +/pc_deps/ /NX-torrent-player.nro /.updatetest/ /NX-torrent-player.elf /NX-torrent-player.nacp /pctest/enginetest *.o +/pctest/test_suite +/pctest/test_suite.exe +/pctest/test_httpsrc +/pctest/test_httpsrc.exe +/pctest/test_net +/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 b926ffc..119ea0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,22 @@ 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), `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), + `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 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 +86,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 316d010..59d7dc4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,12 +14,14 @@ 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) set(VERSION_MAJOR "0") -set(VERSION_MINOR "5") -set(VERSION_ALTER "0") +set(VERSION_MINOR "6") +set(VERSION_ALTER "1") set(PROJECT_AUTHOR "shodowlo") # On-device app name shown in the Homebrew Menu. set(APP_TITLE "NX Torrent Player") @@ -30,9 +32,18 @@ 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") +# 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) @@ -42,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 @@ -58,10 +71,12 @@ 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") +# 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) @@ -82,7 +97,9 @@ 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}" + TMDB_API_KEY="${TMDB_API_KEY}") if (PLATFORM_SWITCH) # Bundle borealis' resources into the nro's romfs (borealis reads them from @@ -121,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 cc47db7..c118ff7 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,14 @@ 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. +- 🏠 **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. +- 🇷🇺 **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,12 +169,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 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 they need no network; CI runs them 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..c4e5911 100644 --- a/README.ru.md +++ b/README.ru.md @@ -39,7 +39,14 @@ - 🕒 **История просмотров и возобновление** — Вкладка **History** отслеживает просмотренные видео из всех источников (локальные, Jackett, Stremio). Запоминается прогресс и позиция, позволяя продолжить просмотр в один клик. - 📂 **Умный выбор файлов и естественная сортировка** — Удобное окно выбора файлов для раздач с несколькими сериями/файлами. Сортировка учитывает числа в названиях (серии 1, 2, ..., 10 идут по порядку, а не 1, 10, 2). - ⚡ **Улучшенный движок и опрос трекеров** — Фоновый пул потоков для одновременного анонса на более чем 30 трекерах, асинхронное получение метаданных для magnet-ссылок с возможностью отмены, а также фолбэк на DHT для бестрекерных раздач. -- 🧪 **Тестовый набор для ПК** — Автономный тестовый набор (`pctest/`) с встроенной поддержкой SHA-1 для проверки и отладки торрент-движка на компьютере без необходимости сборки под Switch. +- 🏠 **Главная и карточки (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**). К серверам в локальной сети подключение всегда напрямую. +- 🇷🇺 **Русский интерфейс** — Полный перевод на русский, включается в **Options → General → Language**. +- 🧪 **Тестовый набор для ПК** — Офлайн-тесты (`pctest/`) для торрент-движка, чтения `https` по диапазонам и ссылок TorrServer: мок-трекеры, мок-пиры и мок-серверы на loopback. Не нужны ни Switch, ни devkitPro, ни сеть. ### 🎬 Базовые возможности - **Последовательный стриминг** — Быстрый запуск воспроизведения; движок скачивает куски с опережением и мгновенно перестраивается при перемотке. @@ -161,11 +168,19 @@ docker run --rm -v st_build:/build -v "$PWD":/project \ > В среде Windows (Git Bash или MSYS2) добавляйте перед командами `MSYS_NO_PATHCONV=1`, чтобы избежать искажения путей к каталогам. ### Сборка тестов для ПК -Скомпилировать автономный тест движка можно в Linux/macOS или WSL: +Офлайн-тестам нужны только gcc/g++ и заголовки 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/appdata.hpp b/app/appdata.hpp index c5fdfd8..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). @@ -21,6 +27,16 @@ // 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" +// 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/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=