Równoległe przetwarzanie plików – ForEach-Parallel

Taki oto postanowiłem poruszyć temat. To są moje początki z PowerShellem więc jeśli widzicie coś do poprawy to zapraszam do kontaktu. Do rzeczy…

Jak wiadomo ok 4h dziennie podróżuje autem. Czas ten wykorzystuje na słuchanie książek i muzyki. Problemem stała się wielkość plików które zapisuje na pendrive. Zawsze podczas rippowania CD z muzyką starałem się uzyskać maksymalną jakość dzwięku więc moja biblioteka zajmuje bardzo dużo miejsca. Ostatnio gdy zacząłem używać Spotify zacząłem dostrzegać, że nie potrzebuje już takiej jakości – po pierwsze dlatego że w aucie tego nie słychać, po drugie dlatego, że w związku z wielkością plików nie wiele mi się mieści na nośniku jaki mam w aucie. Wiadomo, można mieć wiele nośników ale po co…

I tak postanowiłem przetranskodować wszystkie swoje mp3 do mniejszego bitrate’u aby można było zabrać więcej muzyki ze sobą. Posiedziałem trochę i odsłuchując różne próbki doszedłem do wniosku że max co jestem w stanie docenić w aucie to 192kbps.

Oczywiście nie znalazłem takiego programu (darmowego) który pozwoli na przetranskodowanie całej bibioteki zachowując strukturę katalogów za jednym zamachem. Postanowiłem więc nauczyć się czegoś. Napisałem skrypt, który za pomocą kodeka Lame realizuje moje założenia.

 

#   Skrypt do transcodowania audio w konkretną jakość.
#   Skrypt działa rekursywnie, więc może jednym ciągiem przetranscodować Ci całą bibiotekę uwzględniając strukturę folderów.
#   Założenia: VBR, min Bitrate 32kbps, Max 192kbps
#   Jakość zmienisz za pomocą zmiennych MinBitrate i MaxBitrate
#   Jeśli chcesz zmieniać więcej parametrów mp3 musisz odpalić 'lame.exe --longhelp' i wydłubać to w funkcji TranscodeFile.
#   Używamy biblioteki Lame x64
#   JS. 2015-11-11


# Zmienne
$MinBitrate = '32'
$MaxBitrate = '192'
$QualityVBR = '5'

# W tej funckcji możesz ustawić więcej opcji dotyczącej jakości.
Function TranscodeFile ($SourceFiles, $DestonationFiles){
         ./Lame.exe "$SourceFiles" "$DestonationFiles" --mp3input -V $QualityVBR -b $MinBitrate -B $MaxBitrate
         }

function Read-SourceFolder([string]$Message, [string]$InitialDirectory)
{
    $app = New-Object -ComObject Shell.Application
    $global:sfolder = $app.BrowseForFolder(0, $Message, 0, $InitialDirectory)
    if ($global:sfolder) { return $global:sfolder.Self.Path } else { return '' }
}

function Read-DestFolder([string]$Message, [string]$InitialDirectory)
{
    $app = New-Object -ComObject Shell.Application
    $global:dfolder = $app.BrowseForFolder(0, $Message, 0, $InitialDirectory)
    if ($global:dfolder) { return $global:dfolder.Self.Path } else { return '' }
}

Write-Host "Czekam na wybranie folderu źródłowego"
Read-SourceFolder "Wybierz folder z plikami MP3 do przetworzenia" "d:\"
If ([string]::IsNullOrEmpty($sfolder)){
    (new-object -ComObject wscript.shell).Popup("Brak plików mp3 lub nie wybrano folderu",0,"Done",0x1)
    break
    }
Write-Host "Czekam na wybranie folderu docelowego"
Read-DestFolder "Wybierz Wybierz folder docelową" "d:\"
If ([string]::IsNullOrEmpty($dfolder)){
    (new-object -ComObject wscript.shell).Popup("Nie wybrałeś folderu docelowego",0,"Done",0x1)
    break
    }

Write-Host "To jest folder źródłowy: " $sfolder.Self.Path -BackgroundColor DarkGreen
Write-Host "To jest folder docelowy: " $dfolder.Self.Path -BackgroundColor DarkGreen

$Source = $sfolder.Self.Path
$Destination = $dfolder.Self.Path
$FIlesToTranscode = Get-ChildItem $Source -Filter *.mp3 -File -Recurse



