Organize Course Videos with Python

Build a safe Python CLI that organizes mislabeled course video files.

Introduction

30 Second Summary

A folder of course downloads can quickly become impossible to navigate when every file has a gibberish name. A misleading extension also makes each file hard to trust at a glance.

In this project, you will build a local Python command-line organizer that identifies mislabeled course videos by inspecting their contents. It uses a CSV manifest to preview readable module names plus lecture names before you choose to move anything.

What You'll Build

You will open File Explorer to a course library where gibberish downloads have become readable .mp4 lectures grouped into named modules.

By the end of this project, you'll have:

  • A content-aware scanner that finds MP4-family candidates hiding behind .html extensions while leaving real web pages out.
  • A dry-run preview that pairs each download with its CSV title and shows every planned destination without changing a file.
  • A guarded apply workflow that sanitizes Windows filenames, blocks count mismatches, catches destination collisions, and moves files only after explicit confirmation.
  • Secret Mission: Add an optional recursive scan that finds course downloads hidden inside nested folders.

Are there any prerequisites?

You should be comfortable writing Python scripts. This project uses Python 3, Visual Studio Code, and Windows PowerShell with no third-party packages.

Before We Start

This opening checkpoint is your chance to lock in the problem you are solving before the hands-on work begins. You are committing to a safe local organizer that previews planned moves before changing real course downloads.

Set Up the Organizer Workspace

A command-line interface becomes harder to debug when its scripts and terminal start in different folders. A predictable workspace keeps path problems away from the file-automation work.

In this step, you will create the course-video-organizer workspace in Visual Studio Code. You will verify the Python 3 launcher before adding the organizer's command-line help.

In this step, get ready to:
  • Create the course-video-organizer workspace in Visual Studio Code.
  • Verify that the Windows Python launcher can start Python 3.
  • Create the three starter files and add the organizer's command-line help.
Create the workspace

Visual Studio Code treats an opened folder as a workspace. Its Explorer and integrated terminal then use that folder as their shared project context.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

What is a workspace?

A workspace is the folder Visual Studio Code has opened for your project. Opening the correct folder keeps the editor and terminal focused on the same files.

  • Click File in the top menu bar.
  • Select Open Folder....
  • Select Desktop in the folder dialog.
  • Select New Folder.
  • Type course-video-organizer and press Enter.

You should now see the new course-video-organizer folder selected in the dialog.

  • Select the course-video-organizer folder.
  • Click Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

Good progress. The course-video-organizer name now appears at the top of the Explorer sidebar.

  • Click Terminal in the top menu bar.
  • Select New Terminal.

The terminal opens inside course-video-organizer. Its terminal tab should show Windows PowerShell.

Don't See Windows PowerShell?

  • Open the terminal profile dropdown in the terminal panel.
  • Select Windows PowerShell from the available profiles.
  • Select Terminal: Select Default Profile if you want new terminals to use Windows PowerShell.

Use this if the expected profile is missing: Help me select Windows PowerShell in Visual Studio Code.

Verify the Python launcher

The Windows Python launcher selects an installed Python runtime for a command. Checking it now confirms that the organizer can run from this workspace.

  • Check the installed Python 3 runtime by running this command:
py -3 --version

What Does This Command Do?

  • The py command starts the Windows Python launcher.
  • The -3 option requests an installed Python 3 runtime.
  • The --version option prints the selected runtime version.

✔️ I See the Installed Version

Your output starts with Python 3. That confirms the launcher can start the runtime this organizer needs.

ⓧ The Launcher Is Unavailable

The launcher needs a working Python installation before the organizer can run.

  • Open the official Python downloads for Windows page in your browser.
  • Download the current Python 3 release.
  • Run the downloaded installer.
  • Return to Visual Studio Code after the installation finishes.
  • Click Terminal in the top menu bar.
  • Select New Terminal.
  • Check the launcher again by running this command:
py -3 --version

What Should I See?

The new terminal should print an installed version beginning with Python 3.

Still Can't Launch Python?

Close Visual Studio Code after the installation. Reopen the course-video-organizer folder so the terminal can pick up the new launcher.

Use this for environment-specific help: Help me get the Windows Python launcher working.

Create the starter files and CLI

The organizer uses one Python file for its command-line workflow. A second Python file will generate sample downloads in the next step.

The third file will hold a CSV manifest of module names and lecture titles. For now, the sample generator and manifest remain empty starter files.

  • Select New File... in the Explorer sidebar.
  • Type organizer.py and press Enter.

You should see organizer.py in the Explorer sidebar.

  • Select New File... in the Explorer sidebar.
  • Type make_samples.py and press Enter.

You should now see make_samples.py beneath the organizer file.

  • Select New File... in the Explorer sidebar.
  • Type titles.csv and press Enter.

The Explorer sidebar should now list organizer.py, make_samples.py, and titles.csv.

  • Select organizer.py in the Explorer sidebar.
  • Add the starter command-line organizer by copying this code into the editor:
import argparse
import csv
from pathlib import Path


def main():
    parser = argparse.ArgumentParser(
        description="Organize mislabeled course videos from a CSV manifest."
    )
    parser.add_argument("source", type=Path, help="Folder containing downloaded files")
    parser.add_argument("manifest", type=Path, help="CSV file with module and lecture columns")
    parser.add_argument(
        "--output",
        default="Organized Course",
        help="Output folder created inside the source folder",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply the plan instead of performing a dry run",
    )
    args = parser.parse_args()

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

What Does This Code Do?

  • The argparse import provides the parser that turns terminal input into Python values.
  • The csv import prepares the organizer to read the title manifest in a later step.
  • The Path class represents source folders and manifest paths.
  • The source argument identifies the folder containing downloaded files.
  • The manifest argument identifies the CSV file containing course titles.
  • The --output option gives the organized course folder a default name.
  • The --apply flag prepares the interface for explicit file changes later in the project.
  • The main() entry point creates the parser whenever you run the script directly.
  • Save organizer.py by pressing Ctrl+S.

Before you test it, which four arguments do you expect the help screen to name?

  • Display the organizer's command-line help by running this command:
py -3 organizer.py --help

What Does This Command Prove?

The command starts organizer.py with the parser's built-in help request. A complete help screen proves that Python can load the file and construct its command-line interface.

