Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Flatten a Messy Media Library into A-Z Bucket Folders

A PowerShell script that sorts every show and movie in a folder into flat A-Z buckets, skips anything that would collide, and dry-runs honestly.

AT A GLANCEMove-MediaToBucket.ps1
What it does
Moves each top-level show folder, movie folder or loose video file into a bucket folder named for its first letter (A-Z, 0-9, or
Requires
  • PowerShell 7+ or Windows PowerShell 5.1
  • No modules
Permissions
Read and write access to the source and destination folders.
Runs on
Windows, macOS, Linux (NAS shares work too)
Tested
Parse-checked and dry-run against a folder of dummy shows and movies in PowerShell 7.4 on Linux, with -WhatIf, a real move, an in-place sort and a collision.

Part 1 of the thread File wrangling

Every media library starts out tidy and ends up as one enormous folder. Shows, movies, a stray .nfo, three copies of something with slightly different names. Scroll to find anything and your file manager takes a breath first.

I like my library as plain local MKV and MP4 files in folders I can read without any app in the way, so when I cleaned mine up I went with the simplest structure I could think of: flat bucket folders. One level of A through Z, a 0-9 for titles like 24, and a # for the oddballs. Inside each bucket, the show folders and movies sit exactly as they were. No nesting by genre, no year folders, nothing clever to maintain.

I did it with a handful of PowerShell scripts at the time. This is a fresh, cleaned-up version of that idea as one script: it handles leading articles, never overwrites anything, tells you exactly what it skipped, and has a -WhatIf you can actually trust.

Move-MediaToBucket.ps1Download
<#
.SYNOPSIS
    Moves movies and TV show folders into flat A-Z "bucket" folders.
.DESCRIPTION
    Looks at every top-level folder and video file in a source folder and moves it into
    a bucket folder under the destination, based on the first character of its name:
    A through Z, "0-9" for titles that start with a number, and "#" for everything else.
    Leading articles ("The", "A", "An") can be ignored so "The Wire" lands in W.
    Nothing is overwritten. If the target already exists, the item is skipped and reported.
    Supports -WhatIf and -Confirm, returns one object per item, and can log to CSV.
.PARAMETER SourcePath
    The messy folder to sort. Only its top-level items are moved; what's inside a show or
    movie folder is left alone.
.PARAMETER DestinationPath
    Where the bucket folders live. Can be the same as SourcePath to sort a folder in place.
.PARAMETER Scheme
    Letter gives one bucket per letter (A, B, C...). Range gives five wider buckets
    (A-E, F-J, K-O, P-T, U-Z) for smaller libraries.
.PARAMETER IgnoreArticle
    Skip a leading "The", "A" or "An" when picking the bucket. The name itself isn't changed.
.PARAMETER Extension
    Loose files with these extensions are treated as movies. Anything else is left in place.
.PARAMETER LogPath
    Optional CSV file for the results. Written even during -WhatIf, so you can review the plan.
.EXAMPLE
    .\Move-MediaToBucket.ps1 -SourcePath D:\Incoming\TV -DestinationPath D:\TV -IgnoreArticle -WhatIf
.EXAMPLE
    .\Move-MediaToBucket.ps1 -SourcePath D:\Movies -DestinationPath D:\Movies -LogPath .\movies.csv
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string]$SourcePath,

    [Parameter(Mandatory)]
    [string]$DestinationPath,

    [ValidateSet('Letter', 'Range')]
    [string]$Scheme = 'Letter',

    [switch]$IgnoreArticle,

    [string[]]$Extension = @('.mkv', '.mp4', '.m4v', '.avi'),

    [string]$LogPath
)

