# Mully's Bar karaoke night: one-time setup for Windows 10/11. # # irm https://www.adamscampustoolbox.org.uk/guides/mullys-karaoke/setup-win.ps1 | iex # # Installs Node.js LTS, yt-dlp, ffmpeg and Open Video Downloader (via winget) # and Karaoke Eternal (via npm), creates a KaraokeSongs folder on the Desktop, # and puts three shortcuts on the Desktop: # # Mully's Karaoke starts the server, opens the browser # Open Video Downloader paste a YouTube link, download it # Song Search type a song name, grabs the top YouTube result # # Open Video Downloader (github.com/jely2002/youtube-dl-gui) is preset to save # MP4s into KaraokeSongs. It only takes links, so Song Search covers "I only # know the song name". # # Our two shortcuts are .lnk files with their own icons. Everything they run # is kept in %LOCALAPPDATA%\MullysKaraoke: thin .bat wrappers around # PowerShell scripts, so double-clicking works (a .ps1 opens in Notepad) and # no command line has to survive cmd/VBScript quoting. # # Safe to run again: anything already installed is left alone and the # shortcuts are rewritten (shortcuts from older versions of this script are # replaced). # # Guide: https://www.adamscampustoolbox.org.uk/committee-tips/mullys-karaoke # # This file is deliberately plain ASCII: Windows PowerShell 5.1 can mis-decode # anything else when it arrives through irm. Write-Host "Setting up Mully's Bar karaoke..." -ForegroundColor Green $desktop = [Environment]::GetFolderPath('Desktop') # follows OneDrive Desktop redirection $songsDir = Join-Path $desktop 'KaraokeSongs' $supportDir = Join-Path $env:LOCALAPPDATA 'MullysKaraoke' $iconsUrl = 'https://www.adamscampustoolbox.org.uk/guides/mullys-karaoke/icons' New-Item -ItemType Directory -Force -Path $songsDir, $supportDir | Out-Null function Update-SessionPath { $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') } # ---- Tools (winget) --------------------------------------------------------- if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Write-Host "winget is missing. Install 'App Installer' from the Microsoft Store, then run this again." -ForegroundColor Red return } function Install-WingetPackage([string]$id) { # 'winget list' exits 0 when the package is already installed; 'winget # install' on an installed package exits non-zero, so check first. $null = winget list --id $id --exact --accept-source-agreements if ($LASTEXITCODE -eq 0) { Write-Host "==> $id already installed" return } Write-Host "==> Installing $id" winget install --id $id --exact --silent --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { Write-Host "!! winget could not install $id (exit code $LASTEXITCODE)." -ForegroundColor Yellow } } Install-WingetPackage 'OpenJS.NodeJS.LTS' Install-WingetPackage 'yt-dlp.yt-dlp' Install-WingetPackage 'Gyan.FFmpeg' # Open Video Downloader: fetches its own yt-dlp and ffmpeg on first launch and # keeps them updated. Installs per user, no admin prompt. Install-WingetPackage 'jely2002.youtube-dl-gui' Update-SessionPath if (-not (Get-Command node.exe -ErrorAction SilentlyContinue)) { Write-Host "!! Node.js is not on the PATH. Close this window, open a new PowerShell and run the command again." -ForegroundColor Red return } # Karaoke Eternal 2.x needs Node 24 or newer. $nodeMajor = [int]((node.exe -p "process.versions.node.split('.')[0]") | Select-Object -First 1) if ($nodeMajor -lt 24) { Write-Host "!! Node $nodeMajor is installed; Karaoke Eternal needs Node 24+." -ForegroundColor Red Write-Host "!! Run: winget upgrade OpenJS.NodeJS.LTS then run this setup again." -ForegroundColor Red return } # ---- Karaoke Eternal (npm) ---------------------------------------------------- # npm.cmd, not npm: plain 'npm' resolves to npm.ps1, which the default # execution policy refuses to run. Write-Host '==> Installing Karaoke Eternal' npm.cmd install -g karaoke-eternal if ($LASTEXITCODE -ne 0) { Write-Host '!! npm install failed; see the messages above.' -ForegroundColor Red return } Update-SessionPath # Karaoke Eternal shows "CAN HAZ MOAR SONGZ?" on the projector when the queue # is empty. Swap it for our own line in the installed client bundle (a # minified build\client\958..js, where the text is a double-quoted JS # string). The apostrophe is the typographic one, written into the bundle as # the JS escape \u2019, so the quotes are untouched and this file stays ASCII. # Re-running npm install restores the original, which is why this runs # straight after it every time. A future version that moved or reworded the # text only gets a warning. $placeholderFrom = 'CAN HAZ MOAR SONGZ?' $placeholderTo = 'Mully\u2019s Karaoke Night' $placeholderSet = $false try { $npmRoot = (npm.cmd root -g | Select-Object -First 1).Trim() $clientDir = Join-Path $npmRoot 'karaoke-eternal\build\client' $utf8NoBom = New-Object System.Text.UTF8Encoding($false) foreach ($bundle in (Get-ChildItem -LiteralPath $clientDir -Filter '*.js' -File -ErrorAction Stop)) { $text = [IO.File]::ReadAllText($bundle.FullName, $utf8NoBom) if ($text.Contains($placeholderFrom)) { [IO.File]::WriteAllText($bundle.FullName, $text.Replace($placeholderFrom, $placeholderTo), $utf8NoBom) # A precompressed copy would be served instead of the patched file. foreach ($ext in '.gz', '.br') { Remove-Item -LiteralPath ($bundle.FullName + $ext) -Force -ErrorAction SilentlyContinue } $placeholderSet = $true } elseif ($text.Contains($placeholderTo)) { $placeholderSet = $true } } } catch { # No install folder, or a bundle we could not rewrite: warn below. } if ($placeholderSet) { Write-Host "==> Empty-queue text set to: Mully's Karaoke Night" } else { Write-Host "!! Could not set the empty-queue text; Karaoke Eternal will show its own." -ForegroundColor Yellow } # ---- yt-dlp settings ------------------------------------------------------------ # A config file rather than command-line flags: Windows PowerShell 5.1 drops # empty-string arguments ('') to native programs, which --replace-in-metadata # needs. $ytdlpConfig = @' # yt-dlp settings for the Mully's karaoke downloader. # Karaoke Eternal reads "Artist - Title" from the file name, and plays MP4. # A link from a playlist downloads just that one video. --no-playlist # MP4 (H.264 + AAC where available), merged with ffmpeg. -t mp4 # YouTube needs a JavaScript runtime; Node is already installed. --js-runtimes node # Drop bracketed clutter: (Karaoke Version), [Official Video], (Lyrics), ... --replace-in-metadata title '(?i)\s*[\(\[][^\)\]]*(karaok|lyric|instrumental|official|video|audio|version|\bhd\b|\b4k\b)[^\)\]]*[\)\]]' '' # Auto-generated "Artist - Topic" channels: keep just the artist. --replace-in-metadata uploader ' - Topic$' '' # Most karaoke uploads are titled "Artist - Title": split that out. --parse-metadata 'title:(?P.+?) - (?P.+)' # Otherwise fall back to the channel name as the artist. -o '%(artist,creator,uploader)s - %(title)s.%(ext)s' '@ Set-Content -Path (Join-Path $supportDir 'yt-dlp.conf') -Value $ytdlpConfig -Encoding ASCII # ---- Open Video Downloader settings ------------------------------------------------ # Preset: MP4s into KaraokeSongs, named "Artist - Title". Its settings are a # JSON file (tauri-plugin-store, key "config", camelCase fields from # src-tauri/src/state/config_models.rs in its repo); the app fills in anything # missing with its defaults, so only these fields are written, merged into # whatever is already there. Node does the merge: it writes UTF-8 without a # BOM, which the app's JSON reader needs. $ovdPreset = @' // Presets Open Video Downloader for Mully's karaoke. Run by the setup script // with OVD_CONFIG (its config.store.json) and SONGS_DIR set. const fs = require("fs"); const path = require("path"); const file = process.env.OVD_CONFIG; const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {}); let store = {}; try { store = obj(JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""))); } catch { // No settings yet (app never opened): start from nothing. } const config = (store.config = obj(store.config)); const output = (config.output = obj(config.output)); output.downloadDir = process.env.SONGS_DIR; // "Artist - " only when YouTube knows the artist; otherwise the upload title, // which karaoke channels already write as "Artist - Title (Karaoke Version)". output.fileNameTemplate = "%(artist&{} - |)s%(title)s.%(ext)s"; output.video = obj(output.video); output.video.container = "mp4"; // A link from a playlist downloads just that one video. const input = (config.input = obj(config.input)); input.preferVideoInMixedLinks = true; fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(store, null, 2)); '@ $ovdPresetPath = Join-Path $supportDir 'ovd-preset.js' Set-Content -Path $ovdPresetPath -Value $ovdPreset -Encoding ASCII $env:OVD_CONFIG = Join-Path $env:APPDATA 'com.jelleglebbeek.youtube-dl-gui\config.store.json' $env:SONGS_DIR = $songsDir node.exe $ovdPresetPath if ($LASTEXITCODE -ne 0) { Write-Host '!! Could not preset Open Video Downloader; set its Locations to KaraokeSongs by hand.' -ForegroundColor Yellow } # ---- Launcher ------------------------------------------------------------------- $launchScript = @' # Starts Karaoke Eternal on port 8888 and opens it in the browser. # Close this window (or press Ctrl+C) to stop the karaoke server. $Host.UI.RawUI.WindowTitle = "Mully's Karaoke" $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') $port = 8888 Clear-Host Write-Host '========================================================' Write-Host " MULLY'S BASEMENT KARAOKE IS RUNNING" Write-Host '========================================================' Write-Host " Karaoke opens in your browser at http://localhost:$port" Write-Host ' Leave this window open. Close it to stop karaoke.' Write-Host '========================================================' Write-Host '' $main = Join-Path (npm.cmd root -g) 'karaoke-eternal\build\server\main.js' $node = (Get-Command node.exe).Source $server = Start-Process -FilePath $node -ArgumentList "`"$main`" --port $port --scan all" -NoNewWindow -PassThru # Open the host's browser once the server is actually answering. for ($i = 0; $i -lt 60; $i++) { try { Invoke-WebRequest -Uri "http://localhost:$port/" -UseBasicParsing -TimeoutSec 2 | Out-Null Start-Process "http://localhost:$port/" break } catch { Start-Sleep -Seconds 1 } } $server.WaitForExit() '@ Set-Content -Path (Join-Path $supportDir 'launch.ps1') -Value $launchScript -Encoding ASCII $launchBat = @' @echo off powershell -NoProfile -ExecutionPolicy Bypass -File "%LOCALAPPDATA%\MullysKaraoke\launch.ps1" pause '@ Set-Content -Path (Join-Path $supportDir 'launch.bat') -Value $launchBat -Encoding ASCII # ---- Song Search ---------------------------------------------------------------- $downloadScript = @' # Asks for search words (or a YouTube link) and downloads the top result into # the Desktop KaraokeSongs folder as "Artist - Title.mp4". $Host.UI.RawUI.WindowTitle = 'Song Search' $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') $songsDir = Join-Path ([Environment]::GetFolderPath('Desktop')) 'KaraokeSongs' $config = Join-Path $env:LOCALAPPDATA 'MullysKaraoke\yt-dlp.conf' Add-Type -AssemblyName Microsoft.VisualBasic $query = [Microsoft.VisualBasic.Interaction]::InputBox( "Type the song to search YouTube for, or paste a link.`r`n`r`n(e.g. Taylor Swift Cruel Summer karaoke)", 'Song Search') # Cancel, or OK on an empty box: nothing to do. Double quotes are dropped # because Windows PowerShell 5.1 passes them to programs unescaped. $query = ($query -replace '"', '').Trim() if (-not $query) { exit } if ($query -match '^https?://') { $target = $query } else { $target = "ytsearch1:$query" } Write-Host "Fetching: $query" New-Item -ItemType Directory -Force -Path $songsDir | Out-Null yt-dlp.exe --ignore-config --config-locations $config -P $songsDir $target Write-Host '' if ($LASTEXITCODE -eq 0) { Write-Host 'Saved to KaraokeSongs.' -ForegroundColor Green Write-Host 'Check the file name above reads Artist - Title; rename it in' Write-Host 'KaraokeSongs if the two came out the wrong way round.' } else { Write-Host 'Download failed. If yt-dlp is out of date, run: winget upgrade yt-dlp.yt-dlp' -ForegroundColor Red } Read-Host 'Press Enter to close' '@ Set-Content -Path (Join-Path $supportDir 'download.ps1') -Value $downloadScript -Encoding ASCII $downloadBat = @' @echo off powershell -NoProfile -ExecutionPolicy Bypass -File "%LOCALAPPDATA%\MullysKaraoke\download.ps1" '@ Set-Content -Path (Join-Path $supportDir 'download.bat') -Value $downloadBat -Encoding ASCII # ---- Desktop shortcuts ------------------------------------------------------------ # A shortcut whose icon failed to download still works; it just looks plain. function New-KaraokeShortcut([string]$name, [string]$icon, [string]$target) { $iconPath = Join-Path $supportDir "$icon.ico" $ProgressPreference = 'SilentlyContinue' # PS 5.1's progress bar slows downloads to a crawl try { Invoke-WebRequest -Uri "$iconsUrl/$icon.ico" -OutFile $iconPath -UseBasicParsing } catch { Write-Host "!! Could not download the $name icon; the shortcut will use a plain one." -ForegroundColor Yellow } $shell = New-Object -ComObject WScript.Shell $link = $shell.CreateShortcut((Join-Path $desktop "$name.lnk")) $link.TargetPath = Join-Path $supportDir $target $link.WorkingDirectory = $supportDir $link.Description = $name if (Test-Path $iconPath) { $link.IconLocation = "$iconPath,0" } $link.Save() } New-KaraokeShortcut "Mully's Karaoke" 'mullys-karaoke' 'launch.bat' New-KaraokeShortcut 'Song Search' 'song-downloader' 'download.bat' # Open Video Downloader: a Desktop shortcut to its own .exe (and icon). Its # silent installer may already have made one of the same name; this replaces # it. The uninstall entry says where it went; per-user installs default to # %LOCALAPPDATA%\Open Video Downloader. function Find-OvdExe { $keys = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' $entry = Get-ItemProperty $keys -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like 'Open Video Downloader*' } | Select-Object -First 1 $candidates = @() if ($entry) { if ($entry.DisplayIcon) { $candidates += (($entry.DisplayIcon -split ',')[0]).Trim('"') } if ($entry.InstallLocation) { $candidates += Join-Path $entry.InstallLocation.Trim('"') 'open-video-downloader.exe' } } $candidates += Join-Path $env:LOCALAPPDATA 'Open Video Downloader\open-video-downloader.exe' $candidates += Join-Path $env:ProgramFiles 'Open Video Downloader\open-video-downloader.exe' foreach ($c in $candidates) { if ($c -and $c -like '*.exe' -and (Test-Path -LiteralPath $c)) { return $c } } return $null } $ovdExe = Find-OvdExe if ($ovdExe) { $shell = New-Object -ComObject WScript.Shell $link = $shell.CreateShortcut((Join-Path $desktop 'Open Video Downloader.lnk')) $link.TargetPath = $ovdExe $link.WorkingDirectory = Split-Path $ovdExe $link.Description = 'Open Video Downloader' $link.IconLocation = "$ovdExe,0" $link.Save() } else { Write-Host '!! Open Video Downloader was not found; Song Search still works.' -ForegroundColor Yellow Write-Host '!! Try again later with: winget install jely2002.youtube-dl-gui' -ForegroundColor Yellow } # Shortcuts left on the Desktop by earlier versions of this script. foreach ($old in @('Song Downloader.lnk', "1. Mully's Karaoke.bat", '2. yt-dlp Song Downloader.bat', '2. yt-dlp Song Downloader.vbs')) { Remove-Item -LiteralPath (Join-Path $desktop $old) -Force -ErrorAction SilentlyContinue } Write-Host '' Write-Host 'Setup complete. Your Desktop now has:' -ForegroundColor Green Write-Host " Mully's Karaoke" Write-Host ' Open Video Downloader' Write-Host ' Song Search' Write-Host " KaraokeSongs folder: $songsDir"