You should see the organizer description followed by the source, manifest, --output, and --apply arguments.

Don't See the Help Screen?

  • Confirm that the terminal is open inside the course-video-organizer workspace.
  • Save organizer.py before running the command again.
  • Check the file name for the exact organizer.py spelling.

Use this if the help command still fails: Help me debug my organizer help command.

✔️ Awesome, I've Got Everything

Your three starter files are saved. The organizer help screen confirms that the workspace is ready.

ⓧ I'd Like to Double Check the Full Code

import argparse
import csv
from pathlib import Path


def main():
    parser = argparse.ArgumentParser(
        description="Organize mislabeled course videos from a CSV manifest."
    )
    parser.add_argument("source", type=Path, help="Folder containing downloaded files")
    parser.add_argument("manifest", type=Path, help="CSV file with module and lecture columns")
    parser.add_argument(
        "--output",
        default="Organized Course",
        help="Output folder created inside the source folder",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply the plan instead of performing a dry run",
    )
    args = parser.parse_args()

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

That setup is complete: your workspace has the three starter files plus a working command-line interface. Next, you will create synthetic downloads and test the organizer's first scanner.

Build the Naive Scanner

Your command-line interface now accepts a source folder plus a manifest. The next goal is to test whether filenames can identify the downloaded course files safely.

A filename-only filter can group different file contents together. You will create a controlled test set so the simple rule can prove where its assumption falls short.

In this step, get ready to:
  • Generate three synthetic media candidates plus one real HTML control file.
  • Create a three-row CSV manifest for the synthetic files.
  • Build a scanner that selects every regular file with the .html suffix.
Create controlled sample files

A synthetic fixture is a small file designed to test one behavior. Each media candidate contains an ftyp binary atom without needing a playable video.

  • Replace the empty contents of make_samples.py with the atom helper below:
from pathlib import Path


def make_atom(atom_type, payload):
    atom_size = 8 + len(payload)
    return atom_size.to_bytes(4, byteorder="big") + atom_type + payload

What does this helper build?

  • The make_atom() helper calculates the total size of one synthetic atom.
  • The to_bytes() call stores that size in big-endian byte order.
  • The return value combines the size with the atom type plus its payload.
  • Add the fixture generator below make_atom() by pasting this code:
def main():
    sample_folder = Path("sample_downloads")
    sample_folder.mkdir(exist_ok=True)

    for number in range(1, 4):
        payload = b"demo" + number.to_bytes(4, byteorder="big")
        sample_bytes = make_atom(b"ftyp", payload)
        sample_path = sample_folder / f"f{number:03}.html"
        sample_path.write_bytes(sample_bytes)

    real_html = sample_folder / "course-portal.html"
    real_html.write_text(
        "<!doctype html><html><body>Course portal</body></html>",
        encoding="utf-8",
    )

    Path("titles.csv").write_text(
        "module,lecture\n"
        "01 Foundations,01 Welcome\n"
        "01 Foundations,02 Course Tour\n"
        "02 Core Skills,01 First Workflow\n",
        encoding="utf-8",
    )

    print("Created three synthetic ftyp fixtures, one HTML control, and titles.csv.")


if __name__ == "__main__":
    main()

What does this generator create?

  • The loop writes f001.html through f003.html with synthetic ftyp atoms.
  • The course-portal.html file stores a real HTML page for comparison.
  • The script replaces titles.csv with three module and lecture rows.
  • The entry point runs main() when you launch the script.

✔️ Awesome, I've got everything!

Your fixture generator now defines the complete controlled test set.

ⓧ I'd like to double check the full code

from pathlib import Path


def make_atom(atom_type, payload):
    atom_size = 8 + len(payload)
    return atom_size.to_bytes(4, byteorder="big") + atom_type + payload


def main():
    sample_folder = Path("sample_downloads")
    sample_folder.mkdir(exist_ok=True)

    for number in range(1, 4):
        payload = b"demo" + number.to_bytes(4, byteorder="big")
        sample_bytes = make_atom(b"ftyp", payload)
        sample_path = sample_folder / f"f{number:03}.html"
        sample_path.write_bytes(sample_bytes)

    real_html = sample_folder / "course-portal.html"
    real_html.write_text(
        "<!doctype html><html><body>Course portal</body></html>",
        encoding="utf-8",
    )

    Path("titles.csv").write_text(
        "module,lecture\n"
        "01 Foundations,01 Welcome\n"
        "01 Foundations,02 Course Tour\n"
        "02 Core Skills,01 First Workflow\n",
        encoding="utf-8",
    )

    print("Created three synthetic ftyp fixtures, one HTML control, and titles.csv.")


if __name__ == "__main__":
    main()

How to use this reference

Compare this full file with your copy of make_samples.py. Pay close attention to indentation inside main().

  • Save make_samples.py.
  • Generate the controlled samples by running this command:
py -3 make_samples.py

What does this command create?

The script creates the sample_downloads folder. It also fills titles.csv with the manifest rows.

You will see Created three synthetic ftyp fixtures, one HTML control, and titles.csv. in the terminal.

Good work. Your controlled folder now has three media candidates plus one real web page for the scanner to classify.

Sample files missing?

  • Check that the terminal path ends with course-video-organizer.
  • Check the indentation inside main() against the full-file reference.
  • Run the generator again after saving make_samples.py.

Ask for help with the fixture generator.

Add the extension-only scanner

The first scanner uses the filename as its only classification signal. It keeps regular files whose lowercase suffix equals .html.

  • Add find_html_files() above main() in organizer.py by pasting this code:
def find_html_files(source):
    files = [
        path
        for path in source.iterdir()
        if path.is_file()
        and path.suffix.lower() == ".html"
    ]
    return sorted(
        files,
        key=lambda path: (path.stat().st_mtime_ns, path.name.casefold()),
    )

What does this scanner select?

  • The source.iterdir() call checks paths directly inside the supplied source folder.
  • The is_file() check excludes folders.
  • The suffix condition accepts every regular file ending in .html.
  • The final sort uses modification time with the filename as a tie-breaker.
  • Save organizer.py.
  • Confirm that the organizer still loads its command-line interface by running:
py -3 organizer.py --help

What should you see?

You should still see the organizer description. The help output should also list source, manifest, --output, plus --apply.

