this post was submitted on 14 Dec 2025
45 points (97.9% liked)

Programming

23894 readers
457 users here now

Welcome to the main community in programming.dev! Feel free to post anything relating to programming here!

Cross posting is strongly encouraged in the instance. If you feel your post or another person's post makes sense in another community cross post into it.

Hope you enjoy the instance!

Rules

Rules

  • Follow the programming.dev instance rules
  • Keep content related to programming in some way
  • If you're posting long videos try to add in some form of tldr for those who don't want to watch videos

Wormhole

Follow the wormhole through a path of communities !webdev@programming.dev



founded 2 years ago
MODERATORS
 

So I'm learning python (by doing, reading, and doing some more), and I've been wanting to automate updating the listening port in my qbittorrent docker container when the gluetun vpn container changes the forwarded port.

This is what I came up with, which I combined with an hourly cronjob to keep it the listen port updated.

(Code under the spoiler tag to make this more readable).

Tap for spoiler

import re
import os
import logging
from datetime import datetime
from dotenv import load_dotenv

import docker
from qbittorrent import Client

# FUNCTION DECLARATION #


def log(code, message):
    logFilePath = "/opt/pyprojects/linux-iso-torrents/pf.log"
    logDateTime = datetime.today().strftime("%Y-%m-%d %H:%M:%S")
    message = f"[{logDateTime}] {message}"
    logger = logging.getLogger(__name__)
    logging.basicConfig(
        filename=f"{logFilePath}", encoding="utf-8", level=logging.DEBUG
    )
    match code:
        case "debug":
            logger.debug(message)
        case "info":
            logger.info(message)
        case "warning":
            logger.warning(message)
        case "error":
            logger.error(message)


def get_current_forwarded_port():
    client = docker.from_env()
    container = client.containers.get("a40124291102d")
    logs = container.logs().decode("utf-8")
    allPortForwards = re.findall(r".+port forwarded is [0-9]{5}", logs)
    currentForwardedPort = allPortForwards[-1].split(" ")[-1]

    return currentForwardedPort


def get_current_listening_port():
    qbConfigUrl = "/home/server/docker/qbit/config/qBittorrent/qBittorrent.conf"
    with open(qbConfigUrl) as f:
        config = f.read()
        qbitListenPort = re.search(r"Session\\Port=[0-9]{5}", config)
        currentListeningPort = qbitListenPort.group(0).split("=")[-1]

    return currentListeningPort


def update_qbittorrent_listen_port(port):
    QB_URL = os.getenv("QB_URL")
    QB_USER = os.getenv("QB_USER")
    QB_PASSWORD = os.getenv("QB_PASSWORD")
    portJSON = {}
    portJSON["listen_port"] = port
    qb = Client(QB_URL)
    qb.login(f"{QB_USER}", f"{QB_PASSWORD}")

    qb.set_preferences(**portJSON)


# BEGIN SCRIPT #

load_dotenv()

currentForwardedPort = get_current_forwarded_port()
currentListeningPort = get_current_listening_port()

if currentForwardedPort != currentListeningPort:
    update_qbittorrent_listen_port(currentPort)
    log("info", f"qbittorrent listen port set to {currentForwardedPort}")
else:
    log("info", "forwarded port and listen port are a match")

There's more I want to do, the next thing being to check the status of both containers and if one or both are down, to log that and gracefully exit, but for now, I'm pretty happy with this (open to any feedback, always willing to learn).

top 18 comments
sorted by: hot top controversial new old
[–] SinTan1729@programming.dev 2 points 3 hours ago* (last edited 3 hours ago)

Good work, but this can be done in a more efficient way by utilizing the qBittorrent API in more places. Also, you may wanna utilize gluetun's VPN_PORT_FORWARDING_UP_COMMAND for calling the script.

Here's my script. I used bash since the gluetun container doesn't have Python in it.

Code

#!/usr/bin/env bash

# Adapted from https://github.com/claabs/qbittorrent-port-forward-file/blob/master/main.sh

# set -e

qbt_username="${QBT_USERNAME}"
qbt_password="${QBT_PASSWORD}"
qbt_addr="${QBT_ADDR:-http://localhost:8085/}"

if [ -z ${qbt_username} ]; then
    echo "You need to provide a username by the QBT_USERNAME env variable"
    exit 1
fi

if [ -z ${qbt_password} ]; then
    echo "You need to provide a password by the QBT_PASSWORD env variable"
    exit 1
fi

port_number="$1"
if [ -z "$port_number" ]; then
    port_number=$(cat /tmp/gluetun/forwarded_port)
fi

if [ -z "$port_number" ]; then
    echo "Could not figure out which port to set."
    exit 1
fi

