#!/usr/bin/env bash # # Mully's Bar karaoke night: one-time setup for a Mac. # # curl -fsSL https://www.adamscampustoolbox.org.uk/guides/mullys-karaoke/setup-mac.sh | bash # # Installs Homebrew (if missing), Node.js, yt-dlp, ffmpeg, Karaoke Eternal # and Open Video Downloader, creates ~/Desktop/KaraokeSongs, and puts three # apps on the Desktop: # # Mully's Karaoke.app starts the server, opens the browser # Open Video Downloader (link to the app) paste a YouTube link, download # Song Search.app 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 .apps are only wrappers with an icon: each opens Terminal on a # script kept in ~/Library/Application Support/MullysKaraoke, which is where # the actual launcher and search downloader live. # # 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 # # Everything lives inside main(), called on the last line. When this script is # piped into bash, bash reads it from stdin as it goes; wrapping it means the # whole file is read before anything runs, so an installer that reads stdin # can't swallow the rest of the script. set -euo pipefail main() { mkdir -p "$HOME/Desktop/KaraokeSongs" # ── Homebrew ────────────────────────────────────────────────────────────── if ! command -v brew >/dev/null 2>&1 && [ ! -x /opt/homebrew/bin/brew ] && [ ! -x /usr/local/bin/brew ]; then echo "==> Installing Homebrew (it will ask for your Mac password)" # /dev/null 2>&1; then echo "==> $pkg already installed" else echo "==> Installing $pkg" brew install "$pkg" fi done # Karaoke Eternal 2.x needs Node 24 or newer. An older node earlier on the # PATH (nvm, an old installer) would make the npm install fail obscurely. local node_major node_major="$(node -p 'process.versions.node.split(".")[0]')" if [ "$node_major" -lt 24 ]; then echo "!! $(command -v node) is Node $node_major; Karaoke Eternal needs Node 24+." echo "!! Run 'brew upgrade node' (or switch your nvm version) and run this again." exit 1 fi echo "==> Installing Karaoke Eternal" npm install -g karaoke-eternal set_queue_placeholder # ── Open Video Downloader ───────────────────────────────────────────────── # Notarised, so Gatekeeper opens it without a prompt. It fetches its own # copies of yt-dlp and ffmpeg on first launch and keeps them updated. if [ -d "/Applications/$OVD_APP" ] || [ -d "$HOME/Applications/$OVD_APP" ] || brew list --cask open-video-downloader >/dev/null 2>&1; then echo "==> Open Video Downloader already installed" else echo "==> Installing Open Video Downloader" if ! brew install --cask open-video-downloader; then echo "!! Open Video Downloader did not install; Song Search still works." echo "!! Try again later with: brew install --cask open-video-downloader" fi fi configure_ovd install_shortcuts echo "" echo "Setup complete. Your Desktop now has:" echo " Mully's Karaoke" echo " Open Video Downloader" echo " Song Search" echo " KaraokeSongs (folder)" } # 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 (doubled below for node -e), so the string's double # quotes are untouched and file encoding never comes into it. 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. set_queue_placeholder() { local client client="$(npm root -g)/karaoke-eternal/build/client" if ! CLIENT_DIR="$client" node -e ' const fs = require("fs"); const path = require("path"); const dir = process.env.CLIENT_DIR; const from = "CAN HAZ MOAR SONGZ?"; const to = "Mully\\u2019s Karaoke Night"; let patched = 0, done = 0; for (const f of fs.readdirSync(dir)) { if (!/\.js$/.test(f)) continue; const file = path.join(dir, f); const src = fs.readFileSync(file, "utf8"); if (src.includes(from)) { fs.writeFileSync(file, src.split(from).join(to)); // A precompressed copy would be served instead of the patched file. for (const ext of [".gz", ".br"]) fs.rmSync(file + ext, { force: true }); patched++; } else if (src.includes(to)) { done++; } } if (!patched && !done) process.exit(2); '; then echo "!! Could not set the empty-queue text; Karaoke Eternal will show its own." else echo "==> Empty-queue text set to: Mully's Karaoke Night" fi } OVD_APP="Open Video Downloader.app" # Presets Open Video Downloader: MP4s into ~/Desktop/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 the # fields we care about are written, merged into whatever is already there. configure_ovd() { local support_dir="$HOME/Library/Application Support/MullysKaraoke" mkdir -p "$support_dir" write_ovd_preset "$support_dir/ovd-preset.js" OVD_CONFIG="$HOME/Library/Application Support/com.jelleglebbeek.youtube-dl-gui/config.store.json" \ SONGS_DIR="$HOME/Desktop/KaraokeSongs" \ node "$support_dir/ovd-preset.js" || echo "!! Could not preset Open Video Downloader; set its Locations to KaraokeSongs by hand." } write_ovd_preset() { cat >"$1" <<'PRESET' // 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)); PRESET } # Put Homebrew on this script's PATH (Apple Silicon or Intel). load_brew() { local b for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do if [ -x "$b" ]; then eval "$("$b" shellenv)" return 0 fi done command -v brew >/dev/null 2>&1 || { echo "!! Homebrew did not install. See https://brew.sh and run this again." exit 1 } } # Writes the launcher and Song Search into Application Support and wraps each # in a small .app on the Desktop, so Finder shows a proper icon. Links Open # Video Downloader onto the Desktop too. install_shortcuts() { local support_dir="$HOME/Library/Application Support/MullysKaraoke" local icons_url="https://www.adamscampustoolbox.org.uk/guides/mullys-karaoke/icons" mkdir -p "$support_dir" "$HOME/Desktop" # ── yt-dlp settings (shared by the downloader) ──────────────────────────── write_ytdlp_config "$support_dir/yt-dlp.conf" # ── Launcher ────────────────────────────────────────────────────────────── cat >"$support_dir/launch.command" <<'LAUNCHER' #!/usr/bin/env bash # Starts Karaoke Eternal on port 8888 and opens it in the browser. # Close this window (or press Ctrl+C) to stop the karaoke server. # Finder starts this without Homebrew on the PATH on a fresh Mac. for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do if [ -x "$b" ]; then eval "$("$b" shellenv)"; break; fi done PORT=8888 printf '\033]0;%s\007' "Mully's Karaoke" clear echo "========================================================" echo " MULLY'S BASEMENT KARAOKE IS RUNNING" echo "========================================================" echo " Karaoke opens in your browser at http://localhost:${PORT}" echo " Leave this window open. Close it to stop karaoke." echo "========================================================" echo "" # Open the host's browser once the server is actually answering. ( for _ in $(seq 1 60); do if curl -s -o /dev/null "http://localhost:${PORT}/"; then open "http://localhost:${PORT}/" exit 0 fi sleep 1 done ) & exec karaoke-eternal-server --port "$PORT" --scan all LAUNCHER # ── Song Search ─────────────────────────────────────────────────────────── cat >"$support_dir/download.command" <<'DOWNLOADER' #!/usr/bin/env bash # Asks for search words (or a YouTube link) and downloads the top result into # ~/Desktop/KaraokeSongs as "Artist - Title.mp4". for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do if [ -x "$b" ]; then eval "$("$b" shellenv)"; break; fi done SONGS_DIR="$HOME/Desktop/KaraokeSongs" CONFIG="$HOME/Library/Application Support/MullysKaraoke/yt-dlp.conf" printf '\033]0;%s\007' "Song Search" QUERY="$(osascript \ -e 'display dialog "Type the song to search YouTube for (e.g. Taylor Swift Cruel Summer karaoke), or paste a link:" default answer "" with title "Song Search"' \ -e 'text returned of result' 2>/dev/null)" # Cancel, or OK on an empty box: nothing to do. [ -n "$QUERY" ] || exit 0 case "$QUERY" in http://* | https://*) TARGET="$QUERY" ;; *) TARGET="ytsearch1:$QUERY" ;; esac echo "Fetching: $QUERY" mkdir -p "$SONGS_DIR" if yt-dlp --ignore-config --config-locations "$CONFIG" -P "$SONGS_DIR" "$TARGET"; then osascript -e 'display notification "Saved to KaraokeSongs." with title "Song Search"' echo "" echo "Done. Check the file name above reads Artist - Title; rename it in" echo "KaraokeSongs if the two came out the wrong way round." else osascript -e 'display notification "Download failed. See the Terminal window." with title "Song Search"' echo "" echo "Download failed. If yt-dlp is out of date, run: brew upgrade yt-dlp" fi DOWNLOADER chmod +x "$support_dir/launch.command" "$support_dir/download.command" # ── Desktop apps ────────────────────────────────────────────────────────── make_app "Mully's Karaoke" mullys-karaoke launch.command "$support_dir" "$icons_url" make_app "Song Search" song-downloader download.command "$support_dir" "$icons_url" # Open Video Downloader lives in Applications; a link on the Desktop shows # its own icon. Never replace a real file or folder of that name. local ovd_link="$HOME/Desktop/Open Video Downloader" ovd_dir for ovd_dir in /Applications "$HOME/Applications"; do if [ -d "$ovd_dir/$OVD_APP" ]; then if [ -L "$ovd_link" ] || [ ! -e "$ovd_link" ]; then rm -f "$ovd_link" ln -s "$ovd_dir/$OVD_APP" "$ovd_link" fi break fi done # Shortcuts and settings left by earlier versions of this script. rm -rf "$HOME/Desktop/Song Downloader.app" rm -f "$HOME/Desktop/1. Mully's Karaoke.command" \ "$HOME/Desktop/2. yt-dlp Song Downloader.command" \ "$HOME/.mullys-karaoke/yt-dlp.conf" rmdir "$HOME/.mullys-karaoke" 2>/dev/null || true } # make_app NAME ICON SCRIPT SUPPORT_DIR ICONS_URL # # Builds ~/Desktop/NAME.app: an Info.plist naming the icon, and an executable # that opens SCRIPT (in SUPPORT_DIR) in a Terminal window. Made on this Mac # rather than downloaded, it carries no quarantine flag, so Gatekeeper lets it # open without the "unidentified developer" prompt. make_app() { local name="$1" icon="$2" script="$3" support_dir="$4" icons_url="$5" local app="$HOME/Desktop/$name.app" rm -rf "$app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" # A missing icon only costs the artwork: the app still works. if ! curl -fsSL "$icons_url/$icon.icns" -o "$app/Contents/Resources/$icon.icns"; then echo "!! Could not download the $name icon; the shortcut will use a plain one." rm -f "$app/Contents/Resources/$icon.icns" fi cat >"$app/Contents/MacOS/run" <"$app/Contents/Info.plist" < CFBundleName$name CFBundleDisplayName$name CFBundleIdentifieruk.org.adamscampustoolbox.$icon CFBundleExecutablerun CFBundleIconFile$icon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString1.0 CFBundleInfoDictionaryVersion6.0 LSUIElement PLIST # Finder caches icons; touching the bundle and re-registering it makes the # new one show straight away. touch "$app" local lsregister=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister if [ -x "$lsregister" ]; then "$lsregister" -f "$app" >/dev/null 2>&1 || true; fi } write_ytdlp_config() { cat >"$1" <<'CONF' # 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' CONF } main "$@"