diff --git a/apps/qbittorrent-nox/Dockerfile b/apps/qbittorrent-nox/Dockerfile new file mode 100644 index 0000000..2541785 --- /dev/null +++ b/apps/qbittorrent-nox/Dockerfile @@ -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"] \ No newline at end of file diff --git a/apps/qbittorrent-nox/README.md b/apps/qbittorrent-nox/README.md new file mode 100644 index 0000000..302ee9b --- /dev/null +++ b/apps/qbittorrent-nox/README.md @@ -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. diff --git a/apps/qbittorrent-nox/build.sh b/apps/qbittorrent-nox/build.sh new file mode 100644 index 0000000..5dac9ec --- /dev/null +++ b/apps/qbittorrent-nox/build.sh @@ -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" diff --git a/apps/qbittorrent-nox/entrypoint.sh b/apps/qbittorrent-nox/entrypoint.sh new file mode 100755 index 0000000..38c29ad --- /dev/null +++ b/apps/qbittorrent-nox/entrypoint.sh @@ -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 \ No newline at end of file diff --git a/services/our/package-lock.json b/services/our/package-lock.json index 490b102..108ef0c 100644 --- a/services/our/package-lock.json +++ b/services/our/package-lock.json @@ -1,12 +1,12 @@ { - "name": "futureporn", - "version": "2.5.0", + "name": "futureporn-our", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "futureporn", - "version": "2.5.0", + "name": "futureporn-our", + "version": "2.6.0", "dependencies": { "@aws-sdk/client-s3": "3.726.1", "@aws-sdk/s3-request-presigner": "^3.844.0", @@ -30,6 +30,7 @@ "@types/fs-extra": "^11.0.4", "@types/node": "^22.16.3", "@types/node-fetch": "^2.6.12", + "@types/ssh2": "^1.15.5", "cache-manager": "^7.0.1", "canvas": "^3.1.2", "chokidar-cli": "^3.0.0", @@ -70,6 +71,7 @@ "devDependencies": { "@eslint/compat": "^1.3.1", "@eslint/js": "^9.31.0", + "bencode": "^4.0.0", "buttplug": "^3.2.2", "chokidar": "^4.0.3", "esbuild": "^0.25.9", @@ -4489,6 +4491,30 @@ "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", "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": { "version": "8.36.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", diff --git a/services/our/package.json b/services/our/package.json index cee3122..6ad01e5 100644 --- a/services/our/package.json +++ b/services/our/package.json @@ -4,13 +4,14 @@ "version": "2.6.0", "type": "module", "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: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: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: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", "start": "echo please use either start:server or start:worker; exit 1", "start:server": "tsx ./src/index.ts", @@ -26,6 +27,7 @@ "devDependencies": { "@eslint/compat": "^1.3.1", "@eslint/js": "^9.31.0", + "bencode": "^4.0.0", "buttplug": "^3.2.2", "chokidar": "^4.0.3", "esbuild": "^0.25.9", @@ -71,6 +73,7 @@ "@types/fs-extra": "^11.0.4", "@types/node": "^22.16.3", "@types/node-fetch": "^2.6.12", + "@types/ssh2": "^1.15.5", "cache-manager": "^7.0.1", "canvas": "^3.1.2", "chokidar-cli": "^3.0.0", diff --git a/services/our/src/config/env.ts b/services/our/src/config/env.ts index 99bcf07..3db7510 100644 --- a/services/our/src/config/env.ts +++ b/services/our/src/config/env.ts @@ -33,6 +33,9 @@ const EnvSchema = z.object({ LOG_LEVEL: z.string().default('info'), B2_APPLICATION_KEY_ID: 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); diff --git a/services/our/src/tasks/createTorrent.ts b/services/our/src/tasks/createTorrent.ts index 1492da8..41c7e26 100644 --- a/services/our/src/tasks/createTorrent.ts +++ b/services/our/src/tasks/createTorrent.ts @@ -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 { PrismaClient } from "../../generated/prisma"; import { withAccelerate } from "@prisma/extension-accelerate"; @@ -8,7 +20,9 @@ import { nanoid } from "nanoid"; import { getNanoSpawn } from "../utils/nanoSpawn"; import logger from "../utils/logger"; 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()); @@ -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 { 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`); } +// 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) const { vodId } = payload 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() // * [x] download video segments from pull-thru cache const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo) logger.debug(`videoFilePath=${videoFilePath}`) - // * [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 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); + const { magnetLink, torrentFilePath } = await createQBittorrentTorrent(vodId, videoFilePath) + await uploadTorrentToSeedbox(videoFilePath, torrentFilePath) logger.debug(`updating vod record`); await prisma.vod.update({ @@ -144,7 +190,7 @@ export default async function createTorrent(payload: any, helpers: Helpers) { data: { magnetLink } }); - logger.debug(`all done.`) + logger.info(`πŸ† torrent creation complete.`) } \ No newline at end of file diff --git a/services/our/src/tests/fixtures/ubuntu-24.04.3-desktop-amd64.iso.torrent b/services/our/src/tests/fixtures/ubuntu-24.04.3-desktop-amd64.iso.torrent new file mode 100644 index 0000000..516328e Binary files /dev/null and b/services/our/src/tests/fixtures/ubuntu-24.04.3-desktop-amd64.iso.torrent differ diff --git a/services/our/src/tests/qbittorrent.integration.test.ts b/services/our/src/tests/qbittorrent.integration.test.ts new file mode 100644 index 0000000..999b7e0 --- /dev/null +++ b/services/our/src/tests/qbittorrent.integration.test.ts @@ -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((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:&xt=urn:btmh:&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 + + }) + +}); + diff --git a/services/our/src/tests/sftp.integration.test.ts b/services/our/src/tests/sftp.integration.test.ts new file mode 100644 index 0000000..9dea41a --- /dev/null +++ b/services/our/src/tests/sftp.integration.test.ts @@ -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((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'); + }); +}); diff --git a/services/our/src/utils/logger.ts b/services/our/src/utils/logger.ts index 62e54a3..9a27844 100644 --- a/services/our/src/utils/logger.ts +++ b/services/our/src/utils/logger.ts @@ -4,7 +4,7 @@ import { env } from '../config/env' let hooks const isTest = env.NODE_ENV === 'test' -if (env.NODE_ENV === 'test') { +if (isTest) { const { prettyFactory } = require('pino-pretty') const prettify = prettyFactory({ sync: true, colorize: true }) hooks = { @@ -15,6 +15,9 @@ if (env.NODE_ENV === 'test') { } } + + + const isProd = env.NODE_ENV === 'production' const logger = pino({ level: env.LOG_LEVEL, diff --git a/services/our/src/utils/qbittorrent/README.md b/services/our/src/utils/qbittorrent/README.md new file mode 100644 index 0000000..6dc4246 --- /dev/null +++ b/services/our/src/utils/qbittorrent/README.md @@ -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 \ No newline at end of file diff --git a/services/our/src/utils/qbittorrent/qbittorrent.ts b/services/our/src/utils/qbittorrent/qbittorrent.ts new file mode 100644 index 0000000..487f61e --- /dev/null +++ b/services/our/src/utils/qbittorrent/qbittorrent.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + + 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 { + 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({}); diff --git a/services/our/src/utils/sftp.ts b/services/our/src/utils/sftp.ts new file mode 100644 index 0000000..44e4cc9 --- /dev/null +++ b/services/our/src/utils/sftp.ts @@ -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 { + if (this.connected) return; + + await new Promise((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 { + 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 { + await this.connect(); + return new Promise((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 { + 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((resolve, reject) => { + sftp.fastPut(localFilePath, remoteFilePath, (err) => (err ? reject(err) : resolve())); + }); + } + + async downloadFile(remoteFilePath: string, localPath: string): Promise { + logger.info(`downloading remoteFilePath=${remoteFilePath} to localPath=${localPath}`) + await this.connect(); + const sftp = await this.getSFTP(); + + await new Promise((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, +}); diff --git a/services/our/src/views/perks.hbs b/services/our/src/views/perks.hbs index 5522659..549bc6c 100644 --- a/services/our/src/views/perks.hbs +++ b/services/our/src/views/perks.hbs @@ -18,8 +18,8 @@ Feature User - Supporter Tier 1 - Supporter Tier 6 + Supporter + {{!-- Supporter Tier 6 --}} @@ -27,57 +27,48 @@ View βœ… βœ… - βœ… RSS βœ… βœ… - βœ… Torrent Downloads βœ… βœ… - βœ… CDN Downloads ❌ βœ… - βœ… API ❌ βœ… - βœ… Ad-Free ❌ βœ… - βœ… Upload ❌ βœ… - βœ… Funscripts ❌ βœ… - βœ… Closed Captions ❌ βœ… - βœ… - {{!-- + {{!-- @todo add these things CC Search diff --git a/services/our/src/views/vod.hbs b/services/our/src/views/vod.hbs index 716fa01..a0f94b2 100644 --- a/services/our/src/views/vod.hbs +++ b/services/our/src/views/vod.hbs @@ -39,7 +39,7 @@ -
+
{{#if vod.hlsPlaylist}}