function Get-BucketName {
    param([string]$Name)

    $sortName = $Name.Trim()
    if ($IgnoreArticle) { $sortName = $sortName -replace '^(the|an|a)\s+', '' }
    $sortName = $sortName.TrimStart("'", '"', '(', '[', '.', '-', '_', ' ')
    if (-not $sortName) { return '#' }

    # "Élite" should land in E, so strip accents before looking at the first letter.
    $first = ([string]$sortName[0]).Normalize([Text.NormalizationForm]::FormD)[0]
    $first = [char]::ToUpperInvariant($first)

    if ($first -ge [char]'0' -and $first -le [char]'9') { return '0-9' }
    if ($first -lt [char]'A' -or $first -gt [char]'Z') { return '#' }
    if ($Scheme -eq 'Letter') { return [string]$first }

    foreach ($range in 'A-E', 'F-J', 'K-O', 'P-T', 'U-Z') {
        if ($first -ge $range[0] -and $first -le $range[2]) { return $range }
    }
}

$source      = (Resolve-Path -LiteralPath $SourcePath).ProviderPath.TrimEnd('\', '/')
$destination = [IO.Path]::GetFullPath($DestinationPath).TrimEnd('\', '/')
$bucketNames = @('0-9', '#') + $(if ($Scheme -eq 'Letter') { [char[]](65..90) | ForEach-Object { [string]$_ } } else { 'A-E', 'F-J', 'K-O', 'P-T', 'U-Z' })
$sortInPlace = $source -eq $destination

$items = Get-ChildItem -LiteralPath $source | Where-Object {
    if ($_.PSIsContainer) {
        # Don't try to sort the buckets themselves, or the destination if it sits inside the source.
        -not ($sortInPlace -and $bucketNames -contains $_.Name) -and $_.FullName.TrimEnd('\', '/') -ne $destination
    }
    else {
        $Extension -contains $_.Extension.ToLowerInvariant()
    }
}

$results = foreach ($item in $items) {
    $bucket = Get-BucketName -Name $item.Name
    $target = Join-Path (Join-Path $destination $bucket) $item.Name
    $result = [pscustomobject]@{
        Name        = $item.Name
        Type        = if ($item.PSIsContainer) { 'Folder' } else { 'File' }
        Bucket      = $bucket
        Status      = $null
        Source      = $item.FullName
        Destination = $target
        Message     = $null
    }

    if (Test-Path -LiteralPath $target) {
        $result.Status  = 'Skipped'
        $result.Message = 'Something with this name is already in the bucket.'
        Write-Warning "Skipped '$($item.Name)': already exists in $bucket"
    }
    elseif ($PSCmdlet.ShouldProcess($item.FullName, "Move to $bucket")) {
        try {
            $bucketPath = Join-Path $destination $bucket
            if (-not (Test-Path -LiteralPath $bucketPath)) {
                New-Item -Path $bucketPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
            }
            Move-Item -LiteralPath $item.FullName -Destination $target -ErrorAction Stop
            $result.Status = 'Moved'
            Write-Verbose "Moved '$($item.Name)' to $bucket"
        }
        catch {
            $result.Status  = 'Error'
            $result.Message = $_.Exception.Message
            Write-Warning "Failed on '$($item.Name)': $($_.Exception.Message)"
        }
    }
    else {
        $result.Status = if ($WhatIfPreference) { 'WhatIf' } else { 'Declined' }
    }

    $result
}

if ($LogPath -and $results) {
    $results | Export-Csv -LiteralPath $LogPath -NoTypeInformation -WhatIf:$false -Confirm:$false
}

$results

Parameters

ParameterTypeDefaultWhat it's for
-SourcePathstring—The messy folder. Only its top-level items move; the insides of show and movie folders are left alone.
-DestinationPathstring—Where the buckets go. Point it at the same folder as SourcePath to sort in place. Created if it doesn't exist.
-SchemestringLetterLetter makes one bucket per letter. Range makes five wider ones (A-E, F-J, K-O, P-T, U-Z) for smaller collections.
-IgnoreArticleswitch—Skip a leading The, A or An when choosing the bucket, so The Wire lands in W. Folder names aren't renamed.
-Extensionstring[].mkv, .mp4, .m4v, .aviLoose files with these extensions count as movies. Everything else (subtitles, text files, junk) stays put.
-LogPathstring—Optional CSV of every result. Written during -WhatIf too, so you can review the plan in a spreadsheet first.

Run it

See what would happen, and save the plan to a CSV.

.\Move-MediaToBucket.ps1 -SourcePath D:\Incoming\TV -DestinationPath D:\TV -IgnoreArticle -WhatIf -LogPath .\tv-plan.csv

Sort a movie folder in place.

.\Move-MediaToBucket.ps1 -SourcePath D:\Movies -DestinationPath D:\Movies -IgnoreArticle

Wider buckets for a small collection on a NAS.

.\Move-MediaToBucket.ps1 -SourcePath \\nas01\media\Docs -DestinationPath \\nas01\media\Docs -Scheme Range

After a real run, list only the things that need a human.

.\Move-MediaToBucket.ps1 -SourcePath D:\Incoming\TV -DestinationPath D:\TV -IgnoreArticle | Where-Object Status -ne 'Moved'

What you'll see

Example outputvalues are illustrative
WARNING: Skipped 'The Wire': already exists in W

Name                 Type   Bucket Status
----                 ----   ------ ------
[REC] (2007)         Folder R      Moved
24                   Folder 0-9    Moved
A Quiet Place (2018) Folder Q      Moved
Breaking Bad         Folder B      Moved
Élite                Folder E      Moved
The Wire             Folder W      Skipped
Alien (1979).mp4     File   A      Moved
Heat (1995).mkv      File   H      Moved

How it works

  1. Pick up the top level only. Every folder in the source is treated as one show or one movie, along with any loose video file with a matching extension. Whatever's inside those folders comes along untouched.
  2. Work out the bucket. It trims leading quotes and brackets (so [REC] goes to R), drops "The", "A" or "An" if you asked it to, strips accents so Élite lands in E, and then looks at the first character: a letter gets its own bucket, a digit goes to 0-9, anything else goes to #.
  3. Check before it touches anything. If the target already exists, the item is marked Skipped and you get a warning. Otherwise it creates the bucket if needed and moves the item, all behind ShouldProcess, so -WhatIf and -Confirm behave the way you'd expect.
  4. Return one object per item. Name, type, bucket, status, source, destination and any error message. Errors are caught per item, so one locked file doesn't stop the other few hundred.
  5. Log it if you want. The CSV is written with -WhatIf:$false, which means a dry run still produces a file you can open and sort before committing.

Sorting in place is safe to repeat. When the source and destination are the same folder, the script ignores the bucket folders themselves, so a second run only picks up whatever you've dropped in since.

Take it further

  • Make it your drop-folder routine. Point new downloads or rips at an incoming folder, then run this against it every so often with the library as the destination. Anything that collides stays behind for you to look at.
  • Change the rules for your own library. Get-BucketName is the only place the naming logic lives. Want a separate bucket for anything starting with "Star"? That's a two-line change there.
  • Keep the CSVs. A log of what moved where is handy the day you go looking for something and can't remember which bucket a weird title ended up in.

Things that'll trip you up

  • Collisions are skipped, not merged. If a folder with the same name is already in the bucket, the script leaves the new one where it is and says so. That's on purpose. Merging two copies of a show is a decision, not a file operation, so eyeball both and combine them yourself.
  • Hidden files are ignored. Get-ChildItem skips hidden and system items unless you add -Force. That keeps things like desktop.ini and .DS_Store out of your buckets, but it also means a hidden show folder won't move.
  • Your media server will notice. Plex, Jellyfin and friends usually cope with folders moving inside a library path, but some will treat moved items as new and lose watch history or custom artwork. Try a few titles first, and make sure the library points at the parent of the buckets, not at a single bucket.
  • Moving across drives is a copy. Within one drive a move is instant. Across drives or to a network share, Move-Item copies and then deletes, which takes as long as copying the data. Start with a small batch so you know what you're in for.
  • Only the first character counts. "The 100" with -IgnoreArticle goes to 0-9, and a title that starts with a non-Latin character ends up in #. If you'd rather file those somewhere else, move them by hand after; the script won't fight you on the next run.