Help command stopped working?

  • Check that find_html_files() sits above main().
  • Check the closing square bracket after the file filter.
  • Compare the function indentation with the code block above.

Ask for help with the scanner function.

  • Find args = parser.parse_args() inside main().
  • Add the scan output directly below that line by pasting this code:
    files = find_html_files(args.source)
    for path in files:
        print(path)

Why print each path?

The loop makes every selected file visible in the terminal. The output gives you direct evidence of what the suffix rule accepts.

✔️ Awesome, I've got everything!

Your organizer now passes the supplied source folder into the naive scanner.

ⓧ I'd like to double check the full code

import argparse
import csv
from pathlib import Path


def find_html_files(source):
    files = [
        path
        for path in source.iterdir()
        if path.is_file()
        and path.suffix.lower() == ".html"
    ]
    return sorted(
        files,
        key=lambda path: (path.stat().st_mtime_ns, path.name.casefold()),
    )


def main():
    parser = argparse.ArgumentParser(
        description="Organize mislabeled course videos from a CSV manifest."
    )
    parser.add_argument("source", type=Path, help="Folder containing downloaded files")
    parser.add_argument("manifest", type=Path, help="CSV file with module and lecture columns")
    parser.add_argument(
        "--output",
        default="Organized Course",
        help="Output folder created inside the source folder",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply the plan instead of performing a dry run",
    )
    args = parser.parse_args()

    files = find_html_files(args.source)
    for path in files:
        print(path)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

How to use this reference

Compare the scanner placement with your copy of organizer.py. The print loop belongs inside main().

  • Save organizer.py.
Run the scanner and inspect the result

Before you run the scanner, do you expect every selected path to contain the same kind of data?

  • Test the filename-only rule against the controlled folder by running:
py -3 organizer.py sample_downloads titles.csv

What does the scanner reveal?

You will see four matching paths. The output includes f001.html, f002.html, f003.html, plus course-portal.html.

The real course portal passes the filter because its filename ends in the expected suffix.

That false positive is the intended result. You have proved that an extension-only scanner cannot safely identify the course videos.

Why is this result unsafe?

A suffix describes how a file is named. It cannot prove what bytes the file contains.

Renaming every match as a video would misclassify the real HTML page. The scanner needs evidence from inside each file.

Do you see fewer than four paths?

  • Run the sample generator again if files are missing from sample_downloads.
  • Check that the suffix comparison uses .html in lowercase.
  • Check that the print loop sits below files = find_html_files(args.source) inside main().

Ask for help with the scanner output.

You have exposed the exact weakness in filename-based detection. Next up, you will inspect the binary contents so the real HTML page disappears from the results.

Inspect Binary File Contents

Your naive scanner proved that a .html suffix can describe both a course download and a real web page.

Now you'll use binary inspection to read each candidate's top-level atoms. A valid ftyp atom identifies an MP4-family candidate from its contents.

In this step, get ready to:
  • Add Windows-safe path rules to the organizer.
  • Detect the top-level ftyp atom inside each candidate.
  • Sort the verified candidates deterministically.
Add Windows-safe naming rules

The organizer will eventually turn manifest text into folder names. Windows reserves specific characters plus specific device names.

  • In the Visual Studio Code Explorer sidebar, select organizer.py.
  • Add the following constants plus helper below the imports:
INVALID_CHARS = '<>:"/\\|?*'
RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL"}
RESERVED_NAMES.update(f"COM{number}" for number in range(1, 10))
RESERVED_NAMES.update(f"LPT{number}" for number in range(1, 10))
RESERVED_NAMES.update({"COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"})
SCAN_LIMIT = 1024 * 1024


def clean_component(value):
    characters = []
    for character in value:
        if ord(character) < 32 or character in INVALID_CHARS:
            characters.append("-")
        else:
            characters.append(character)

    cleaned = "".join(characters).strip(" .")
    while "--" in cleaned:
        cleaned = cleaned.replace("--", "-")

    if not cleaned:
        cleaned = "Untitled"

    base_name = cleaned.split(".", 1)[0].upper()
    if base_name in RESERVED_NAMES:
        cleaned = f"_{cleaned}"

    return cleaned[:100].rstrip(" .") or "Untitled"

What does this code add?

  • INVALID_CHARS records the characters Windows excludes from file names.
  • RESERVED_NAMES covers reserved device names such as CON plus COM1.
  • clean_component() replaces unsafe characters with hyphens.
  • The helper removes trailing spaces plus periods.
  • The final return limits each path component to 100 characters.
  • SCAN_LIMIT limits binary inspection to the first megabyte of each candidate.
  • Save organizer.py.
  • Confirm the script still reaches the existing scanner by running:
py -3 organizer.py sample_downloads titles.csv

What should I see?

You'll see the same four matches from the naive scanner. The list still includes course-portal.html.

This confirms the naming rules loaded successfully. The false positive remains available for the content check to solve.

Does the script stop before listing files?

  • Check that every opening parenthesis in clean_component() has a matching closing parenthesis.
  • Check that the backslash remains escaped inside INVALID_CHARS.

Need help checking the naming helper?

Inspect top-level binary atoms

A top-level atom starts with a size field. Its next four bytes identify the atom type.

The scanner reads those bytes in binary mode. Big-endian conversion turns the first four bytes into the atom's numeric size.

  • In organizer.py, find the existing find_html_files() function.
  • Replace that function with the two functions below:
def has_ftyp_atom(path):
    file_size = path.stat().st_size
    offset = 0
    with path.open("rb") as media_file:
        while offset + 8 <= file_size and offset < SCAN_LIMIT:
            media_file.seek(offset)
            header = media_file.read(8)
            if len(header) != 8:
                return False
            atom_size = int.from_bytes(header[:4], byteorder="big")
            atom_type = header[4:8]
            if atom_type == b"ftyp":
                return True
            offset += atom_size
    return False

def find_html_files(source):
    files = [
        path
        for path in source.iterdir()
        if path.is_file()
        and path.suffix.lower() == ".html"
        and has_ftyp_atom(path)
    ]
    return sorted(
        files,
        key=lambda path: (path.stat().st_mtime_ns, path.name.casefold()),
    )

