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