$sw = [Diagnostics.Stopwatch]::StartNew()



ForEach ($item in $FIlesToTranscode){
        $Out = $item.FullName -replace [regex]::escape($Source),$Destination
        $OutDir = ($item.FullName -replace [regex]::escape($Source),$Destination) -replace [regex]::Escape($item.Name)
        If ((Test-Path $OutDir) -eq $false){
            New-Item -Path $OutDir -ItemType Directory
            }
        $In = $Item.FullName
        TranscodeFile "$In" "$Out"
        Write-Host "Przetworzono " $in "do" $out -BackgroundColor DarkGreen
        
        }
$sw.Stop()
Write-Host "Czas wykonania zadania to:" $sw.Elapsed
Pause

 

Niestety zadanie było realizowane baaaaaaardzo wolno. MP3 musi być 'liniowo’ więc nie używany jest więcej niż jeden wątek do przetworzenia jednego pliku, poza tym wiadomo że ForEach przetwarza $items po kolei jeden po drugim. Gdyby jednak skorzystać ze wszystkich dostępnych wątków procesora i odpalić równocześnie kilka procesów czas można by skrócić wielokrotnie. Pogooglałem i z kilku pomysłów postanowiłem użyć funkcji ForEach-Parallel którą znalazłem tutaj: https://powertoe.wordpress.com/2012/05/03/foreach-parallel/

Natknąłem się po drodzę na kilka problemów. Największym był problem przekazania zmiennych do scriptblock’a który znajduje się w tej funkcji. Pomógł guru. :). Rozwiązanie okazało się bardzo proste. Należało do funkcji przekazać nie tylko obiekty do przetworzenia, ale również pozostałe zmienne. Utworzyłem więc listę która zawierała wszystkie potrzebne zmienne.

$ItemsToTranscode = @($sources | ForEach-Object {
        $Out = $_.FullName -replace [regex]::escape($Source),$Destination
        $OutDir = ($_.FullName -replace [regex]::escape($Source),$Destination) -replace [regex]::Escape($_.Name)
        $In = $_.FullName
    return @{
        "source" = $In;
        "outDir" = $outDir;
        "destination" = $Out;
        "MinBitrate" = $MinBitrate
        "Maxbitrate" = $MaxBitRate
        "Quality" = $QualityVBR
        "Root" = $BaseDIr
    }
})

 

Całość skryptu wygląda tak.

#   Skrypt do transcodowania audio w konkretną jakość.
#   Skrypt działa rekursywnie, więc może jednym ciągiem przetranscodować Ci całą bibiotekę uwzględniając strukturę folderów.
#   Założenia: VBR, min Bitrate 32kbps, Max 192kbps
#   Jakość zmienisz za pomocą zmiennych MinBitrate i MaxBitrate
#   Jeśli chcesz zmieniać więcej parametrów mp3 musisz odpalić 'lame.exe --longhelp' i wydłubać to w skrypcie w miejscy gdzie używam lame.exe.
#   Używamy biblioteki Lame x64
#   JS. 2015-11-11
#
#   Zmienne:
#   $MinBitrate = minimalny bitrate
#   $MaxBitrate = maxymalny bitrate
#   $QualityVBR = jakość VBR
#   $Threads = maxymalna ilość procesów odpalanych jednocześnie.
#
# 



# Tu ustaw zmienne jeśli potrzebujesz
$MinBitrate = '32'
$MaxBitrate = '192'
$QualityVBR = '5'
$Threads = '8'

# ForEach-Parallel - https://powertoe.wordpress.com/2012/05/03/foreach-parallel/