How does content detection work?

  • has_ftyp_atom() opens each candidate with rb so Python reads bytes.
  • int.from_bytes() interprets the first four bytes in big-endian order.
  • b"ftyp" represents the four-byte file type compatibility atom identifier.
  • find_html_files() keeps regular .html files whose content check returns True.
  • The sort uses modification time as its primary key.
  • A case-insensitive filename supplies the tie-breaker.
  • Save organizer.py.

Before you run the corrected scanner, which file do you expect to disappear from the results?

  • Test the content filter by running:
py -3 organizer.py sample_downloads titles.csv

What should I see?

You'll see f001.html, f002.html, plus f003.html.

course-portal.html is absent. That false positive is gone because your scanner now checks evidence inside each file.

Still seeing the real HTML file?

  • Check that find_html_files() includes the has_ftyp_atom(path) condition.
  • Check that atom_type is compared with the bytes value b"ftyp".
  • Check that make_samples.py created the fixtures inside sample_downloads.

Ask for help with the binary filter.

Handle extended atom sizes

An atom size of 1 signals a following 64-bit extended size. Invalid sizes must also stop the scan before an offset points outside the file.

  • In has_ftyp_atom(), find this block:
            atom_size = int.from_bytes(header[:4], byteorder="big")
            atom_type = header[4:8]

            if atom_type == b"ftyp":
                return True

            offset += atom_size

What is missing here?

This version reads the standard size field. It does not validate zero sizes or read the 64-bit extended size field.

  • Replace that block with the hardened version below:
            atom_size = int.from_bytes(header[:4], byteorder="big")
            atom_type = header[4:8]
            header_size = 8

            if atom_size == 1:
                extended_size = media_file.read(8)
                if len(extended_size) != 8:
                    return False
                atom_size = int.from_bytes(extended_size, byteorder="big")
                header_size = 16
            elif atom_size == 0:
                return False

            if atom_size < header_size or offset + atom_size > file_size:
                return False

            if atom_type == b"ftyp":
                return True

            offset += atom_size

How does the hardened parser stay safe?

  • header_size starts at 8 bytes for a standard atom header.
  • An atom_size of 1 causes the parser to read the next eight bytes.
  • An atom_size of 0 stops the scan.
  • The boundary check rejects an atom that extends beyond the file.
  • The offset advances by the validated atom size.
  • Save organizer.py.

Before you rerun the scanner, do you expect the hardened parser to keep the same three candidates?

  • Verify the completed binary scanner by running:
py -3 organizer.py sample_downloads titles.csv

What should I see?

You'll see f001.html, f002.html, plus f003.html in deterministic order. You will not see course-portal.html.

You have now replaced suffix-only guessing with a bounded binary content check. The scanner can identify MP4-family candidates without accepting the real web page.

Did the corrected scan stop working?

  • Check that header_size = 8 appears before the size branches.
  • Check that the extended-size branch reads exactly eight bytes.
  • Check that offset += atom_size remains at the end of the loop.

Ask for help reviewing the atom loop.

✔️ Awesome, I've got everything

Great work. Save organizer.py one more time so the corrected scanner is ready for the next step.

ⓧ I'd like to double check the full code

Compare the section below with the constants plus functions in organizer.py.

INVALID_CHARS = '<>:"/\\|?*'
RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL"}
RESERVED_NAMES.update(f"COM{number}" for number in range(1, 10))
RESERVED_NAMES.update(f"LPT{number}" for number in range(1, 10))
RESERVED_NAMES.update({"COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"})
SCAN_LIMIT = 1024 * 1024


def clean_component(value):
    characters = []
    for character in value:
        if ord(character) < 32 or character in INVALID_CHARS:
            characters.append("-")
        else:
            characters.append(character)

    cleaned = "".join(characters).strip(" .")
    while "--" in cleaned:
        cleaned = cleaned.replace("--", "-")

    if not cleaned:
        cleaned = "Untitled"

    base_name = cleaned.split(".", 1)[0].upper()
    if base_name in RESERVED_NAMES:
        cleaned = f"_{cleaned}"

    return cleaned[:100].rstrip(" .") or "Untitled"


def has_ftyp_atom(path):
    file_size = path.stat().st_size
    offset = 0

    with path.open("rb") as media_file:
        while offset + 8 <= file_size and offset < SCAN_LIMIT:
            media_file.seek(offset)
            header = media_file.read(8)
            if len(header) != 8:
                return False

            atom_size = int.from_bytes(header[:4], byteorder="big")
            atom_type = header[4:8]
            header_size = 8

            if atom_size == 1:
                extended_size = media_file.read(8)
                if len(extended_size) != 8:
                    return False
                atom_size = int.from_bytes(extended_size, byteorder="big")
                header_size = 16
            elif atom_size == 0:
                return False

            if atom_size < header_size or offset + atom_size > file_size:
                return False

            if atom_type == b"ftyp":
                return True

            offset += atom_size

    return False


def find_html_files(source):
    files = [
        path
        for path in source.iterdir()
        if path.is_file()
        and path.suffix.lower() == ".html"
        and has_ftyp_atom(path)
    ]
    return sorted(
        files,
        key=lambda path: (path.stat().st_mtime_ns, path.name.casefold()),
    )

What should match?

This reference contains every constant plus helper changed in this step. Keep the existing argument parser below these functions unchanged.

Your scanner now trusts binary evidence and produces a stable candidate order. Next, you'll pair those candidates with the CSV manifest to build a safe dry-run plan.

Create a Safe Dry-Run Plan

Your scanner now identifies the three synthetic media candidates. It leaves course-portal.html out of the results.

File detection solves only half of the organization problem. A reviewed dry run prevents guessed titles before any file moves.

In this step, get ready to:
  • Load module names and lecture names from the CSV manifest.
  • Build sanitized destination paths with count checks and collision checks.
  • Print the complete organization plan without changing any files.

Why a CSV manifest?

A CSV manifest keeps the intended module names and lecture names in a format you can review before running the organizer.

The organizer pairs these rows with the detected files. An equal-count check stops the plan when either side is missing an item.

Load and validate the manifest

The manifest becomes the source of truth for readable destination names. Its two required headers are module and lecture.

  • In organizer.py, find this import line:
import argparse

Why find this line?

This line anchors the import edit at the top of organizer.py.

  • Replace that line with this import group:
import argparse
import csv

What does this import add?

The csv module provides the dictionary-based reader used by the manifest loader.

  • Scroll to the space above find_html_files(source).
  • Add the manifest loader by pasting this code:
