rTorrent/ruTorrent with Nginx & PHP-FPM on Entware
Note
Some assumptions:
- SSH access to device
- Entware installed
- Neither rTorrent/ruTorrent, nor Nginx nor PHP-FPM previously installed
Install required base packages
$ opkg install bash curl ffmpeg git git-http gzip htop iotop mediainfo\
mktorrent nano nginx-ssl php8 php8-cli php8-fpm php8-mod-bcmath\
php8-mod-ctype php8-mod-curl php8-mod-dom php8-mod-fileinfo php8-mod-filter\
php8-mod-mbstring php8-mod-opcache php8-mod-openssl php8-mod-phar\
php8-mod-session php8-mod-xml php8-mod-zip procps-ng-pgrep python3 python3-pip\
rtorrent-rpc socat unrar unzip wget-ssl
Note
The PHP extensions above are not optional decoration. Leaving one out
disables plugins silently, or worse, throws a fatal on every call:
- mbstring - tracklabels and _getdir call mb_strtolower(). Without
it the directory picker returns a 500 and tracklabels fatals on every
request.
- dom - extsearch
- openssl - xmpp
- zip - source
- fileinfo - tracklabels
ruTorrent also needs a GNU stat, because php/utility/lfs.php calls it
with -Lc%d:%i:%f:%h:%u:%g:%s:%X:%Y:%Z:%B:%b. On QNAP the system binary
at /usr/bin/stat accepts that, which is what $pathToExternals below
points at. If your device has no GNU stat there, install the
coreutils-stat package and use /opt/bin/stat instead.
Configure rTorrent
Create necessary directories:
$ mkdir --parents /share/data/{.rtorrent,torrents}
$ mkdir /share/data/.rtorrent/{.session,watch}
Create rTorrent configuration file:
$ nano /share/data/.rtorrent/.rtorrent.rc
## Run rtorrent as a daemon (disables user interface, only XMLRPC control)
system.daemon.set = true
## Control rtorrent via UNIX XMLRPC socket
network.scgi.open_local = (cat, "/opt/var/run/rtorrent.sock")
execute.nothrow = chmod, 770, (cat, "/opt/var/run/rtorrent.sock")
## Listening port for incoming peer traffic, don't randomize
network.port_random.set = no
network.port_range.set = 49879-49879
## Set default data save directory, session file path
directory.default.set = /share/data/torrents
session.path.set = /share/data/.rtorrent/.session
## Disable UDP protocol to trackers, disable DHT/PEX BitTorrent protocols and enable traffic encryption if possible
trackers.use_udp.set = no
dht.mode.set = off
protocol.pex.set = no
protocol.encryption.set = allow_incoming, try_outgoing, enable_retry
## Preferred filename encoding
encoding.add = UTF-8
## Disable hash check on torrents that have finished downloading (intensive process)
pieces.hash.on_completion.set = no
## Minimum and maximum number of peers to connect to per torrent while downloading.
throttle.min_peers.normal.set = 32
throttle.max_peers.normal.set = 64
## Minimum and maximum number of peers to connect to per torrent while seeding. (-1 for same value as above)
throttle.min_peers.seed.set = -1
throttle.max_peers.seed.set = -1
## Maximum number of simultaneous downloads and uploads slots per torrent
throttle.max_downloads.set = 32
throttle.max_uploads.set = 32
## Memory address space to map file chunks.
pieces.memory.max.set = 2048M
## Watch directory, saving a torrent file to this directory will automatically start the download.
schedule2 = watch_directory, 5, 5, load.start=/share/data/.rtorrent/watch/*.torrent
schedule2 = untied_directory, 5, 5, stop_untied=/share/data/.rtorrent/watch/*.torrent
## Stop rtorrent from downloading data when disk space is low.
schedule2 = low_diskspace, 15, 60, ((close_low_diskspace, 50G))
## Save rtorrent session data every 5 minutes (default: 20min)
schedule2 = session_save, 300, 300, ((session.save))
## Initialize ruTorrent plugins
execute2 = {sh,-c,/opt/bin/php-cli /opt/var/www/rutorrent/php/initplugins.php &}
## Logging:
## levels = critical error warn notice info debug
## groups = connection_* dht_* peer_* rpc_* storage_* thread_* tracker_* torrent_
# Log info
# log.open_file = "logfile", (cat,/opt/var/log/rtorrent.log)
# log.add_output = "debug", "logfile"
# log.add_output = "tracker_debug", "logfile"
Create rTorrent init file:
Note
This init file is heavily inspired by Entware's
/opt/etc/init.d/rc.func.
$ nano /opt/etc/init.d/S85rtorrent
#!/bin/sh
PROCS=rtorrent
ARGS="-D -n -o import=/share/data/.rtorrent/.rtorrent.rc"
DESC=$PROCS
PATH=/opt/sbin:/opt/bin:/opt/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ulimit -n 8192 # rtorrent derives network.max_open_files from this
ansi_red="\033[1;31m";
ansi_white="\033[1;37m";
ansi_green="\033[1;32m";
ansi_yellow="\033[1;33m";
ansi_blue="\033[1;34m";
ansi_bell="\007";
ansi_blink="\033[5m";
ansi_std="\033[m";
ansi_rev="\033[7m";
ansi_ul="\033[4m";
ACTION=$1
start() {
echo -e -n "$ansi_white Starting $DESC... $ansi_std"
if [ -n "$(pgrep -x -f "$PROC $ARGS")" ]; then
echo -e " $ansi_yellow already running. $ansi_std"
return 0
fi
$PROC $ARGS > /dev/null 2>&1 &
COUNTER=0
LIMIT=10
while [ -z "$(pgrep -x -f "$PROC $ARGS")" -a "$COUNTER" -le "$LIMIT" ]; do
sleep 1;
COUNTER=$((COUNTER + 1))
done
if [ -z "$(pgrep -x -f "$PROC $ARGS")" ]; then
echo -e " $ansi_red failed. $ansi_std"
logger "Failed to start $DESC from $CALLER."
return 255
else
echo -e " $ansi_green done. $ansi_std"
logger "Started $DESC from $CALLER."
return 0
fi
}
stop() {
case "$ACTION" in
stop | restart)
echo -e -n "$ansi_white Shutting down $PROC... $ansi_std"
kill "$(pgrep -x -f "$PROC $ARGS")" 2>/dev/null
COUNTER=0
LIMIT=${RTORRENT_STOP_TIMEOUT:-300}
while [ -n "$(pgrep -x -f "$PROC $ARGS")" -a "$COUNTER" -lt "$LIMIT" ]; do
sleep 1;
COUNTER=$((COUNTER + 1))
done
;;
kill)
echo -e -n "$ansi_white Killing $PROC... $ansi_std"
kill -9 "$(pgrep -x -f "$PROC $ARGS")" 2>/dev/null
sleep 1
;;
esac
if [ -n "$(pgrep -x -f "$PROC $ARGS")" ]; then
echo -e " $ansi_red failed. $ansi_std"
return 255
else
rm -f /opt/var/run/rtorrent.sock
echo -e " $ansi_green done. $ansi_std"
return 0
fi
}
check() {
echo -e -n "$ansi_white Checking $DESC... "
if [ -n "$(pgrep -x -f "$PROC $ARGS")" ]; then
echo -e " $ansi_green alive. $ansi_std";
return 0
else
echo -e " $ansi_red dead. $ansi_std";
return 1
fi
}
for PROC in $PROCS; do
case $ACTION in
start)
start
;;
stop | kill )
check && stop
;;
restart)
if check > /dev/null; then
stop || exit 255
fi
start
;;
check)
check
;;
*)
echo -e "$ansi_white Usage: $0 (start|stop|restart|check)$ansi_std"
exit 1
;;
esac
done
Then run:
$ chmod +x /opt/etc/init.d/S85rtorrent
Note
The ulimit -n 8192 line matters once you are seeding more than a few
hundred torrents. rTorrent does not take the file-descriptor limit
directly, it scales network.max_open_files from it. Inheriting the
default soft limit of 1024 leaves rTorrent capped at 128 open files, so it
churns descriptors constantly reopening them. Check the effective value
with:
$ grep 'open files' /proc/$(pgrep -f 'rtorrent -D -n')/limits
Match on the command line with -f. rTorrent names its main thread
rtorrent main, so pgrep -x rtorrent, which matches against comm,
finds nothing.
Install and configure ruTorrent
$ mkdir /opt/var/www
$ git clone https://github.com/Novik/ruTorrent.git /opt/var/www/rutorrent
Create ruTorrent 'user' config
We're not creating any ruTorrent users so we'll edit the default config.php file.
$ nano /opt/var/www/rutorrent/conf/config.php
Add the following:
<?php
// configuration parameters
// for snoopy client
$httpUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36';
$httpTimeOut = 30; // in seconds
$httpUseGzip = true;
$httpIP = null; // IP string. Or null for any.
$httpProxy = array
(
'use' => false,
'proto' => 'http', // 'http' or 'https'
'host' => 'PROXY_HOST_HERE',
'port' => 3128
);
// for xmlrpc actions
$rpcTimeOut = 5; // in seconds
$rpcLogCalls = false;
$rpcLogFaults = true;
// for php
$phpUseGzip = false;
$phpGzipLevel = 2;
$schedule_rand = 10; // rand for schedulers start, +0..X seconds
$do_diagnostic = true; // Diagnose ruTorrent. Recommended to keep enabled, unless otherwise required.
$al_diagnostic = true; // Diagnose auto-loader. Set to "false" to make composer plugins work.
$log_file = '/opt/var/log/rutorrent/errors.log'; // path to log file (comment or leave blank to disable logging)
$saveUploadedTorrents = true; // Save uploaded torrents to profile/torrents directory or not
$overwriteUploadedTorrents = false; // Overwrite existing uploaded torrents in profile/torrents directory or make unique name
$topDirectory = '/share/data/torrents/'; // Upper available directory. Absolute path with trail slash.
$forbidUserSettings = false;
// $scgi_port = 5000;
// $scgi_host = "127.0.0.1";
// For web->rtorrent link through unix domain socket
// (scgi_local in rtorrent conf file), change variables
// above to something like this:
//
$scgi_port = 0;
$scgi_host = "unix:///opt/var/run/rtorrent.sock";
$XMLRPCMountPoint = "/RPC2"; // DO NOT DELETE THIS LINE!!! DO NOT COMMENT THIS LINE!!!
$throttleMaxSpeed = 327625*1024; // DO NOT EDIT THIS LINE!!! DO NOT COMMENT THIS LINE!!!
// Can't be greater then 327625*1024 due to limitation in libtorrent ResourceManager::set_max_upload_unchoked function.
$pathToExternals = array(
"curl" => '/opt/bin/curl',
"gzip" => '/opt/bin/gzip',
"id" => '/usr/bin/id',
"php" => '/opt/bin/php-cli',
"pgrep" => '/opt/bin/pgrep',
"python" => '/opt/bin/python3',
"stat" => '/usr/bin/stat'
);
$localHostedMode = true; // Set to true if rTorrent is hosted on the SAME machine as ruTorrent
$cachedPluginLoading = true; // Set to true to enable rapid cached loading of ruTorrent plugins
// Required to clear web browser cache when upgrading versions
$localhosts = array( // list of local interfaces
"127.0.0.1",
"localhost",
);
$profilePath = '../../share'; // Path to user profiles
$profileMask = 0777; // Mask for files and directory creation in user profiles.
// Both Webserver and rtorrent users must have read-write access to it.
// For example, if Webserver and rtorrent users are in the same group then the value may be 0770.
$tempDirectory = null; // Temp directory. Absolute path with trail slash. If null, then autodetect will be used.
$canUseXSendFile = false; // If true then use X-Sendfile feature if it exist
$locale = "UTF8";
$enableCSRFCheck = false; // If true then Origin and Referer will be checked
$enabledOrigins = array(); // List of enabled domains for CSRF check (only hostnames, without protocols, port etc.).
// If empty, then will retrieve domain from HTTP_HOST / HTTP_X_FORWARDED_HOST
Configure ruTorrent plugins follows:
$ nano /opt/var/www/rutorrent/conf/plugins.ini
;; Plugins' permissions.
;; If flag is not found in plugin section, corresponding flag from "default" section is used.
;; If flag is not found in "default" section, it is assumed to be "yes".
;;
;; For setting individual plugin permissions you must write something like that:
;;
;; [ratio]
;; enabled = yes ;; also may be "user-defined", in this case user can control plugin's state from UI
;; canChangeToolbar = yes
;; canChangeMenu = yes
;; canChangeOptions = no
;; canChangeTabs = yes
;; canChangeColumns = yes
;; canChangeStatusBar = yes
;; canChangeCategory = yes
;; canBeShutdowned = yes
[default]
enabled = user-defined
canChangeToolbar = yes
canChangeMenu = yes
canChangeOptions = yes
canChangeTabs = yes
canChangeColumns = yes
canChangeStatusBar = yes
canChangeCategory = yes
canBeShutdowned = yes
[ipad]
enabled = no
[httprpc]
enabled = no
[retrackers]
enabled = no
[rpc]
enabled = no
[rutracker_check]
enabled = no
[geoip]
enabled = no
[geoip2]
enabled = yes
[spectrogram]
enabled = no
Configure create plugin
$ nano /opt/var/www/rutorrent/plugins/create/conf.php
$useExternal = 'mktorrent';
$pathToCreatetorrent = '/opt/bin/mktorrent';
Install cloudscraper plugin
cloudscraper is a dependency of the _cloudflare plugin.
$ pip3 install cloudscraper
Install ratiocolor plugin
$ cd /opt/var/www/rutorrent/plugins
$ git clone [email protected]:Micdu70/rutorrent-ratiocolor.git ratiocolor
Install geoip2 plugin
$ cd /opt/var/www/rutorrent/plugins
$ git clone [email protected]:Micdu70/geoip2-rutorrent.git geoip2
Install nfo plugin
$ cd /opt/var/www/rutorrent/plugins
$ git clone [email protected]:phracker/ruTorrent-nfo.git nfo
Hint
You might need to "fix" tracklabels missing icons
as described here.
Configure PHP/PHP-FPM
For PHP:
$ nano /opt/etc/php.ini
memory_limit = 128M
;doc_root = "/opt/share/www"
upload_max_filesize = 64M
max_file_uploads = 64
date.timezone = "Europe/Luxembourg"
sys_temp_dir = "/opt/tmp"
For PHP-FPM:
$ nano /opt/etc/php8-fpm.d/www.conf
user = admin
env[PATH] = /opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:/usr/bin/X11:/usr/local/sbin:/usr/local/bin
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
slowlog = /opt/var/log/php8-fpm.slow.log
request_slowlog_timeout = 5s
Note
The packaged default is pm.max_children = 5. ruTorrent fires several
requests per poll tick, so a single browser tab can occupy most of that on
its own, and check_port can hold a worker for up to $httpTimeOut
seconds waiting on an external site. Watch for
server reached pm.max_children setting in /opt/var/log/php8-fpm.log.
Each worker is bounded by memory_limit (128M above), so 20 workers is a
2.5G worst case. Size it down on a device with little RAM.
request_slowlog_timeout is worth setting before you need it. Any request
over the threshold is dumped to slowlog with a full PHP backtrace, which
turns "the UI froze" into an exact function and line.
Allow PHP-FPM to run as root
$ nano /opt/etc/init.d/S79php8-fpm
ARGS="--daemonize -R --fpm-config /opt/etc/php8-fpm.conf"
Note
You'll need to edit the init file to allow php-fpm to run as root each
time the package is updated.
Configure Nginx
$ nano /opt/etc/nginx/nginx.conf
user admin administrators;
worker_processes auto;
pid /opt/var/run/nginx.pid;
include /opt/etc/nginx/modules-enabled/*.conf;
events {
worker_connections 1024;
use epoll;
# multi_accept on;
}
http {
##
# Basic Settings
##
sendfile on;
tcp_nopush on;
tcp_nodelay off;
keepalive_timeout 65;
keepalive_requests 100;
keepalive_disable msie6;
types_hash_max_size 2048;
server_tokens off;
# server_names_hash_bucket_size 64;
# server_name_in_redirect off;
include /opt/etc/nginx/mime.types;
default_type application/octet-stream;
##
# SSL Settings
##
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
ssl_prefer_server_ciphers on;
##
# Logging Settings
##
access_log /opt/var/log/nginx/access.log;
error_log /opt/var/log/nginx/error.log;
##
# Gzip Settings
##
gzip on;
gzip_disable "msie6";
gzip_min_length 512;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_types
text/css
text/javascript
text/xml
text/plain
text/x-component
application/javascript
application/x-javascript
application/json
application/xml
application/rss+xml
application/vnd.ms-fontobject
font/truetype
font/opentype
image/svg+xml;
##
# Virtual Host Configs
##
include /opt/etc/nginx/conf.d/*.conf;
include /opt/etc/nginx/sites-enabled/*;
}
Create the proper Nginx symlink
$ ln -s /opt/etc/nginx/sites-available/rutorrent.example.com.conf /opt/etc/nginx/sites-enabled/rutorrent.example.com.conf
Configure Nginx SCGI
$ nano /opt/etc/nginx/scgi_params
scgi_param REQUEST_METHOD $request_method;
scgi_param REQUEST_URI $request_uri;
scgi_param QUERY_STRING $query_string;
scgi_param CONTENT_TYPE $content_type;
scgi_param DOCUMENT_URI $document_uri;
scgi_param DOCUMENT_ROOT $document_root;
scgi_param SCGI 1;
scgi_param SERVER_PROTOCOL $server_protocol;
scgi_param REQUEST_SCHEME $scheme;
scgi_param HTTPS $https if_not_empty;
scgi_param REMOTE_ADDR $remote_addr;
scgi_param REMOTE_PORT $remote_port;
scgi_param SERVER_PORT $server_port;
scgi_param SERVER_NAME $server_name;
Create Nginx vhost
$ mkdir --parent /opt/etc/nginx/{sites-available,sites-enabled}
$ nano /opt/etc/nginx/sites-available/rutorrent.example.com.conf
server {
listen 80;
server_name rutorrent.example.com;
return 301 https://$server_name$request_uri;
access_log /opt/var/log/nginx/example.com/rutorrent/access.log;
error_log /opt/var/log/nginx/example.com/rutorrent/error.log;
}
server {
listen 443 ssl;
http2 on;
server_name rutorrent.example.com;
access_log /opt/var/log/nginx/example.com/rutorrent/access.log;
error_log /opt/var/log/nginx/example.com/rutorrent/error.log;
ssl_certificate /opt/etc/acme.sh/rutorrent.example.com_ecc/fullchain.cer;
ssl_certificate_key /opt/etc/acme.sh/rutorrent.example.com_ecc/rutorrent.example.com.key;
ssl_dhparam /opt/etc/acme.sh/rutorrent.example.com_ecc/dhparam.pem;
ssl_session_timeout 5m;
ssl_prefer_server_ciphers on;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
add_header Strict-Transport-Security "max-age=15768000" always; # six month
charset utf-8;
index index.html index.php;
root /opt/var/www/rutorrent;
client_max_body_size 64M;
error_page 500 502 503 504 /50x.html;
location /RPC2 {
include scgi_params;
scgi_pass unix:/opt/var/run/rtorrent.sock;
}
location = /50x.html {
root /opt/share/nginx/html;
}
location = /favicon.ico {
access_log off;
log_not_found off;
}
location ~ ^/(conf|share)/(.+)$ {
deny all;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS on;
# Avoid sending the security headers twice
fastcgi_param modHeadersAvailable true;
fastcgi_param front_controller_active true;
fastcgi_pass unix:/opt/var/run/php8-fpm.sock;
fastcgi_intercept_errors on;
fastcgi_request_buffering off;
}
location ~* \.(jpg|jpeg|gif|css|png|js|map|woff|woff2|ttf|svg|eot)$ {
expires 30d;
access_log off;
}
}
Generate TLS certificates for vhost
Install acme.sh:
$ cd /tmp
$ git clone https://github.com/acmesh-official/acme.sh.git
$ cd acme.sh/
$ ./acme.sh --install --nocron --home /opt/etc/acme.sh --accountemail "[email protected]"
Configure your DNS provider API keys:
$ nano /opt/etc/acme.sh/dnsapi/dns_cf.sh
#!/opt/bin/bash
#
#CF_Key="sdfsdfsdfljlbjkljlkjsdfoiwje"
#
#CF_Email="[email protected]"
CF_Token="foo"
CF_Account_ID="bar"
CF_Zone_ID="foobar"
CF_Api="https://api.cloudflare.com/client/v4"
Generate TLS certificate:
$ /opt/etc/acme.sh/acme.sh --home /opt/etc/acme.sh --server letsencrypt --issue -d example.com --dns dns_cf
Setup a cronjob to automatically renew your certificate:
$ crontab -e
30 3 * * 0 "/opt/etc/acme.sh/acme.sh" --cron --home "/opt/etc/acme.sh" > /dev/null
Setup a Diffie-Helmann key as follows:
$ wget https://ssl-config.mozilla.org/ffdhe4096.txt -O /opt/etc/acme.sh/rutorrent.example.com_ecc/dhparam.pem
Restart services
$ /opt/etc/init.d/S79php8-fpm restart
$ /opt/etc/init.d/S80nginx restart
$ /opt/etc/init.d/S85rtorrent start
Hint
Nginx might error out with the following message:
nginx: [error] open() "/opt/var/run/nginx.pid" failed (2: No such file or directory)
To fix it, simply run the following command:
$ nginx -c /opt/etc/nginx/nginx.conf
You should now be all set!
Maintenance
Restarting rTorrent with a large session
rTorrent flushes its session to disk on shutdown, and how long that takes
depends on how much state is dirty rather than on the torrent count alone.
Two measurements on the same machine at roughly 2450 torrents: a stop with a
stale session took about 80 seconds, while one taken shortly after a
session.save completed in 13. The init file above waits up to
RTORRENT_STOP_TIMEOUT seconds (300 by default), which covers both, and
removes the socket only once the process has actually exited, so restart is
safe to use:
$ /opt/etc/init.d/S85rtorrent restart
Override the timeout for a very large session:
$ RTORRENT_STOP_TIMEOUT=600 /opt/etc/init.d/S85rtorrent restart
Warning
Entware's stock rc.func, and earlier revisions of this guide, wait only
ten seconds and then rm the socket unconditionally. Once a shutdown runs
past those ten seconds the socket is unlinked while rTorrent still holds
it, stop() returns failure, the dispatch ignores that failure and calls
start(), and start() sees the still-dying process and reports "already
running". rTorrent ends up stopped with no socket, and the script prints
success. If you are running an older copy, check that stop() removes the
socket only after confirming the process is gone.
Upgrading Entware packages
Warning
opkg upgrade php8 can leave PHP unable to start. PHP links against a
specific libxml2 soname, but the dependency is declared unversioned, so
opkg happily upgrades PHP while leaving an older libxml2 in place. Both
binaries then fail with:
error while loading shared libraries: libxml2.so.16: cannot open shared object file
Upgrade libxml2 in the same transaction. Note that libxslt and
python3-lxml link the old soname, so upgrade those together or they
break instead. Check what needs what before restarting anything:
$ ldd /opt/bin/php8-fpm | grep -E 'libxml2|not found'
Warning
A python3 minor-version bump (3.11 to 3.13, say) moves
site-packages and strands everything installed with pip, including
cloudscraper for the _cloudflare plugin. pip itself goes too, so
reinstall it before anything else. This is the one case that genuinely
needs --force-reinstall, because the package version is unchanged and
only its install path moved, so refresh the index first (see the warning
below):
$ opkg update
$ opkg install --force-reinstall python3-pip
$ python3 -m pip install cloudscraper==1.2.71
The old tree is left behind at /opt/lib/python3.<old> and is dead
weight once nothing references it.
Warning
Avoid opkg install --force-reinstall unless you have checked the package
is actually downloadable. It removes the installed copy before fetching
the replacement, so a stale index (the .ipk listed but missing from the
feed) leaves you with the package uninstalled and no way to put it back.
Run opkg update first, and prefer a plain opkg install.