# Xbox Screenshot Unterordner.ps1
# Sortiert Xbox Game Bar Captures in Unterordner nach Spiel.
# 1) Spielname aus Dateiname (mit Regex-Mapping)
# 2) wenn generisch/leer -> Fallback: aktiver Vordergrund-Prozess (für ALLE Spiele)
# Zusätzlich: PNG -> JPG konvertieren und PNG löschen.
# =============================
# === Konfiguration ============
# =============================
$CapturesPath = "F:\Aufnahmen\Screenshots"
$Extensions = @('.png', '.jpg', '.jpeg', '.bmp', '.mp4') # überwachte Formate
$Debug = $true # auf $false setzen, wenn keine Debug-Ausgaben gewünscht
$JpegQuality = 92 # 1..100 (höher = bessere Qualität/größere Datei)
# Dateiname -> Zielordner (Regex => Ordnername)
$GameNameMap = @{
'^(?i)stellar\s*blade' = 'Stellar Blade'
'^(?i)kingdom\s*come[\s_]*deliverance\s*ii\b' = 'Kingdom Come: Deliverance II'
# Beispiele:
# '^(?i)elden\s*ring' = 'ELDEN RING'
# '^(?i)forza\s*horizon\s*5' = 'Forza Horizon 5'
}
# Prozessname (ohne .exe) -> hübscher Zielordnername (optional)
$ProcessToGame = @{
'SB-Win64-Shipping' = 'Stellar Blade'
'StellarBlade' = 'Stellar Blade'
'KCD2-Win64-Shipping' = 'Kingdom Come: Deliverance II'
'KingdomComeDeliverance2' = 'Kingdom Come: Deliverance II'
}
# Prozesse, die NICHT als Spiel gelten
$IgnoreProcs = 'ApplicationFrameHost','XboxGameBar','GameBar','explorer','ShellExperienceHost','SearchApp','csrss','winlogon','sihost','StartMenuExperienceHost'
# =============================
# === Win32: Foreground-Prozess
# =============================
$code = @"
using System;
using System.Runtime.InteropServices;
public static class Win32Helper {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
}
"@
try { Add-Type -TypeDefinition $code -ErrorAction Stop } catch { }
function Get-ForegroundProcess {
$h = [Win32Helper]::GetForegroundWindow()
if ($h -eq [IntPtr]::Zero) { return $null }
[uint32]$fgPid = 0
[Win32Helper]::GetWindowThreadProcessId($h, [ref]$fgPid) | Out-Null
if ($fgPid -eq 0) { return $null }
try { return Get-Process -Id $fgPid -ErrorAction Stop } catch { return $null }
}
# =============================
# === JPG-Konvertierung =========
# =============================
# System.Drawing (GDI+) laden
try { Add-Type -AssemblyName System.Drawing -ErrorAction Stop } catch { }
function Convert-PngToJpg {
param([string]$PngFullPath, [int]$Quality = 92)
if (-not (Test-Path -LiteralPath $PngFullPath)) { return $false }
# Zielpfad (gleicher Name, .jpg)
$jpgPath = [IO.Path]::ChangeExtension($PngFullPath, ".jpg")
# Falls bereits vorhanden, Zähler anhängen
$i = 1
$base = [IO.Path]::Combine([IO.Path]::GetDirectoryName($jpgPath), [IO.Path]::GetFileNameWithoutExtension($jpgPath))
while (Test-Path -LiteralPath $jpgPath) {
$jpgPath = "{0} ({1}).jpg" -f $base, $i
$i++
}
try {
$img = [System.Drawing.Image]::FromFile($PngFullPath)
# JPEG-Encoder + Qualität setzen
$encoder = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
$encQuality = New-Object System.Drawing.Imaging.EncoderParameters(1)
$encQuality.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, [int]$Quality)
$img.Save($jpgPath, $encoder, $encQuality)
$img.Dispose()
# PNG löschen NACH erfolgreicher Speicherung
Remove-Item -LiteralPath $PngFullPath -Force
if ($Debug) { Write-Host "[Debug] PNG -> JPG: '$jpgPath' (Qualität $Quality) / PNG gelöscht." }
return $true
} catch {
Write-Warning "Fehler bei PNG->JPG: $($_.Exception.Message)"
return $false
}
}
# =============================
# === Hilfsfunktionen =========
# =============================
function Sanitize-ForPath {
param([string]$Text)
if (-not $Text) { return '_Unsortiert' }
$invalid = [IO.Path]::GetInvalidFileNameChars()
$safe = -join ($Text.ToCharArray() | ForEach-Object { if ($invalid -notcontains $_) { $_ } })
$safe = ($safe -replace '^\.+','').Trim()
if ([string]::IsNullOrWhiteSpace($safe)) { return '_Unsortiert' }
return $safe
}
function From-FileName {
param([string]$FileNameNoExt)
if ([string]::IsNullOrWhiteSpace($FileNameNoExt)) { return $null }
$name = $FileNameNoExt.Trim()
$name = $name -replace "[\u2010-\u2015]", '-' # Unicode Dashes -> '-'
$name = $name -replace '[\u2122\u00AE\u00A9\u200B-\u200D\uFEFF]', '' # ™ ® © & zero-width
$name = $name -replace '\s*\(\d+\)$', '' # (1) am Ende
# Datums-/Zeitmuster am Ende entfernen
$name = $name -replace '\s*\d{4}[-_.]\d{2}[-_.]\d{2}[\s\-_.]?\d{2}[-_:]\d{2}[-_:]\d{2}$', ''
$name = $name -replace '\s*\d{2}[-_.]\d{2}[-_.]\d{4}[\s\-_.]?\d{2}[-_:]\d{2}[-_:]\d{2}$', ''
# "<Spiel> - <Rest>" -> links nehmen
$split = $name -split '\s*-\s+'
$candidate = if ($split.Count -ge 2 -and $split[0].Trim()) { $split[0].Trim() } else { $name.Trim() }
# Unterstriche als Leerzeichen, Mehrfach-Leerzeichen normalisieren
$candidate = ($candidate -replace '[_]+',' ') -replace '\s{2,}',' '
$candidate = $candidate.Trim()
# generische Namen wie "Screenshot", "Aufnahme" filtern
if ($candidate -match '^(?i)(screenshot|bildschirmfoto|clip|aufnahme|aufzeichnung)\b') { return $null }
foreach ($pattern in $GameNameMap.Keys) {
if ($candidate -match $pattern) { return $GameNameMap[$pattern] }
}
return $candidate
}
function From-Foreground {
foreach ($delay in 250,300,400) {
Start-Sleep -Milliseconds $delay
$p = Get-ForegroundProcess
if (-not $p) { continue }
if ($IgnoreProcs -contains $p.Name) { continue }
if ($Debug) { Write-Host "[Debug] Foreground-Process: $($p.Name)" }
if ($ProcessToGame.ContainsKey($p.Name)) {
return $ProcessToGame[$p.Name]
} else {
# Fallback: Fenstertitel oder Prozessname
try {
$title = (Get-Process -Id $p.Id | Select-Object -ExpandProperty MainWindowTitle)
if ($title) { return $title }
} catch { }
return $p.Name
}
}
return $null
}
function Resolve-GameName {
param([string]$FileNameNoExt)
# 1) aus Dateiname
$name = From-FileName -FileNameNoExt $FileNameNoExt
# 2) generisch/leer? -> Prozess-Fallback (für ALLE Spiele)
if (-not $name) { $name = From-Foreground }
return Sanitize-ForPath $name
}
function Wait-UntilFileIsReady {
param([string]$Path, [int]$TimeoutSec = 60)
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $TimeoutSec) {
try {
$fs = [System.IO.File]::Open($Path, 'Open', 'ReadWrite', 'None')
$fs.Close()
return $true
} catch { Start-Sleep -Milliseconds 250 }
}
return $false
}
function Move-Capture {
param([string]$FullPath)
if (-not (Test-Path -LiteralPath $FullPath)) { return }
$ext = [IO.Path]::GetExtension($FullPath).ToLowerInvariant()
if ($Extensions -notcontains $ext) { return }
if (-not (Wait-UntilFileIsReady -Path $FullPath)) {
Write-Warning "Datei ist gesperrt/noch in Benutzung? Übersprungen: $FullPath"
return
}
$nameNoExt = [IO.Path]::GetFileNameWithoutExtension($FullPath)
$game = Resolve-GameName -FileNameNoExt $nameNoExt
if ($Debug) { Write-Host "[Debug] '$nameNoExt' -> Ordner: '$game'" }
$targetDir = Join-Path $CapturesPath $game
if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir | Out-Null }
$dest = Join-Path $targetDir ([IO.Path]::GetFileName($FullPath))
$i = 1; $base = [IO.Path]::GetFileNameWithoutExtension($dest)
while (Test-Path -LiteralPath $dest) {
$dest = Join-Path $targetDir ("{0} ({1}){2}" -f $base, $i, $ext); $i++
}
Move-Item -LiteralPath $FullPath -Destination $dest
Write-Host "Verschoben: `"$FullPath`" -> `"$dest`""
# === PNG -> JPG konvertieren (und PNG löschen) ===
if ($ext -eq '.png') {
[void](Convert-PngToJpg -PngFullPath $dest -Quality $JpegQuality)
}
}
# =============================
# === Start / Bootstrap =======
# =============================
if (-not (Test-Path -LiteralPath $CapturesPath)) {
Write-Error "Captures-Ordner nicht gefunden: $CapturesPath"
Read-Host "Taste drücken zum Schließen"
exit 1
}
Write-Host "Überwache: $CapturesPath"
# Bestehende Dateien beim Start einmal sortieren
Get-ChildItem -LiteralPath $CapturesPath -File |
Where-Object { $Extensions -contains $_.Extension.ToLowerInvariant() } |
ForEach-Object { Move-Capture -FullPath $_.FullName }
# Live-Überwachung
$fsw = New-Object System.IO.FileSystemWatcher $CapturesPath, '*.*'
$fsw.IncludeSubdirectories = $false
$fsw.EnableRaisingEvents = $true
$action = {
param($s,$e)
try { Move-Capture -FullPath $e.FullPath } catch { Write-Warning "Fehler: $($_.Exception.Message)" }
}
Register-ObjectEvent -InputObject $fsw -EventName Created -SourceIdentifier 'CapturesCreated' -Action $action | Out-Null
Register-ObjectEvent -InputObject $fsw -EventName Renamed -SourceIdentifier 'CapturesRenamed' -Action $action | Out-Null
Write-Host "Läuft. Fenster offen lassen (Strg+C zum Beenden)."
while ($true) { Start-Sleep -Seconds 1 }