wait_time=1
tries=0
while [ $tries -lt 10 ]; do
    wget --save-cookies=/tmp/cookies.txt --keep-session-cookies --header="Referer: $qbt_addr" --header="Content-Type: application/x-www-form-urlencoded" \
      --post-data="username=$qbt_username&password=$qbt_password" --output-document /dev/null --quiet "$qbt_addr/api/v2/auth/login"

    listen_port=$(wget --load-cookies=/tmp/cookies.txt --output-document - --quiet "$qbt_addr/api/v2/app/preferences" | grep -Eo '"listen_port":[0-9]+' | awk -F: '{print $2}')

    if [ ! "$listen_port" ]; then
        [ $wait_time -eq 1 ] && second_word="second" || second_word="seconds"
        echo "Could not get current listen port, trying again after $wait_time $second_word..."
        sleep $wait_time
        [ $wait_time -lt 32 ] && wait_time=$(( wait_time*2 )) # Set a max wait time of 32 secs
        tries=$(( tries+1 ))
        continue
    fi

    if [ "$port_number" = "$listen_port" ]; then
        echo "Port already set to $port_number, exiting..."
        exit 0
    fi

    echo "Updating port to $port_number"

    wget --load-cookies=/tmp/cookies.txt --header="Content-Type: application/x-www-form-urlencoded" --post-data='json={"listen_port": "'$port_number'"}' \
      --output-document /dev/null --quiet "$qbt_addr/api/v2/app/setPreferences"

    echo "Successfully updated port"
    exit 0
done

echo "Failed after 10 attempts!"
exit 2

For the auto-exit stuff, you may wanna check out docker's healthcheck functionality.

Not trying to put you down or anything here, it's great to learn to do things by yourself. Just giving you some pointers.

[–] TehPers@beehaw.org 5 points 1 day ago (1 children)

open to any feedback, always willing to learn

