diff --git a/.gitignore b/.gitignore index 6bed30c..ff04f91 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ local.properties *.aab captures/ .idea/ + +# Personal, machine-local files: dev settings (local/dev.env), notes, test data, backups. Never in git. +/local/ +# Debug tour built from one's own server by tools/tour/build_tour.py. +/app/src/debug/assets/tour.json +__pycache__/ diff --git a/tools/dev.env.example b/tools/dev.env.example new file mode 100644 index 0000000..8365f25 --- /dev/null +++ b/tools/dev.env.example @@ -0,0 +1,25 @@ +# Personal settings of the dev tools in tools/. Copy to local/dev.env (the whole local/ is git-ignored) and fill in. +# Plain KEY=value lines, no quotes and no $expansion: the shell scripts and the Python ones both read this file. +# A variable already set in the environment wins over the file. + +# JDK 21 for Gradle and the desktop app (otherwise the JAVA_HOME of the environment, or java on PATH). +#JAVA_HOME=/path/to/jdk-21 +# adb of the Android SDK (otherwise adb on PATH, or ~/Android/Sdk/platform-tools/adb). +#ADB=/path/to/Android/Sdk/platform-tools/adb + +# The test TV for tools/tv/env.sh: adb address ip:port (classic adb over TCP is :5555; the port of +# «Беспроводная отладка» changes after a reboot). +TV_ADB= +# Jellyfin server the tools talk to, by its LAN address, e.g. http://192.168.1.10:8096. +JF_URL= +# Test account on that server. Test runs change its watch state; tools/linux/watchdata.py puts it back. +JF_USER= + +# Series id for the baseline-profile walk over a series page (gen_profile in tools/tv/env.sh). +PROFILE_SERIES= +# Three consecutive episodes of one season, space-separated ids (S1E2 S1E3 S1E4), for tools/linux/k2.py. +K2_EPISODES= +# Any 1080p video item for tools/linux/l0/float_test.py. +L0_FLOAT_ITEM= +# Test cases of tools/linux/l0/run_all.sh, relative to the repo root: lines " [run_case.py options]". +L0_CASES=local/l0-cases.txt diff --git a/tools/devenv.py b/tools/devenv.py new file mode 100644 index 0000000..2fb5a1f --- /dev/null +++ b/tools/devenv.py @@ -0,0 +1,28 @@ +"""Personal settings of the dev tools: local/dev.env (git-ignored; template tools/dev.env.example). + +A variable already set in the environment wins over the file. +""" +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def load(): + path = os.path.join(ROOT, "local", "dev.env") + if not os.path.exists(path): + return + for line in open(path, encoding="utf-8"): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip()) + + +def need(name): + """The variable's value, or exit with a hint where to set it.""" + load() + value = os.environ.get(name) + if not value: + sys.exit(f"{name} is not set: put it in local/dev.env (see tools/dev.env.example)") + return value diff --git a/tools/devenv.sh b/tools/devenv.sh new file mode 100644 index 0000000..6a171a8 --- /dev/null +++ b/tools/devenv.sh @@ -0,0 +1,13 @@ +# Personal settings of the dev tools: local/dev.env (git-ignored; template tools/dev.env.example). +# Sourced by the shell scripts in tools/; a variable already set in the environment wins over the file. +_devenv_load() { + local f k v + f="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/local/dev.env" + [ -f "$f" ] || return 0 + while IFS='=' read -r k v || [ -n "$k" ]; do + case $k in '' | \#*) continue ;; esac + [ -n "${!k+x}" ] || export "$k=$v" + done <"$f" +} +_devenv_load +unset -f _devenv_load diff --git a/tools/linux/desktop.sh b/tools/linux/desktop.sh index 809ab12..c3197b7 100755 --- a/tools/linux/desktop.sh +++ b/tools/linux/desktop.sh @@ -5,7 +5,7 @@ # (tools/linux/stand.sh) points them at its own directory. set -e R=$(cd "$(dirname "$0")/../.." && pwd) -export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1} +source "$R/tools/devenv.sh" # JAVA_HOME from local/dev.env, if set there [ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath) -exec "$JAVA_HOME/bin/java" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \ +exec "${JAVA_HOME:+$JAVA_HOME/bin/}java" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \ -cp "$(cat "$R/desktop/build/desktop.classpath")" ru.stenfredd.sleepyowl.desktop.MainKt "$@" diff --git a/tools/linux/k2.py b/tools/linux/k2.py index b0257da..af74ffb 100755 --- a/tools/linux/k2.py +++ b/tools/linux/k2.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Checkpoint К2 on the test stand (LINUX_PLAN.md §3): the player window and the system integration, end to end. +"""Checkpoint К2 on the test stand (docs/desktop.md): the player window and the system integration, end to end. tools/linux/k2.py runs it; PASS/FAIL per check, screenshots in $STAND/shots/k2-*.png @@ -7,18 +7,22 @@ Everything happens on the stand's display :87 and its own D-Bus (stand.sh): the are not touched (mpv plays into ao=null), the app runs niced. The player's state is read back from MPRIS (status, position, volume, title), the windows from X11 (_NET_WM_STATE, geometry, frame), the screensaver from fake_screensaver.py. The player settings start from scratch (first launch) and are put back afterwards, as is the test account's watch data. +Needs K2_EPISODES (three consecutive episodes) in local/dev.env, see tools/dev.env.example. """ import os, re, shutil, subprocess, sys, time from Xlib import X, XK, display from Xlib.ext import xtest from PIL import Image +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import devenv + R = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) STAND = os.environ.get("STAND") or os.path.expanduser("~/.cache/sleepyowl-test") STAND_SH = os.path.join(R, "tools/linux/stand.sh") PREFS = os.path.join(STAND, "config/sleepyowl") LOG = os.path.join(STAND, "state/sleepyowl/log/sleepyowl.log") -E2, E3, E4 = "00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000" +E2, E3, E4 = devenv.need("K2_EPISODES").split() results = [] diff --git a/tools/linux/l0/float_test.py b/tools/linux/l0/float_test.py index ccb43e3..8d92f7e 100644 --- a/tools/linux/l0/float_test.py +++ b/tools/linux/l0/float_test.py @@ -8,6 +8,9 @@ float_test.py <037|041> import json, os, socket, subprocess, sys, time from Xlib import X, display +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) +import devenv + # mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x) M037 = os.environ.get("MPV037", "mpv") RUN = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000"), "l0") @@ -15,8 +18,8 @@ os.makedirs(RUN, exist_ok=True) ver = sys.argv[1] sock = os.path.join(RUN, f"float{ver}.sock") if os.path.exists(sock): os.unlink(sock) -item = "00000000000000000000000000000000" -url = f"http://192.168.1.10:8096/Videos/{item}/stream?static=true&mediaSourceId={item}" +item = devenv.need("L0_FLOAT_ITEM") +url = f"{devenv.need('JF_URL')}/Videos/{item}/stream?static=true&mediaSourceId={item}" opts = ["--config=no", "--mute=yes", "--start=30%", "--hwdec=auto-safe", "--vo=gpu", "--gpu-api=opengl", "--ontop", "--no-border", "--geometry=640x360-24-24", "--screen=0", "--keep-open=yes", "--title=L0 float", f"--input-ipc-server={sock}"] diff --git a/tools/linux/l0/run_all.sh b/tools/linux/l0/run_all.sh index 7fc30da..3e64f49 100755 --- a/tools/linux/l0/run_all.sh +++ b/tools/linux/l0/run_all.sh @@ -1,7 +1,8 @@ #!/bin/bash -# L0 (LINUX_PLAN.md §3, итоги §6). Нужны: mpv 0.37 ($MPV037 или mpv в PATH), Flatpak io.mpv.Mpv (0.41), python3-xlib для float_test.py. +# L0: проверка mpv на файлах медиатеки до начала ПК-версии (итоги в docs/desktop.md). Нужны: mpv 0.37 ($MPV037 или mpv в PATH), Flatpak io.mpv.Mpv (0.41), python3-xlib для float_test.py. # Прогон всех файлов: 0.37 и 0.41 в vo=gpu (как render API), HDR/DV ещё в gpu-next 0.41, чересстрочные с деинтерлейсом. -D=$(dirname "$0"); OUT=${L0_OUT:-${XDG_RUNTIME_DIR:-/tmp}/l0/out}; mkdir -p $OUT; R=$OUT/results.jsonl +# Список файлов — L0_CASES из local/dev.env (строки «<имя> [опции run_case.py]»). +D=$(dirname "$0"); source "$D/../../devenv.sh"; CASES="$D/../../../${L0_CASES:?set L0_CASES in local/dev.env}"; OUT=${L0_OUT:-${XDG_RUNTIME_DIR:-/tmp}/l0/out}; mkdir -p $OUT; R=$OUT/results.jsonl run() { timeout 150 python3 -I $D/run_case.py "$@" --play 15 >> $R || echo "{\"name\":\"$4\",\"ver\":\"$1\",\"vo\":\"$2\",\"error\":\"timeout\"}" >> $R; } while read -r name id rest; do for ver in 037 041; do run $ver gpu $id $name $rest; done @@ -11,5 +12,5 @@ while read -r name id rest; do run 041 gpu $id ${name}_deint --extra=--deinterlace=yes run 041 gpu $id ${name}_deintauto --extra=--deinterlace=auto;; esac -done < $D/cases.txt +done < "$CASES" echo DONE diff --git a/tools/linux/l0/run_case.py b/tools/linux/l0/run_case.py index 035aa50..dd0740a 100644 --- a/tools/linux/l0/run_case.py +++ b/tools/linux/l0/run_case.py @@ -1,16 +1,19 @@ #!/usr/bin/env python3 """L0: один прогон файла в mpv с замерами через IPC. -run_case.py [--sid N] [--aid N] [--sub-url URL] +run_case.py [--sid N] [--aid N] [--sub-url URL|/path on the server] [--start SEC] [--play SEC] [--seek SEC] [--extra "--opt=val ..."] Печатает одну строку JSON с результатами в stdout. """ import argparse, json, os, shlex, socket, subprocess, sys, threading, time +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) +import devenv + OUT = os.environ.get("L0_OUT") or os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "l0", "out") # mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x) M037 = os.environ.get("MPV037", "mpv") -SERVER = "http://192.168.1.10:8096" +SERVER = devenv.need("JF_URL") # Токен-заглушка: сервер отдаёт static-поток и без токена; проверяем, что заголовок доходит и не ломает запрос. HEADER = 'Authorization: MediaBrowser Token="0123456789abcdef0123456789abcdef"' @@ -49,6 +52,7 @@ if a.start is not None: opts.append(f"--start={a.start}") else: opts.append("--start=40%") if a.sid: opts.append(f"--sid={a.sid}") if a.aid: opts.append(f"--aid={a.aid}") +if a.sub_url and a.sub_url.startswith("/"): a.sub_url = SERVER + a.sub_url if a.sub_url: opts += [f"--sub-files-append={a.sub_url}", "--sid=auto"] opts += shlex.split(a.extra) diff --git a/tools/linux/stand.sh b/tools/linux/stand.sh index 11d4938..6d44ec8 100755 --- a/tools/linux/stand.sh +++ b/tools/linux/stand.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Test stand of the desktop app (LINUX_PLAN.md §3 «Как проверяем»): a private virtual display :87 with the real +# Test stand of the desktop app (docs/desktop.md): a private virtual display :87 with the real # window manager (Muffin), so window, mouse and keyboard tests do not touch the user's screen. Muffin and the app share # the stand's own D-Bus session bus: the app's MPRIS player and screensaver calls never reach the user's desktop. # mpv plays into no sound device (ao=null, unless SLEEPYOWL_MPV_OPTIONS says otherwise): the stand never touches the @@ -14,7 +14,7 @@ # stand.sh log the app's log (sleepyowl.log) # stand.sh bus CMD… run CMD on the stand's D-Bus (busctl --user, gdbus, dbus-monitor…) # -# The app's files live in $STAND (default ~/.cache/sleepyowl-test): its own config with the test account, +# The app's files live in $STAND (default ~/.cache/sleepyowl-test): its own config with the test account (JF_USER), # cache, log and lock — the user's own ~/.config/sleepyowl is never used. Gradle builds before `app` unless # DESKTOP_NOBUILD=1. SLEEPYOWL_APP= runs that instead of the Gradle build, e.g. the packaged app unpacked by # `dpkg-deb -x sleepyowl_*.deb DIR` → SLEEPYOWL_APP=DIR/opt/sleepyowl/bin/sleepyowl (also for k2.py). @@ -22,7 +22,7 @@ set -e R=$(cd "$(dirname "$0")/../.." && pwd) STAND=${STAND:-$HOME/.cache/sleepyowl-test} D=:87 -export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1} +source "$R/tools/devenv.sh" mkdir -p "$STAND/shots" "$STAND/run" running() { [ -f "$STAND/run/$1.pid" ] && kill -0 "$(cat "$STAND/run/$1.pid")" 2>/dev/null; } diff --git a/tools/linux/watchdata.py b/tools/linux/watchdata.py index 5e225d0..b347154 100644 --- a/tools/linux/watchdata.py +++ b/tools/linux/watchdata.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 -"""Watch data of the test account on the server, before and after a test run (LINUX_PLAN.md §3 «Как проверяем»). +"""Watch data of the test account on the server, before and after a test run (docs/desktop.md). watchdata.py snapshot [FILE] saves the user's watch data (positions, played, counts, dates, favourites) watchdata.py diff [FILE] what the run changed, compared with the snapshot watchdata.py restore [FILE] puts the changed items back as they were in the snapshot -FILE defaults to $STAND/watchdata.json. The user is the test account: its token is taken from the test stand's saved accounts +FILE defaults to $STAND/watchdata.json. The user is JF_USER: its token is taken from the test stand's saved accounts ($STAND/config/sleepyowl/jellyfin_session.preferences_pb, STAND=~/.cache/sleepyowl-test) or from JF_TOKEN. -JF_USER picks another saved account by name. Server: JF_URL, default the LAN address. +Server JF_URL and user JF_USER come from local/dev.env (template tools/dev.env.example) or the environment. Restore writes the saved UserData back (POST /UserItems/{id}/UserData). An item the run started from nothing (not in the snapshot) is reset: played and its date removed (DELETE /UserPlayedItems), position and count back to 0. @@ -15,9 +15,12 @@ The resume list follows LastPlayedDate, so its order comes back with the dates. """ import json, os, re, sys, urllib.request +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import devenv + STAND = os.environ.get("STAND") or os.path.expanduser("~/.cache/sleepyowl-test") -URL = os.environ.get("JF_URL", "http://192.168.1.10:8096") -USER = os.environ.get("JF_USER", "test") +URL = devenv.need("JF_URL") +USER = devenv.need("JF_USER") FIELDS = ("PlaybackPositionTicks", "PlayCount", "Played", "LastPlayedDate", "IsFavorite") diff --git a/tools/tour/build_tour.py b/tools/tour/build_tour.py index 5572584..45f998e 100644 --- a/tools/tour/build_tour.py +++ b/tools/tour/build_tour.py @@ -7,7 +7,7 @@ subtitle codec) and the pairs that matter for engine choice (container x codec, Each entry also fixes which audio and subtitle track to select, so subtitle/audio codecs are actually exercised. Usage: - python3 tools/tour/build_tour.py --server http://192.168.1.10:8096 --token [--out app/src/debug/assets/tour.json] + python3 tools/tour/build_tour.py --server http://:8096 --token [--out app/src/debug/assets/tour.json] python3 tools/tour/build_tour.py --input library.json ... # reuse a saved /Items response Token from the TV: `source tools/tv/env.sh; tv_token`. """ diff --git a/tools/tv/env.sh b/tools/tv/env.sh index 6d8d81c..7ca721a 100755 --- a/tools/tv/env.sh +++ b/tools/tv/env.sh @@ -1,9 +1,10 @@ #!/bin/bash # Helpers for driving the test TV over adb. Usage: source tools/tv/env.sh -# TV_ADB: адрес из «Беспроводной отладки» (порт меняется после перезагрузки ТВ). -export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1} -TV_ADB=${TV_ADB:-192.168.1.20:5555} -A="$HOME/Android/Sdk/platform-tools/adb -s $TV_ADB" +# TV_ADB (адрес ТВ), JF_URL (сервер), ADB, JAVA_HOME, PROFILE_SERIES — из local/dev.env (шаблон tools/dev.env.example). +source "$(dirname "${BASH_SOURCE[0]}")/../devenv.sh" +: "${TV_ADB:?set TV_ADB in local/dev.env}" +ADB=${ADB:-$(command -v adb || echo "$HOME/Android/Sdk/platform-tools/adb")} +A="$ADB -s $TV_ADB" OUT=${OUT:-/tmp/movies-tv}; mkdir -p "$OUT" k() { for x in "$@"; do $A shell input keyevent "$x"; sleep 0.7; done; } shot() { $A exec-out screencap -p > "$OUT/$1.png"; echo "$OUT/$1.png"; } @@ -20,7 +21,7 @@ plog() { $A logcat -d | grep -E " (I|W|E) PlaybackSession" | sed 's/^.*PlaybackS tv_token() { $A exec-out run-as ru.stenfredd.sleepyowl cat files/datastore/jellyfin_session.preferences_pb | grep -aoE '"token":"[0-9a-f]{32}"' | head -1 | grep -oE '[0-9a-f]{32}'; } # Что играет на ТВ по данным сервера: название, позиция (с), PlayMethod. X-Emby-Token сервер 12.x не принимает. sess() { - curl -s -H "Authorization: MediaBrowser Token=\"${TV_TOKEN:=$(tv_token)}\"" "http://192.168.1.10:8096/Sessions?activeWithinSeconds=60" | + curl -s -H "Authorization: MediaBrowser Token=\"${TV_TOKEN:=$(tv_token)}\"" "${JF_URL:?set JF_URL in local/dev.env}/Sessions?activeWithinSeconds=60" | python3 -c 'import json,sys for s in json.load(sys.stdin): n=s.get("NowPlayingItem") @@ -51,8 +52,9 @@ install_release() { # Gradle может закончить с «failing tests» при успешном прогоне — тогда профиль копируется вручную. # PROFILE_SERIES — id сериала для прохода по странице сериала. gen_profile() { + : "${PROFILE_SERIES:?set PROFILE_SERIES in local/dev.env}" ANDROID_SERIAL=$TV_ADB ./gradlew :app:generateReleaseBaselineProfile -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true \ - -Pandroid.testInstrumentationRunnerArguments.seriesId=${PROFILE_SERIES:-00000000000000000000000000000000} + -Pandroid.testInstrumentationRunnerArguments.seriesId=$PROFILE_SERIES local src="baselineprofile/build/outputs/connected_android_test_additional_output/nonMinifiedRelease/connected" src=$(find "$src" -name 'BaselineProfileGenerator_generate-startup-prof.txt' | head -1) [ -n "$src" ] || { echo "профиль не найден"; return 1; }