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

  • harsh3466@lemmy.mlOP
    link
    fedilink
    arrow-up
    1
    ·
    3 hours ago

    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
      link
      fedilink
      arrow-up
      1
      ·
      edit-2
      2 hours ago

      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.mlOP
        link
        fedilink
        arrow-up
        1
        ·
        1 hour 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!

  • TehPers@beehaw.org
    link
    fedilink
    English
    arrow-up
    4
    ·
    9 hours ago

    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.mlOP
      link
      fedilink
      arrow-up
      2
      ·
      4 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
    link
    fedilink
    English
    arrow-up
    10
    ·
    edit-2
    2 hours ago

    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.mlOP
      link
      fedilink
      arrow-up
      1
      ·
      3 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
    link
    fedilink
    English
    arrow-up
    9
    ·
    14 hours ago

    Nice one 👍

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

  • 6nk06@sh.itjust.works
    link
    fedilink
    arrow-up
    7
    ·
    13 hours ago

    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.mlOP
      link
      fedilink
      arrow-up
      1
      ·
      4 hours ago

      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
        link
        fedilink
        arrow-up
        1
        ·
        edit-2
        2 hours ago

        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.mlOP
          link
          fedilink
          arrow-up
          1
          ·
          1 hour 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!!

  • SGH@lemmy.ml
    link
    fedilink
    arrow-up
    4
    ·
    edit-2
    14 hours ago

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

  • theherk@lemmy.world
    link
    fedilink
    arrow-up
    3
    ·
    14 hours ago

    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.mlOP
      link
      fedilink
      arrow-up
      2
      ·
      edit-2
      3 hours ago

      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!