Code:
param (
[IO.DirectoryInfo] $root,
[switch] $nomissing,
[switch] $fixgaps,
[switch] $whatif
)
[Environment]::CurrentDirectory=(Get-Location -PSProvider FileSystem).ProviderPath
$metaflac = "C:\Tools\flac-1.2.1\bin\metaflac.exe"
$requiredTags = @(
"AccurateRipDiscID",
"AccurateRipResult",
"Album",
"AlbumArtist",
"Artist",
"CDDB Disc ID",
"CDGap",
"CDIndex",
"CDTOC",
"Composer",
"CRC",
"Date",
"DiscNumber",
"Encoded By",
"Encoder",
"Encoder Settings",
"Genre",
"Length",
"Organization",
"Profile",
"replaygain_album_gain",
"replaygain_album_peak",
"replaygain_track_gain",
"replaygain_track_peak",
"Source",
"Title",
"TotalDiscs",
"TotalTracks",
"TrackNumber",
"UPC"
)
$requiredClassicalTags = @(
"Period"
)
$optionalTags = @(
"Album Artist Sort",
"Artist Sort",
"Comment",
"Compilation",
"ComposerSort",
"Conductor",
"ConductorSort",
"Description",
"ISRC",
"Performer",
"Rating",
"Style"
)
$shouldBeIdenticalTags = @(
"Album",
"Album Artist Sort",
"AlbumArtist",
"CDDB Disc ID",
"CDTOC",
"Compilation",
"Date",
"DiscNumber",
"Encoded By",
"Encoder",
"Encoder Settings",
"Genre",
"Organization",
"Profile",
"replaygain_album_gain",
"replaygain_album_peak",
"Source",
"Style",
"TotalDiscs",
"TotalTracks",
"UPC"
)
$shouldBeDifferentTags = @(
"AccurateRipDiscID",
"CDGap",
"CDIndex",
"ISRC"
)
function Assert([scriptblock]$expr)
{
if (! (&$expr)) {
throw "Assertion Failure at $((get-pscallstack)[1].Location): $([string]$expr)"
}
}
Function ReadTags([IO.FileInfo]$path)
{
$tags = @{}
$lastKey = $null
&$metaflac --export-tags-to=- $path.FullName |
% {
$key, $value = $_ -split "="
if ($value -eq $null) {
$value = $key
$key = $lastKey
} else {
$lastkey = $key
}
if ($tags.ContainsKey($key)) {
if ($tags[$key] -is [array]) {
$tags[$key] += $value
} else {
$tags[$key] = $tags[$key], $value
}
} else {
$tags += @{ $key = $value }
}
}
return $tags
}
function FindAlbums([IO.DirectoryInfo]$root)
{
gci $root -recurse -filter *.flac |
% { $_.DirectoryName } |
Get-Unique
}
function ReadAllTracksTags([IO.DirectoryInfo]$album)
{
$allTags = @(@())
foreach ($track in (gci -literalpath $album -filter *.flac)) {
$tags = ReadTags $track
Assert {$tags.ContainsKey("DiscNumber")}
Assert {$tags.ContainsKey("TrackNumber")}
$discnum = [int]$tags.DiscNumber
$tracknum = [int]$tags.TrackNumber
if ($discnum -ge $allTags.count) {
$allTags += @(@($null)) * ($discnum - $allTags.count + 1)
$allTags[$discnum] = @(@{})
}
if ($tracknum -ge $allTags[$discnum].count) {
$allTags[$discnum] += @($null) * ($tracknum - $allTags[$discnum].count + 1)
} elseif ($allTags[$discnum][$tracknum] -ne $null) {
throw "Two track files found with disc/track *blooper*$discnum/$tracknum in album:`n $($album.FullName)"
}
$allTags[$discnum][$tracknum] = $tags
$allTags[$discnum][$tracknum].__TRACKFILE__ = $track
foreach ($key in $tags.Keys) {
if (!$allTags[$discnum][0].ContainsKey($key)) {
$allTags[$discnum][0].$key = $tags.$key
}
}
}
return $allTags
}
function CharacterizeTags($trackTags)
{
$commonTags = $trackTags[0].Clone()
for ($i = 1; $i -lt $trackTags.count; ++$i) {
if ($trackTags[$i] -eq $null) {continue}
$tempHash = $commonTags.Clone()
foreach ($key in $tempHash.Keys) {
if (!$trackTags[$i].ContainsKey($key)) {
$commonTags.Remove($key)
}
}
}
$identicalTags = $commonTags.Clone()
for ($i = 2; $i -lt $trackTags.count; ++$i) {
if ($trackTags[$i] -eq $null) {continue}
$tempHash = $identicalTags.Clone()
foreach ($key in $tempHash.Keys) {
if (@(Compare-Object $identicalTags.$key $trackTags[$i].$key -sync 0).length -ne 0) {
$identicalTags.Remove($key)
}
}
}
return $commonTags, $identicalTags
}
function FindMissingTags($trackTags)
{
$missingTags = New-Object object[]($trackTags.count)
$desiredTags = $requiredTags
if ($trackTags[1].Genre -eq "Classical") {
$desiredTags += $requiredClassicalTags
}
$commonMissingTags = @{}
$missingTags[0] = @()
foreach ($tag in $desiredTags) {
if (!$trackTags[0].ContainsKey($tag)) {
$missingTags[0] += $tag
$commonMissingTags[$tag] = 1
}
}
for ($i = 1; $i -lt $trackTags.count; ++$i) {
if ($trackTags[$i] -eq $null) {continue}
$missingTags[$i] = @()
foreach ($tag in $desiredTags) {
if (!$commonMissingTags.ContainsKey($tag) -and !$trackTags[$i].ContainsKey($tag)) {
$missingTags[$i] += $tag
}
}
}
return $missingTags
}
function FormatCommaSeparatedArray([object[]]$array)
{
$OFS = ", "
$text = [string]@($array)
Remove-Variable OFS
return $text
}
function FormatMissingTags($missingTags)
{
$text = $null
for ($i = 0; $i -lt $missingTags.count; ++$i) {
if ($missingTags[$i] -ne $null) {
if ($i -eq 0) {
$text += " All tracks: "
} else {
$text += " Track *blooper*$($i): "
}
$text += (FormatCommaSeparatedArray $missingTags[$i]) + "`n"
}
}
if ($text -ne $null) {
$text = " Missing Tags:`n" + $text
}
return $text
}
function CheckTagsReasonable($trackTags, $commonTags, $identicalTags)
{
$text = $null
$missingTracks = @()
for ($i = 1; $i -le $trackTags[0].TotalTracks; ++$i) {
if ($trackTags[$i] -eq $null) {
$missingTracks += $i
}
}
if ($missingTracks.count -ne 0) {
$text += " Tracks missing (long pathnames?): " + (FormatCommaSeparatedArray $missingTracks) + "`n"
}
$mismatchTags = @()
foreach ($tag in $shouldBeIdenticalTags) {
if ($trackTags[0].ContainsKey($tag) -and !$identicalTags.ContainsKey($tag)) {
$mismatchTags += $tag
}
}
if ($mismatchTags.count -ne 0) {
$text += " Tags not same across all tracks: " + (FormatCommaSeparatedArray $mismatchTags) + "`n"
}
foreach ($tag in $shouldBeDifferentTags) {
if ($trackTags[0].ContainsKey($tag)) {
$ok = $true
$prevValues = @{}
for ($i = 1; $i -lt $trackTags.count; ++$i) {
if ($trackTags[$i] -eq $null) {continue}
if ($trackTags[$i].ContainsKey($tag)) {
$value = $trackTags[$i].$tag
if ($prevValues.ContainsKey($value)) {
$ok = $false
$prevValues[$value] = @($prevValues[$value]) + $i
} else {
$prevValues[$value] = $i
}
}
}
if (!$ok) {
$text += " Tag '$tag' duplicated in multiple tracks:`n"
foreach ($value in $prevValues.Keys) {
if ($prevValues[$value].count -gt 1) {
$text += " '$value' in tracks " + (FormatCommaSeparatedArray $prevValues[$value]) + "`n"
}
}
}
}
}
if (!$identicalTags.ContainsKey("Artist")) {
for ($i = 1; $i -lt $trackTags.count; ++$i) {
if ($trackTags[$i] -eq $null) {continue}
if (@($trackTags[$i].Artist) -notcontains $trackTags[0].AlbumArtist) {
if ($trackTags[0].Genre -eq "Soundtrack") {
if ($trackTags[0].AlbumArtist -ne "Soundtrack") {
$text += " AlbumArtist should be 'Soundtrack', not '$($trackTags[0].AlbumArtist)'`n"
}
} else {
if ($trackTags[0].AlbumArtist -ne "Various Artists") {
$text += " AlbumArtist should be 'Various Artists', not '$($trackTags[0].AlbumArtist)'`n"
}
}
}
break
}
}
if ($trackTags[0].ContainsKey("Compilation")) {
if ($trackTags[0].Genre -eq "Classical") {
if ($identicalTags.ContainsKey("Composer")) {
$text += " For classical compilation, Composer should not be '$($identicalTags.Composer)' for all tracks`n"
}
} elseif ($trackTags[0].Genre -eq "Soundtrack") {
if ($trackTags[0].AlbumArtist -ne "Soundtrack") {
$text += " For soundtrack compilation, AlbumArtist should be 'Soundtrack', not '$($trackTags[0].AlbumArtist)'`n"
}
} else {
if ($trackTags[0].AlbumArtist -ne "Various Artists") {
$text += " For this compilation, AlbumArtist should be 'Various Artists', not '$($trackTags[0].AlbumArtist)'`n"
}
}
}
if ($trackTags[0].ContainsKey("Profile")) {
if ((($trackTags[0].Genre -eq "Classical") -and ($trackTags[0].Profile -ne "Classical")) `
-or (($trackTags[0].Genre -ne "Classical") -and ($trackTags[0].Profile -ne "(default)")))
{
$text += " Unexpected profile '$($trackTags[0].Profile)' for genre '$($trackTags[0].Genre)'`n"
}
}
for ($i = 1; $i -le $trackTags.count; ++$i) {
if ($trackTags[$i] -eq $null) {continue}
foreach ($tag in $trackTags[$i].keys) {
if ($tag -eq "Description") {continue}
if ($trackTags[$i].$tag -is [array]) {
$tagArray = $trackTags[$i].$tag
:label for ($j = 1; $j -lt $tagArray.count; ++$j) {
for ($k = 0; $k -lt $j; ++$k) {
if ($tagArray[$j] -eq $tagArray[$k]) {
$text += " Track $i has duplicate value in tag '$($tag)': " + (FormatCommaSeparatedArray $tagArray) + "`n"
break label
}
}
}
}
}
}
return $text
}
function InvokeOrWhatIf([string]$cmd)
{
if ($whatif) {
Write-Host $cmd
} else {
Invoke-Expression $cmd
}
}
function FixGaps($discTags, $identicalTags)
{
$needCDGap = !$discTags[0].ContainsKey("CDGap")
$needCDIndex = !$discTags[0].ContainsKey("CDIndex")
if (!$needCDGap -and !$needCDIndex) {
Write-Host "-fixgaps ignored, CDGap and CDIndex tags seen in at least some tracks"
return
}
Assert {$identicalTags.ContainsKey("CDTOC")}
Assert {$identicalTags.ContainsKey("TotalTracks")}
$cdtoc = ($identicalTags["CDTOC"] -split "\+" | % {[int]("0x" + $_)})
Assert {$cdtoc[0] -eq $identicalTags.TotalTracks}
for ($i = 1; $i -lt $cdtoc.count - 1; ++$i) {
$startLBA = $cdtoc[$i] - $cdtoc[1]
$endLBA = $cdtoc[$i+1] - $cdtoc[1]
$path = $discTags[$i].__TRACKFILE__.FullName
if ($needCDGap) {
InvokeOrWhatIf "&$metaflac `"$path`" `"--set-tag=CDGap=$startLBA`:$endLBA`:$endLBA`""
}
if ($needCDIndex) {
InvokeOrWhatIf "&$metaflac `"$path`" `"--set-tag=CDIndex=$startLBA`:$endLBA`:I1,$startLBA`""
}
}
}
$albums = FindAlbums $root
if ($albums.count -gt 1 -and $fixgaps) {
throw "Error: -fixgaps only allowed with single album"
}
foreach ($album in $albums) {
$allTags = ReadAllTracksTags $album
foreach ($discTags in $allTags[1..($allTags.count-1)]) {
if ($discTags -eq $null) {continue}
$commonTags, $identicalTags = CharacterizeTags $discTags
$disctext = ""
if ([int]$discTags[0].TotalDiscs -ne 1) {
$disctext = " (Disc $($discTags[0].DiscNumber))"
}
Write-Host "Checking '$($album)$($disctext)'"
$text = $null
if (!$nomissing) {
$missingTags = FindMissingTags $discTags
$text += FormatMissingTags $missingTags
}
$text += CheckTagsReasonable $discTags $commonTags $identicalTags
Write-Host $text
if ($fixgaps) {
FixGaps $discTags $identicalTags
}
}
}
I was using this script as an opportunity to learn Powershell, so no claims that the code is optimal. The script requires tweaking for someone else's use, at least replacing the location of metaflac.exe. You'd probably have to change the code to reflect your choices on things like naming schemes, as well. The script makes some assumptions about how compilations, multi-CD sets, and soundtracks are ripped. It would probably help to have my naming settings. For non-classical CDs (the default profile), that's: