add qbittorrent-nox docker image
Some checks failed
ci / build (push) Failing after 12m34s
ci / test (push) Failing after 8m11s

This commit is contained in:
CJ_Clippy 2025-09-08 02:56:37 -08:00
parent ea6c5b5bd7
commit c3da9e26bc
17 changed files with 1334 additions and 103 deletions

View 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"]

View 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.

View 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"

View 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

View File

@ -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",

View File

@ -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",

View File

@ -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);

View File

@ -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.`)
} }

View 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 didnt 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
})
});

View 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');
});
});

View File

@ -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,

View 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

View 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({});

View 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,
});

View File

@ -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,57 +27,48 @@
<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
<tr> <tr>
<td><abbr title="Closed Captions">CC</abbr> Search</td> <td><abbr title="Closed Captions">CC</abbr> Search</td>

View File

@ -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}}">