def load_titles(manifest):
    with manifest.open("r", encoding="utf-8", newline="") as csv_file:
        reader = csv.DictReader(csv_file)
        required_headers = {"module", "lecture"}

        if reader.fieldnames is None or not required_headers.issubset(reader.fieldnames):
            raise ValueError("The manifest must contain module and lecture headers.")

        titles = []
        for row_number, row in enumerate(reader, start=2):
            module = (row.get("module") or "").strip()
            lecture = (row.get("lecture") or "").strip()
            if not module or not lecture:
                raise ValueError(
                    f"Manifest row {row_number} needs both module and lecture values."
                )
            titles.append((module, lecture))

    return titles

What does this code do?

  • The dictionary reader uses the first CSV row as field names.
  • The header check requires both expected columns before reading any titles.
  • The row check rejects an empty module value or lecture value.
  • The returned tuples preserve the manifest order for later pairing.
  • Save organizer.py.
  • Confirm the updated file still runs by executing this command:
py -3 organizer.py sample_downloads titles.csv

What should I see?

You should still see f001.html, f002.html, and f003.html in the scanner output. The absence of a traceback confirms that the new import and function parse successfully.

Seeing a manifest-related error?

  • Check that the import is spelled import csv.
  • Check that load_titles(manifest) starts at the left edge of the file.
  • Compare the function indentation with the code block above.

Help me troubleshoot my manifest loader.

Build the preflight plan

A preflight plan pairs each detected source file with one sanitized destination. It validates the entire batch before any move can begin.

  • Add the planning function below load_titles(manifest) by pasting this code:
def build_plan(files, titles, output):
    if len(files) != len(titles):
        raise ValueError(
            f"Found {len(files)} matching file(s), but the manifest has "
            f"{len(titles)} data row(s)."
        )

    plan = []
    planned_destinations = set()

    for source_file, title in zip(files, titles):
        module, lecture = title
        module_folder = output / clean_component(module)
        destination = module_folder / f"{clean_component(lecture)}.mp4"
        destination_key = str(destination).casefold()

        if destination_key in planned_destinations:
            raise ValueError(f"Duplicate destination in plan: {destination}")
        if destination.exists():
            raise FileExistsError(f"Destination already exists: {destination}")

        planned_destinations.add(destination_key)
        plan.append((source_file, destination))

    return plan

How does the plan stay safe?

  • The count check stops mismatched files and manifest rows before pairing begins.
  • The existing clean_component() function makes each module name and lecture name safe for Windows.
  • The case-folded set catches two planned paths that Windows would treat as the same destination.
  • The existence check blocks a move when the destination path already contains a file.
  • Save organizer.py.
  • Confirm the planning function contains no syntax errors by running:
py -3 organizer.py sample_downloads titles.csv

What does this check prove?

The command should still list the three detected media candidates without a traceback. This confirms that Python can load the new planning function.

Does the script stop before listing files?

  • Check that every line inside build_plan(files, titles, output) uses consistent indentation.
  • Check that both formatted message lines remain inside the count error.
  • Check that planned_destinations is spelled the same way in all three places.

Help me fix my planning function.

Print and connect the dry run

The execution function owns the boundary between previewing paths and changing files. A false confirmation value prints the plan before returning without creating folders.

  • Add the execution function below build_plan(files, titles, output) by pasting this code:
def execute_plan(plan, apply_changes):
    print("MODE: APPLY" if apply_changes else "MODE: DRY RUN")

    for source_file, destination in plan:
        print(f"{source_file} -> {destination}")

    if not apply_changes:
        print("\nNo files were changed.")
        return

    for source_file, destination in plan:
        destination.parent.mkdir(parents=True, exist_ok=True)
        source_file.rename(destination)

    print(f"\nMoved {len(plan)} file(s).")

How does dry-run mode work?

  • The first line labels the current mode before showing any paths.
  • The first loop prints every source-to-destination pairing for review.
  • The early return ends dry-run mode before directory creation or renaming.
  • The second loop remains available for the explicit apply workflow in the next step.
  • Save organizer.py.
  • Confirm the new function parses before connecting it by running:
py -3 organizer.py sample_downloads titles.csv

What should remain unchanged?

You should still see the three scanner matches. The command-line flow does not call execute_plan() until you connect it in the next edit.

  • Inside main(), find the scanner section that begins with files = find_html_files(args.source).
  • Replace that scanner section with this planning sequence:
        files = find_html_files(args.source)
        titles = load_titles(args.manifest)
        output = args.source / clean_component(args.output)
        plan = build_plan(files, titles, output)
        execute_plan(plan, args.apply)

What does this connection do?

  • The scanner supplies the ordered source files.
  • The manifest loader supplies the ordered destination titles.
  • The output path places Organized Course inside the source folder.
  • The final call prints the preflighted plan using the current apply setting.
  • Save organizer.py.

Before you run the organizer, do you expect the source files or folder structure to change?

  • Reveal the completed dry-run plan by running:
py -3 organizer.py sample_downloads titles.csv

What should I see?

The first line should read MODE: DRY RUN. Three source-to-destination pairings should follow.

  • The first destination should end with Organized Course\01 Foundations\01 Welcome.mp4.
  • The second destination should end with Organized Course\01 Foundations\02 Course Tour.mp4.
  • The third destination should end with Organized Course\02 Core Skills\01 First Workflow.mp4.

The final line should read No files were changed..

  • In the Visual Studio Code Explorer sidebar, expand sample_downloads.

You should still see the original four .html files. You should not see an Organized Course folder.

Dry run not showing three pairings?

  • Check that titles.csv still contains three data rows beneath its headers.
  • Check that sample_downloads still contains the three synthetic media fixtures.
  • Check that the planning sequence sits inside the existing try block in main().

Help me debug my dry-run output.

✔️ Awesome, I've got everything!

Your organizer now validates the manifest and previews all three destinations. Double-check that you saved organizer.py.

ⓧ I'd like to double check the full code

import argparse
import csv
from pathlib import Path

INVALID_CHARS = '<>:"/\\|?*'
RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL"}
RESERVED_NAMES.update(f"COM{number}" for number in range(1, 10))
RESERVED_NAMES.update(f"LPT{number}" for number in range(1, 10))
RESERVED_NAMES.update({"COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"})
SCAN_LIMIT = 1024 * 1024


