add qbittorrent-nox docker image
This commit is contained in:
parent
ea6c5b5bd7
commit
c3da9e26bc
159
apps/qbittorrent-nox/Dockerfile
Normal file
159
apps/qbittorrent-nox/Dockerfile
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# create an up-to-date base image for everything
|
||||||
|
FROM alpine:latest AS base
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
apk --no-cache --update-cache upgrade
|
||||||
|
|
||||||
|
# run-time dependencies
|
||||||
|
RUN \
|
||||||
|
apk --no-cache add \
|
||||||
|
7zip \
|
||||||
|
bash \
|
||||||
|
curl \
|
||||||
|
doas \
|
||||||
|
libcrypto3 \
|
||||||
|
libssl3 \
|
||||||
|
python3 \
|
||||||
|
qt6-qtbase \
|
||||||
|
qt6-qtbase-sqlite \
|
||||||
|
tini \
|
||||||
|
tzdata \
|
||||||
|
openssl \
|
||||||
|
zlib
|
||||||
|
|
||||||
|
# image for building
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
ARG QBT_VERSION \
|
||||||
|
BOOST_VERSION_MAJOR="1" \
|
||||||
|
BOOST_VERSION_MINOR="86" \
|
||||||
|
BOOST_VERSION_PATCH="0" \
|
||||||
|
LIBBT_VERSION="RC_2_0" \
|
||||||
|
LIBBT_CMAKE_FLAGS=""
|
||||||
|
|
||||||
|
# check environment variables
|
||||||
|
RUN \
|
||||||
|
if [ -z "${QBT_VERSION}" ]; then \
|
||||||
|
echo 'Missing QBT_VERSION variable. Check your command line arguments.' && \
|
||||||
|
exit 1 ; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# alpine linux packages:
|
||||||
|
# https://git.alpinelinux.org/aports/tree/community/libtorrent-rasterbar/APKBUILD
|
||||||
|
# https://git.alpinelinux.org/aports/tree/community/qbittorrent/APKBUILD
|
||||||
|
RUN \
|
||||||
|
apk add \
|
||||||
|
cmake \
|
||||||
|
git \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
ninja \
|
||||||
|
openssl-dev \
|
||||||
|
qt6-qtbase-dev \
|
||||||
|
qt6-qtbase-private-dev \
|
||||||
|
qt6-qttools-dev \
|
||||||
|
zlib-dev
|
||||||
|
|
||||||
|
# compiler, linker options:
|
||||||
|
# https://gcc.gnu.org/onlinedocs/gcc/Option-Summary.html
|
||||||
|
# https://gcc.gnu.org/onlinedocs/gcc/Link-Options.html
|
||||||
|
# https://sourceware.org/binutils/docs/ld/Options.html
|
||||||
|
ENV CFLAGS="-pipe -fstack-clash-protection -fstack-protector-strong -fno-plt -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 -D_GLIBCXX_ASSERTIONS" \
|
||||||
|
CXXFLAGS="-pipe -fstack-clash-protection -fstack-protector-strong -fno-plt -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 -D_GLIBCXX_ASSERTIONS" \
|
||||||
|
LDFLAGS="-gz -Wl,-O1,--as-needed,--sort-common,-z,now,-z,pack-relative-relocs,-z,relro"
|
||||||
|
|
||||||
|
# prepare boost
|
||||||
|
RUN \
|
||||||
|
wget -O boost.tar.gz "https://archives.boost.io/release/$BOOST_VERSION_MAJOR.$BOOST_VERSION_MINOR.$BOOST_VERSION_PATCH/source/boost_${BOOST_VERSION_MAJOR}_${BOOST_VERSION_MINOR}_${BOOST_VERSION_PATCH}.tar.gz" && \
|
||||||
|
tar -xf boost.tar.gz && \
|
||||||
|
mv boost_* boost && \
|
||||||
|
cd boost && \
|
||||||
|
./bootstrap.sh && \
|
||||||
|
./b2 stage --stagedir=./ --with-headers
|
||||||
|
|
||||||
|
# build libtorrent
|
||||||
|
RUN \
|
||||||
|
git clone \
|
||||||
|
--branch "${LIBBT_VERSION}" \
|
||||||
|
--depth 1 \
|
||||||
|
--recurse-submodules \
|
||||||
|
https://github.com/arvidn/libtorrent.git && \
|
||||||
|
cd libtorrent && \
|
||||||
|
cmake \
|
||||||
|
-B build \
|
||||||
|
-G Ninja \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF \
|
||||||
|
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||||
|
-DCMAKE_CXX_STANDARD=20 \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||||
|
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON \
|
||||||
|
-DBOOST_ROOT=/boost/lib/cmake \
|
||||||
|
-Ddeprecated-functions=OFF \
|
||||||
|
$LIBBT_CMAKE_FLAGS && \
|
||||||
|
cmake --build build -j $(nproc) && \
|
||||||
|
cmake --install build
|
||||||
|
|
||||||
|
# build qbittorrent
|
||||||
|
RUN \
|
||||||
|
if [ "${QBT_VERSION}" = "devel" ]; then \
|
||||||
|
git clone \
|
||||||
|
--depth 1 \
|
||||||
|
--recurse-submodules \
|
||||||
|
https://github.com/qbittorrent/qBittorrent.git && \
|
||||||
|
cd qBittorrent ; \
|
||||||
|
else \
|
||||||
|
wget "https://github.com/qbittorrent/qBittorrent/archive/refs/tags/release-${QBT_VERSION}.tar.gz" && \
|
||||||
|
tar -xf "release-${QBT_VERSION}.tar.gz" && \
|
||||||
|
cd "qBittorrent-release-${QBT_VERSION}" ; \
|
||||||
|
fi && \
|
||||||
|
cmake \
|
||||||
|
-B build \
|
||||||
|
-G Ninja \
|
||||||
|
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||||
|
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON \
|
||||||
|
-DBOOST_ROOT=/boost/lib/cmake \
|
||||||
|
-DGUI=OFF && \
|
||||||
|
cmake --build build -j $(nproc) && \
|
||||||
|
cmake --install build
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
ldd /usr/bin/qbittorrent-nox | sort -f
|
||||||
|
|
||||||
|
# record compile-time Software Bill of Materials (sbom)
|
||||||
|
RUN \
|
||||||
|
printf "Software Bill of Materials for building qbittorrent-nox\n\n" >> /sbom.txt && \
|
||||||
|
echo "boost $BOOST_VERSION_MAJOR.$BOOST_VERSION_MINOR.$BOOST_VERSION_PATCH" >> /sbom.txt && \
|
||||||
|
cd libtorrent && \
|
||||||
|
echo "libtorrent-rasterbar git $(git rev-parse HEAD)" >> /sbom.txt && \
|
||||||
|
cd .. && \
|
||||||
|
if [ "${QBT_VERSION}" = "devel" ]; then \
|
||||||
|
cd qBittorrent && \
|
||||||
|
echo "qBittorrent git $(git rev-parse HEAD)" >> /sbom.txt && \
|
||||||
|
cd .. ; \
|
||||||
|
else \
|
||||||
|
echo "qBittorrent ${QBT_VERSION}" >> /sbom.txt ; \
|
||||||
|
fi && \
|
||||||
|
echo >> /sbom.txt && \
|
||||||
|
apk list -I | sort >> /sbom.txt && \
|
||||||
|
cat /sbom.txt
|
||||||
|
|
||||||
|
# image for running
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
adduser \
|
||||||
|
-D \
|
||||||
|
-H \
|
||||||
|
-s /sbin/nologin \
|
||||||
|
-u 1000 \
|
||||||
|
qbtUser && \
|
||||||
|
echo "permit nopass :root" >> "/etc/doas.d/doas.conf"
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/qbittorrent-nox /usr/bin/qbittorrent-nox
|
||||||
|
|
||||||
|
COPY --from=builder /sbom.txt /sbom.txt
|
||||||
|
RUN echo "idk333"
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "-g", "--", "/entrypoint.sh"]
|
42
apps/qbittorrent-nox/README.md
Normal file
42
apps/qbittorrent-nox/README.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# qbittorrent-nox
|
||||||
|
|
||||||
|
headless qBittorrent.
|
||||||
|
|
||||||
|
We build our own docker container because the official version uses libtorrent 1. We need libtorrent 2 which provides support for creating hybrid torrents.
|
||||||
|
|
||||||
|
See https://github.com/qbittorrent/docker-qbittorrent-nox/tree/main/manual_build
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### QBT_USERNAME
|
||||||
|
- **Type:** string
|
||||||
|
- **Default:** unset
|
||||||
|
- **Description:** Username for qBittorrent WebUI. Must be set together with `QBT_PASSWORD`.
|
||||||
|
|
||||||
|
### QBT_PASSWORD
|
||||||
|
- **Type:** string
|
||||||
|
- **Default:** unset
|
||||||
|
- **Description:** Password for qBittorrent WebUI. Must be set together with `QBT_USERNAME`.
|
||||||
|
Generates a PBKDF2-HMAC-SHA512 hash with a **random 16-byte salt** automatically.
|
||||||
|
|
||||||
|
### QBT_LEGAL_NOTICE
|
||||||
|
- **Type:** string
|
||||||
|
- **Default:** unset
|
||||||
|
- **Description:** Must be set to `confirm` to automatically accept the qBittorrent legal notice.
|
||||||
|
|
||||||
|
### QBT_WEBUI_PORT
|
||||||
|
- **Type:** integer
|
||||||
|
- **Default:** 8080
|
||||||
|
- **Description:** Overrides the default WebUI port.
|
||||||
|
|
||||||
|
### QBT_TORRENTING_PORT
|
||||||
|
- **Type:** integer
|
||||||
|
- **Default:** 6881
|
||||||
|
- **Description:** Overrides the default torrenting port for incoming torrent connections.
|
||||||
|
|
||||||
|
### QBT_DISABLE_NETWORK
|
||||||
|
- **Type:** boolean (any non-empty value)
|
||||||
|
- **Default:** unset
|
||||||
|
- **Description:** If set, disables all peer-to-peer networking features (DHT, LSD, PEX, uploads/downloads, max connections), effectively running qBittorrent in “WebUI only” mode.
|
23
apps/qbittorrent-nox/build.sh
Normal file
23
apps/qbittorrent-nox/build.sh
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
QBT_VERSION="${1:-5.1.2}"
|
||||||
|
REGISTRY="gitea.futureporn.net/futureporn/qbittorrent-nox"
|
||||||
|
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--build-arg QBT_VERSION="$QBT_VERSION" \
|
||||||
|
--build-arg CACHE_BUST=$(date +%s) \
|
||||||
|
-t qbittorrent-nox:"$QBT_VERSION" \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker tag qbittorrent-nox:"$QBT_VERSION" "$REGISTRY:$QBT_VERSION"
|
||||||
|
docker tag qbittorrent-nox:"$QBT_VERSION" "$REGISTRY:latest"
|
||||||
|
|
||||||
|
echo
|
||||||
|
read -n 1 -s -r -p "Press any key to publish or Ctrl+C to quit"
|
||||||
|
echo
|
||||||
|
|
||||||
|
|
||||||
|
docker push "$REGISTRY:$QBT_VERSION"
|
||||||
|
docker push "$REGISTRY:latest"
|
180
apps/qbittorrent-nox/entrypoint.sh
Executable file
180
apps/qbittorrent-nox/entrypoint.sh
Executable file
@ -0,0 +1,180 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
downloadsPath="/downloads"
|
||||||
|
profilePath="/config"
|
||||||
|
qbtConfigFile="$profilePath/qBittorrent/config/qBittorrent.conf"
|
||||||
|
|
||||||
|
isRoot="0"
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
isRoot="1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$isRoot" = "1" ]; then
|
||||||
|
if [ -n "$PUID" ] && [ "$PUID" != "$(id -u qbtUser)" ]; then
|
||||||
|
sed -i "s|^qbtUser:x:[0-9]*:|qbtUser:x:$PUID:|g" /etc/passwd
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PGID" ] && [ "$PGID" != "$(id -g qbtUser)" ]; then
|
||||||
|
sed -i "s|^\(qbtUser:x:[0-9]*\):[0-9]*:|\1:$PGID:|g" /etc/passwd
|
||||||
|
sed -i "s|^qbtUser:x:[0-9]*:|qbtUser:x:$PGID:|g" /etc/group
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PAGID" ]; then
|
||||||
|
_origIFS="$IFS"
|
||||||
|
IFS=','
|
||||||
|
for AGID in $PAGID; do
|
||||||
|
AGID=$(echo "$AGID" | tr -d '[:space:]"')
|
||||||
|
addgroup -g "$AGID" "qbtGroup-$AGID"
|
||||||
|
addgroup qbtUser "qbtGroup-$AGID"
|
||||||
|
done
|
||||||
|
IFS="$_origIFS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$qbtConfigFile" ]; then
|
||||||
|
mkdir -p "$(dirname $qbtConfigFile)"
|
||||||
|
cat << EOF > "$qbtConfigFile"
|
||||||
|
[BitTorrent]
|
||||||
|
Session\DefaultSavePath=$downloadsPath
|
||||||
|
Session\Port=6881
|
||||||
|
Session\TempPath=$downloadsPath/temp
|
||||||
|
[Preferences]
|
||||||
|
WebUI\Port=8080
|
||||||
|
General\Locale=en
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
argLegalNotice=""
|
||||||
|
_legalNotice=$(echo "$QBT_LEGAL_NOTICE" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [ "$_legalNotice" = "confirm" ]; then
|
||||||
|
argLegalNotice="--confirm-legal-notice"
|
||||||
|
else
|
||||||
|
# for backward compatibility
|
||||||
|
# TODO: remove in next major version release
|
||||||
|
_eula=$(echo "$QBT_EULA" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [ "$_eula" = "accept" ]; then
|
||||||
|
echo "QBT_EULA=accept is deprecated and will be removed soon. The replacement is QBT_LEGAL_NOTICE=confirm"
|
||||||
|
argLegalNotice="--confirm-legal-notice"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
argTorrentingPort=""
|
||||||
|
if [ -n "$QBT_TORRENTING_PORT" ]; then
|
||||||
|
argTorrentingPort="--torrenting-port=$QBT_TORRENTING_PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
argWebUIPort=""
|
||||||
|
if [ -n "$QBT_WEBUI_PORT" ]; then
|
||||||
|
argWebUIPort="--webui-port=$QBT_WEBUI_PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$isRoot" = "1" ]; then
|
||||||
|
# those are owned by root by default
|
||||||
|
# don't change existing files owner in `$downloadsPath`
|
||||||
|
if [ -d "$downloadsPath" ]; then
|
||||||
|
chown qbtUser:qbtUser "$downloadsPath"
|
||||||
|
fi
|
||||||
|
if [ -d "$profilePath" ]; then
|
||||||
|
chown qbtUser:qbtUser -R "$profilePath"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set umask just before starting qbt
|
||||||
|
if [ -n "$UMASK" ]; then
|
||||||
|
umask "$UMASK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$QBT_USERNAME" ] && [ -n "$QBT_PASSWORD" ]; then
|
||||||
|
user="$QBT_USERNAME"
|
||||||
|
pass="$QBT_PASSWORD"
|
||||||
|
|
||||||
|
salt_hex=$(openssl rand -hex 16)
|
||||||
|
|
||||||
|
hash_bin=$(openssl kdf -binary \
|
||||||
|
-keylen 64 \
|
||||||
|
-kdfopt digest:SHA512 \
|
||||||
|
-kdfopt pass:"$pass" \
|
||||||
|
-kdfopt hexsalt:"$salt_hex" \
|
||||||
|
-kdfopt iter:100000 PBKDF2)
|
||||||
|
|
||||||
|
hash_b64=$(echo -n "$hash_bin" | base64 -w 0)
|
||||||
|
|
||||||
|
# convert salt to base64
|
||||||
|
salt_b64=$(echo "$salt_hex" | xxd -r -p | base64)
|
||||||
|
|
||||||
|
pbkdf2="@ByteArray(${salt_b64}:${hash_b64})"
|
||||||
|
|
||||||
|
# ensure enabled line exists
|
||||||
|
if grep -q "^WebUI\\Enabled=" "$qbtConfigFile"; then
|
||||||
|
sed -i "s|^WebUI\\Enabled=.*|WebUI\\Enabled=true|" "$qbtConfigFile"
|
||||||
|
else
|
||||||
|
echo "WebUI\\Enabled=true" >> "$qbtConfigFile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ensure username line exists
|
||||||
|
if grep -q "^WebUI\\Username=" "$qbtConfigFile"; then
|
||||||
|
sed -i "s|^WebUI\\Username=.*|WebUI\\Username=$user|" "$qbtConfigFile"
|
||||||
|
else
|
||||||
|
echo "WebUI\\Username=$user" >> "$qbtConfigFile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ensure password line exists
|
||||||
|
if grep -q "^WebUI\\Password_PBKDF2=" "$qbtConfigFile"; then
|
||||||
|
sed -i "s|^WebUI\\Password_PBKDF2=.*|WebUI\\Password_PBKDF2=$pbkdf2|" "$qbtConfigFile"
|
||||||
|
else
|
||||||
|
echo "WebUI\\Password_PBKDF2=$pbkdf2" >> "$qbtConfigFile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if [ -n "$QBT_DISABLE_NETWORK" ]; then
|
||||||
|
# Ensure the [BitTorrent] section exists
|
||||||
|
if ! grep -qxF '[BitTorrent]' "$qbtConfigFile"; then
|
||||||
|
echo "[BitTorrent]" >> "$qbtConfigFile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List of options to disable network (POSIX style)
|
||||||
|
for opt in \
|
||||||
|
"Session\\\\DHTEnabled=false" \
|
||||||
|
"Session\\\\LSDEnabled=false" \
|
||||||
|
"Session\\\\MaxActiveDownloads=0" \
|
||||||
|
"Session\\\\MaxActiveUploads=0" \
|
||||||
|
"Session\\\\MaxConnections=0" \
|
||||||
|
"Session\\\\MaxConnectionsPerTorrent=0" \
|
||||||
|
"Session\\\\PeXEnabled=false"; do
|
||||||
|
|
||||||
|
# If option exists, replace it; else append after [BitTorrent]
|
||||||
|
if grep -q "^${opt%%=*}" "$qbtConfigFile"; then
|
||||||
|
sed -i "s|^${opt%%=*}=.*|$opt|" "$qbtConfigFile"
|
||||||
|
else
|
||||||
|
sed -i "/^\[BitTorrent\]/a $opt" "$qbtConfigFile"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if [ "$isRoot" = "1" ]; then
|
||||||
|
exec \
|
||||||
|
doas -u qbtUser \
|
||||||
|
qbittorrent-nox \
|
||||||
|
"$argLegalNotice" \
|
||||||
|
--profile="$profilePath" \
|
||||||
|
"$argTorrentingPort" \
|
||||||
|
"$argWebUIPort" \
|
||||||
|
"$@"
|
||||||
|
else
|
||||||
|
exec \
|
||||||
|
qbittorrent-nox \
|
||||||
|
"$argLegalNotice" \
|
||||||
|
--profile="$profilePath" \
|
||||||
|
"$argTorrentingPort" \
|
||||||
|
"$argWebUIPort" \
|
||||||
|
"$@"
|
||||||
|
fi
|
34
services/our/package-lock.json
generated
34
services/our/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "futureporn",
|
"name": "futureporn-our",
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "futureporn",
|
"name": "futureporn-our",
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.726.1",
|
"@aws-sdk/client-s3": "3.726.1",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.844.0",
|
"@aws-sdk/s3-request-presigner": "^3.844.0",
|
||||||
@ -30,6 +30,7 @@
|
|||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^22.16.3",
|
"@types/node": "^22.16.3",
|
||||||
"@types/node-fetch": "^2.6.12",
|
"@types/node-fetch": "^2.6.12",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
"cache-manager": "^7.0.1",
|
"cache-manager": "^7.0.1",
|
||||||
"canvas": "^3.1.2",
|
"canvas": "^3.1.2",
|
||||||
"chokidar-cli": "^3.0.0",
|
"chokidar-cli": "^3.0.0",
|
||||||
@ -70,6 +71,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.3.1",
|
"@eslint/compat": "^1.3.1",
|
||||||
"@eslint/js": "^9.31.0",
|
"@eslint/js": "^9.31.0",
|
||||||
|
"bencode": "^4.0.0",
|
||||||
"buttplug": "^3.2.2",
|
"buttplug": "^3.2.2",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"esbuild": "^0.25.9",
|
"esbuild": "^0.25.9",
|
||||||
@ -4489,6 +4491,30 @@
|
|||||||
"integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==",
|
"integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ssh2": {
|
||||||
|
"version": "1.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
|
||||||
|
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^18.11.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ssh2/node_modules/@types/node": {
|
||||||
|
"version": "18.19.123",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.123.tgz",
|
||||||
|
"integrity": "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ssh2/node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.36.0",
|
"version": "8.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz",
|
||||||
|
@ -4,13 +4,14 @@
|
|||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently npm:dev:serve npm:dev:build:server npm:dev:build:client npm:dev:worker npm:dev:compose npm:dev:sftp",
|
"dev": "concurrently npm:dev:serve npm:dev:build:server npm:dev:build:client npm:dev:worker npm:dev:compose npm:dev:sftp npm:dev:qbittorrent",
|
||||||
"dev:serve": "npx @dotenvx/dotenvx run -f ../../.env.development.local -- tsx watch ./src/index.ts",
|
"dev:serve": "npx @dotenvx/dotenvx run -f ../../.env.development.local -- tsx watch ./src/index.ts",
|
||||||
"dev:compose": "docker compose -f compose.development.yaml up",
|
"dev:compose": "docker compose -f compose.development.yaml up",
|
||||||
"dev:worker": "npx @dotenvx/dotenvx run -e GRAPHILE_LOGGER_DEBUG=1 -f ../../.env.development.local -- tsx watch ./src/worker.ts",
|
"dev:worker": "npx @dotenvx/dotenvx run -e GRAPHILE_LOGGER_DEBUG=1 -f ../../.env.development.local -- tsx watch ./src/worker.ts",
|
||||||
"dev:build": "echo please use either dev:build:server or dev:build:client",
|
"dev:build": "echo please use either dev:build:server or dev:build:client",
|
||||||
"dev:build:server": "chokidar 'src/**/*.{js,ts}' --ignore 'src/client/**' -c tsup --clean",
|
"dev:build:server": "chokidar 'src/**/*.{js,ts}' --ignore 'src/client/**' -c tsup --clean",
|
||||||
"dev:build:client": "chokidar 'src/client/**/*.{js,css}' -c 'node build.mjs'",
|
"dev:build:client": "chokidar 'src/client/**/*.{js,css}' -c 'node build.mjs'",
|
||||||
|
"dev:qbittorrent": "npx @dotenvx/dotenvx run -f ../../.env.development.local -- docker run --rm --name fp-dev-qbittorrent --tmpfs /tmp -e QBT_LEGAL_NOTICE -e QBT_TORRENTING_PORT -e QBT_WEBUI_PORT -e QBT_DISABLE_NETWORK -e QBT_USERNAME -e QBT_PASSWORD -p 8083:8083 gitea.futureporn.net/futureporn/qbittorrent-nox:latest",
|
||||||
"dev:sftp": "docker run -p 2222:22 --rm atmoz/sftp user:pass:::watch",
|
"dev:sftp": "docker run -p 2222:22 --rm atmoz/sftp user:pass:::watch",
|
||||||
"start": "echo please use either start:server or start:worker; exit 1",
|
"start": "echo please use either start:server or start:worker; exit 1",
|
||||||
"start:server": "tsx ./src/index.ts",
|
"start:server": "tsx ./src/index.ts",
|
||||||
@ -26,6 +27,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.3.1",
|
"@eslint/compat": "^1.3.1",
|
||||||
"@eslint/js": "^9.31.0",
|
"@eslint/js": "^9.31.0",
|
||||||
|
"bencode": "^4.0.0",
|
||||||
"buttplug": "^3.2.2",
|
"buttplug": "^3.2.2",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"esbuild": "^0.25.9",
|
"esbuild": "^0.25.9",
|
||||||
@ -71,6 +73,7 @@
|
|||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^22.16.3",
|
"@types/node": "^22.16.3",
|
||||||
"@types/node-fetch": "^2.6.12",
|
"@types/node-fetch": "^2.6.12",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
"cache-manager": "^7.0.1",
|
"cache-manager": "^7.0.1",
|
||||||
"canvas": "^3.1.2",
|
"canvas": "^3.1.2",
|
||||||
"chokidar-cli": "^3.0.0",
|
"chokidar-cli": "^3.0.0",
|
||||||
|
@ -33,6 +33,9 @@ const EnvSchema = z.object({
|
|||||||
LOG_LEVEL: z.string().default('info'),
|
LOG_LEVEL: z.string().default('info'),
|
||||||
B2_APPLICATION_KEY_ID: z.string(),
|
B2_APPLICATION_KEY_ID: z.string(),
|
||||||
B2_APPLICATION_KEY: z.string(),
|
B2_APPLICATION_KEY: z.string(),
|
||||||
|
SEEDBOX_SFTP_URL: z.string(),
|
||||||
|
SEEDBOX_SFTP_USERNAME: z.string(),
|
||||||
|
SEEDBOX_SFTP_PASSWORD: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = EnvSchema.safeParse(process.env);
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
|
@ -1,3 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* downloading a random sample of linux torrents,
|
||||||
|
* I see that most people create their torrent files using
|
||||||
|
* * transmission
|
||||||
|
* * mktorrent
|
||||||
|
* * qbittorrent
|
||||||
|
* *
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import type { Helpers } from "graphile-worker";
|
import type { Helpers } from "graphile-worker";
|
||||||
import { PrismaClient } from "../../generated/prisma";
|
import { PrismaClient } from "../../generated/prisma";
|
||||||
import { withAccelerate } from "@prisma/extension-accelerate";
|
import { withAccelerate } from "@prisma/extension-accelerate";
|
||||||
@ -8,7 +20,9 @@ import { nanoid } from "nanoid";
|
|||||||
import { getNanoSpawn } from "../utils/nanoSpawn";
|
import { getNanoSpawn } from "../utils/nanoSpawn";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import SftpClient from 'ssh2-sftp-client';
|
import { generateS3Path } from "../utils/formatters";
|
||||||
|
import { sshClient } from "../utils/sftp";
|
||||||
|
import { qbtClient } from "../utils/qbittorrent/qbittorrent";
|
||||||
|
|
||||||
const prisma = new PrismaClient().$extends(withAccelerate());
|
const prisma = new PrismaClient().$extends(withAccelerate());
|
||||||
|
|
||||||
@ -18,32 +32,123 @@ interface Payload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// async function createTorrent(payload: any, helpers: Helpers) {
|
|
||||||
// logger.debug(`createTorrent`)
|
|
||||||
|
|
||||||
|
|
||||||
// if (!inputFilePath) {
|
|
||||||
// throw new Error("inputFilePath is missing");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const outputFilePath = inputFilePath.replace(/\.[^/.]+$/, '') + '-thumb.png';
|
|
||||||
// const spawn = await getNanoSpawn();
|
|
||||||
|
|
||||||
|
|
||||||
// logger.debug('result as follows')
|
|
||||||
// logger.debug(JSON.stringify(result, null, 2))
|
|
||||||
|
|
||||||
// logger.info(`✅ Thumbnail saved to: ${outputFilePath}`);
|
|
||||||
// return outputFilePath
|
|
||||||
|
|
||||||
// }
|
|
||||||
function assertPayload(payload: any): asserts payload is Payload {
|
function assertPayload(payload: any): asserts payload is Payload {
|
||||||
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
|
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
|
||||||
if (typeof payload.vodId !== "string") throw new Error(`invalid payload-- ${JSON.stringify(payload)} was missing vodId`);
|
if (typeof payload.vodId !== "string") throw new Error(`invalid payload-- ${JSON.stringify(payload)} was missing vodId`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// async function createImdlTorrent(
|
||||||
|
// vodId: string,
|
||||||
|
// videoFilePath: string
|
||||||
|
|
||||||
export default async function createTorrent(payload: any, helpers: Helpers) {
|
// ): Promise<{ magnetLink: string; torrentFilePath: string }> {
|
||||||
|
// const spawn = await getNanoSpawn()
|
||||||
|
|
||||||
|
// const torrentFilePath = join(env.CACHE_ROOT, `${nanoid()}.torrent`);
|
||||||
|
// logger.debug('creating torrent & magnet link via imdl');
|
||||||
|
|
||||||
|
// const result = await spawn('imdl', [
|
||||||
|
// 'torrent',
|
||||||
|
// 'create',
|
||||||
|
// '--input', videoFilePath,
|
||||||
|
// '--link',
|
||||||
|
// '--output', torrentFilePath,
|
||||||
|
// ], {
|
||||||
|
// cwd: env.APP_DIR,
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// logger.trace(JSON.stringify(result));
|
||||||
|
|
||||||
|
|
||||||
|
// const match = result.stdout.match(/magnet:\?[^\s]+/);
|
||||||
|
// if (!match) {
|
||||||
|
// throw new Error('No magnet link found in imdl output:\n' + result.stdout);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const magnetLink = match[0];
|
||||||
|
// logger.debug(`Magnet link=${magnetLink}`);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// magnetLink,
|
||||||
|
// torrentFilePath
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
async function createQBittorrentTorrent(
|
||||||
|
vodId: string,
|
||||||
|
videoFilePath: string
|
||||||
|
): Promise<{
|
||||||
|
magnetLink: string,
|
||||||
|
torrentFilePath: string
|
||||||
|
}> {
|
||||||
|
const torrentInfo = await qbtClient.createTorrent(videoFilePath);
|
||||||
|
return torrentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// async function createTorrentfileTorrent(
|
||||||
|
// vodId: string,
|
||||||
|
// videoFilePath: string
|
||||||
|
// ): Promise<{ magnetLink: string; torrentFilePath: string }> {
|
||||||
|
// const spawn = await getNanoSpawn()
|
||||||
|
// // * [x] run torrentfile
|
||||||
|
|
||||||
|
// // torrentfile create
|
||||||
|
// // --magnet
|
||||||
|
// // --prog 0
|
||||||
|
// // --out ./test-fixture.torrent
|
||||||
|
// // --announce udp://tracker.futureporn.net/
|
||||||
|
// // --source https://futureporn.net/
|
||||||
|
// // --comment https://futureporn.net/
|
||||||
|
// // --meta-version 3
|
||||||
|
// // ~/Downloads/test-fixture.ts
|
||||||
|
|
||||||
|
// const torrentFilePath = join(env.CACHE_ROOT, `${nanoid()}.torrent`);
|
||||||
|
|
||||||
|
// logger.debug('creating torrent & magnet link')
|
||||||
|
// const result = await spawn('torrentfile', [
|
||||||
|
// 'create',
|
||||||
|
// '--magnet',
|
||||||
|
// '--prog', '0',
|
||||||
|
// '--meta-version', '3', // torrentfile creates invalid hybrid torrents!
|
||||||
|
// '--out', torrentFilePath,
|
||||||
|
// videoFilePath,
|
||||||
|
// ], {
|
||||||
|
// cwd: env.APP_DIR,
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// logger.trace(JSON.stringify(result));
|
||||||
|
|
||||||
|
|
||||||
|
// const match = result.stdout.match(/magnet:\?[^\s]+/);
|
||||||
|
// if (!match) {
|
||||||
|
// throw new Error('No magnet link found in torrentfile output:\n' + result.stdout);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const magnetLink = match[0];
|
||||||
|
// logger.debug(`Magnet link=${magnetLink}`);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// magnetLink,
|
||||||
|
// torrentFilePath
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
async function uploadTorrentToSeedbox(videoFilePath: string, torrentFilePath: string) {
|
||||||
|
|
||||||
|
await sshClient.uploadFile(videoFilePath, './data');
|
||||||
|
await sshClient.uploadFile(torrentFilePath, './watch');
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function main(payload: any, helpers: Helpers) {
|
||||||
assertPayload(payload)
|
assertPayload(payload)
|
||||||
const { vodId } = payload
|
const { vodId } = payload
|
||||||
const vod = await prisma.vod.findFirstOrThrow({
|
const vod = await prisma.vod.findFirstOrThrow({
|
||||||
@ -67,76 +172,17 @@ export default async function createTorrent(payload: any, helpers: Helpers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
logger.info('Creating magnet link.')
|
logger.info('Creating torrent.')
|
||||||
const s3Client = getS3Client()
|
const s3Client = getS3Client()
|
||||||
|
|
||||||
// * [x] download video segments from pull-thru cache
|
// * [x] download video segments from pull-thru cache
|
||||||
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
|
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
|
||||||
logger.debug(`videoFilePath=${videoFilePath}`)
|
logger.debug(`videoFilePath=${videoFilePath}`)
|
||||||
|
|
||||||
// * [x] run torrentfile
|
|
||||||
|
|
||||||
// torrentfile create
|
const { magnetLink, torrentFilePath } = await createQBittorrentTorrent(vodId, videoFilePath)
|
||||||
// --magnet
|
|
||||||
// --prog 0
|
|
||||||
// --out ./test-fixture.torrent
|
|
||||||
// --announce udp://tracker.futureporn.net/
|
|
||||||
// --source https://futureporn.net/
|
|
||||||
// --comment https://futureporn.net/
|
|
||||||
// --meta-version 3
|
|
||||||
// ~/Downloads/test-fixture.ts
|
|
||||||
|
|
||||||
const torrentOutputFile = join(env.CACHE_ROOT, `${nanoid()}.torrent`);
|
|
||||||
|
|
||||||
const result = await spawn('torrentfile', [
|
|
||||||
'create',
|
|
||||||
'--magnet',
|
|
||||||
'--prog', '0',
|
|
||||||
'--meta-version', '2',
|
|
||||||
'--comment', 'https://future.porn',
|
|
||||||
'--source', `https://future.porn/vod/${vodId}`,
|
|
||||||
'--out', torrentOutputFile,
|
|
||||||
videoFilePath,
|
|
||||||
], {
|
|
||||||
cwd: env.APP_DIR,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger.trace(JSON.stringify(result));
|
|
||||||
|
|
||||||
|
|
||||||
const match = result.stdout.match(/magnet:\?[^\s]+/);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error('No magnet link found in torrentfile output:\n' + result.stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
const magnetLink = match[0];
|
|
||||||
logger.debug(`Magnet link=${magnetLink}`);
|
|
||||||
|
|
||||||
|
|
||||||
// upload torrent file to seedbox sftp
|
|
||||||
// Actually I don't think we need this, because our seedbox can use the RSS feed and get informed about torrents that way
|
|
||||||
// let sftp = new SftpClient();
|
|
||||||
// const torrentBasename = basename(torrentOutputFile);
|
|
||||||
|
|
||||||
// const parsed = new URL(env.SEEDBOX_SFTP_URL);
|
|
||||||
// logger.debug(`url=${env.SEEDBOX_SFTP_URL} hostname=${parsed.hostname} port=${parsed.port} username=${env.SEEDBOX_SFTP_USERNAME} password=${env.SEEDBOX_SFTP_PASSWORD}`);
|
|
||||||
// await sftp.connect({
|
|
||||||
// host: parsed.hostname,
|
|
||||||
// port: parsed.port,
|
|
||||||
// username: env.SEEDBOX_SFTP_USERNAME,
|
|
||||||
// password: env.SEEDBOX_SFTP_PASSWORD
|
|
||||||
// })
|
|
||||||
|
|
||||||
// const remoteFilePath = join(parsed.pathname, torrentBasename)
|
|
||||||
|
|
||||||
// const data = await sftp.list(parsed.pathname);
|
|
||||||
// logger.debug(`the data=${JSON.stringify(data)}`);
|
|
||||||
|
|
||||||
// logger.debug(`uploading ${torrentOutputFile} to ${remoteFilePath}`)
|
|
||||||
// await sftp.put(torrentOutputFile, remoteFilePath);
|
|
||||||
|
|
||||||
|
await uploadTorrentToSeedbox(videoFilePath, torrentFilePath)
|
||||||
|
|
||||||
logger.debug(`updating vod record`);
|
logger.debug(`updating vod record`);
|
||||||
await prisma.vod.update({
|
await prisma.vod.update({
|
||||||
@ -144,7 +190,7 @@ export default async function createTorrent(payload: any, helpers: Helpers) {
|
|||||||
data: { magnetLink }
|
data: { magnetLink }
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(`all done.`)
|
logger.info(`🏆 torrent creation complete.`)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
BIN
services/our/src/tests/fixtures/ubuntu-24.04.3-desktop-amd64.iso.torrent
vendored
Normal file
BIN
services/our/src/tests/fixtures/ubuntu-24.04.3-desktop-amd64.iso.torrent
vendored
Normal file
Binary file not shown.
142
services/our/src/tests/qbittorrent.integration.test.ts
Normal file
142
services/our/src/tests/qbittorrent.integration.test.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { join, resolve, basename } from 'path';
|
||||||
|
import { execSync, spawn, spawnSync, ChildProcess } from 'child_process';
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs';
|
||||||
|
import { access, readFile } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { QBittorrentClient } from '../utils/qbittorrent/qbittorrent.ts'
|
||||||
|
import bencode from "bencode";
|
||||||
|
import { pbkdf2Sync } from 'crypto';
|
||||||
|
import logger from '../utils/logger.ts';
|
||||||
|
|
||||||
|
const fixtures = {
|
||||||
|
mp4: join(__dirname, 'fixtures', 'sample.mp4'),
|
||||||
|
torrent: join(__dirname, 'fixtures', 'ubuntu-24.04.3-desktop-amd64.iso.torrent'),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let qbContainer: ChildProcess;
|
||||||
|
let tmpDir: string;
|
||||||
|
let downloadsDir: string;
|
||||||
|
let configDir: string;
|
||||||
|
let qbtPassword: string = 'adminadmin';
|
||||||
|
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
|
||||||
|
|
||||||
|
// create a single tmp dir for created .torrent files
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'vitest-qbittorrent-'));
|
||||||
|
downloadsDir = join(tmpDir, 'downloads');
|
||||||
|
configDir = join(tmpDir, 'config');
|
||||||
|
mkdirSync(downloadsDir);
|
||||||
|
mkdirSync(configDir);
|
||||||
|
|
||||||
|
|
||||||
|
// start Docker qBittorrent container
|
||||||
|
qbContainer = spawn('docker', [
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'--name', 'vitest-qbittorrent-nox',
|
||||||
|
'--tmpfs', '/tmp',
|
||||||
|
'-e', 'QBT_LEGAL_NOTICE=confirm',
|
||||||
|
'-e', 'QBT_TORRENTING_PORT=6883',
|
||||||
|
'-e', 'QBT_WEBUI_PORT=8084',
|
||||||
|
'-e', 'QBT_DISABLE_NETWORK=true',
|
||||||
|
'-e', 'QBT_USERNAME=admin', // @TODO with a user/pass, we can no longer connect
|
||||||
|
'-e', 'QBT_PASSWORD=adminadmin',
|
||||||
|
'-v', `${join(__dirname, 'fixtures')}:/fixtures`,
|
||||||
|
// '-v', `${tmpDir}:${tmpDir}`,
|
||||||
|
// // '-v', `${tmpDir}/config:/config`,
|
||||||
|
// '-v', `${tmpDir}/downloads:/downloads`,
|
||||||
|
'-p', '8084:8084',
|
||||||
|
'gitea.futureporn.net/futureporn/qbittorrent-nox:latest' // we use libtorrent 2
|
||||||
|
// 'qbittorrentofficial/qbittorrent-nox:5.1.2-2' // libtorrent is too old! (it's v1 we need v2)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// "docker run --rm --name fp-dev-qbittorrent --tmpfs /tmp -e QBT_LEGAL_NOTICE=confirm -e QBT_TORRENTING_PORT=6883 QBT_WEBUI_PORT=8084 -p 8084:8084"
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
|
||||||
|
// on qbittorrentofficial/qbittorrent-nox, a random password is created
|
||||||
|
// we capture it using this code
|
||||||
|
qbContainer.stdout?.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
logger.trace(`[qBittorrent] ${text}`);
|
||||||
|
|
||||||
|
// get the temporary password from stdout
|
||||||
|
const match = text.match(/temporary password.*:\s+(\S+)/i);
|
||||||
|
if (match) {
|
||||||
|
qbtPassword = match[1].trim();
|
||||||
|
logger.info(`[TEST] Captured qBittorrent password. qbtPassword=${qbtPassword}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
qbContainer.stderr?.on('data', (data) => console.error('[qBittorrent ERR]', data.toString()));
|
||||||
|
|
||||||
|
// fallback: resolve after timeout (if log didn’t contain password)
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (!qbtPassword) throw new Error("Failed to capture qBittorrent temporary password.");
|
||||||
|
logger.debug(`qbtPassword=${qbtPassword}`)
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
execSync('docker rm -f vitest-qbittorrent-nox');
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function sha256sum(filePath: string): string {
|
||||||
|
const data = readFileSync(filePath, { encoding: 'utf-8' });
|
||||||
|
return createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe('qbittorrent Torrent Creator integration', () => {
|
||||||
|
it('creates a .torrent file', async () => {
|
||||||
|
const qbtClient = new QBittorrentClient({
|
||||||
|
host: "localhost",
|
||||||
|
port: 8084,
|
||||||
|
username: "admin",
|
||||||
|
password: qbtPassword,
|
||||||
|
});
|
||||||
|
const { torrentFilePath, magnetLink } = await qbtClient.createTorrent(`/fixtures/${basename(fixtures.mp4)}`);
|
||||||
|
|
||||||
|
// 1. File naming and magnet format
|
||||||
|
expect(torrentFilePath.endsWith('.torrent'));
|
||||||
|
expect(magnetLink.startsWith('magnet://'));
|
||||||
|
|
||||||
|
logger.debug(`the torrentFilePath=${torrentFilePath} and magnetLink=${magnetLink}`)
|
||||||
|
|
||||||
|
// 2. File exists
|
||||||
|
const absPath = resolve(torrentFilePath);
|
||||||
|
await expect(access(absPath)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// 3. File contents are valid bencoded data
|
||||||
|
const buf = await readFile(absPath);
|
||||||
|
const decoded = bencode.decode(buf);
|
||||||
|
|
||||||
|
// 4. Check for required keys
|
||||||
|
expect(decoded).toHaveProperty("info");
|
||||||
|
expect(typeof decoded.info).toBe("object");
|
||||||
|
expect(decoded.info).toHaveProperty("name");
|
||||||
|
|
||||||
|
// 5. Verify the magnet link has both v1 (btih) and v2 (btmh)
|
||||||
|
// magnet:?xt=urn:btih:<v1hash>&xt=urn:btmh:<v2hash>&dn=...
|
||||||
|
const xtMatches = [...magnetLink.matchAll(/xt=urn:(btih|btmh):([a-fA-F0-9]+)/g)];
|
||||||
|
const types = xtMatches.map(m => m[1]);
|
||||||
|
expect(types, 'magnet link btih is missing').toContain("btih"); // v1 hash
|
||||||
|
expect(types, 'magnet link btmh is missing').toContain("btmh"); // v2 hash
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
});
|
||||||
|
|
85
services/our/src/tests/sftp.integration.test.ts
Normal file
85
services/our/src/tests/sftp.integration.test.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { sshClient } from '../utils/sftp.ts';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { execSync, spawn, ChildProcess } from 'child_process';
|
||||||
|
import { mkdtempSync, readFileSync, rmdirSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { env } from '../config/env.ts';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
const fixtures = {
|
||||||
|
mp4: join(__dirname, 'fixtures', 'sample.mp4'),
|
||||||
|
torrent: join(__dirname, 'fixtures', 'ubuntu-24.04.3-desktop-amd64.iso.torrent'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sftpContainer: ChildProcess;
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// start Docker SFTP container
|
||||||
|
sftpContainer = spawn('docker', [
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'--name', 'vitest-sftp',
|
||||||
|
'-p', '2228:22',
|
||||||
|
'atmoz/sftp',
|
||||||
|
`${env.SEEDBOX_SFTP_USERNAME}:${env.SEEDBOX_SFTP_PASSWORD}:::data,watch`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
sftpContainer.stdout?.on('data', (data) => console.log('[SFTP]', data.toString()));
|
||||||
|
sftpContainer.stderr?.on('data', (data) => console.error('[SFTP ERR]', data.toString()));
|
||||||
|
setTimeout(resolve, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// create a single tmp dir for downloads
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'vitest-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
sshClient.end();
|
||||||
|
execSync('docker rm -f vitest-sftp');
|
||||||
|
rmdirSync(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function sha256sum(filePath: string): string {
|
||||||
|
const data = readFileSync(filePath, { encoding: 'utf-8' });
|
||||||
|
return createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload, download, verify sha256
|
||||||
|
*/
|
||||||
|
async function uploadAndVerify(localPath: string, remoteDir: string) {
|
||||||
|
|
||||||
|
|
||||||
|
const fileName = localPath.split('/').pop()!;
|
||||||
|
const originalHash = sha256sum(localPath);
|
||||||
|
console.log(`upload and veryfi localPath=${localPath}, remoteDir=${remoteDir}, fileName=${fileName}, originalHash=${originalHash}`)
|
||||||
|
|
||||||
|
// upload
|
||||||
|
await sshClient.uploadFile(localPath, remoteDir);
|
||||||
|
|
||||||
|
// download to temp dir
|
||||||
|
const tmpFile = join(tmpDir, fileName);
|
||||||
|
console.log(`tmpFile=${tmpFile}`)
|
||||||
|
await sshClient.downloadFile(`${remoteDir}/${fileName}`, tmpFile);
|
||||||
|
|
||||||
|
// verify
|
||||||
|
const downloadedHash = sha256sum(tmpFile);
|
||||||
|
expect(downloadedHash).toBe(originalHash);
|
||||||
|
|
||||||
|
|
||||||
|
rmSync(tmpFile, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
describe('SSHClient integration', () => {
|
||||||
|
it('uploads a file to ~/data and verifies sha256', async () => {
|
||||||
|
await uploadAndVerify(fixtures.mp4, './data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads a file to ~/watch and verifies sha256', async () => {
|
||||||
|
await uploadAndVerify(fixtures.torrent, './watch');
|
||||||
|
});
|
||||||
|
});
|
@ -4,7 +4,7 @@ import { env } from '../config/env'
|
|||||||
let hooks
|
let hooks
|
||||||
const isTest = env.NODE_ENV === 'test'
|
const isTest = env.NODE_ENV === 'test'
|
||||||
|
|
||||||
if (env.NODE_ENV === 'test') {
|
if (isTest) {
|
||||||
const { prettyFactory } = require('pino-pretty')
|
const { prettyFactory } = require('pino-pretty')
|
||||||
const prettify = prettyFactory({ sync: true, colorize: true })
|
const prettify = prettyFactory({ sync: true, colorize: true })
|
||||||
hooks = {
|
hooks = {
|
||||||
@ -15,6 +15,9 @@ if (env.NODE_ENV === 'test') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const isProd = env.NODE_ENV === 'production'
|
const isProd = env.NODE_ENV === 'production'
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
level: env.LOG_LEVEL,
|
level: env.LOG_LEVEL,
|
||||||
|
27
services/our/src/utils/qbittorrent/README.md
Normal file
27
services/our/src/utils/qbittorrent/README.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# qbittorrent task
|
||||||
|
|
||||||
|
Creates a v1/v2 hybrid torrent via qbittorrent API.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
It's really hard to create v1/v2 hybrid torrents that are compatible with rTorrent. Every CLI torrent creator I know of can't do it for one reason or another.
|
||||||
|
|
||||||
|
* [mktorrent](https://github.com/pobrn/mktorrent) only does v1
|
||||||
|
* [torf](https://github.com/rndusr/torf-cli) only does v1
|
||||||
|
* [transmission-cli](https://transmissionbt.com/) only does v1
|
||||||
|
* [imdl](https://github.com/casey/intermodal) only does v1
|
||||||
|
* [torrentfile](https://github.com/alexpdev/torrentfile) produces v1/v2 hybrid torrent files that rTorrent can't import.
|
||||||
|
* [torrenttools](https://github.com/fbdtemme/torrenttools) is discontinued.
|
||||||
|
* [torrent-creator](https://github.com/itzrealbeluga/torrent-creator) is bleeding edge brand new (who knows if they will be around in 6 months)
|
||||||
|
|
||||||
|
To create v1/v2 hybrids, I think we need to rely on a popular, well maintained library. And what are the most popular, most well maintained? Full-featured programs like qBittorrent.
|
||||||
|
|
||||||
|
## qBittorrent Torrent Creator API History
|
||||||
|
|
||||||
|
qBittorrent introduced Torrent Creator via their API in qbittorrent 5 https://github.com/qbittorrent/qBittorrent/blob/becfd19e348028bd572056b755f5232976e30146/Changelog#L84. In versions before that, the only way to create a torrent in qBittorrent was via their API.
|
||||||
|
|
||||||
|
## Project Requirements
|
||||||
|
|
||||||
|
* [x] Javascript (not typescript)
|
||||||
|
* [x] vitest integration testing
|
||||||
|
* [ ] Continuous Integration testing
|
382
services/our/src/utils/qbittorrent/qbittorrent.ts
Normal file
382
services/our/src/utils/qbittorrent/qbittorrent.ts
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* src/utils/qbittorrent.ts
|
||||||
|
*
|
||||||
|
* qBittorrent API client
|
||||||
|
* Used for creating torrent v1/v2 hybrids.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "path";
|
||||||
|
import { URL } from "url";
|
||||||
|
import { env } from "../../config/env";
|
||||||
|
import logger from "../logger";
|
||||||
|
import { readFile, writeFile } from "fs/promises";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join, basename } from "path";
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
|
||||||
|
interface QBittorrentClientOptions {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TorrentCreatorTaskStatus {
|
||||||
|
errorMessage: string;
|
||||||
|
optimizeAlignment: boolean;
|
||||||
|
paddedFileSizeLimit: number;
|
||||||
|
pieceSize: number;
|
||||||
|
private: boolean;
|
||||||
|
sourcePath: string;
|
||||||
|
status: "Running" | "Finished" | "Failed" | string; // API may expand
|
||||||
|
taskID: string;
|
||||||
|
timeAdded: string; // raw string from qBittorrent
|
||||||
|
timeFinished: string; // raw string
|
||||||
|
timeStarted: string; // raw string
|
||||||
|
trackers: string[];
|
||||||
|
urlSeeds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TorrentCreatorTaskStatusMap = Record<string, TorrentCreatorTaskStatus>;
|
||||||
|
|
||||||
|
export interface QBTorrentInfo {
|
||||||
|
added_on: number;
|
||||||
|
amount_left: number;
|
||||||
|
auto_tmm: boolean;
|
||||||
|
availability: number;
|
||||||
|
category: string;
|
||||||
|
comment: string;
|
||||||
|
completed: number;
|
||||||
|
completion_on: number;
|
||||||
|
content_path: string;
|
||||||
|
dl_limit: number;
|
||||||
|
dlspeed: number;
|
||||||
|
download_path: string;
|
||||||
|
downloaded: number;
|
||||||
|
downloaded_session: number;
|
||||||
|
eta: number;
|
||||||
|
f_l_piece_prio: boolean;
|
||||||
|
force_start: boolean;
|
||||||
|
has_metadata: boolean;
|
||||||
|
hash: string;
|
||||||
|
inactive_seeding_time_limit: number;
|
||||||
|
infohash_v1: string;
|
||||||
|
infohash_v2: string;
|
||||||
|
last_activity: number;
|
||||||
|
magnet_uri: string;
|
||||||
|
max_inactive_seeding_time: number;
|
||||||
|
max_ratio: number;
|
||||||
|
max_seeding_time: number;
|
||||||
|
name: string;
|
||||||
|
num_complete: number;
|
||||||
|
num_incomplete: number;
|
||||||
|
num_leechs: number;
|
||||||
|
num_seeds: number;
|
||||||
|
popularity: number;
|
||||||
|
priority: number;
|
||||||
|
private: boolean;
|
||||||
|
progress: number;
|
||||||
|
ratio: number;
|
||||||
|
ratio_limit: number;
|
||||||
|
reannounce: number;
|
||||||
|
root_path: string;
|
||||||
|
save_path: string;
|
||||||
|
seeding_time: number;
|
||||||
|
seeding_time_limit: number;
|
||||||
|
seen_complete: number;
|
||||||
|
seq_dl: boolean;
|
||||||
|
size: number;
|
||||||
|
state: string;
|
||||||
|
super_seeding: boolean;
|
||||||
|
tags: string;
|
||||||
|
time_active: number;
|
||||||
|
total_size: number;
|
||||||
|
tracker: string;
|
||||||
|
trackers_count: number;
|
||||||
|
up_limit: number;
|
||||||
|
uploaded: number;
|
||||||
|
uploaded_session: number;
|
||||||
|
upspeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QBittorrentClient
|
||||||
|
*
|
||||||
|
* @see https://qbittorrent-api.readthedocs.io/en/latest/apidoc/torrentcreator.html
|
||||||
|
*/
|
||||||
|
export class QBittorrentClient {
|
||||||
|
private readonly host: string;
|
||||||
|
private readonly port: number;
|
||||||
|
private readonly username: string;
|
||||||
|
private readonly password: string;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private sidCookie: string | null = null;
|
||||||
|
|
||||||
|
constructor(options: QBittorrentClientOptions = {}) {
|
||||||
|
const defaults = {
|
||||||
|
host: "localhost",
|
||||||
|
port: 8083,
|
||||||
|
username: "admin",
|
||||||
|
password: "adminadmin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { host, port, username, password } = { ...defaults, ...options };
|
||||||
|
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
|
||||||
|
this.baseUrl = `http://${this.host}:${this.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
logger.debug("Connecting to qBittorrent...");
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs into qBittorrent Web API.
|
||||||
|
*
|
||||||
|
* Example (cURL):
|
||||||
|
* curl -i \
|
||||||
|
* --header 'Referer: http://localhost:8080' \
|
||||||
|
* --data 'username=admin&password=adminadmin' \
|
||||||
|
* http://localhost:8080/api/v2/auth/login
|
||||||
|
*
|
||||||
|
* Then use the returned SID cookie for subsequent requests.
|
||||||
|
*/
|
||||||
|
private async login(): Promise<void> {
|
||||||
|
logger.debug(`login() begin. using username=${this.username}, password=${this.password} env=${env.NODE_ENV}`)
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Referer: this.baseUrl,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg = `Login request failed (${response.status} ${response.statusText}). body=${responseBody}`;
|
||||||
|
logger.error(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Login response: status=${response.status} ${response.statusText}`);
|
||||||
|
logger.trace(`Headers: ${JSON.stringify([...response.headers.entries()])}`);
|
||||||
|
|
||||||
|
// Extract SID cookie
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
if (!setCookie) {
|
||||||
|
const msg = `Login failed: No SID cookie was returned. status=${response.status} ${response.statusText}. body=${responseBody}`;
|
||||||
|
logger.error(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sidCookie = setCookie;
|
||||||
|
logger.info("Successfully logged into qBittorrent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private async addTorrentCreationTask(sourcePath: string): Promise<string> {
|
||||||
|
const torrentFilePath = join(tmpdir(), `${nanoid()}.torrent`);
|
||||||
|
logger.debug(`addTorrentCreationTask using sourcePath=${sourcePath}, torrentFilePath=${torrentFilePath}`)
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v2/torrentcreator/addTask`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Cookie": this.sidCookie!,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
sourcePath,
|
||||||
|
torrentFilePath,
|
||||||
|
format: "hybrid"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text()
|
||||||
|
throw new Error(`addTask failed. status=${response.status} statusText=${response.statusText} body=${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
logger.debug({ addTaskResponse: data });
|
||||||
|
return data.taskID;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async pollTorrentStatus(taskId: string): Promise<TorrentCreatorTaskStatus> {
|
||||||
|
while (true) {
|
||||||
|
logger.debug(`Polling torrent creation taskID=${taskId}`);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${this.baseUrl}/api/v2/torrentcreator/status?taskId=${taskId}`,
|
||||||
|
{ headers: { Cookie: this.sidCookie! } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`status failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
logger.debug('the request to poll for torrent status was successful.')
|
||||||
|
|
||||||
|
const statusMap = (await res.json()) as TorrentCreatorTaskStatusMap;
|
||||||
|
logger.debug({ statusMap: statusMap })
|
||||||
|
const task = Object.values(statusMap).find((t) => t.taskID === taskId);
|
||||||
|
logger.debug({ task: task })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task ${taskId} not found in status response`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(` Torrent creator task status=${task.status}`);
|
||||||
|
|
||||||
|
switch (task.status) {
|
||||||
|
case "Failed":
|
||||||
|
const msg = `Torrent creation failed: ${task.errorMessage}`;
|
||||||
|
logger.error(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
case "Finished":
|
||||||
|
return task;
|
||||||
|
default:
|
||||||
|
// still running.. wait 1s and retry
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetchTorrentFile
|
||||||
|
*
|
||||||
|
* Fetch a .torrent file from qBittorrent after having created said torrent
|
||||||
|
* using the Torrent Creator API.
|
||||||
|
*
|
||||||
|
* @param taskId
|
||||||
|
* @param outputDir
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private async fetchTorrentFile(taskId: string, outputDir = tmpdir()): Promise<string> {
|
||||||
|
logger.debug(`fetchTorrentFile with taskId=${taskId}, outputDir=${outputDir}`)
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/v2/torrentcreator/torrentFile?taskId=${taskId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: this.sidCookie! },
|
||||||
|
body: new URLSearchParams({ taskID: taskId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`torrentFile failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
const filePath = path.join(outputDir, `${taskId}.torrent`);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a local torrent file to qBittorrent.
|
||||||
|
* Returns the infoHash of the added torrent.
|
||||||
|
*/
|
||||||
|
async addTorrent(localFilePath: string): Promise<void> {
|
||||||
|
|
||||||
|
logger.debug(`addTorrent using localFilePath=${localFilePath}`)
|
||||||
|
|
||||||
|
if (!this.sidCookie) {
|
||||||
|
throw new Error("Not connected: SID cookie missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
const fileBuffer = await readFile(localFilePath);
|
||||||
|
|
||||||
|
|
||||||
|
const blob = new Blob([fileBuffer]); // wrap Buffer in Blob
|
||||||
|
|
||||||
|
form.append("torrents", blob, path.basename(localFilePath));
|
||||||
|
|
||||||
|
|
||||||
|
form.append("savepath", "/tmp"); // optional: specify download path
|
||||||
|
form.append("paused", "true"); // start downloading immediately
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/v2/torrents/add`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: this.sidCookie,
|
||||||
|
},
|
||||||
|
body: form as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`addTorrent failed: ${res.status} ${res.statusText} ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('addTorrent success.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMagnetLink(fileName: string): Promise<string> {
|
||||||
|
logger.debug(`getMagnetLink using fileName=${fileName}`)
|
||||||
|
|
||||||
|
// qBittorrent does NOT return infoHash directly here
|
||||||
|
// we have to get it by querying the torrents list
|
||||||
|
const torrentsRes = await fetch(`${this.baseUrl}/api/v2/torrents/info`, {
|
||||||
|
headers: { Cookie: this.sidCookie! },
|
||||||
|
});
|
||||||
|
const torrents = await torrentsRes.json() as Array<{ hash: string; name: string }>;
|
||||||
|
const torrent = torrents.find((t) => t.name === fileName) as QBTorrentInfo;
|
||||||
|
|
||||||
|
if (!torrent) {
|
||||||
|
throw new Error(`Torrent ${fileName} not found in qBittorrent after adding`);
|
||||||
|
}
|
||||||
|
logger.debug({ torrent })
|
||||||
|
|
||||||
|
return torrent.magnet_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTorrent(localFilePath: string): Promise<{ torrentFilePath: string; magnetLink: string }> {
|
||||||
|
logger.info(`Creating torrent from file: ${localFilePath}`);
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
if (!this.sidCookie) {
|
||||||
|
throw new Error("sidCookie was missing. This is likely a bug.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. start task
|
||||||
|
const taskId = await this.addTorrentCreationTask(localFilePath);
|
||||||
|
logger.debug(`Created torrent task ${taskId}`);
|
||||||
|
|
||||||
|
// 2. poll until finished
|
||||||
|
await this.pollTorrentStatus(taskId);
|
||||||
|
|
||||||
|
// 3. fetch torrent file
|
||||||
|
const torrentFilePath = await this.fetchTorrentFile(taskId, env.CACHE_ROOT);
|
||||||
|
|
||||||
|
// 4. add the torrent to qBittorrent
|
||||||
|
await this.addTorrent(torrentFilePath);
|
||||||
|
|
||||||
|
// 5. Get magnet link
|
||||||
|
const magnetLink = await this.getMagnetLink(basename(localFilePath));
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
magnetLink, torrentFilePath
|
||||||
|
})
|
||||||
|
|
||||||
|
return { torrentFilePath, magnetLink };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const qbtClient = new QBittorrentClient({});
|
119
services/our/src/utils/sftp.ts
Normal file
119
services/our/src/utils/sftp.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// src/utils/sftp.ts
|
||||||
|
import { Client, ConnectConfig, SFTPWrapper } from 'ssh2';
|
||||||
|
import path from 'path';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import logger from './logger';
|
||||||
|
|
||||||
|
interface SSHClientOptions {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SSHClient {
|
||||||
|
private client = new Client();
|
||||||
|
private sftp?: SFTPWrapper;
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
constructor(private options: SSHClientOptions) { }
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.connected) return;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.client
|
||||||
|
.on('ready', () => resolve())
|
||||||
|
.on('error', reject)
|
||||||
|
.connect({
|
||||||
|
host: this.options.host,
|
||||||
|
port: this.options.port || 22,
|
||||||
|
username: this.options.username,
|
||||||
|
password: this.options.password,
|
||||||
|
privateKey: this.options.privateKey,
|
||||||
|
} as ConnectConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSFTP(): Promise<SFTPWrapper> {
|
||||||
|
if (!this.sftp) {
|
||||||
|
this.sftp = await new Promise((resolve, reject) => {
|
||||||
|
this.client.sftp((err, sftp) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(sftp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.sftp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(command: string): Promise<string> {
|
||||||
|
await this.connect();
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
this.client.exec(command, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
stream
|
||||||
|
.on('close', (code: number) => {
|
||||||
|
if (code !== 0) reject(new Error(`Command failed: ${stderr}`));
|
||||||
|
else resolve(stdout.trim());
|
||||||
|
})
|
||||||
|
.on('data', (data: Buffer) => (stdout += data.toString()))
|
||||||
|
.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(localFilePath: string, remoteDir: string): Promise<void> {
|
||||||
|
logger.info(`Uploading localFilePath=${localFilePath} to remoteDir=${remoteDir}...`);
|
||||||
|
|
||||||
|
logger.debug('awaiting connect')
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
logger.debug('getting sftp')
|
||||||
|
const sftp = await this.getSFTP();
|
||||||
|
|
||||||
|
logger.debug('getting fileName')
|
||||||
|
const fileName = path.basename(localFilePath);
|
||||||
|
|
||||||
|
logger.debug(`fileName=${fileName}`)
|
||||||
|
|
||||||
|
const remoteFilePath = path.posix.join(remoteDir, fileName);
|
||||||
|
logger.debug(`remoteFilePath=${remoteFilePath}`)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
sftp.fastPut(localFilePath, remoteFilePath, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(remoteFilePath: string, localPath: string): Promise<void> {
|
||||||
|
logger.info(`downloading remoteFilePath=${remoteFilePath} to localPath=${localPath}`)
|
||||||
|
await this.connect();
|
||||||
|
const sftp = await this.getSFTP();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
sftp.fastGet(remoteFilePath, localPath, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
end(): void {
|
||||||
|
this.client.end();
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- usage helper ---
|
||||||
|
const url = URL.parse(env.SEEDBOX_SFTP_URL);
|
||||||
|
const hostname = url?.hostname;
|
||||||
|
const port = url?.port;
|
||||||
|
|
||||||
|
export const sshClient = new SSHClient({
|
||||||
|
host: hostname!,
|
||||||
|
port: port ? parseInt(port) : 22,
|
||||||
|
username: env.SEEDBOX_SFTP_USERNAME,
|
||||||
|
password: env.SEEDBOX_SFTP_PASSWORD,
|
||||||
|
});
|
@ -18,8 +18,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Feature</th>
|
<th>Feature</th>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Supporter Tier 1</th>
|
<th>Supporter</th>
|
||||||
<th>Supporter Tier 6</th>
|
{{!-- <th>Supporter Tier 6</th> --}}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -27,55 +27,46 @@
|
|||||||
<td>View</td>
|
<td>View</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>RSS</td>
|
<td>RSS</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Torrent Downloads</td>
|
<td>Torrent Downloads</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>CDN Downloads</td>
|
<td>CDN Downloads</td>
|
||||||
<td>❌</td>
|
<td>❌</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>API</td>
|
<td>API</td>
|
||||||
<td>❌</td>
|
<td>❌</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Ad-Free</td>
|
<td>Ad-Free</td>
|
||||||
<td>❌</td>
|
<td>❌</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Upload</td>
|
<td>Upload</td>
|
||||||
<td>❌</td>
|
<td>❌</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><abbr title="Sex toy playback syncronization">Funscripts</abbr></td>
|
<td><abbr title="Sex toy playback syncronization">Funscripts</abbr></td>
|
||||||
<td>❌</td>
|
<td>❌</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Closed Captions</td>
|
<td>Closed Captions</td>
|
||||||
<td>❌</td>
|
<td>❌</td>
|
||||||
<td>✅</td>
|
<td>✅</td>
|
||||||
<td>✅</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{{!--
|
{{!--
|
||||||
@todo add these things
|
@todo add these things
|
||||||
|
@ -39,7 +39,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
<div class="section" x-data="{}">
|
<div class="section pt-0" x-data="{}">
|
||||||
<section>
|
<section>
|
||||||
{{#if vod.hlsPlaylist}}
|
{{#if vod.hlsPlaylist}}
|
||||||
<div class="video-container" data-supporter="{{hasRole 'supporterTier1' user}}">
|
<div class="video-container" data-supporter="{{hasRole 'supporterTier1' user}}">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user