references/transcripts.md
# YouTube Transcript Downloader
Download transcripts (subtitles/captions) from YouTube videos using yt-dlp.
## How It Works
### Priority Order:
1. **Check if yt-dlp is installed** - install if needed
2. **List available subtitles** - see what's actually available
3. **Try manual subtitles first** (`--write-sub`) - highest quality
4. **Fallback to auto-generated** (`--write-auto-sub`) - usually available
5. **Last resort: Whisper transcription** - if no subtitles exist (requires user confirmation)
6. **Confirm the download** and show the user where the file is saved
7. **Optionally clean up** the VTT format if the user wants plain text
## Installation Check
**IMPORTANT**: Always check if yt-dlp is installed first:
```bash
which yt-dlp || command -v yt-dlp
```
### If Not Installed
Attempt automatic installation based on the system:
**macOS (Homebrew)**:
```bash
brew install yt-dlp
```
**Linux (apt/Debian/Ubuntu)**:
```bash
sudo apt update && sudo apt install -y yt-dlp
```
**Alternative (pip - works on all systems)**:
```bash
pip3 install yt-dlp
# or
python3 -m pip install yt-dlp
```
**If installation fails**: Inform the user they need to install yt-dlp manually and provide them with installation instructions from https://github.com/yt-dlp/yt-dlp#installation
## Check Available Subtitles
**ALWAYS do this first** before attempting to download:
```bash
yt-dlp --list-subs "YOUTUBE_URL"
```
This shows what subtitle types are available without downloading anything. Look for:
- Manual subtitles (better quality)
- Auto-generated subtitles (usually available)
- Available languages
## Download Strategy
### Option 1: Manual Subtitles (Preferred)
Try this first - highest quality, human-created:
```bash
yt-dlp --write-sub --skip-download --output "OUTPUT_NAME" "YOUTUBE_URL"
```
### Option 2: Auto-Generated Subtitles (Fallback)
If manual subtitles aren't available:
```bash
yt-dlp --write-auto-sub --skip-download --output "OUTPUT_NAME" "YOUTUBE_URL"
```
Both commands create a `.vtt` file (WebVTT subtitle format).
## Option 3: Whisper Transcription (Last Resort)
**ONLY use this if both manual and auto-generated subtitles are unavailable.**
### Step 1: Show File Size and Ask for Confirmation
```bash
# Get audio file size estimate
yt-dlp --print "%(filesize,filesize_approx)s" -f "bestaudio" "YOUTUBE_URL"
# Or get duration to estimate
yt-dlp --print "%(duration)s %(title)s" "YOUTUBE_URL"
```
**IMPORTANT**: Display the file size to the user and ask: "No subtitles are available. I can download the audio (approximately X MB) and transcribe it using Whisper. Would you like to proceed?"
**Wait for user confirmation before continuing.**
### Step 2: Check for Whisper Installation
```bash
command -v whisper
```
If not installed, ask user: "Whisper is not installed. Install it with `pip install openai-whisper` (requires ~1-3GB for models)? This is a one-time installation."
**Wait for user confirmation before installing.**
Install if approved:
```bash
pip3 install openai-whisper
```
### Step 3: Download Audio Only
```bash
yt-dlp -x --audio-format mp3 --output "audio_%(id)s.%(ext)s" "YOUTUBE_URL"
```
### Step 4: Transcribe with Whisper
```bash
# Auto-detect language (recommended)
whisper audio_VIDEO_ID.mp3 --model base --output_format vtt
# Or specify language if known
whisper audio_VIDEO_ID.mp3 --model base --language en --output_format vtt
```
**Model Options** (stick to `base` for now):
- `tiny` - fastest, least accurate (~1GB)
- `base` - good balance (~1GB) ← **USE THIS**
- `small` - better accuracy (~2GB)
- `medium` - very good (~5GB)
- `large` - best accuracy (~10GB)
### Step 5: Cleanup
After transcription completes, ask user: "Transcription complete! Would you like me to delete the audio file to save space?"
If yes:
```bash
rm audio_VIDEO_ID.mp3
```
## Getting Video Information
### Extract Video Title (for filename)
```bash
yt-dlp --print "%(title)s" "YOUTUBE_URL"
```
Use this to create meaningful filenames based on the video title. Clean the title for filesystem compatibility:
- Replace `/` with `-`
- Replace special characters that might cause issues
- Consider using sanitized version: `$(yt-dlp --print "%(title)s" "URL" | tr '/' '-' | tr ':' '-')`
## Post-Processing
### Convert to Plain Text (Recommended)
YouTube's auto-generated VTT files contain **duplicate lines** because captions are shown progressively with overlapping timestamps. Always deduplicate when converting to plain text while preserving the original speaking order.
```bash
python3 -c "
import sys, re
seen = set()
with open('transcript.en.vtt', 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('WEBVTT') and not line.startswith('Kind:') and not line.startswith('Language:') and '-->' not in line:
clean = re.sub('<[^>]*>', '', line)
clean = clean.replace('&', '&').replace('>', '>').replace('<', '<')
if clean and clean not in seen:
print(clean)
seen.add(clean)
" > transcript.txt
```
### Complete Post-Processing with Video Title
```bash
# Get video title
VIDEO_TITLE=$(yt-dlp --print "%(title)s" "YOUTUBE_URL" | tr '/' '_' | tr ':' '-' | tr '?' '' | tr '"' '')
# Find the VTT file
VTT_FILE=$(ls *.vtt | head -n 1)
# Convert with deduplication
python3 -c "
import sys, re
seen = set()
with open('$VTT_FILE', 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('WEBVTT') and not line.startswith('Kind:') and not line.startswith('Language:') and '-->' not in line:
clean = re.sub('<[^>]*>', '', line)
clean = clean.replace('&', '&').replace('>', '>').replace('<', '<')
if clean and clean not in seen:
print(clean)
seen.add(clean)
" > "${VIDEO_TITLE}.txt"
echo "✓ Saved to: ${VIDEO_TITLE}.txt"
# Clean up VTT file
rm "$VTT_FILE"
echo "✓ Cleaned up temporary VTT file"
```
## Output Formats
- **VTT format** (`.vtt`): Includes timestamps and formatting, good for video players
- **Plain text** (`.txt`): Just the text content, good for reading or analysis
## Tips
- The filename will be `{output_name}.{language_code}.vtt` (e.g., `transcript.en.vtt`)
- Most YouTube videos have auto-generated English subtitles
- Some videos may have multiple language options
- If auto-subtitles aren't available, try `--write-sub` instead for manual subtitles
## Complete Workflow Example
```bash
VIDEO_URL="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
# Get video title for filename
VIDEO_TITLE=$(yt-dlp --print "%(title)s" "$VIDEO_URL" | tr '/' '_' | tr ':' '-' | tr '?' '' | tr '"' '')
OUTPUT_NAME="transcript_temp"
# ============================================
# STEP 1: Check if yt-dlp is installed
# ============================================
if ! command -v yt-dlp &> /dev/null; then
echo "yt-dlp not found, attempting to install..."
if command -v brew &> /dev/null; then
brew install yt-dlp
elif command -v apt &> /dev/null; then
sudo apt update && sudo apt install -y yt-dlp
else
pip3 install yt-dlp
fi
fi
# ============================================
# STEP 2: List available subtitles
# ============================================
echo "Checking available subtitles..."
yt-dlp --list-subs "$VIDEO_URL"
# ============================================
# STEP 3: Try manual subtitles first
# ============================================
echo "Attempting to download manual subtitles..."
if yt-dlp --write-sub --skip-download --output "$OUTPUT_NAME" "$VIDEO_URL" 2>/dev/null; then
echo "✓ Manual subtitles downloaded successfully!"
ls -lh ${OUTPUT_NAME}.*
else
# ============================================
# STEP 4: Fallback to auto-generated
# ============================================
echo "Manual subtitles not available. Trying auto-generated..."
if yt-dlp --write-auto-sub --skip-download --output "$OUTPUT_NAME" "$VIDEO_URL" 2>/dev/null; then
echo "✓ Auto-generated subtitles downloaded successfully!"
ls -lh ${OUTPUT_NAME}.*
else
# ============================================
# STEP 5: Last resort - Whisper transcription
# ============================================
echo "⚠ No subtitles available for this video."
# Get file size
FILE_SIZE=$(yt-dlp --print "%(filesize_approx)s" -f "bestaudio" "$VIDEO_URL")
DURATION=$(yt-dlp --print "%(duration)s" "$VIDEO_URL")
TITLE=$(yt-dlp --print "%(title)s" "$VIDEO_URL")
echo "Video: $TITLE"
echo "Duration: $((DURATION / 60)) minutes"
echo "Audio size: ~$((FILE_SIZE / 1024 / 1024)) MB"
echo ""
echo "Would you like to download and transcribe with Whisper? (y/n)"
read -r RESPONSE
if [[ "$RESPONSE" =~ ^[Yy]$ ]]; then
# Check for Whisper
if ! command -v whisper &> /dev/null; then
echo "Whisper not installed. Install now? (requires ~1-3GB) (y/n)"
read -r INSTALL_RESPONSE
if [[ "$INSTALL_RESPONSE" =~ ^[Yy]$ ]]; then
pip3 install openai-whisper
else
echo "Cannot proceed without Whisper. Exiting."
exit 1
fi
fi
# Download audio
echo "Downloading audio..."
yt-dlp -x --audio-format mp3 --output "audio_%(id)s.%(ext)s" "$VIDEO_URL"
# Get the actual audio filename
AUDIO_FILE=$(ls audio_*.mp3 | head -n 1)
# Transcribe
echo "Transcribing with Whisper (this may take a few minutes)..."
whisper "$AUDIO_FILE" --model base --output_format vtt
# Cleanup
echo "Transcription complete! Delete audio file? (y/n)"
read -r CLEANUP_RESPONSE
if [[ "$CLEANUP_RESPONSE" =~ ^[Yy]$ ]]; then
rm "$AUDIO_FILE"
echo "Audio file deleted."
fi
ls -lh *.vtt
else
echo "Transcription cancelled."
exit 0
fi
fi
fi
# ============================================
# STEP 6: Convert to readable plain text with deduplication
# ============================================
VTT_FILE=$(ls ${OUTPUT_NAME}*.vtt 2>/dev/null || ls *.vtt | head -n 1)
if [ -f "$VTT_FILE" ]; then
echo "Converting to readable format and removing duplicates..."
python3 -c "
import sys, re
seen = set()
with open('$VTT_FILE', 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('WEBVTT') and not line.startswith('Kind:') and not line.startswith('Language:') and '-->' not in line:
clean = re.sub('<[^>]*>', '', line)
clean = clean.replace('&', '&').replace('>', '>').replace('<', '<')
if clean and clean not in seen:
print(clean)
seen.add(clean)
" > "${VIDEO_TITLE}.txt"
echo "✓ Saved to: ${VIDEO_TITLE}.txt"
# Clean up temporary VTT file
rm "$VTT_FILE"
echo "✓ Cleaned up temporary VTT file"
else
echo "⚠ No VTT file found to convert"
fi
echo "✓ Complete!"
```
**Note**: This complete workflow handles all scenarios with proper error checking and user prompts at each decision point.
## Error Handling
### Common Issues and Solutions:
**1. yt-dlp not installed**
- Attempt automatic installation based on system (Homebrew/apt/pip)
- If installation fails, provide manual installation link
- Verify installation before proceeding
**2. No subtitles available**
- List available subtitles first to confirm
- Try both `--write-sub` and `--write-auto-sub`
- If both fail, offer Whisper transcription option
- Show file size and ask for user confirmation before downloading audio
**3. Invalid or private video**
- Check if URL is correct format: `https://www.youtube.com/watch?v=VIDEO_ID`
- Some videos may be private, age-restricted, or geo-blocked
- Inform user of the specific error from yt-dlp
**4. Whisper installation fails**
- May require system dependencies (ffmpeg, rust)
- Provide fallback: "Install manually with: `pip3 install openai-whisper`"
- Check available disk space (models require 1-10GB depending on size)
**5. Download interrupted or failed**
- Check internet connection
- Verify sufficient disk space
- Try again with `--no-check-certificate` if SSL issues occur
**6. Multiple subtitle languages**
- By default, yt-dlp downloads all available languages
- Can specify with `--sub-langs en` for English only
- List available with `--list-subs` first
### Best Practices:
- Always check what's available before attempting download (`--list-subs`)
- Verify success at each step before proceeding to next
- Ask user before large downloads (audio files, Whisper models)
- Clean up temporary files after processing
- Provide clear feedback about what's happening at each stage
- Handle errors gracefully with helpful messages
scripts/ytmp3
#!/usr/bin/env ruby
# frozen_string_literal: true
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "thor"
gem "gum"
end
require "json"
require "fileutils"
require "open3"
# Service class for YouTube/MP3 operations
class YouTubeService
STAGING_DIR = File.expand_path("~/Music/staging")
def self.staging_dir = STAGING_DIR
# Fetch playlist metadata
def self.playlist_info(url)
info = {}
# Get title and count
out, = run("yt-dlp", "--flat-playlist",
"--print", "%(playlist_title)s",
"--print", "%(playlist_count)s",
url)
lines = out.strip.split("\n")
info[:album] = lines[0]
info[:track_count] = lines[1].to_i
# Get artist from first track
out, = run("yt-dlp",
"--print", "%(artist)s",
"--print", "%(album)s",
"--playlist-items", "1",
url)
lines = out.strip.split("\n")
info[:artist] = lines[0] unless lines[0].to_s.empty?
# Get track listing
out, = run("yt-dlp", "--flat-playlist",
"--print", "%(playlist_index)s\t%(title)s",
url)
info[:tracks] = out.strip.split("\n").map do |line|
num, title = line.split("\t", 2)
{number: num.to_i, title: title}
end
info
end
# Download playlist to staging
def self.download(url, output_dir: nil)
output_dir ||= File.join(STAGING_DIR, "download_#{Time.now.to_i}")
FileUtils.mkdir_p(output_dir)
system("yt-dlp", "-x", "--audio-format", "mp3", "--audio-quality", "0",
"-o", "%(playlist_index)s - %(title)s.%(ext)s",
url,
chdir: output_dir)
output_dir
end
# List MP3 files in a directory
def self.list_mp3s(dir)
Dir.glob(File.join(dir, "*.mp3")).sort
end
# Tag a single file
def self.tag_file(file, artist:, album:, year:, genre:, track:, total:, title:)
run("eyeD3",
"-a", artist,
"-A", album,
"-Y", year.to_s,
"--recording-date", year.to_s,
"-G", genre,
"-n", track.to_s,
"-N", total.to_s,
"-t", title,
file)
end
# Tag all files in a directory
def self.tag_all(dir, artist:, album:, year:, genre:)
files = list_mp3s(dir)
total = files.count
files.each do |file|
basename = File.basename(file)
match = basename.match(/^(\d+) - (.+)\.mp3$/)
next unless match
track_num = match[1].to_i
title = match[2]
# Strip artist prefix if present (YouTube format: "## - Artist - Title")
title = title.sub(/^#{Regexp.escape(artist)}\s*-\s*/, "")
tag_file(file,
artist: artist,
album: album,
year: year,
genre: genre,
track: track_num,
total: total,
title: title)
end
end
# Download cover art
def self.download_cover(url, output_path)
system("curl", "-sL", url, "-o", output_path)
File.exist?(output_path) && File.size(output_path) > 1000
end
# Embed cover art in file
def self.embed_cover(file, cover_path)
run("eyeD3", "--add-image", "#{cover_path}:FRONT_COVER", file)
end
# Embed cover in all files
def self.embed_cover_all(dir, cover_path)
list_mp3s(dir).each { |f| embed_cover(f, cover_path) }
end
# Rename files to final structure
def self.finalize(dir, artist:, album:)
final_dir = File.join(STAGING_DIR, artist, "#{artist} - #{album}")
FileUtils.mkdir_p(final_dir)
list_mp3s(dir).each do |file|
basename = File.basename(file)
match = basename.match(/^(\d+) - (.+)\.mp3$/)
next unless match
track_num = match[1]
title = match[2]
# Strip artist prefix if present (YouTube format: "## - Artist - Title")
title = title.sub(/^#{Regexp.escape(artist)}\s*-\s*/, "")
# Sanitize: replace full-width punctuation with ASCII equivalents
title = title.delete("?").tr("!", "!").tr(":", ":").tr("&", "&")
new_name = "#{track_num} #{title} - #{artist}.mp3"
FileUtils.mv(file, File.join(final_dir, new_name))
end
# Move cover if exists
cover = File.join(dir, "folder.jpg")
FileUtils.mv(cover, File.join(final_dir, "folder.jpg")) if File.exist?(cover)
# Clean up empty source dir
FileUtils.rmdir(dir) if Dir.empty?(dir)
final_dir
end
# View tags on a file
def self.view_tags(file)
out, = run("eyeD3", file)
out
end
# Parse ID3 tags from a file into a hash
def self.read_tags(file)
out, = run("eyeD3", file)
tags = {}
out.each_line do |line|
case line
when /^title:\s*(.+)$/
tags[:title] = $1.strip
when /^artist:\s*(.+)$/
tags[:artist] = $1.strip
when /^album:\s*(.+)$/
tags[:album] = $1.strip
when /^recording date:\s*(.+)$/
tags[:year] = $1.strip
when /^track:\s*(\d+)\/(\d+)\s+genre:\s*(.+?)\s*\(id/
tags[:track] = $1.to_i
tags[:total] = $2.to_i
tags[:genre] = $3.strip
when /^track:\s*(\d+)\/(\d+)/
tags[:track] = $1.to_i
tags[:total] = $2.to_i
when /FRONT_COVER Image:/
tags[:cover] = true
end
end
tags
end
def self.run(*args)
stdout, stderr, status = Open3.capture3(*args)
unless status.success?
warn "Command failed: #{args.join(" ")}"
warn stderr unless stderr.empty?
end
[stdout, status.success?]
end
end
# Thor CLI
class CLI < Thor
DEFAULT_GENRE = "Christian Rock"
desc "info URL", "Show playlist info without downloading"
def info(url)
say "Fetching playlist info...", :cyan
info = YouTubeService.playlist_info(url)
puts ""
say "Album: #{info[:album]}", :green
say "Artist: #{info[:artist] || "(unknown)"}", :green
say "Tracks: #{info[:track_count]}", :green
puts ""
say "Track listing:", :yellow
info[:tracks].each do |t|
puts " #{t[:number].to_s.rjust(2)}. #{t[:title]}"
end
end
desc "download URL", "Download playlist to staging directory"
option :output, aliases: "-o", desc: "Output directory name (default: auto-generated)"
def download(url)
# Get info first to name the folder
say "Fetching playlist info...", :cyan
info = YouTubeService.playlist_info(url)
output_name = options[:output] || info[:album]&.gsub(/[^a-zA-Z0-9\s-]/, "")&.strip || "download_#{Time.now.to_i}"
output_dir = File.join(YouTubeService.staging_dir, output_name)
if Dir.exist?(output_dir) && !Dir.empty?(output_dir)
say "Directory already exists: #{output_dir}", :red
return
end
say "Downloading to: #{output_dir}", :cyan
puts ""
YouTubeService.download(url, output_dir: output_dir)
puts ""
say "Download complete!", :green
say "Files saved to: #{output_dir}", :green
puts ""
say "Next steps:", :yellow
say " ytmp3 tag '#{output_dir}' --artist 'Artist' --album 'Album' --year 1995"
end
desc "staging", "List albums in staging directory"
def staging
staging = YouTubeService.staging_dir
unless Dir.exist?(staging)
say "Staging directory doesn't exist: #{staging}", :red
return
end
entries = Dir.children(staging).sort
if entries.empty?
say "Staging directory is empty", :yellow
return
end
say "Staging directory: #{staging}", :cyan
puts ""
entries.each do |entry|
path = File.join(staging, entry)
next unless File.directory?(path)
mp3_count = Dir.glob(File.join(path, "**/*.mp3")).count
has_cover = File.exist?(File.join(path, "folder.jpg")) ||
Dir.glob(File.join(path, "**/folder.jpg")).any?
status = []
status << "#{mp3_count} tracks" if mp3_count > 0
status << "has cover" if has_cover
puts " #{entry}/"
puts " #{status.join(", ")}" unless status.empty?
end
end
desc "tag DIR", "Tag all MP3 files in directory"
option :artist, aliases: "-a", required: true, desc: "Artist name"
option :album, aliases: "-A", required: true, desc: "Album name"
option :year, aliases: "-y", required: true, desc: "Release year"
option :genre, aliases: "-g", default: DEFAULT_GENRE, desc: "Genre"
def tag(dir)
dir = File.expand_path(dir)
unless Dir.exist?(dir)
say "Directory not found: #{dir}", :red
return
end
files = YouTubeService.list_mp3s(dir)
if files.empty?
say "No MP3 files found in: #{dir}", :red
return
end
say "Tagging #{files.count} files...", :cyan
say " Artist: #{options[:artist]}", :green
say " Album: #{options[:album]}", :green
say " Year: #{options[:year]}", :green
say " Genre: #{options[:genre]}", :green
puts ""
YouTubeService.tag_all(dir,
artist: options[:artist],
album: options[:album],
year: options[:year],
genre: options[:genre])
say "Tagged #{files.count} files", :green
puts ""
say "Next: ytmp3 cover '#{dir}' --url 'COVER_URL'"
end
desc "cover DIR", "Download and embed album cover"
option :url, aliases: "-u", desc: "Cover art URL"
option :file, aliases: "-f", desc: "Local cover art file"
def cover(dir)
dir = File.expand_path(dir)
unless Dir.exist?(dir)
say "Directory not found: #{dir}", :red
return
end
cover_path = File.join(dir, "folder.jpg")
if options[:url]
say "Downloading cover art...", :cyan
unless YouTubeService.download_cover(options[:url], cover_path)
say "Failed to download cover art", :red
return
end
say "Downloaded: #{cover_path}", :green
elsif options[:file]
FileUtils.cp(File.expand_path(options[:file]), cover_path)
say "Copied: #{cover_path}", :green
else
say "Provide --url or --file", :red
return
end
files = YouTubeService.list_mp3s(dir)
say "Embedding cover in #{files.count} files...", :cyan
YouTubeService.embed_cover_all(dir, cover_path)
say "Cover art embedded!", :green
puts ""
say "Next: ytmp3 finalize '#{dir}' --artist 'Artist' --album 'Album'"
end
desc "finalize DIR", "Rename files to final structure"
option :artist, aliases: "-a", required: true, desc: "Artist name"
option :album, aliases: "-A", required: true, desc: "Album name"
def finalize(dir)
dir = File.expand_path(dir)
unless Dir.exist?(dir)
say "Directory not found: #{dir}", :red
return
end
say "Finalizing...", :cyan
final_dir = YouTubeService.finalize(dir,
artist: options[:artist],
album: options[:album])
say "Complete!", :green
puts ""
# Show directory structure
display_summary(final_dir)
end
desc "show FILE", "Show tags on an MP3 file"
def show(file)
file = File.expand_path(file)
unless File.exist?(file)
say "File not found: #{file}", :red
return
end
puts YouTubeService.view_tags(file)
end
desc "summary DIR", "Display album summary with ID3 tags"
def summary(dir)
dir = File.expand_path(dir)
unless Dir.exist?(dir)
say "Directory not found: #{dir}", :red
return
end
files = YouTubeService.list_mp3s(dir)
if files.empty?
say "No MP3 files found in: #{dir}", :red
return
end
display_summary(dir)
end
desc "version", "Show version"
def version
puts "ytmp3 0.2.0"
end
private
def display_summary(dir)
# Header
puts Gum.style(
" #{File.basename(File.dirname(dir))} / #{File.basename(dir)} ",
foreground: "#7D56F4",
bold: true,
border: :rounded,
border_foreground: "#7D56F4",
padding: "0 1"
)
puts ""
# Build table data
files = YouTubeService.list_mp3s(dir).sort
has_cover = File.exist?(File.join(dir, "folder.jpg"))
rows = files.map do |file|
tags = YouTubeService.read_tags(file)
[
tags[:track]&.to_s&.rjust(2) || "?",
tags[:title] || File.basename(file, ".mp3"),
tags[:artist] || "-",
tags[:year] || "-",
tags[:genre] || "-",
tags[:cover] ? "✓" : "-"
]
end
# Display table (use tab separator to handle commas in titles)
Gum.table(
rows,
columns: ["#", "Title", "Artist", "Year", "Genre", "Art"],
print: true,
border: :rounded,
header_foreground: "#7D56F4",
separator: "\t"
)
puts ""
# Summary line
puts Gum.style(
"#{files.count} tracks#{has_cover ? " • folder.jpg present" : " • no cover art"}",
faint: true
)
puts Gum.style("Location: #{dir}", faint: true)
end
end
CLI.start(ARGV)