How to Install Packages on an Offline Linux System (Debian/Ubuntu) using apt-offline

If you are coming from a Windows background, installing software on an offline computer is usually straightforward: you download an .exe installer or a portable .zip file from a connected machine, transfer it to the offline machine via a USB drive, and install it.

However, Linux handles software differently. If your Linux machine is online, a simple command like apt install or using the Software Center is safer and more convenient than Windows. But if your Linux machine is offline, you hit a wall. You can’t just copy a single installer file because Linux software relies on “dependencies.”

Installing an application (Package A) might require Package B and C, and Package C might depend on Package D. Without an internet connection, your system can’t resolve and download these dependencies automatically.

To solve this, we can use a tool called apt-offline. It helps you generate a “shopping list” (signature file) of all missing dependencies on the offline machine, which you can then fulfill using any computer with an internet connection.

0. Prerequisites

The Offline Machine

Must be running a Debian-based Linux distribution (e.g., Ubuntu, Debian…).

How to check: Open your terminal and type apt. If you see a list of commands, you are good to go. If you see apt: command not found, this guide is not for your system (you might be on RedHat/Fedora).

The Online Machine

Any computer with an internet connection.

It can be running Linux (easiest) or Windows (requires Python).

1. Installing apt-offline

We need the tool installed on both computers first.

1.1 On the Online Machine (If Linux)

Simply install it from the repository:

sudo apt install apt-offline

Verify the installation:

apt-offline --version

1.2 On the Offline Machine

Since this machine has no internet, we need to “sideload” the installer.

  1. On your connected computer, go to pkgs.org.
  2. Search for apt-offline and download the .deb file.
    • Note: Look for a file ending in _all.deb. This means it works on any architecture (Intel, AMD, or ARM).
  3. Transfer the file to your offline machine via USB.
  4. Install it using the command line (recommended to see errors):
sudo dpkg -i apt-offline_*.deb

2. The Workflow: Installing Software

(Optional) Step 1: Update Repository Lists

If your offline machine hasn’t been updated in a long time, its knowledge of available software packages will be outdated. It’s highly recommended to do this step first.

1. [Offline Machine] Generate an update request:

sudo apt-offline set update.sig --update

This creates a file named update.sig.

2. [Online Machine] Download the update data: Transfer update.sig to the online computer and run:

apt-offline get update.sig --bundle update.zip

3. [Offline Machine] Apply the update: Transfer update.zip back to the offline computer and run:

sudo apt-offline install update.zip

Your offline machine now knows about the latest software versions.

Step 2: Install Your Application

Let’s use PDF Arranger (a lightweight tool for merging/splitting PDFs) as an example.

1. [Offline Machine] Generate the install request: Tell the system what you want to install. It will calculate all missing dependencies.

sudo apt-offline set pdf-arranger.sig --install-packages pdfarranger

This creates the “shopping list” file: pdf-arranger.sig.

Note: If you get an “Unable to locate package” error, perform the Optional Step above.

2. [Online Machine] Download the packages: Transfer the .sig file to your connected computer and download the bundle:

apt-offline get /path/to/pdf-arranger.sig --bundle pdf-arranger.zip

This creates the “delivery package”: pdf-arranger.zip.

3. [Offline Machine] Install the software: Transfer the .zip file back to the offline machine.

First, load the data into the local cache:

sudo apt-offline install pdf-arranger.zip

(The terminal will show that data is being synced to /var/cache/apt/archives)

Second, trigger the actual installation:

sudo apt install pdfarranger

Since all dependencies are now sitting in your cache, the installation will proceed instantly without needing the internet.

Appendix: Using Windows as the Online Machine

If your internet-connected computer is running Windows, apt-offline works as a Python script.

  1. Install Python: Download and install Python from python.org. Crucial: Ensure you check the box “Add Python to PATH” during installation.
  2. Download apt-offline: Go to the apt-offline GitHub Releases, download the apt-offline-***-windows.zip (zip), and extract it to a folder.
  3. Open Terminal: Inside the extracted folder, Right Click and select “Open in Terminal”.
  4. Run Commands: Use the python prefix for commands.
    • For example, to download the bundle:
python apt-offline get pdf-arranger.sig --bundle pdf-arranger.zip

Smart volume leveling for video files, without re-encoding the video

In the past, when I needed to fix a video’s volume, I would often do this:

  • Add a Hard Limiter effect in Adobe Premiere Pro
  • Export the video
  • Re-encode again in HandBrake

This works, but it is slow. It also risks losing video quality because you are re-encoding the video stream.

Recently, I hit the same problem again and discussed it with an AI. We came up with a better approach: use a Python script to call FFmpeg, so that only the audio is processed. The video stream is copied as-is (no re-encode), so quality stays intact and the processing time is much lower.

