How to Batch Install Software with WinGet After Reinstalling Windows

In the past, before reinstalling Windows, I would take a screenshot of the list of apps I had installed. After reinstalling the system, I would look at that screenshot, visit each official website, download the installers one by one, and install everything manually.

Recently, after using package managers on Linux and macOS, such as apt, dnf, and Homebrew, I started to feel that it would be much more convenient and elegant if Windows could also install software in a similar way. That led me to WinGet, also known as Windows Package Manager. It is Microsoft’s command-line package manager for Windows, and on modern Windows 11 systems, it is usually available by default through App Installer.

With WinGet, you can install software from the command line instead of manually downloading installers from different websites.

Basic WinGet Commands

Searching and Installing

Installing a specific app with WinGet usually involves two steps: first, search for the package ID; then, install the app using that package ID.

First, open PowerShell. You can right-click the Windows logo — the same button you left-click to open the Start menu — and choose Terminal. You can also search for PowerShell from the Start menu.

For example, if I want to install PotPlayer, I can type the following command in the terminal. This means: search for packages that contain potplayer.

winget search potplayer

This may return several search results. At this point, you need a little judgment. Many WinGet package IDs use a format similar to DeveloperName.AppName. The first part is often the developer, organization, or project name, and the second part is the software name.

So in the search results, Daum.PotPlayer is the package ID we want for PotPlayer.

Then we run the second step:

winget install Daum.PotPlayer

You can either type Daum.PotPlayer manually or copy and paste it from the search results.

In Windows Terminal or modern PowerShell windows, you can usually use Ctrl+C and Ctrl+V for copying and pasting. In older console windows, selecting text and right-clicking may copy it, and right-clicking again may paste it.

If you want to be more precise, especially when writing scripts, you can also use the --id and --exact options:

winget install --id Daum.PotPlayer --exact

This tells WinGet to install the package with this exact ID.

Uninstalling

If you need to uninstall an app, you can use the uninstall command.

For example, to uninstall the PotPlayer package we just installed, run:

winget uninstall Daum.PotPlayer

If the software was installed earlier and you no longer remember its package ID, you can use the list command to view installed apps:

winget list

The apps shown by winget list include both apps installed through WinGet and apps installed by other methods. Apps installed through WinGet usually show winget in the Source column.

However, apps installed by other methods may not always be uninstalled smoothly through WinGet.

Upgrading

To check which apps can be upgraded, run:

winget upgrade

This command only shows the available upgrades. It does not upgrade anything yet.

If you want to upgrade all apps listed there, run:

winget upgrade --all

If you only want to upgrade one specific app, copy its package ID and run a command like this:

winget upgrade Daum.PotPlayer

In practice, I recommend running winget upgrade first to preview the list, and then deciding whether to upgrade everything with winget upgrade --all.

Batch Installation Script

The package IDs we search for can be saved for future use. After reinstalling Windows, we can use the saved list of package IDs to run winget install commands one by one.

This avoids the trouble of going to each software website manually. It also reduces the need to rely on third-party software managers, which may include ads, recommendations, or bundled software.

Instead of installing each app one by one, we can also put the saved package IDs into a PowerShell script and let WinGet install them automatically.

Here is the batch installation script I currently use. You can add or remove apps according to your own needs:

$apps = @(
    "SublimeHQ.SublimeText.4",
    "Daum.PotPlayer",
    "Valve.Steam",
    "HandBrake.HandBrake",
    "Cryptomator.Cryptomator",
    "WinFsp.WinFsp",
    "PDFArranger.PDFArranger",
    "Anki.Anki",
    "FastStone.Viewer",
    "ShareX.ShareX",
    "voidtools.Everything",
    "M2Team.NanaZip",
    "chrox.Readest",
    "LocalSend.LocalSend",
    "Gyan.FFmpeg"
)

foreach ($app in $apps) {
    Write-Host "Installing: $app" -ForegroundColor Cyan

    winget install --id $app --exact --source winget --accept-package-agreements --accept-source-agreements
}

The important part is the $apps list. Each line is a WinGet package ID. If you want to add another app, search for its package ID first and then add it to the list.

The foreach loop goes through the list and installs each app automatically.

In the install command:

--id

means we are installing by package ID.

--exact

means the ID must match exactly, which helps avoid installing the wrong package.

--source winget

means we are using the WinGet source.

--accept-package-agreements
--accept-source-agreements

automatically accepts package and source agreements, so the installation process does not stop repeatedly to ask for confirmation.

Some apps may still require administrator permission, additional confirmation, or a restart. If something fails during batch installation, you can copy the corresponding winget install --id Package.ID --exact command and run it separately to see the detailed error message.

Usually, you do not need to run winget source update manually. But if you have just reinstalled Windows, or if WinGet cannot find an app that should exist, you can try updating the source data first:

winget source update

After that, search or install again.

Final Thoughts

For me, the biggest benefit of WinGet is not just that it saves a few clicks. More importantly, it changes the workflow after reinstalling Windows.

Instead of relying on screenshots, memory, browser downloads, and manual installers, I can keep a clean list of package IDs and use a script to rebuild most of my software environment.

It feels much closer to the package manager experience on Linux and macOS, and it makes reinstalling Windows much less tedious.

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.