<# .SYNOPSIS Library Check: a plain-English check of a Jellyfin, Emby or Plex setup on this Windows PC. .DESCRIPTION Answers the questions beginners get stuck on, in this order: 1. Is a media server running on this PC, and which one? 2. What address do I type on my TV or phone? 3. Will Windows let my TV in? (network type, Windows Firewall, antivirus firewalls) 4. Will this PC fall asleep during a film? 5. Are my films named so the server finds the right film, and will they play? It only looks. The one thing it can change is a Windows Firewall rule for your server, and only if you run it as administrator with -OfferFirewallFix and answer yes. It talks to nothing outside this PC. Part of the ALT VIDEO movie library guide. .PARAMETER Folder Your Movies folder, for example C:\Media\Movies. If you leave it out, it asks. .PARAMETER OfferFirewallFix If Windows Firewall is likely blocking other devices, offer to add an inbound rule for the server program on private networks. Needs an administrator PowerShell. Asks before changing anything. .PARAMETER NoPause Do not wait for Enter at the end (for scripted runs). .EXAMPLE powershell -ExecutionPolicy Bypass -File .\LibraryCheck.ps1 -Folder "C:\Media\Movies" #> [CmdletBinding()] param( [string]$Folder = "", [switch]$OfferFirewallFix, [switch]$NoPause ) $ErrorActionPreference = "Stop" $script:Report = New-Object System.Collections.Generic.List[string] $script:Fixes = 0 function Say([string]$kind, [string]$text, [string]$todo = "") { $colour = @{ "OK" = "Green"; "FIX" = "Yellow"; "NOTE" = "Cyan"; "STOP" = "Red" }[$kind] Write-Host (" [{0}] " -f $kind) -ForegroundColor $colour -NoNewline Write-Host $text $script:Report.Add(("[{0}] {1}" -f $kind, $text)) if ($todo) { Write-Host (" What to do: " + $todo) -ForegroundColor Gray $script:Report.Add(" What to do: " + $todo) } if ($kind -eq "FIX" -or $kind -eq "STOP") { $script:Fixes++ } } function Heading([string]$text) { Write-Host "" Write-Host $text.ToUpper() -ForegroundColor White $script:Report.Add(""); $script:Report.Add($text.ToUpper()) } function Get-Json([string]$url) { try { return Invoke-RestMethod -Uri $url -TimeoutSec 4 -UseBasicParsing } catch { return $null } } function Finish { $out = Join-Path ([Environment]::GetFolderPath("MyDocuments")) "Library Check.txt" try { $script:Report | Set-Content -LiteralPath $out -Encoding UTF8; Write-Host ""; Write-Host (" Saved this report to " + $out) -ForegroundColor Gray } catch { } if (-not $NoPause -and $Host.Name -eq "ConsoleHost") { Write-Host ""; [void](Read-Host "Press Enter to close") } } Write-Host "" Write-Host "LIBRARY CHECK / ALT VIDEO movie library guide" -ForegroundColor Yellow Write-Host "It only looks; nothing changes unless you say yes." -ForegroundColor Gray $script:Report.Add("LIBRARY CHECK / " + (Get-Date -Format "yyyy-MM-dd HH:mm")) if (-not $Folder -and -not $NoPause -and $Host.Name -eq "ConsoleHost") { Write-Host "" Write-Host "Paste the path of your Movies folder (copy it from File Explorer's address bar)," -ForegroundColor Gray $Folder = Read-Host "or press Enter to skip the film checks" } # a pasted path may carry quotes, and "D:\" arrives as D:" from the command line $Folder = $Folder.Trim().Trim('"').Trim() if ($Folder -match '^[A-Za-z]:$') { $Folder += "\" } # ---------------------------------------------------------------- 1. the server Heading "1. Your server" $servers = @() $info = Get-Json "http://localhost:8096/System/Info/Public" if ($info -and "$($info.ProductName)" -match "Jellyfin") { $servers += [pscustomobject]@{ Name = "Jellyfin"; Port = 8096; Exe = "jellyfin.exe"; Version = $info.Version; ServerName = $info.ServerName } } else { $emby = Get-Json "http://localhost:8096/emby/System/Info/Public" if (-not $emby -and $info) { $emby = $info } if ($emby) { $servers += [pscustomobject]@{ Name = "Emby"; Port = 8096; Exe = "EmbyServer.exe"; Version = $emby.Version; ServerName = $emby.ServerName } } } try { $plex = Invoke-WebRequest -Uri "http://localhost:32400/identity" -TimeoutSec 4 -UseBasicParsing $xml = [xml]$plex.Content $servers += [pscustomobject]@{ Name = "Plex"; Port = 32400; Exe = "Plex Media Server.exe"; Version = $xml.MediaContainer.version; ServerName = "" } } catch { } if ($servers.Count -eq 0) { $procs = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -match "^(jellyfin|EmbyServer|Plex Media Server)$" }) if ($procs.Count -gt 0) { Say "FIX" ("{0} is running, but its web page does not answer on this PC." -f $procs[0].ProcessName) "Wait a minute after starting it, then run this check again. If it still fails, quit the server from its icon in the notification area and start it again." } else { Say "STOP" "No media server is running on this PC." "Start Jellyfin, Emby or Plex (look for its icon in the notification area or the Start menu), then run this check again." } } else { foreach ($s in $servers) { $label = "{0} {1}" -f $s.Name, $s.Version if ($s.ServerName) { $label += (' named "{0}"' -f $s.ServerName) } Say "OK" ("{0} is running and its web page answers at http://localhost:{1}." -f $label, $s.Port) } } $both = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -match "^(jellyfin|EmbyServer)$" }) if ($both.Count -gt 1) { $holder = "" try { $holder = (Get-Process -Id (Get-NetTCPConnection -LocalPort 8096 -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess).ProcessName } catch { } Say "FIX" ("Jellyfin and Emby are both running. Only one can use port 8096{0}." -f $(if ($holder) { "; right now $holder has it" } else { "" })) "Quit the one you are not using from its icon in the notification area." } # ---------------------------------------------------------------- 2. the address for the TV Heading "2. The address to type on your TV or phone" # real network cards only: virtual adapters (WSL, Hyper-V, VPNs) have addresses a TV cannot reach $physical = @(Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" } | ForEach-Object { $_.ifIndex }) $addresses = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -match "^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" -and $_.AddressState -eq "Preferred" -and ($physical.Count -eq 0 -or $physical -contains $_.InterfaceIndex) }) if ($addresses.Count -eq 0) { Say "FIX" "This PC has no home-network address right now." "Connect it to your home Wi-Fi or router, then run this check again." } elseif ($servers.Count -eq 0) { Say "NOTE" ("This PC's home-network address is {0}. Once your server runs, the address to type on your TV is http://{0}:8096 for Jellyfin or Emby, or http://{0}:32400/web for Plex." -f $addresses[0].IPAddress) } else { foreach ($a in $addresses) { $url = "http://{0}:{1}" -f $a.IPAddress, $servers[0].Port if ($servers[0].Name -eq "Plex") { $url += "/web" } Say "OK" ("On another device on the same network, open {0} (network: {1})" -f $url, $a.InterfaceAlias) } Say "NOTE" "Type the numbers, not 'localhost'. localhost always means the device you are typing on." "Test with your phone's web browser first, with the phone on your home Wi-Fi, not mobile data or a guest network. If the page opens there, your TV can reach the server too." } # ---------------------------------------------------------------- 3. will Windows let the TV in? Heading "3. Will Windows let other devices in?" $profiles = @(Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $physical.Count -eq 0 -or $physical -contains $_.InterfaceIndex }) foreach ($p in $profiles) { if ("$($p.NetworkCategory)" -eq "Public") { Say "FIX" ('Windows treats "{0}" as a Public network, so it blocks devices that try to connect to this PC.' -f $p.Name) ('Open Settings > Network & internet > {0} > Properties and choose Private network. Only do this on your own home network.' -f $p.InterfaceAlias) } else { Say "OK" ('"{0}" is a {1} network.' -f $p.Name, $p.NetworkCategory) } } function RuleApplies($rule) { "$($rule.Enabled)" -eq "True" -and "$($rule.Direction)" -eq "Inbound" -and ("$($rule.Profile)" -match "Private|Any") } $admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if ($servers.Count -gt 0) { Write-Host " Checking Windows Firewall. This can take a minute." -ForegroundColor Gray } foreach ($s in $servers) { $allowed = $false; $blocked = $false $exePattern = "\\" + [regex]::Escape($s.Exe) + "$" try { foreach ($f in @(Get-NetFirewallApplicationFilter -ErrorAction Stop | Where-Object { "$($_.Program)" -match $exePattern })) { $rule = $f | Get-NetFirewallRule -ErrorAction SilentlyContinue if ($rule -and (RuleApplies $rule)) { if ("$($rule.Action)" -eq "Block") { $blocked = $true } else { $allowed = $true } } } foreach ($f in @(Get-NetFirewallPortFilter -ErrorAction Stop | Where-Object { "$($_.LocalPort)" -eq "$($s.Port)" })) { $rule = $f | Get-NetFirewallRule -ErrorAction SilentlyContinue if ($rule -and (RuleApplies $rule)) { if ("$($rule.Action)" -eq "Block") { $blocked = $true } else { $allowed = $true } } } } catch { Say "NOTE" "Could not read the Windows Firewall rules from this window." "Run PowerShell as administrator for this part, or check Windows Security > Firewall & network protection > Allow an app through firewall." continue } $fwOn = @(Get-NetFirewallProfile -Profile Private -ErrorAction SilentlyContinue | Where-Object { "$($_.Enabled)" -eq "True" }).Count -gt 0 if ($blocked -and $fwOn) { # a Block rule beats any Allow rule; Windows adds one when someone clicks Cancel on its firewall prompt Say "FIX" ("A Windows Firewall rule blocks {0}. Block rules win over allow rules, so your TV cannot reach it." -f $s.Name) ("Open Windows Security > Firewall & network protection > Advanced settings > Inbound Rules. Find the rules for {0} with a red no-entry sign, right-click each and choose Disable Rule. Then run this check again." -f $s.Exe) } elseif ($allowed -or -not $fwOn) { Say "OK" ("Windows Firewall lets other devices reach {0} on private networks." -f $s.Name) } else { Say "FIX" ("Windows Firewall has no rule letting other devices reach {0}. Your TV will probably not find it." -f $s.Name) ("Open Windows Security > Firewall & network protection > Allow an app through firewall, choose Change settings, and tick {0} under Private. Or run this check as administrator with -OfferFirewallFix." -f $s.Name) if ($OfferFirewallFix -and -not $admin) { Say "NOTE" "-OfferFirewallFix needs an administrator PowerShell, so nothing was changed." "Right-click PowerShell, choose Run as administrator, and run this check again." } elseif ($OfferFirewallFix) { $proc = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -eq [IO.Path]::GetFileNameWithoutExtension($s.Exe) } | Select-Object -First 1 $answer = Read-Host (" Let other devices on private networks reach {0}? (y/N)" -f $s.Name) if ($answer -match "^(y|yes)$") { # a rule for the program covers every port it uses, including the one TV apps use to find servers if ($proc -and $proc.Path) { New-NetFirewallRule -DisplayName ("{0} (Library Check)" -f $s.Name) -Direction Inbound -Action Allow -Program $proc.Path -Profile Private | Out-Null } else { New-NetFirewallRule -DisplayName ("{0} (Library Check)" -f $s.Name) -Direction Inbound -Action Allow -Protocol TCP -LocalPort $s.Port -Profile Private | Out-Null } Say "OK" ("Added a Windows Firewall rule for {0} on private networks." -f $s.Name) } } } } try { $serverName = "your server" if ($servers.Count -gt 0) { $serverName = $servers[0].Name } foreach ($o in @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName FirewallProduct -ErrorAction Stop)) { Say "NOTE" ("{0} has its own firewall, which can block your TV even when Windows allows it." -f $o.displayName) ("If your TV still cannot connect, allow {0} in {1}'s settings, or mark your home network as trusted there." -f $serverName, $o.displayName) } } catch { } # ---------------------------------------------------------------- 4. sleep Heading "4. Will this PC fall asleep during a film?" try { $q = (powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE) -join "`n" $vals = @([regex]::Matches($q, "0x[0-9a-fA-F]{8}") | ForEach-Object { [Convert]::ToInt32($_.Value, 16) }) if ($vals.Count -ge 2) { $ac = $vals[$vals.Count - 2] if ($ac -eq 0) { Say "OK" "When plugged in, this PC never goes to sleep on its own." } elseif ($ac -lt 4 * 3600) { Say "FIX" ("When plugged in, this PC sleeps after {0} minutes without keyboard or mouse use. A sleeping PC disappears from your TV, and a film playing from it stops." -f [int]($ac / 60)) "Open Settings > System > Power (called Power & battery on laptops) and set the sleep time when plugged in to Never, or to a few hours." } else { Say "OK" ("When plugged in, this PC sleeps after {0} hours." -f [math]::Round($ac / 3600, 1)) } } else { Say "NOTE" "Could not read the sleep setting." } } catch { Say "NOTE" "Could not read the sleep setting." } # ---------------------------------------------------------------- 5. the films Heading "5. Your films" if (-not $Folder) { Say "NOTE" "No folder given, so the film checks were skipped." 'Run it again with -Folder "C:\Media\Movies" (your own Movies folder).' } elseif (-not (Test-Path -LiteralPath $Folder -PathType Container)) { Say "STOP" ("The folder {0} does not exist or cannot be opened." -f $Folder) "Check the path. Copy it from File Explorer's address bar." } else { $Folder = (Get-Item -LiteralPath $Folder).FullName.TrimEnd("\") $ffprobe = $null foreach ($c in @("$env:ProgramFiles\Jellyfin\Server\ffprobe.exe", "$env:ProgramFiles\Jellyfin\Server\jellyfin-ffmpeg\ffprobe.exe", "$env:APPDATA\Emby-Server\system\ffprobe.exe", "$env:ProgramFiles\Emby-Server\system\ffprobe.exe")) { if (Test-Path -LiteralPath $c) { $ffprobe = $c; break } } if (-not $ffprobe) { $cmd = Get-Command ffprobe -ErrorAction SilentlyContinue; if ($cmd) { $ffprobe = $cmd.Source } } if ($env:LIBRARY_CHECK_FFPROBE) { $ffprobe = $env:LIBRARY_CHECK_FFPROBE } $videoExt = @(".mp4", ".m4v", ".mkv", ".avi", ".mov", ".wmv", ".ts", ".m2ts", ".webm", ".mpg", ".mpeg") $extrasDir = '\\(trailers|extras|featurettes|behind the scenes|deleted scenes|interviews|scenes|shorts|samples?|other)\\' $files = @(Get-ChildItem -LiteralPath $Folder -Recurse -File -ErrorAction SilentlyContinue | Where-Object { ($videoExt -contains $_.Extension.ToLower() -or ($_.Extension -eq "" -and $_.Length -gt 1MB) -or ($_.Length -gt 100MB -and $_.Extension -notmatch "^\.(iso|zip|rar|7z|exe|msi|img)$")) -and ($_.FullName.Substring($Folder.Length) -notmatch $extrasDir) -and ($_.BaseName -notmatch "-(trailer|sample|featurette|behindthescenes|deleted|interview|scene|short|other)$") }) if ($files.Count -eq 0) { Say "STOP" ("No video files found in {0}." -f $Folder) "Check that you chose the folder that holds your film folders." } foreach ($f in $files) { $rel = $f.FullName.Substring($Folder.Length).TrimStart("\") Write-Host "" Write-Host (" " + $rel) -ForegroundColor White $script:Report.Add(""); $script:Report.Add(" " + $rel) # online-only cloud files (OneDrive and similar) cannot be read by the server $attr = [int]$f.Attributes if (($attr -band 0x1000) -or ($attr -band 0x400000)) { Say "FIX" "This file is stored online only. The server cannot read it." "Right-click it in File Explorer and choose 'Always keep on this device', or keep your films outside OneDrive." continue } # what the file really is, from its first bytes $kind = "unknown" try { $fs = [System.IO.File]::OpenRead($f.FullName) $buf = New-Object byte[] 192; $n = $fs.Read($buf, 0, 192); $fs.Close() $hex = ($buf[0..3] | ForEach-Object { $_.ToString("X2") }) -join "" if ($hex -eq "1A45DFA3") { $kind = "mkv" } elseif ($n -ge 8 -and [System.Text.Encoding]::ASCII.GetString($buf, 4, 4) -eq "ftyp") { $kind = "mp4" } elseif ($hex -eq "52494646") { $kind = "avi" } elseif ($hex -eq "3026B275") { $kind = "wmv" } elseif ($n -ge 189 -and $buf[0] -eq 0x47 -and $buf[188] -eq 0x47) { $kind = "ts" } } catch { Say "FIX" "Windows would not let this check open the file." "Check that the file is not open in another program and that your account can read the folder."; continue } $ext = $f.Extension.ToLower().TrimStart(".") $article = { param($k) if ($k -match "^(mkv|mp4|avi)$") { "an " + $k.ToUpper() + " file" } elseif ($k -eq "unknown") { "a video file" } else { "a " + $k.ToUpper() + " file" } } if ($ext -eq "") { Say "FIX" ("This file has no extension, so the server skips it. It is really {0}." -f (& $article $kind)) ('Rename it to end in .{0}. In File Explorer, turn on View > Show > File name extensions first.' -f $(if ($kind -eq "unknown") { "mp4 or .mkv" } else { $kind })) } elseif ($kind -ne "unknown" -and -not (($kind -eq "mp4" -and $ext -match "^(mp4|m4v|mov)$") -or ($kind -eq "mkv" -and $ext -match "^(mkv|webm)$") -or ($kind -eq $ext) -or ($kind -eq "ts" -and $ext -match "^(ts|m2ts)$"))) { Say "FIX" (".{0} is on the name, but the file is really {1}. Renaming does not convert a film." -f $ext, (& $article $kind)) ("Rename it back to .{0}." -f $kind) } # the name: Title (Year), in a folder of the same name $base = if ($ext -eq "") { $f.Name } else { [IO.Path]::GetFileNameWithoutExtension($f.Name) } $parent = Split-Path -Leaf $f.DirectoryName if ($base -notmatch "\((19|20)\d\d\)") { # suggest a name: a download-style name such as The.Film.1999.1080p becomes The Film (1999) $words = $base -replace "[._]", " " if ($words -match "^(.+?)\s*[\[(]?((19|20)\d\d)[\])]?(\s|$)" -and $Matches[1].Trim()) { $suggest = ("{0} ({1})" -f $Matches[1].Trim(), $Matches[2]) } else { $suggest = ("{0} (YEAR)" -f (($words -replace "\s+(1080p|720p|2160p|4k|bluray|web-?dl|webrip|hdtv|x26[45]|hevc).*$", "").Trim())) } Say "FIX" "The name has no year in brackets, so the server may match a different film with the same title." ('Rename the folder and the file to "{0}", using the year the film came out.' -f $suggest) } elseif ($f.DirectoryName.TrimEnd("\") -eq $Folder) { Say "FIX" "This film is loose in the Movies folder, not in its own folder." ('Make a folder called "{0}" and move the file into it.' -f $base) } elseif ($parent -ne $base -and $parent -notmatch [regex]::Escape(($base -replace " - .*$", ""))) { Say "NOTE" ('The folder "{0}" and the file "{1}" have different names.' -f $parent, $base) "Give them the same title and year so the match is clear." } else { Say "OK" "Named with its title and year, in its own folder." } # will it play? if (-not $ffprobe) { continue } $json = $null $saved = $ErrorActionPreference; $ErrorActionPreference = "Continue" try { $raw = & $ffprobe -v error -show_entries "stream=codec_type,codec_name,profile,pix_fmt:format=format_name,duration" -of json -- $f.FullName 2>$null | Out-String if ($LASTEXITCODE -eq 0 -and $raw.Trim()) { $json = $raw | ConvertFrom-Json } } catch { $json = $null } $ErrorActionPreference = $saved if (-not $json) { Say "FIX" "The server's tools could not read this file. It may be damaged or incomplete." "Play it on this PC. If it fails here too, copy it again from the original."; continue } $v = @($json.streams | Where-Object { $_.codec_type -eq "video" }) | Select-Object -First 1 $audio = @($json.streams | Where-Object { $_.codec_type -eq "audio" }) $subs = @($json.streams | Where-Object { $_.codec_type -eq "subtitle" }) if (-not $v) { Say "FIX" "No video track found." "Check that this is the film file and not a sample, trailer or audio file."; continue } $names = @{ "h264" = "H.264"; "hevc" = "HEVC"; "mpeg2video" = "MPEG-2"; "mpeg4" = "MPEG-4"; "av1" = "AV1"; "vp9" = "VP9"; "aac" = "AAC"; "mp3" = "MP3"; "ac3" = "Dolby Digital (AC3)"; "eac3" = "Dolby Digital Plus"; "dts" = "DTS"; "truehd" = "Dolby TrueHD"; "flac" = "FLAC"; "opus" = "Opus" } $vname = if ($names.ContainsKey($v.codec_name)) { $names[$v.codec_name] } else { "$($v.codec_name)".ToUpper() } $tenbit = ("$($v.pix_fmt)" -match "10") -or ("$($v.profile)" -match "10") if ($audio.Count -eq 0) { $summary = "{0}{1} video with no sound track" -f $vname, $(if ($tenbit) { " 10-bit" } else { "" }) $a = "none" } else { $a = $audio[0].codec_name $aname = if ($names.ContainsKey($a)) { $names[$a] } else { "$a".ToUpper() } $summary = "{0}{1} video, {2} sound" -f $vname, $(if ($tenbit) { " 10-bit" } else { "" }), $aname } if ($v.codec_name -eq "h264" -and -not $tenbit -and ($a -match "^(aac|mp3)$" -or $a -eq "none")) { Say "OK" ("{0}: plays almost everywhere, including web browsers, without converting." -f $summary) } elseif ($v.codec_name -eq "h264" -and -not $tenbit) { Say "NOTE" ("{0}: the picture plays almost everywhere; web browsers and some TVs need the sound converted, which the server does as it plays." -f $summary) "This is usually fine. If the sound is missing or the film stutters, test on your TV's app rather than a browser." } else { Say "NOTE" ("{0}: many TVs play this directly, but web browsers and older devices need the server to convert it as it plays." -f $summary) "Converting takes a lot of computing power. If it stutters, try the TV's own app, or convert the file once to H.264 video with AAC sound." } if (@($subs | Where-Object { $_.codec_name -match "pgs|dvd" }).Count -gt 0) { Say "NOTE" "It has picture-based subtitles (from a disc). Showing them makes the server redraw every frame." "If it stutters with subtitles on, use text subtitles (.srt) instead." } } if (-not $ffprobe) { $plexOnly = ($servers.Count -gt 0) -and (@($servers | Where-Object { $_.Name -ne "Plex" }).Count -eq 0) if ($plexOnly) { Say "NOTE" "The format check was skipped: it uses a tool called ffprobe, which Plex does not include. The other checks above are complete." } else { Say "NOTE" "The format check was skipped: it uses the ffprobe tool that Jellyfin and Emby install, and none was found. The other checks above are complete." } } } # ---------------------------------------------------------------- summary Heading "Summary" if ($script:Fixes -eq 0) { Say "OK" "Nothing to fix. Test on your phone's web browser, then your TV." } else { Write-Host (" {0} thing(s) to fix above, in order. Fix the first one, then run this check again." -f $script:Fixes) -ForegroundColor Yellow; $script:Report.Add((" {0} thing(s) to fix." -f $script:Fixes)) } Finish