Core idea

Files like *.mp4 and *.mkv are containers. A container is just a wrapper that can hold multiple streams:

  • video
  • audio
  • subtitles
  • metadata

Because the streams are separate, FFmpeg can process the audio stream while leaving the video stream untouched.

This Python script (generated with AI) calls FFmpeg to:

  1. Run a first pass with loudnorm to analyze loudness
  2. Parse the JSON output from loudnorm
  3. Run a second pass using those measured values to apply corrected normalization
  4. Output a new file where video is not re-encoded, loudness is consistent

Loudness target settings

The normalization uses three key parameters. Based on the AI suggestion:

  • Integrated loudness: -16 LUFS (common for online video)
  • True peak ceiling: -1.5 dBTP (helps avoid clipping)
  • Loudness range constraint: 11 LU (limits the gap between quiet and loud parts)

If you want a deeper explanation of these parameters, you can ask an AI something like: “Explain LUFS, dBTP, and LRA in loudnorm, and how changing them affects the listening experience.” You can also adjust these values based on your own use case.

Preparation

You need:

  • FFmpeg
  • Python

If you are unsure how to install them, you can ask an AI directly, for example: “How do I install FFmpeg on Windows 11?”

Main code

Save the following code as a .py file, for example loudnorm_2pass.py.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

def run_capture(cmd: List[str]) -> subprocess.CompletedProcess:
    """
    Run a command and capture stdout/stderr (used in pass 1 to capture JSON).
    Note: -nostdin is explicitly added in the command to prevent ffmpeg
    from waiting for interactive input.
    """
    print("\n>>", " ".join(cmd))
    return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

def run_passthrough(cmd: List[str]) -> int:
    """
    Run a command and pass all output directly to the terminal.
    Used in pass 2 so the user can see progress and error messages.
    """
    print("\n>>", " ".join(cmd))
    p = subprocess.run(cmd)
    return p.returncode

def must_exist_executable(name: str) -> None:
    if shutil.which(name) is None:
        raise SystemExit(f"{name} not found. Please install it and ensure it is in PATH.")

def resolve_path(p: str) -> str:
    return str(Path(p).expanduser().resolve())

def ffprobe_audio_streams(input_path: str) -> Tuple[int, Dict[str, Any]]:
    """
    Return (number_of_audio_streams, raw ffprobe JSON output).
    """
    must_exist_executable("ffprobe")
    cmd = [
        "ffprobe",
        "-hide_banner",
        "-v", "error",
        "-print_format", "json",
        "-show_streams",
        "-select_streams", "a",
        input_path,
    ]
    r = run_capture(cmd)
    if r.returncode != 0:
        raise SystemExit(f"ffprobe failed:\n{r.stderr}")
    try:
        data = json.loads(r.stdout)
    except json.JSONDecodeError:
        raise SystemExit("Failed to parse ffprobe output (not valid JSON).")
    streams = data.get("streams", []) or []
    return len(streams), data