def clean_component(value):
    characters = []
    for character in value:
        if ord(character) < 32 or character in INVALID_CHARS:
            characters.append("-")
        else:
            characters.append(character)

    cleaned = "".join(characters).strip(" .")
    while "--" in cleaned:
        cleaned = cleaned.replace("--", "-")

    if not cleaned:
        cleaned = "Untitled"

    base_name = cleaned.split(".", 1)[0].upper()
    if base_name in RESERVED_NAMES:
        cleaned = f"_{cleaned}"

    return cleaned[:100].rstrip(" .") or "Untitled"


def has_ftyp_atom(path):
    file_size = path.stat().st_size
    offset = 0

    with path.open("rb") as media_file:
        while offset + 8 <= file_size and offset < SCAN_LIMIT:
            media_file.seek(offset)
            header = media_file.read(8)
            if len(header) != 8:
                return False

            atom_size = int.from_bytes(header[:4], byteorder="big")
            atom_type = header[4:8]
            header_size = 8

            if atom_size == 1:
                extended_size = media_file.read(8)
                if len(extended_size) != 8:
                    return False
                atom_size = int.from_bytes(extended_size, byteorder="big")
                header_size = 16
            elif atom_size == 0:
                return False

            if atom_size < header_size or offset + atom_size > file_size:
                return False

            if atom_type == b"ftyp":
                return True

            offset += atom_size

    return False


def find_html_files(source):
    files = [
        path
        for path in source.iterdir()
        if path.is_file()
        and path.suffix.lower() == ".html"
        and has_ftyp_atom(path)
    ]
    return sorted(
        files,
        key=lambda path: (path.stat().st_mtime_ns, path.name.casefold()),
    )


def load_titles(manifest):
    with manifest.open("r", encoding="utf-8", newline="") as csv_file:
        reader = csv.DictReader(csv_file)
        required_headers = {"module", "lecture"}

        if reader.fieldnames is None or not required_headers.issubset(reader.fieldnames):
            raise ValueError("The manifest must contain module and lecture headers.")

        titles = []
        for row_number, row in enumerate(reader, start=2):
            module = (row.get("module") or "").strip()
            lecture = (row.get("lecture") or "").strip()
            if not module or not lecture:
                raise ValueError(
                    f"Manifest row {row_number} needs both module and lecture values."
                )
            titles.append((module, lecture))

    return titles


def build_plan(files, titles, output):
    if len(files) != len(titles):
        raise ValueError(
            f"Found {len(files)} matching file(s), but the manifest has "
            f"{len(titles)} data row(s)."
        )

    plan = []
    planned_destinations = set()

    for source_file, title in zip(files, titles):
        module, lecture = title
        module_folder = output / clean_component(module)
        destination = module_folder / f"{clean_component(lecture)}.mp4"
        destination_key = str(destination).casefold()

        if destination_key in planned_destinations:
            raise ValueError(f"Duplicate destination in plan: {destination}")
        if destination.exists():
            raise FileExistsError(f"Destination already exists: {destination}")

        planned_destinations.add(destination_key)
        plan.append((source_file, destination))

    return plan


def execute_plan(plan, apply_changes):
    print("MODE: APPLY" if apply_changes else "MODE: DRY RUN")

    for source_file, destination in plan:
        print(f"{source_file} -> {destination}")

    if not apply_changes:
        print("\nNo files were changed.")
        return

    for source_file, destination in plan:
        destination.parent.mkdir(parents=True, exist_ok=True)
        source_file.rename(destination)

    print(f"\nMoved {len(plan)} file(s).")


def main():
    parser = argparse.ArgumentParser(
        description="Organize mislabeled course videos from a CSV manifest."
    )
    parser.add_argument("source", type=Path, help="Folder containing downloaded files")
    parser.add_argument("manifest", type=Path, help="CSV file with module and lecture columns")
    parser.add_argument(
        "--output",
        default="Organized Course",
        help="Output folder created inside the source folder",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply the plan instead of performing a dry run",
    )
    args = parser.parse_args()

    try:
        if not args.source.is_dir():
            raise ValueError(f"Source folder does not exist: {args.source}")
        if not args.manifest.is_file():
            raise ValueError(f"Manifest file does not exist: {args.manifest}")

        files = find_html_files(args.source)
        titles = load_titles(args.manifest)
        output = args.source / clean_component(args.output)
        plan = build_plan(files, titles, output)
        execute_plan(plan, args.apply)
    except (OSError, ValueError) as error:
        print(f"ERROR: {error}")
        return 1

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

How to use this reference

Compare the import group and each new function with your saved file. Finish by checking the planning sequence inside main().

Your organizer can now prove every destination before touching a source file. Next, you will turn this reviewed preview into an explicit apply workflow.

Apply the Organization Plan

Your command-line interface already builds a complete move plan without touching the source files. You have seen every destination before committing to a rename.

This step adds an explicit confirmation switch. The organizer keeps its safe preview as the default behavior.

In this step, get ready to:
  • Complete the guarded apply path in organizer.py.
  • Review the three source-to-destination pairings one final time.
  • Apply the plan to the synthetic files and inspect the result.
Add the guarded apply path

The completed execute_plan() function has two branches. Preview mode returns before any filesystem change can happen.

  • In organizer.py, locate the existing execute_plan() function.
  • Replace the entire function by copying the code below:
def execute_plan(plan, apply_changes):
    print("MODE: APPLY" if apply_changes else "MODE: DRY RUN")

    for source_file, destination in plan:
        print(f"{source_file} -> {destination}")

    if not apply_changes:
        print("\nNo files were changed.")
        return

    for source_file, destination in plan:
        destination.parent.mkdir(parents=True, exist_ok=True)
        source_file.rename(destination)

    print(f"\nMoved {len(plan)} file(s).")

What Does This Code Do?

  • The mode line makes the current behavior visible before any paths are processed.
  • The first loop prints every planned source and destination.
  • The dry-run guard returns before directory creation or file renaming.
  • The apply loop creates each destination folder. It then renames each preflighted source file.
  • Save organizer.py.

Before you run the organizer, do you expect the default command to create the output folders?

  • Test the guarded default path by running this command:
py -3 organizer.py sample_downloads titles.csv

What Should You See?