A common pattern with executable Python scripts is to:

  • Add a shebang at the top (#!/usr/bin/env python3) to make it easier to execute
  • Check if __name__ == "__main__" before running any of the script so the functions can be imported into another script without running all the code at the bottom
[–] harsh3466@lemmy.ml 2 points 21 hours ago

I'm glad you mentioned the shebang. I was considering doing that (with the path to my venv python), but wasn't sure if that was a good idea.

I will also add the if __name__ == "__main" to the main script. Thank you!

[–] litchralee@sh.itjust.works 13 points 1 day ago* (last edited 19 hours ago) (1 children)

One way to make this more Pythonic -- and less C or POSIX-oriented -- is to use the pathlib library for all filesystem operations. For example, while you could open a file in a contextmanager, pathlib makes it really easy to read a file:

from pathlib import Path
...

config = Path("/some/file/here.conf").read_text()

This automatically opens the file (which checks for existence), reads out the entire file as a string (rather than bytes, but there's a method for that too), and then closes up the file. If any of those steps go awry, you get a Python exception and a backtrace explaining exactly what happened.

[–] harsh3466@lemmy.ml 2 points 20 hours ago

Ooh, that's very nice. I will definitely use that instead of the with open... I'm using now. Thank you!!

[–] rimu@piefed.social 9 points 1 day ago (1 children)

Nice one 👍

You might want to make the container name a command line parameter or env var, incase that changes in future.

[–] harsh3466@lemmy.ml 2 points 21 hours ago

That's a good idea. I will do that. Thank you!

[–] 6nk06@sh.itjust.works 7 points 1 day ago (1 children)

Make the logger a global variable. This may be the only case where its acceptable to be global. And remove the log function. You can call the logger directly.

Also wrap the main code (after BEGIN SCRIPT) in a main function.

Last but not least log everything to the standard output (logging should do that by default), or configure the path in main(), it's cleaner. If you log to stdout, the shell can redirect the logs to any path like python script.py > mylogs.txt

[–] harsh3466@lemmy.ml 1 points 21 hours ago (1 children)

So, if I understand what you mean correctly, with logger, drop the function, and in the main script body (which is now under an if __name__ == "__main__": check, call logger directly something like:

if __name__ == "__main__":
    load_dotenv()
    
    logDateTime = datetime.today().strftime("%Y-%m-%d %H:%M:%S")
    logger = logging.getLogger(__name__)

    currentForwardedPort = get_current_forwarded_port()
    currentListeningPort = get_current_listening_port()

    if currentForwardedPort != currentListeningPort:
        update_qbittorrent_listen_port(currentPort)
        logger.info(f"[{logDateTime}] qbittorrent listen port set to {currentForwardedPort}")
    else:
        loger.info(f"[{logDateTime}] forwarded port and listen port are a match")

And then in the crontab I can do:

0 * * * * /path/stuff/python script.py > /path/to/log/file.txt

Thanks!

[–] 6nk06@sh.itjust.works 1 points 20 hours ago* (last edited 20 hours ago) (1 children)

Yes (and I'm sorry for not having more time to study this but):

logDateTime

That thing should be automatically printed by the logger somehow (most of the time, timestamps and log levels are inside the logger itself). If it's not, check the configuration of the logger. Anyway. logDateTime has got to go. IMHO a logger is a magical object that you don't control. It's the user of the application who should set the rules one way or the other, like:

logger.info("qbittorrent listen port set to...

IIRC the logger can be set from the command-line, which you don't control anyway. It's the same thing in C++ and with any library on the planet (trust me), someone sets up the logging system for you outside of the application (in the main function or on the command-line or with environment variables or in a library/DLL) and all you can do is call "log.debug()" or "log.warning()" and hope for the best. Some libraries have huge systems to handle logs (rotating logs, serial ports, files, network...) and you shouldn't give yourself headaches while using those things. Think of a logger as a pre-configured system that does way more that you should and want to know.

logger = logging.getLogger(...

Put that outside of main, after the imports. You can prepend it with an underscore to show that it's a "private or protected" object belonging to your module. If it's in main, you can't use it in the other functions. You should do something like:

from qbittorrent import Client

_logger = logging.getLogger(...)

This way your logger is independent on whether you're using it from the command line (with main) or not (with an import).

QB_PASSWORD

Same thing with all the QB_ variables IF they don't change, you can put them globally for such a module, like _QB_PASSWORD = ... and use it everywhere if your module were to change in the future. But only if the variables are constants across the usage of the script.

a40124291102d

Last but not least, use argparse if you can, it's a simple module to handle command-line arguments. I guess that the container ID can change, you can either detect it with the subprocess module, or write def get_current_forwarded_port(container_id = "a40124291102d"): or use argparse. Feel free to ask if argparse if too weird, but it's the best module out there and it's built-in.

TL;DR:

  • change logFilePath, what if I don't have a filesystem or a hard drive?
  • change logDateTime and "message", what if I (the user of your script) want another format?
  • change qbConfigUrl to be configurable, what if I use your script on a weird custom Linux or a BSD, or macOS or Windows?

Anyway, great job, writing weird stuff for my own needs is how I started coding. Have fun.

[–] harsh3466@lemmy.ml 1 points 19 hours ago

No apologies necessary! This is incredibly helpful! I found in the documentation how to format the message with the basicConfig, so I'm going to get rid of the logDateTime, move the logger out of main (didn't even think about how that would limit it to only main and not the other functions.), and test output to stdout vs the file defined in basicConfig.

Gotta go to work, but will implement this later. Thank you!!

[–] harsh3466@lemmy.ml 1 points 20 hours ago (1 children)

Can you explain your reasoning behind adding the whitespace regex? I'm guessing that's insurance in case the config file syntax changes with an app update. Thank you!

[–] 6nk06@sh.itjust.works 1 points 20 hours ago* (last edited 20 hours ago) (1 children)

Yes. You can say "Session\\Port (lots of space here) = 12345"(formatting sucks, sorry) to align your values properly. Also are you sure that there is a double backslash here because you are using a raw string here?

[–] harsh3466@lemmy.ml 1 points 18 hours ago

That makes sense. I'll add the whitespace capture. The actual string only has one backslash, the second is to escape, though as I'm typing this, I'm thinking I can do a `fr"regex here" to eliminate the second backslash? I'll have to test this later when I have time.

Thank you!

[–] theherk@lemmy.world 4 points 1 day ago (1 children)

A few things. The python is looking good. Nice work.

Did you know about soxfor/qbittorrent-natmap? This is a tool that automates this for you.

Lastly, I’ve been using this for a while, but just now found neither you nor I may need it any more. It looks like gluetun may now handle this on its own. Check out Custom port forwarding up/down command.

So we should be able to use VPN_PORT_FORWARDING_UP_COMMAND=/bin/sh -c 'wget -O- --retry-connrefused --post-data "json={\"listen_port\":{{PORTS}}}" http://127.0.0.1:8080/api/v2/app/setPreferences 2>&1' or something close to that.

[–] harsh3466@lemmy.ml 3 points 20 hours ago* (last edited 20 hours ago) (1 children)

I did run across the qbittorrent-natpmp when I was looking into how to change the port via the api, but wanted to work out the process myself (not because of NIH, but for the learning of figuring it all out).

I had no idea about the up/down command in gluetun! That's very nice. I'm going to look into that for sure.

Thank you!

[–] MalReynolds@piefed.social 1 points 3 hours ago

Cool stuff, I've been using https://github.com/SnoringDragon/gluetun-qbittorrent-port-manager but it's 2 years old and intermittent in functionality (works sometimes, check it if you haven't). Will keep an eye on yours.

If you're up for a challenge flaresolvarr is always in a cat and mouse game with cloudflare if that's your idea of fun.

Well Done!

[–] sgh@lemmy.ml 4 points 1 day ago* (last edited 1 day ago)

Small improvement: Allow for spaces near the Equal sign in the regex (i.e. Port\s*=\s*)