def find_last_json_object(text: str) -> Dict[str, Any]:
    """
    Extract the loudnorm JSON block from ffmpeg stderr.
    Strategy: take the substring from the last '{' to the last '}' and
    verify that required loudnorm fields are present.
    """
    start = text.rfind("{")
    end = text.rfind("}")
    if start == -1 or end == -1 or end <= start:
        raise RuntimeError(
            "No loudnorm JSON found in FFmpeg output. "
            "Make sure print_format=json is enabled in pass 1 "
            "and the input contains an audio stream."
        )

    blob = text[start:end + 1].strip()
    try:
        obj = json.loads(blob)
    except json.JSONDecodeError as e:
        raise RuntimeError(f"Failed to parse loudnorm JSON: {e}\nJSON snippet:\n{blob}")

    # Validate required loudnorm fields
    for k in ("input_i", "input_tp", "input_lra", "input_thresh"):
        if k not in obj:
            raise RuntimeError(
                f"The JSON found does not look like loudnorm output. "
                f"Missing field: {k}\nJSON snippet:\n{blob}"
            )

    return obj
    raise RuntimeError(
        "No loudnorm JSON found in FFmpeg output. "
        "Make sure print_format=json is enabled in pass 1 "
        "and the input contains an audio stream."
    )

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Two-pass EBU R128 loudnorm: copy video, normalize audio; robust non-interactive workflow."
    )
    parser.add_argument("input", help="Input video file (e.g., input.mp4)")
    parser.add_argument("output", help="Output video file (e.g., output.mp4)")

    parser.add_argument("--I", default="-16", help="Target integrated loudness (LUFS), default -16")
    parser.add_argument("--TP", default="-1.5", help="Target true peak (dBTP), default -1.5")
    parser.add_argument("--LRA", default="11", help="Target loudness range, default 11")

    parser.add_argument("--audio-index", type=int, default=0,
                        help="Audio stream index to process, default 0 (i.e. 0:a:0)")
    parser.add_argument("--audio-codec", default="aac", help="Output audio codec, default aac")
    parser.add_argument("--audio-bitrate", default="192k", help="Output audio bitrate, default 192k")

    parser.add_argument("--keep-subtitles", action="store_true",
                        help="Copy subtitle streams if present (0:s?)")
    parser.add_argument("--no-overwrite", action="store_true",
                        help="Do not overwrite output if it exists (default: overwrite with -y)")
    parser.add_argument("--max-muxing-queue-size", type=int, default=1024,
                        help="Set FFmpeg -max_muxing_queue_size, default 1024")

    args = parser.parse_args()

    must_exist_executable("ffmpeg")
    must_exist_executable("ffprobe")

    inp = resolve_path(args.input)
    out = resolve_path(args.output)

    # 0) Pre-check: ensure audio streams exist
    audio_count, _ = ffprobe_audio_streams(inp)
    if audio_count == 0:
        raise SystemExit("No audio streams detected in input file. Cannot apply loudnorm.")
    if args.audio_index < 0 or args.audio_index >= audio_count:
        raise SystemExit(
            f"--audio-index={args.audio_index} is out of range. "
            f"This file has {audio_count} audio streams (valid range: 0..{audio_count-1})."
        )
    if audio_count > 1:
        print(
            f"Note: {audio_count} audio streams detected. "
            f"This script processes only 0:a:{args.audio_index} "
            "(use --audio-index to select a different one)."
        )

    # Output overwrite policy
    if Path(out).exists() and args.no_overwrite:
        raise SystemExit(f"Output file already exists and --no-overwrite is set: {out}")

    # 1) Pass 1: analysis
    analysis_filter = f"loudnorm=I={args.I}:TP={args.TP}:LRA={args.LRA}:print_format=json"
    cmd1 = [
        "ffmpeg",
        "-hide_banner",
        "-nostdin",         # Critical: disable interactive input to avoid apparent hangs
        "-i", inp,
        "-map", f"0:a:{args.audio_index}",
        "-filter:a", analysis_filter,
        "-f", "null", "-"
    ]
    r1 = run_capture(cmd1)
    if r1.returncode != 0:
        print(r1.stderr, file=sys.stderr)
        raise SystemExit("Pass 1 analysis failed.")

    info = find_last_json_object(r1.stderr)

    # Required measured fields
    measured_I = info.get("input_i")
    measured_TP = info.get("input_tp")
    measured_LRA = info.get("input_lra")
    measured_thresh = info.get("input_thresh")

    print("\n=== Pass 1 measurement ===")
    print(json.dumps(info, ensure_ascii=False, indent=2))

    # 2) Pass 2: normalization
    norm_filter = (
        f"loudnorm=I={args.I}:TP={args.TP}:LRA={args.LRA}:"
        f"measured_I={measured_I}:measured_TP={measured_TP}:"
        f"measured_LRA={measured_LRA}:measured_thresh={measured_thresh}"
    )

    cmd2 = [
        "ffmpeg",
        "-hide_banner",
        "-nostdin",
        "-y" if not args.no_overwrite else "-n",
        "-i", inp,
        "-map", "0:v",
        "-map", f"0:a:{args.audio_index}",
        "-c:v", "copy",
        "-filter:a", norm_filter,
        "-c:a", args.audio_codec,
        "-b:a", args.audio_bitrate,
        "-max_muxing_queue_size", str(args.max_muxing_queue_size),
        "-movflags", "+faststart"
    ]

    if args.keep_subtitles:
        cmd2 += ["-map", "0:s?"]  # Copy subtitle streams if present

    cmd2 += [out]

    # Pass 2: run with passthrough output so progress/errors are visible
    rc2 = run_passthrough(cmd2)
    if rc2 != 0:
        raise SystemExit("Pass 2 processing failed.")

    print("\nDone. Output file:", out)

if __name__ == "__main__":
    main()

How to run

Run the following command in a terminal or command prompt:

python loudnorm_2pass.py input.mp4 output.mp4

Make sure to replace the Python script path and the input/output video paths with the correct ones. For example, on Windows:

python "D:\loudnorm_2pass.py" "D:\Cuts\TownScaper.mp4" "D:\Cuts\TownScaper_2.mp4"

The output.mp4 path specifies the newly generated video file. If a file already exists at that location, it will be overwritten.

If the video contains multiple audio tracks, you can specify which one to process using the –audio-index option.