You should see MODE: DRY RUN followed by the three reviewed pairings. The final line should say No files were changed.

The Organized Course folder should still be absent. That confirms the return statement protects the default path.

Dry Run Not Listing Three Files?

  • Confirm the terminal is still inside the course-video-organizer workspace.
  • Confirm sample_downloads still contains the three synthetic .html fixtures.
  • Confirm titles.csv still contains three data rows.

Help me diagnose the dry-run output.

Wire the confirmation flag

The apply branch needs a value from the argument parser. The store_true action keeps that value false until the learner supplies the flag.

  • In main(), locate the line containing args = parser.parse_args().
  • Replace that line through the final return 0 with the code below:
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply the plan instead of performing a dry run",
    )
    args = parser.parse_args()

    try:
        if not args.source.is_dir():
            raise ValueError(f"Source folder does not exist: {args.source}")
        if not args.manifest.is_file():
            raise ValueError(f"Manifest file does not exist: {args.manifest}")

        files = find_html_files(args.source)
        titles = load_titles(args.manifest)
        output = args.source / clean_component(args.output)
        plan = build_plan(files, titles, output)
        execute_plan(plan, args.apply)
    except (OSError, ValueError) as error:
        print(f"ERROR: {error}")
        return 1

    return 0

How Is the Apply Mode Guarded?

  • The parser stores whether the confirmation flag was supplied.
  • The source path check rejects a missing staging folder.
  • The manifest path check rejects a missing CSV file.
  • The complete plan is built before execute_plan() receives the apply value.
  • Save organizer.py.

✔️ Awesome, I've got everything!

  • Keep organizer.py saved before reviewing the plan.

ⓧ I'd like to double check the full code

import argparse
import csv
from pathlib import Path

INVALID_CHARS = '<>:"/\\|?*'
RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL"}
RESERVED_NAMES.update(f"COM{number}" for number in range(1, 10))
RESERVED_NAMES.update(f"LPT{number}" for number in range(1, 10))
RESERVED_NAMES.update({"COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"})
SCAN_LIMIT = 1024 * 1024


def clean_component(value):
    characters = []
    for character in value:
        if ord(character) < 32 or character in INVALID_CHARS:
            characters.append("-")
        else:
            characters.append(character)

    cleaned = "".join(characters).strip(" .")
    while "--" in cleaned:
        cleaned = cleaned.replace("--", "-")

    if not cleaned:
        cleaned = "Untitled"

    base_name = cleaned.split(".", 1)[0].upper()
    if base_name in RESERVED_NAMES:
        cleaned = f"_{cleaned}"

    return cleaned[:100].rstrip(" .") or "Untitled"


def has_ftyp_atom(path):
    file_size = path.stat().st_size
    offset = 0

    with path.open("rb") as media_file:
        while offset + 8 <= file_size and offset < SCAN_LIMIT:
            media_file.seek(offset)
            header = media_file.read(8)
            if len(header) != 8:
                return False

            atom_size = int.from_bytes(header[:4], byteorder="big")
            atom_type = header[4:8]
            header_size = 8

            if atom_size == 1:
                extended_size = media_file.read(8)
                if len(extended_size) != 8:
                    return False
                atom_size = int.from_bytes(extended_size, byteorder="big")
                header_size = 16
            elif atom_size == 0:
                return False

            if atom_size < header_size or offset + atom_size > file_size:
                return False

            if atom_type == b"ftyp":
                return True

            offset += atom_size

    return False


def find_html_files(source):
    files = [
        path
        for path in source.iterdir()
        if path.is_file()
        and path.suffix.lower() == ".html"
        and has_ftyp_atom(path)
    ]
    return sorted(
        files,
        key=lambda path: (path.stat().st_mtime_ns, path.name.casefold()),
    )


def load_titles(manifest):
    with manifest.open("r", encoding="utf-8", newline="") as csv_file:
        reader = csv.DictReader(csv_file)
        required_headers = {"module", "lecture"}

        if reader.fieldnames is None or not required_headers.issubset(reader.fieldnames):
            raise ValueError("The manifest must contain module and lecture headers.")

        titles = []
        for row_number, row in enumerate(reader, start=2):
            module = (row.get("module") or "").strip()
            lecture = (row.get("lecture") or "").strip()
            if not module or not lecture:
                raise ValueError(
                    f"Manifest row {row_number} needs both module and lecture values."
                )
            titles.append((module, lecture))

    return titles


def build_plan(files, titles, output):
    if len(files) != len(titles):
        raise ValueError(
            f"Found {len(files)} matching file(s), but the manifest has "
            f"{len(titles)} data row(s)."
        )

    plan = []
    planned_destinations = set()

    for source_file, title in zip(files, titles):
        module, lecture = title
        module_folder = output / clean_component(module)
        destination = module_folder / f"{clean_component(lecture)}.mp4"
        destination_key = str(destination).casefold()

        if destination_key in planned_destinations:
            raise ValueError(f"Duplicate destination in plan: {destination}")
        if destination.exists():
            raise FileExistsError(f"Destination already exists: {destination}")

        planned_destinations.add(destination_key)
        plan.append((source_file, destination))

    return plan


def execute_plan(plan, apply_changes):
    print("MODE: APPLY" if apply_changes else "MODE: DRY RUN")

    for source_file, destination in plan:
        print(f"{source_file} -> {destination}")

    if not apply_changes:
        print("\nNo files were changed.")
        return

    for source_file, destination in plan:
        destination.parent.mkdir(parents=True, exist_ok=True)
        source_file.rename(destination)

    print(f"\nMoved {len(plan)} file(s).")


def main():
    parser = argparse.ArgumentParser(
        description="Organize mislabeled course videos from a CSV manifest."
    )
    parser.add_argument("source", type=Path, help="Folder containing downloaded files")
    parser.add_argument("manifest", type=Path, help="CSV file with module and lecture columns")
    parser.add_argument(
        "--output",
        default="Organized Course",
        help="Output folder created inside the source folder",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply the plan instead of performing a dry run",
    )
    args = parser.parse_args()

    try:
        if not args.source.is_dir():
            raise ValueError(f"Source folder does not exist: {args.source}")
        if not args.manifest.is_file():
            raise ValueError(f"Manifest file does not exist: {args.manifest}")

        files = find_html_files(args.source)
        titles = load_titles(args.manifest)
        output = args.source / clean_component(args.output)
        plan = build_plan(files, titles, output)
        execute_plan(plan, args.apply)
    except (OSError, ValueError) as error:
        print(f"ERROR: {error}")
        return 1

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