function ForEach-Parallel {
    param(
        [Parameter(Mandatory=$true,position=0)]
        [System.Management.Automation.ScriptBlock] $ScriptBlock,
        [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
        [PSObject]$InputObject,
        [Parameter(Mandatory=$false)]
        [int]$MaxThreads=5
    )
    BEGIN {
        $iss = [system.management.automation.runspaces.initialsessionstate]::CreateDefault()
        $pool = [Runspacefactory]::CreateRunspacePool(1, $maxthreads, $iss, $host)
        $pool.open()
        $threads = @()
        $ScriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock("param(`$_)`r`n" + $Scriptblock.ToString())
    }
    PROCESS {
        $powershell = [powershell]::Create().addscript($scriptblock).addargument($InputObject)
        $powershell.runspacepool=$pool
        $threads+= @{
            instance = $powershell
            handle = $powershell.begininvoke()
        }
    }
    END {
        $notdone = $true
        while ($notdone) {
            $notdone = $false
            for ($i=0; $i -lt $threads.count; $i++) {
                $thread = $threads[$i]
                if ($thread) {
                    if ($thread.handle.iscompleted) {
                        $thread.instance.endinvoke($thread.handle)
                        $thread.instance.dispose()
                        $threads[$i] = $null
                    }
                    else {
                        $notdone = $true
                    }
                }
            }
        }
    }
}

#Funkcje wybierające lokalizacje docelowe.

function Read-SourceFolder([string]$Message, [string]$InitialDirectory)
{
    $app = New-Object -ComObject Shell.Application
    $global:sfolder = $app.BrowseForFolder(0, $Message, 0, $InitialDirectory)
    if ($global:sfolder) { return $global:sfolder.Self.Path } else { return '' }
}

function Read-DestFolder([string]$Message, [string]$InitialDirectory)
{
    $app = New-Object -ComObject Shell.Application
    $global:dfolder = $app.BrowseForFolder(0, $Message, 0, $InitialDirectory)
    if ($global:dfolder) { return $global:dfolder.Self.Path } else { return '' }
}



#Pobieranie folderu źródłowego i docelowego.

Write-Host "Czekam na wybranie folderu źródłowego"
Read-SourceFolder "Wybierz folder z plikami MP3 do przetworzenia" 
If ([string]::IsNullOrEmpty($sfolder)){
    (new-object -ComObject wscript.shell).Popup("Brak plików mp3 lub nie wybrano folderu",0,"Done",0x1)
    break
    }


Write-Host "Czekam na wybranie folderu docelowego"
Read-DestFolder "Wybierz  folder docelowy"
If ([string]::IsNullOrEmpty($dfolder)){
    (new-object -ComObject wscript.shell).Popup("Nie wybrałeś folderu docelowego",0,"Done",0x1)
    break
    }



Write-Host "To jest folder źródłowy: " $sfolder.Self.Path -BackgroundColor DarkGreen
Write-Host "To jest folder docelowy: " $dfolder.Self.Path -BackgroundColor DarkGreen

$Dir = ".\"
$BaseDIr = Resolve-Path $Dir
$Source = $sfolder.Self.Path
$Destination = $dfolder.Self.Path
$sources = GCI $Source -Filter *.mp3 -Recurse

 

$ItemsToTranscode = @($sources | ForEach-Object {
        $Out = $_.FullName -replace [regex]::escape($Source),$Destination
        $OutDir = ($_.FullName -replace [regex]::escape($Source),$Destination) -replace [regex]::Escape($_.Name)
        $In = $_.FullName
    return @{
        "source" = $In;
        "outDir" = $outDir;
        "destination" = $Out;
        "MinBitrate" = $MinBitrate
        "Maxbitrate" = $MaxBitRate
        "Quality" = $QualityVBR
        "Root" = $BaseDIr
    }
})



$sw = [Diagnostics.Stopwatch]::StartNew()



$ItemsToTranscode | ForEach-Parallel -MaxThreads $Threads {
            
            $S = $_.source
            $O = $_.OutDir
            $D = $_.destination
            $Min = $_.MinBitrate
            $Max = $_.MaxBitrate
            $Q = $_.Quality                  
            $R = $_.Root

            Push-Location $R
            If ((Test-Path $_.OutDir) -eq $false){
                Write-Host "Zakładam nowy folder $O" -BackgroundColor DarkMagenta
                New-Item -Path $O -ItemType Directory
                }
            Write-Host " " 
            Write-Host "Transcoduje MP3" -backgroundColor DarkGreen
            Write-Host "Źródło:    $S" -backgroundColor DarkGray
            Write-Host "Cel:       $D" -backgroundColor DarkCyan
            Write-Host " " 
             .\lame.exe "$S" "$D" --mp3input -V $Q -b $Min -B $Max
                         
            }
        

$sw.Stop()
Write-Host "Koniec - Czas wykonania zadania to:" $sw.Elapsed
Pause

Dodałem do skryptu jeszcze licznik czasu, może się komuś przydać jako rodzaj benchmarku.

Działanie wymaga aby w folderze ze skryptem była również biblioteka lame_enc.dll i lame.exe które można pobrać z http://lame.sourceforge.net/.