What Should Match?

Your file should contain the complete non-recursive organizer shown above. The apply argument should feed directly into execute_plan() after every preflight check succeeds.

Review and apply the plan

The final dry run is your approval screen. Its order determines which CSV title each source file receives.

Before you run it, do you expect the three destinations to follow the same row order as titles.csv?

  • Generate the final preview by running this command:
py -3 organizer.py sample_downloads titles.csv

What Should You See?

You should see MODE: DRY RUN followed by three source-to-destination paths. You should also see the final confirmation that no files changed.

  • The first destination should end with 01 Foundations\01 Welcome.mp4.
  • The second destination should end with 01 Foundations\02 Course Tour.mp4.
  • The third destination should end with 02 Core Skills\01 First Workflow.mp4.
  • Compare the first printed pairing with the first data row in titles.csv.
  • Compare the second printed pairing with the second data row in titles.csv.
  • Compare the third printed pairing with the third data row in titles.csv.

Pairings in the Wrong Order?

Stop before applying the plan. Recreate the synthetic files if their modification times changed during earlier testing.

Help me verify the source and manifest order.

Renaming files can feel irreversible, so this run stays limited to the three synthetic fixtures you already reviewed.

Before you run the confirmed command, which three files do you expect to move into Organized Course?

  • Apply the reviewed plan by running this command:
py -3 organizer.py sample_downloads titles.csv --apply

What Should You See?

You should see MODE: APPLY followed by the same three pairings. The final line should report that three files moved.

The folders are created only after the full plan passes its count and collision checks. The real HTML control remains outside the plan.

Apply Run Stopped?

  • Review any count message before changing the manifest or sample files.
  • Remove an old synthetic output only if you intentionally created it during an earlier test.
  • Run the dry preview again after correcting the stated problem.

Help me resolve the failed apply run safely.

The handoff from the editor to File Explorer can be easy to miss. The folder is available through the context menu for sample_downloads in the Explorer sidebar.

  • Right-click sample_downloads in the Visual Studio Code Explorer sidebar.
  • Choose the context-menu action that reveals the folder in File Explorer.
  • Open the Organized Course folder.
  • Open the 01 Foundations folder.

You should see 01 Welcome.mp4 and 02 Course Tour.mp4 inside the first module folder.

  • Return to the Organized Course folder.
  • Open the 02 Core Skills folder.

You should see 01 First Workflow.mp4 inside the second module folder. The original course-portal.html should remain unchanged in sample_downloads.

That is the safety boundary working from end to end. Your organizer moved only the reviewed media candidates.

Reuse the Dry-Run-First Workflow

Real downloads deserve the same safety boundary as the synthetic fixtures. Work with copies in a separate staging folder before involving a larger course library.

  • Keep the original course downloads untouched.
  • Copy a few downloads into a separate staging folder.
  • Replace the data rows in titles.csv with matching module and lecture names.
  • Run the organizer against the staging folder without the confirmation flag.
  • Compare every previewed destination with the updated CSV rows.
  • Supply the confirmation flag only after the entire preview is correct.

Your organizer now defaults to a reviewable plan and requires explicit confirmation before moving anything. You can reuse that workflow whenever another course arrives with unreadable filenames.

Secret mission

Search Nested Download Folders

Some course platforms place downloads inside separate topic folders. Add an optional recursive mode that finds those nested files while keeping the safer single-folder scan as the default.

Clean Up Your Resources

Clean Up Your Resources

Your Visual Studio Code project lives entirely on your local machine, so there are no cloud resources or ongoing costs. Choose whether to keep the workspace, pause your work, or delete it.

Resources you used:

  • The course-video-organizer project folder containing organizer.py, make_samples.py, and titles.csv.
  • The local sample_downloads folder containing the HTML control file plus any synthetic fixtures.
  • The sample_downloads\Organized Course folder containing the organized .mp4 files.
  • The optional nested fixture at sample_downloads\nested\f004.html.

Keep everything running

No action needed. Choose this if you want to reuse the organizer for another course.

  • Keep the course-video-organizer folder on your machine.
  • Replace the sample rows in titles.csv when another course needs organizing.
  • Place copies of future downloads in a separate staging folder.
  • Preview every future organization plan without --apply before changing any files.

Pause - I'll come back to this later

The organizer runs only when you invoke it, so pausing does not require a shutdown command. Keep the workspace intact until you are ready to return.

  • Leave the course-video-organizer folder in place.
  • Close Visual Studio Code when you finish working.
  • Keep titles.csv aligned with the fixtures you plan to scan next.
  • Avoid using --apply until you have reviewed a fresh dry run.

Delete - I don't want to use this again

Deleting the workspace permanently removes the organizer plus every sample inside it. Any organized files copied outside course-video-organizer remain safe.

  • Copy sample_downloads\Organized Course to a location outside course-video-organizer if you want to keep the organized .mp4 files.
  • Close Visual Studio Code to release the workspace.
  • Press the Windows key to open search.
  • Type File Explorer into the search field.
  • Press Enter to open File Explorer.
  • Browse to the location that contains the course-video-organizer folder.
  • Permanently remove the course-video-organizer folder using File Explorer.

That single folder removal deletes every local resource listed above.

  • Confirm that course-video-organizer no longer appears in its previous location.

Your organizer workspace plus its synthetic downloads are now removed from your machine.

Nice Work!

Nice Work!

You did it! You built a local command-line interface that identifies mislabeled MP4-family files before moving approved matches into named module folders.

You've learned how to:

  • Use binary atom inspection to identify top-level ftyp candidates. Real HTML pages stay excluded even when every source file uses the .html extension.
  • Use a CSV manifest to map each candidate to its module path. Generate a dry-run preview for reviewing every lecture filename before changing files.
  • Sanitize Windows-safe filenames before building destinations. Block count mismatches. Reject destination collisions. Require --apply before moving files.
  • Secret Mission: Add recursive scanning with --recursive so nested candidates appear only on demand. Keep content detection active. Preserve deterministic sorting. Retain every preflight safeguard.

Ready to quiz yourself?