diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2f83a0c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,164 @@ +name: Create Release + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + create-release: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Extract version from PR + id: extract_version + run: | + # Check PR title for version pattern (e.g., "Release v1.2.3" or "Version 1.2.3") + PR_TITLE="${{ github.event.pull_request.title }}" + PR_BODY="${{ github.event.pull_request.body }}" + + # Try to extract version from title + if [[ "$PR_TITLE" =~ [Vv]ersion[[:space:]]*([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION="${BASH_REMATCH[1]}" + elif [[ "$PR_TITLE" =~ [Rr]elease[[:space:]]*[Vv]?([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION="${BASH_REMATCH[1]}" + elif [[ "$PR_TITLE" =~ ^[Vv]?([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION="${BASH_REMATCH[1]}" + # Try to extract from PR body + elif [[ "$PR_BODY" =~ [Vv]ersion:[[:space:]]*[Vv]?([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION="${BASH_REMATCH[1]}" + elif [[ "$PR_BODY" =~ [Rr]elease:[[:space:]]*[Vv]?([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION="${BASH_REMATCH[1]}" + else + echo "No version found in PR title or body" + exit 0 + fi + + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "VERSION_TAG=v$VERSION" >> $GITHUB_OUTPUT + echo "Found version: $VERSION" + + - name: Check if release exists + if: steps.extract_version.outputs.VERSION + id: check_release + run: | + if gh release view "v${{ steps.extract_version.outputs.VERSION }}" >/dev/null 2>&1; then + echo "Release v${{ steps.extract_version.outputs.VERSION }} already exists" + echo "EXISTS=true" >> $GITHUB_OUTPUT + else + echo "EXISTS=false" >> $GITHUB_OUTPUT + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create release package + if: steps.extract_version.outputs.VERSION && steps.check_release.outputs.EXISTS == 'false' + run: | + VERSION="${{ steps.extract_version.outputs.VERSION }}" + PACKAGE_NAME="plex-file-renamer-v${VERSION}" + + # Create package directory + mkdir -p "$PACKAGE_NAME" + + # Copy files + cp plex_file_renamer.py "$PACKAGE_NAME/" + cp requirements.txt "$PACKAGE_NAME/" + cp README.md "$PACKAGE_NAME/" + cp install.sh "$PACKAGE_NAME/" + [ -f LICENSE ] && cp LICENSE "$PACKAGE_NAME/" + [ -f CONTRIBUTING.md ] && cp CONTRIBUTING.md "$PACKAGE_NAME/" + + # Create version file + echo "$VERSION" > "$PACKAGE_NAME/VERSION" + + # Create zip + zip -r "${PACKAGE_NAME}.zip" "$PACKAGE_NAME" + + # Create tar.gz + tar -czf "${PACKAGE_NAME}.tar.gz" "$PACKAGE_NAME" + + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_ENV + + - name: Generate release notes + if: steps.extract_version.outputs.VERSION && steps.check_release.outputs.EXISTS == 'false' + id: release_notes + run: | + VERSION="${{ steps.extract_version.outputs.VERSION }}" + + # Get PR description + PR_BODY=$(cat << 'EOF' + ${{ github.event.pull_request.body }} + EOF + ) + + # Create release notes + cat << EOF > release_notes.md + # Plex File Renamer v${VERSION} + + ## What's New + ${PR_BODY} + + ## Installation + + ### Option 1: One-line installer (Recommended) + \`\`\`bash + curl -sSL https://raw.githubusercontent.com/${{ github.repository }}/main/install.sh | bash + \`\`\` + + ### Option 2: Manual installation + 1. Download the release package below + 2. Extract the archive + 3. Run \`pip install -r requirements.txt\` + 4. Run \`python plex_file_renamer.py\` + + ## Requirements + - Python 3.6+ + - ffmpeg (optional, for media info) + - TMDb API key (free from https://www.themoviedb.org/settings/api) + + ## Changes + View the [full changelog](https://github.com/${{ github.repository }}/compare/v${{ steps.extract_version.outputs.PREVIOUS_VERSION }}...v${VERSION}) + + --- + *Released by GitHub Actions from PR #${{ github.event.pull_request.number }}* + EOF + + - name: Create GitHub Release + if: steps.extract_version.outputs.VERSION && steps.check_release.outputs.EXISTS == 'false' + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ steps.extract_version.outputs.VERSION }} + name: Release v${{ steps.extract_version.outputs.VERSION }} + body_path: release_notes.md + draft: false + prerelease: false + files: | + ${{ env.PACKAGE_NAME }}.zip + ${{ env.PACKAGE_NAME }}.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update install.sh with repo info + if: steps.extract_version.outputs.VERSION && steps.check_release.outputs.EXISTS == 'false' + run: | + # Update the install.sh in main branch with correct repo owner + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + + # Update REPO_OWNER in install.sh + sed -i "s/REPO_OWNER=\"your-username\"/REPO_OWNER=\"${GITHUB_REPOSITORY_OWNER}\"/" install.sh + + # Commit if changed + if git diff --exit-code install.sh; then + echo "No changes to commit" + else + git add install.sh + git commit -m "Update install.sh with repository owner [skip ci]" + git push + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7faf40..659085d 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,5 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Local files diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c8e6314 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,101 @@ +# ๐Ÿค Contributing to Plex File Renamer + +Thanks for your interest in contributing! This project welcomes help from the community. + +## ๐Ÿš€ Quick Setup + +```bash +# Fork the repository on GitHub first, then: + +git clone https://github.com/YOUR-USERNAME/plex-file-namer.git +cd plex-file-namer + +# Set up virtual environment +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Set your TMDb API key for testing +export TMDB_API_KEY="your_test_api_key" + +# Test the script +python plex_file_renamer.py test_files/ --dry-run +``` + +## ๐Ÿ“ How to Contribute + +### ๐Ÿ› Bug Reports +If something doesn't work: +1. Check if there's already an issue for it +2. Create a new issue with: + - What you expected to happen + - What actually happened + - The exact command you ran + - Sample filenames (remove personal info) + - Any error messages + +### ๐Ÿ’ก Feature Ideas +Have an idea for improvement? +1. Create an issue describing the feature +2. Explain why it would be useful +3. Give examples of how it would work + +### ๐Ÿ”ง Code Changes +Want to fix something or add a feature? + +1. **Create a branch:** + ```bash + git checkout -b fix-something-cool + ``` + +2. **Make your changes** + +3. **Test it works:** + ```bash + # Test with different file types + python plex_file_renamer.py test_files/ --dry-run + + # Test actual renaming + python plex_file_renamer.py test_files/ --rename + + # Test reverting + python plex_file_renamer.py test_files/ --revert + ``` + +4. **Submit a pull request** + +## ๐ŸŽฏ Areas We Need Help With + +- **๐ŸŽฌ More filename patterns** - Add support for new naming styles +- **๐Ÿ” Better detection** - Improve movie vs TV show detection +- **๐ŸŒ Internationalization** - Support for non-English titles +- **๐Ÿ“š Documentation** - Improve examples and troubleshooting +- **๐Ÿงช Testing** - Test with more file types and edge cases +- **๐Ÿ› Bug fixes** - Fix issues people report + +## ๐Ÿ“‹ Code Guidelines + +Keep it simple: +- Follow the existing code style +- Add comments for tricky parts +- Test your changes with real files +- Don't break existing functionality + +## ๐ŸŽ‰ Recognition + +Contributors get mentioned in: +- The README +- Release notes +- Our thanks! ๐Ÿ™ + +## โ“ Need Help? + +- Check existing issues for similar problems +- Create an issue if you're stuck +- We're friendly and happy to help! + +--- + +**Thanks for making Plex File Renamer better! ๐ŸŽฌ** \ No newline at end of file diff --git a/README.md b/README.md index 6451931..8dae48a 100644 --- a/README.md +++ b/README.md @@ -1 +1,423 @@ -# plex-file-namer \ No newline at end of file +# ๐ŸŽฌ Plex File Renamer + +A powerful Python tool that automatically renames your video files to follow Plex naming conventions by fetching metadata from The Movie Database (TMDb). Say goodbye to manual renaming and hello to perfectly organized media libraries! + +## โœจ Features + +- ๐ŸŽฏ **Smart Detection**: Automatically distinguishes between movies and TV shows +- ๐Ÿ“ **Folder Analysis**: Extracts show names and seasons from folder structure +- ๐Ÿ” **Flexible Parsing**: Handles various filename patterns (S01E01, 1x01, E01, etc.) +- ๐ŸŽฌ **Episode Titles**: Fetches actual episode titles from TMDb API +- ๐ŸŽจ **Quality Detection**: Automatically detects and preserves quality/source info +- ๐Ÿ›ก๏ธ **Safety First**: Creates backup files for easy reverting +- ๐Ÿ“บ **Plex-Perfect**: Follows official Plex naming conventions exactly +- ๐ŸŒ **Free API**: Uses TMDb's free API (no subscription required) +- ๐Ÿš€ **Batch Processing**: Process entire folders recursively +- ๐Ÿ‘€ **Preview Mode**: See changes before applying them +- ๐Ÿ”„ **Multi-Rename Support**: Preserves original filename across multiple renames +- ๐ŸŽž๏ธ **Cinematic Aspect Ratios**: Correctly detects resolution for wide aspect ratio videos +- ๐Ÿ“‚ **Movie Folder Detection**: Won't misidentify movies as TV shows when in "Movies" folders +- โœ… **Interactive Confirmation**: Prompts before each rename (with option to skip) + +## ๐Ÿš€ Quick Start + +### Option A: One-Line Installer (Beta - In Progress) + +> โš ๏ธ **Note**: The automated installer is currently in beta and may have issues. For a reliable installation, use Option B (Manual Installation) below. + +#### Linux/macOS +```bash +curl -sSL https://raw.githubusercontent.com/trentmillar/plex-file-namer/main/install.sh | bash +``` + +#### Windows PowerShell +```powershell +irm https://raw.githubusercontent.com/trentmillar/plex-file-namer/main/install.ps1 | iex +``` + +The installer will: +- Detect your operating system +- Show all available versions and let you choose +- Install Python and ffmpeg if needed +- Set up everything automatically +- Create a sample configuration file + +### Option B: Manual Installation (Recommended) + +```bash +# Clone the repository +git clone https://github.com/trentmillar/plex-file-namer.git +cd plex-file-namer + +# Set up virtual environment +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Install FFmpeg (optional - for video duration detection) +# macOS +brew install ffmpeg +# Ubuntu/Debian +sudo apt update && sudo apt install ffmpeg +# Windows +choco install ffmpeg +``` + +### Get TMDb API Key + +1. Visit [themoviedb.org](https://www.themoviedb.org/settings/api) +2. Create a free account +3. Request an API key +4. Set your API key using one of these methods: + +#### Option A: Configuration File (Recommended) +Create `~/.plex-renamer.conf`: +```ini +[default] +api_key = your_tmdb_api_key_here +default_type = auto +parentheses_only = false +skip_confirmation = false +``` + +#### Option B: Environment Variable +```bash +# Linux/macOS +export TMDB_API_KEY="your_api_key_here" + +# Windows (Command Prompt) +set TMDB_API_KEY=your_api_key_here + +# Windows (PowerShell) +$env:TMDB_API_KEY="your_api_key_here" + +# Windows (Permanent - run as Administrator) +setx TMDB_API_KEY "your_api_key_here" +``` + +#### Option C: Command Line +```bash +python plex_file_renamer.py /path/to/videos --api-key your_key_here +``` + +### Test Run + +```bash +# Preview what would be renamed (safe mode) +python plex_file_renamer.py /path/to/your/videos --dry-run +``` + +## ๐Ÿ“– Usage Guide + +### Basic Commands + +```bash +# Preview changes (recommended first step) +python plex_file_renamer.py /path/to/videos --dry-run + +# Actually rename files (with confirmation prompt for each file) +python plex_file_renamer.py /path/to/videos --rename + +# Rename files without confirmation prompts (auto-approve all) +python plex_file_renamer.py /path/to/videos --rename --yes + +# Process single file +python plex_file_renamer.py /path/to/video.mkv --rename + +# Force movie detection +python plex_file_renamer.py /path/to/videos --type movie --rename + +# Force TV show detection +python plex_file_renamer.py /path/to/videos --type tv --rename + +# Only detect years in parentheses (2004), not - 2004 or space 2004 +python plex_file_renamer.py /path/to/videos --parentheses-only --rename +``` + +### Reverting Changes + +```bash +# Preview what would be reverted +python plex_file_renamer.py /path/to/videos --revert --dry-run + +# Revert all renames (with confirmation prompt) +python plex_file_renamer.py /path/to/videos --revert +``` + +## ๐ŸŽฏ Examples + +### Movie Examples + +**Input:** `avatar_2009.mp4` +**Output:** `Avatar (2009).mp4` + +**Input:** `The Dark Knight - 2008.mkv` +**Output:** `The Dark Knight (2008).mkv` + +**Input:** `inception (2010).avi` +**Output:** `Inception (2010).avi` + +### TV Show Examples + +**Input:** `breaking_bad_s01e01.mkv` +**Output:** `Breaking Bad (2008) - s01e01 - Pilot.mkv` + +**Input:** `The Office 2x05.mp4` +**Output:** `The Office (2005) - s02e05 - Halloween.mp4` + +**Input:** `Friends.1.01.The.One.Where.Monica.Gets.a.Roommate.mkv` +**Output:** `Friends (1994) - s01e01 - The One Where Monica Gets a Roommate.mkv` + +**Input:** `breaking_bad_s01e01_1080p_bluray_x264.mkv` +**Output:** `Breaking Bad (2008) - s01e01 - Pilot [1080p BLURAY X264].mkv` + +**Input:** `the_office_2x05_webdl_720p.mp4` +**Output:** `The Office (2005) - s02e05 - Halloween [WEBDL 720P].mp4` + +### Folder Structure Examples + +``` +# Smart folder detection +/TV Shows/Breaking Bad/S01/pilot.mkv +โ†’ Breaking Bad (2008) - s01e01 - Pilot.mkv + +/Movies/avatar_2009.mp4 +โ†’ Avatar (2009).mp4 + +# Season folder detection +/Shows/The Office/Season 2/E05 Halloween.avi +โ†’ The Office (2005) - s02e05 - Halloween.avi +``` + +## ๐ŸŽ›๏ธ Command-Line Options + +| Flag | Description | Example | +|------|-------------|---------| +| `path` | File or directory to process | `/path/to/videos` | +| `--dry-run` | Preview changes without renaming | `--dry-run` | +| `--rename` | Actually perform the renaming | `--rename` | +| `--yes`, `-y` | Skip confirmation prompt for each rename (auto-approve all) | `--yes` | +| `--type` | Force media type detection | `--type movie` | +| `--api-key` | TMDb API key (or use env var) | `--api-key abc123` | +| `--parentheses-only` | Only detect years in (2004) format | `--parentheses-only` | +| `--revert` | Revert all renames using backup files | `--revert` | + +## ๐ŸŽฌ Sample Output + +``` +Processing: /Users/john/Videos/breaking_bad_s01e01_1080p_bluray.mkv +-------------------------------------------------- +Video duration: 47.2 minutes +Parsed title: breaking bad +Parsed episode: S01E01 +TV show detected: True +โš ๏ธ WARNING: --type movie specified but detected TV show with S01E01 + Overriding to --type tv for proper TV show processing +Detected type: tv + +Searching TMDb for: breaking bad +Found TV show: Breaking Bad (2008) +Found episode title: Pilot +Detected quality/source info: 1080P BLURAY + +[DRY RUN] Would rename: + From: breaking_bad_s01e01_1080p_bluray.mkv + To: Breaking Bad (2008) - s01e01 - Pilot [1080P BLURAY].mkv +``` + +## ๐Ÿง  Smart Detection Features + +### TV Show Detection + +The script automatically detects TV shows when it finds: +- **Season/Episode patterns**: `S01E01`, `1x01`, `E05`, `101` +- **Season folders**: `/S01/`, `/Season 1/`, `/2/` +- **Episode numbers**: Any episode identifier in filename + +### Year Detection Priority + +1. **Trailing years**: `Movie Title - 2004`, `Movie Title 2004`, `Movie Title (2004)` +2. **Parentheses format**: `Movie Title (2004)` (when using `--parentheses-only`) + +### Filename Patterns Supported + +**Movies:** +- `Movie Title (2004).mkv` +- `Movie Title - 2004.mp4` +- `Movie Title 2004.avi` + +**TV Shows:** +- `Show S01E01.mkv` โ†’ Season 1, Episode 1 +- `Show 1x01.mp4` โ†’ Season 1, Episode 1 +- `Show E05.avi` โ†’ Episode 5 (season from folder or default to 1) +- `Show 101.mkv` โ†’ Season 1, Episode 1 (3-digit format) +- `Show 1.05.mkv` โ†’ Season 1, Episode 5 + +### Quality & Source Detection + +The script automatically detects and preserves quality/source information: + +**Quality:** `1080p`, `720p`, `480p`, `4K`, `2160p` +**Sources:** `BluRay`, `WEB-DL`, `HDTV`, `WEBRip` +**Codecs:** `x264`, `x265`, `HEVC`, `H264` +**Audio:** `AAC`, `DTS`, `AC3`, `MP3` +**Special:** `PROPER`, `REPACK`, `EXTENDED`, `UNRATED` + +Quality info is preserved in `[brackets]` which Plex ignores for matching but keeps for user reference. + +## ๐Ÿ›ก๏ธ Safety Features + +### Backup System + +When files are renamed, backup files are automatically created: + +**Backup file:** `New Filename.original.txt` +``` +Original filename: old_name.mkv +Original full path: /full/path/to/old_name.mkv +Renamed to: New Filename.mkv +Renamed on: 2025-01-15T10:30:45.123456 + +# To revert: mv 'New Filename.mkv' 'old_name.mkv' +``` + +### Safety Checks + +- โœ… **Preview mode**: Always test with `--dry-run` first +- โœ… **Conflict detection**: Won't overwrite existing files +- โœ… **Backup creation**: Automatic backup files for reverting +- โœ… **Confirmation prompts**: Asks before reverting changes +- โœ… **Error handling**: Graceful handling of API failures + +## ๐Ÿ“ Supported File Types + +**Video formats:** +`.mp4`, `.mkv`, `.avi`, `.mov`, `.wmv`, `.flv`, `.webm`, `.m4v`, `.mpg`, `.mpeg`, `.3gp`, `.3g2`, `.ts`, `.mts`, `.m2ts`, `.vob`, `.ogv`, `.divx`, `.xvid`, `.rm`, `.rmvb` + +## ๐Ÿ”ง Troubleshooting + +### Common Issues + +**"No TMDb API key found"** +```bash +export TMDB_API_KEY="your_api_key_here" +# Or pass directly: --api-key your_api_key_here +``` + +**"Error getting video duration: No such file or directory: 'ffprobe'"** +- FFmpeg is not installed (optional feature) +- Install with: `brew install ffmpeg` (macOS) or `apt install ffmpeg` (Ubuntu) + +**macOS Permission Issues** +1. Open **System Settings** โ†’ **Privacy & Security** โ†’ **Files and Folders** +2. Grant Terminal/VS Code access to **Network Volumes** +3. Or add to **Full Disk Access** + +**TV Show in Season Folder but Detected as Movie** +``` +โš ๏ธ WARNING: File is in season folder 'S01' but has no episode number! + Consider renaming to include episode number (e.g., S01E01, E01, etc.) +``` + +### Debug Information + +The script provides detailed debug output: +- Parsed title and year information +- Season detection from folders vs filenames +- TV show vs movie detection reasoning +- TMDb search results and matches + +## ๐Ÿš€ Advanced Usage + +### Configuration File + +The script supports a configuration file at `~/.plex-renamer.conf` for persistent settings: + +```ini +[default] +# TMDb API key (no need to set environment variable) +api_key = your_tmdb_api_key_here + +# Default media type: auto, movie, or tv +default_type = auto + +# Only detect years in parentheses format (2004) +parentheses_only = false + +# Skip confirmation prompts for batch operations +skip_confirmation = false +``` + +**Priority order for settings:** +1. Command-line arguments (highest priority) +2. Environment variables +3. Configuration file (lowest priority) + +### Batch Processing Tips + +```bash +# Process multiple folders +for dir in /media/TV/* ; do + python plex_file_renamer.py "$dir" --type tv --rename +done + +# Process by file type +find /media -name "*.mkv" -exec python plex_file_renamer.py {} --rename \; +``` + + +## ๐ŸŽฏ Future Enhancements + +### Usability Improvements We're Considering + +1. **๐Ÿ“ฆ One-Click Installation** + ```bash + curl -sSL install.plex-renamer.com | bash + ``` + +2. **๐Ÿ”— Plex Integration** + - Direct Plex library scanning + - Automatic metadata refresh + - Library health checking + +3. **๐ŸŽจ Enhanced Detection** + - Better pattern matching for edge cases + - Support for more languages + - Custom naming templates + +4. **๐Ÿ“Š Analytics & Reporting** + - Processing statistics + - File organization insights + - Library health reports + +5. **๐Ÿ”Œ Plugin System** + - Custom naming rules + - Additional metadata sources + - Integration with other tools + +## ๐Ÿ“„ License + +MIT License - see LICENSE file for details + +## ๐Ÿค Contributing + +Contributions welcome! Please read CONTRIBUTING.md for guidelines. + +## ๐Ÿ’ก Tips for Best Results + +1. **Always start with `--dry-run`** to preview changes +2. **Use specific folder structures** for TV shows when possible +3. **Include years in filenames** for better movie detection +4. **Organize by media type** (separate Movies and TV Shows folders) +5. **Keep original files** until you're satisfied with the results +6. **Use the revert feature** if something goes wrong + +## โญ Star History + +If this tool helps organize your media library, please consider giving it a star! โญ + +--- + +**Happy organizing! ๐ŸŽฌ๐Ÿ“บ** diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..b50c89c --- /dev/null +++ b/install.ps1 @@ -0,0 +1,232 @@ +# Plex File Renamer - Windows Installation Script (PowerShell) + +param( + [string]$Version = "" +) + +# Configuration +$RepoOwner = "trentmillar" +$RepoName = "plex-file-namer" +$InstallDir = "$env:LOCALAPPDATA\Programs\PlexFileRenamer" +$ConfigDir = "$env:APPDATA\PlexFileRenamer" + +# Colors +function Write-ColorOutput($ForegroundColor) { + $fc = $host.UI.RawUI.ForegroundColor + $host.UI.RawUI.ForegroundColor = $ForegroundColor + if ($args) { + Write-Output $args + } + $host.UI.RawUI.ForegroundColor = $fc +} + +Write-Host "๐ŸŽฌ Plex File Renamer - Windows Installation Script" -ForegroundColor Green +Write-Host "===================================================" -ForegroundColor Green +Write-Host "" + +# Check if running as administrator +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") +if (-not $isAdmin) { + Write-Host "Note: Running without administrator privileges." -ForegroundColor Yellow + Write-Host "Some features may require manual configuration." -ForegroundColor Yellow + Write-Host "" +} + +# Fetch available releases +Write-Host "Fetching available releases..." -ForegroundColor Blue + +try { + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$RepoOwner/$RepoName/releases" -Method Get + $releaseVersions = $releases | Select-Object -First 20 | ForEach-Object { $_.tag_name } +} catch { + Write-Host "Could not fetch releases. Installing from main branch..." -ForegroundColor Yellow + $Version = "main" +} + +# Select version +if ($Version -eq "" -and $releaseVersions.Count -gt 0) { + $latest = $releaseVersions[0] + + Write-Host "Available versions:" -ForegroundColor Green + Write-Host " [0] Latest ($latest) - Recommended" -ForegroundColor Cyan + + for ($i = 0; $i -lt $releaseVersions.Count; $i++) { + Write-Host " [$($i + 1)] $($releaseVersions[$i])" + } + + $selection = Read-Host "`nSelect version [0-$($releaseVersions.Count)], or press Enter for latest" + + if ($selection -eq "" -or $selection -eq "0") { + $Version = $latest + } elseif ([int]$selection -le $releaseVersions.Count) { + $Version = $releaseVersions[[int]$selection - 1] + } else { + $Version = $latest + } +} + +Write-Host "Installing version: $Version" -ForegroundColor Green + +# Check Python installation +Write-Host "`nChecking Python installation..." -ForegroundColor Blue + +$pythonCmd = Get-Command python -ErrorAction SilentlyContinue +if (-not $pythonCmd) { + $pythonCmd = Get-Command python3 -ErrorAction SilentlyContinue +} + +if ($pythonCmd) { + $pythonVersion = & $pythonCmd.Source --version 2>&1 + Write-Host "โœ“ Python found: $pythonVersion" -ForegroundColor Green +} else { + Write-Host "Python not found!" -ForegroundColor Red + Write-Host "Please install Python from: https://www.python.org/downloads/" -ForegroundColor Yellow + Write-Host "Make sure to check 'Add Python to PATH' during installation!" -ForegroundColor Yellow + + $install = Read-Host "Would you like to open the Python download page? (Y/N)" + if ($install -eq "Y" -or $install -eq "y") { + Start-Process "https://www.python.org/downloads/" + } + + Write-Host "`nPlease run this script again after installing Python." -ForegroundColor Yellow + exit 1 +} + +# Check ffmpeg installation +Write-Host "Checking ffmpeg installation..." -ForegroundColor Blue + +$ffmpegCmd = Get-Command ffmpeg -ErrorAction SilentlyContinue +if ($ffmpegCmd) { + Write-Host "โœ“ ffmpeg found" -ForegroundColor Green +} else { + Write-Host "ffmpeg not found (optional but recommended)" -ForegroundColor Yellow + Write-Host "To install ffmpeg:" -ForegroundColor Yellow + Write-Host " 1. Download from: https://ffmpeg.org/download.html" -ForegroundColor Cyan + Write-Host " 2. Or use: choco install ffmpeg (if you have Chocolatey)" -ForegroundColor Cyan + Write-Host " 3. Or use: winget install ffmpeg (if you have Windows Package Manager)" -ForegroundColor Cyan +} + +# Create directories +Write-Host "`nCreating installation directories..." -ForegroundColor Blue +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null + +# Download and extract +Write-Host "Downloading Plex File Renamer $Version..." -ForegroundColor Blue + +$tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } + +if ($Version -eq "main") { + $downloadUrl = "https://github.com/$RepoOwner/$RepoName/archive/main.zip" +} else { + $downloadUrl = "https://github.com/$RepoOwner/$RepoName/releases/download/$Version/plex-file-renamer-$Version.zip" +} + +$zipPath = Join-Path $tempDir "release.zip" + +try { + Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath +} catch { + Write-Host "Failed to download release. Trying alternative URL..." -ForegroundColor Yellow + $downloadUrl = "https://github.com/$RepoOwner/$RepoName/archive/$Version.zip" + Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath +} + +# Extract +Write-Host "Extracting files..." -ForegroundColor Blue +Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force + +# Find extracted folder +$extractedFolder = Get-ChildItem -Path $tempDir -Directory | Select-Object -First 1 + +# Install Python dependencies +Write-Host "Installing Python dependencies..." -ForegroundColor Blue +Push-Location $extractedFolder.FullName +& pip install --user -r requirements.txt +Pop-Location + +# Copy files +Write-Host "Installing files..." -ForegroundColor Blue +Copy-Item -Path (Join-Path $extractedFolder.FullName "plex_file_renamer.py") -Destination $InstallDir -Force +Copy-Item -Path (Join-Path $extractedFolder.FullName "requirements.txt") -Destination $InstallDir -Force + +if (Test-Path (Join-Path $extractedFolder.FullName "README.md")) { + Copy-Item -Path (Join-Path $extractedFolder.FullName "README.md") -Destination $ConfigDir -Force +} + +# Create batch file wrapper +$batchContent = @" +@echo off +python "$InstallDir\plex_file_renamer.py" %* +"@ +$batchPath = Join-Path $InstallDir "plex-renamer.bat" +Set-Content -Path $batchPath -Value $batchContent + +# Create PowerShell wrapper +$ps1Content = @" +#!/usr/bin/env pwsh +python "$InstallDir\plex_file_renamer.py" `$args +"@ +$ps1Path = Join-Path $InstallDir "plex-renamer.ps1" +Set-Content -Path $ps1Path -Value $ps1Content + +# Create sample config if it doesn't exist +$configPath = Join-Path $env:USERPROFILE ".plex-renamer.conf" +if (-not (Test-Path $configPath)) { + $configContent = @" +[default] +# TMDb API key (get free key from https://www.themoviedb.org/settings/api) +# api_key = your_api_key_here + +# Default media type: auto, movie, or tv +default_type = auto + +# Only detect years in parentheses format (2004) +parentheses_only = false + +# Skip confirmation prompts for batch operations +skip_confirmation = false +"@ + Set-Content -Path $configPath -Value $configContent + Write-Host "โœ“ Created sample config at $configPath" -ForegroundColor Green +} + +# Add to PATH +Write-Host "`nUpdating PATH..." -ForegroundColor Blue + +$currentPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($currentPath -notlike "*$InstallDir*") { + $newPath = "$currentPath;$InstallDir" + + if ($isAdmin) { + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + Write-Host "โœ“ Added to PATH (permanent)" -ForegroundColor Green + } else { + $env:Path = $newPath + Write-Host "โœ“ Added to PATH (current session only)" -ForegroundColor Green + Write-Host "To make permanent, run as administrator or add manually:" -ForegroundColor Yellow + Write-Host " $InstallDir" -ForegroundColor Cyan + } +} else { + Write-Host "โœ“ Already in PATH" -ForegroundColor Green +} + +# Clean up +Remove-Item -Path $tempDir -Recurse -Force + +# Done +Write-Host "`nโœ… Installation complete!" -ForegroundColor Green +Write-Host "" +Write-Host "To get started:" -ForegroundColor Cyan +Write-Host " 1. Get a free TMDb API key: https://www.themoviedb.org/settings/api" -ForegroundColor White +Write-Host " 2. Edit $configPath and add your API key" -ForegroundColor White +Write-Host " 3. Run: plex-renamer --help" -ForegroundColor White +Write-Host "" +Write-Host "Usage examples:" -ForegroundColor Cyan +Write-Host " plex-renamer 'C:\Videos\movie.mp4' --dry-run" -ForegroundColor White +Write-Host " plex-renamer 'C:\Videos' --rename" -ForegroundColor White +Write-Host "" + +if (-not $isAdmin) { + Write-Host "Note: For permanent PATH update, run this script as Administrator" -ForegroundColor Yellow +} \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..bcee566 --- /dev/null +++ b/install.sh @@ -0,0 +1,308 @@ +#!/bin/bash + +# Plex File Renamer - Universal Installation Script +# Supports: Linux, macOS, Windows (Git Bash/WSL) + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +REPO_OWNER="trentmillar" +REPO_NAME="plex-file-namer" +INSTALL_DIR="$HOME/.local/bin" +CONFIG_DIR="$HOME/.config/plex-renamer" + +# Detect OS +detect_os() { + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + OS="linux" + elif [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then + OS="windows" + else + echo -e "${RED}Unsupported OS: $OSTYPE${NC}" + exit 1 + fi +} + +# Fetch available releases from GitHub +fetch_releases() { + echo -e "${BLUE}Fetching available releases...${NC}" + + # Get releases from GitHub API + if command -v curl &> /dev/null; then + RELEASES=$(curl -s "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases" | grep '"tag_name"' | cut -d '"' -f 4 | head -20) + elif command -v wget &> /dev/null; then + RELEASES=$(wget -qO- "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases" | grep '"tag_name"' | cut -d '"' -f 4 | head -20) + else + echo -e "${RED}Error: Neither curl nor wget found. Please install one.${NC}" + exit 1 + fi + + if [ -z "$RELEASES" ]; then + echo -e "${YELLOW}No releases found. Installing from main branch...${NC}" + VERSION="main" + return + fi + + # Convert to array + RELEASE_ARRAY=($RELEASES) + LATEST="${RELEASE_ARRAY[0]}" +} + +# Let user select version +select_version() { + if [ "$VERSION" == "main" ]; then + return + fi + + echo -e "${GREEN}Available versions:${NC}" + echo " [0] Latest ($LATEST) - Recommended" + + i=1 + for release in $RELEASES; do + echo " [$i] $release" + ((i++)) + done + + echo "" + read -p "Select version [0-$((i-1))], or press Enter for latest: " selection + + if [ -z "$selection" ] || [ "$selection" == "0" ]; then + VERSION="$LATEST" + else + RELEASE_ARRAY=($RELEASES) + VERSION="${RELEASE_ARRAY[$selection]}" + fi + + if [ -z "$VERSION" ]; then + VERSION="$LATEST" + fi + + echo -e "${GREEN}Installing version: $VERSION${NC}" +} + +# Check and install Python +install_python() { + echo -e "${BLUE}Checking Python installation...${NC}" + + if command -v python3 &> /dev/null; then + PYTHON_VERSION=$(python3 --version | cut -d' ' -f2) + echo -e "${GREEN}โœ“ Python $PYTHON_VERSION found${NC}" + return + fi + + echo -e "${YELLOW}Python not found. Installing...${NC}" + + case $OS in + linux) + if command -v apt-get &> /dev/null; then + sudo apt-get update && sudo apt-get install -y python3 python3-pip + elif command -v yum &> /dev/null; then + sudo yum install -y python3 python3-pip + elif command -v pacman &> /dev/null; then + sudo pacman -S python python-pip + else + echo -e "${RED}Could not install Python. Please install manually.${NC}" + exit 1 + fi + ;; + macos) + if command -v brew &> /dev/null; then + brew install python3 + else + echo -e "${YELLOW}Homebrew not found. Installing Homebrew first...${NC}" + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + brew install python3 + fi + ;; + windows) + echo -e "${YELLOW}Please install Python from: https://www.python.org/downloads/${NC}" + echo "After installation, run this script again." + exit 1 + ;; + esac +} + +# Check and install ffmpeg +install_ffmpeg() { + echo -e "${BLUE}Checking ffmpeg installation...${NC}" + + if command -v ffmpeg &> /dev/null; then + FFMPEG_VERSION=$(ffmpeg -version | head -n1 | cut -d' ' -f3) + echo -e "${GREEN}โœ“ ffmpeg $FFMPEG_VERSION found${NC}" + return + fi + + echo -e "${YELLOW}ffmpeg not found. Installing...${NC}" + + case $OS in + linux) + if command -v apt-get &> /dev/null; then + sudo apt-get update && sudo apt-get install -y ffmpeg + elif command -v yum &> /dev/null; then + sudo yum install -y ffmpeg + elif command -v pacman &> /dev/null; then + sudo pacman -S ffmpeg + else + echo -e "${YELLOW}Could not install ffmpeg automatically.${NC}" + echo "Please install ffmpeg manually: https://ffmpeg.org/download.html" + fi + ;; + macos) + if command -v brew &> /dev/null; then + brew install ffmpeg + else + echo -e "${YELLOW}Please install ffmpeg manually: https://ffmpeg.org/download.html${NC}" + fi + ;; + windows) + echo -e "${YELLOW}Please install ffmpeg from: https://ffmpeg.org/download.html${NC}" + echo "Or use: choco install ffmpeg (if you have Chocolatey)" + ;; + esac +} + +# Download and install plex-file-renamer +install_plex_renamer() { + echo -e "${BLUE}Downloading Plex File Renamer $VERSION...${NC}" + + # Create install directory + mkdir -p "$INSTALL_DIR" + mkdir -p "$CONFIG_DIR" + + # Download URL + if [ "$VERSION" == "main" ]; then + DOWNLOAD_URL="https://github.com/$REPO_OWNER/$REPO_NAME/archive/main.zip" + else + DOWNLOAD_URL="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/$VERSION/plex-file-renamer-$VERSION.zip" + fi + + # Download + TEMP_DIR=$(mktemp -d) + cd "$TEMP_DIR" + + if command -v curl &> /dev/null; then + curl -L "$DOWNLOAD_URL" -o release.zip + else + wget "$DOWNLOAD_URL" -O release.zip + fi + + # Extract + unzip -q release.zip + cd plex-file-namer-* + + # Install Python dependencies + echo -e "${BLUE}Installing Python dependencies...${NC}" + pip3 install --user -r requirements.txt + + # Copy main script + cp plex_file_renamer.py "$INSTALL_DIR/plex-renamer" + chmod +x "$INSTALL_DIR/plex-renamer" + + # Add shebang if not present + if ! head -n1 "$INSTALL_DIR/plex-renamer" | grep -q "^#!"; then + sed -i '1i#!/usr/bin/env python3' "$INSTALL_DIR/plex-renamer" + fi + + # Copy other files + [ -f "README.md" ] && cp README.md "$CONFIG_DIR/" + + # Create sample config if it doesn't exist + if [ ! -f "$HOME/.plex-renamer.conf" ]; then + cat > "$HOME/.plex-renamer.conf" << EOF +[default] +# TMDb API key (get free key from https://www.themoviedb.org/settings/api) +# api_key = your_api_key_here + +# Default media type: auto, movie, or tv +default_type = auto + +# Only detect years in parentheses format (2004) +parentheses_only = false + +# Skip confirmation prompts for batch operations +skip_confirmation = false +EOF + echo -e "${GREEN}โœ“ Created sample config at ~/.plex-renamer.conf${NC}" + fi + + # Clean up + cd / + rm -rf "$TEMP_DIR" +} + +# Add to PATH +update_path() { + echo -e "${BLUE}Updating PATH...${NC}" + + # Determine shell config file + if [ -n "$ZSH_VERSION" ]; then + SHELL_CONFIG="$HOME/.zshrc" + elif [ -n "$BASH_VERSION" ]; then + SHELL_CONFIG="$HOME/.bashrc" + else + SHELL_CONFIG="$HOME/.profile" + fi + + # Check if already in PATH + if echo "$PATH" | grep -q "$INSTALL_DIR"; then + echo -e "${GREEN}โœ“ $INSTALL_DIR already in PATH${NC}" + else + # Add to PATH + echo "" >> "$SHELL_CONFIG" + echo "# Added by Plex File Renamer installer" >> "$SHELL_CONFIG" + echo "export PATH=\"\$PATH:$INSTALL_DIR\"" >> "$SHELL_CONFIG" + + echo -e "${GREEN}โœ“ Added $INSTALL_DIR to PATH in $SHELL_CONFIG${NC}" + echo -e "${YELLOW}Note: Run 'source $SHELL_CONFIG' or restart your terminal${NC}" + fi +} + +# Main installation +main() { + echo -e "${GREEN}๐ŸŽฌ Plex File Renamer - Installation Script${NC}" + echo "============================================" + echo "" + + # Detect OS + detect_os + echo -e "${BLUE}Detected OS: $OS${NC}" + + # Fetch releases and let user select + fetch_releases + select_version + + # Install dependencies + install_python + install_ffmpeg + + # Install plex-renamer + install_plex_renamer + + # Update PATH + update_path + + echo "" + echo -e "${GREEN}โœ… Installation complete!${NC}" + echo "" + echo "To get started:" + echo " 1. Get a free TMDb API key: https://www.themoviedb.org/settings/api" + echo " 2. Edit ~/.plex-renamer.conf and add your API key" + echo " 3. Run: plex-renamer --help" + echo "" + echo "If 'plex-renamer' is not found, run:" + echo " source $SHELL_CONFIG" + echo "Or add this to your PATH:" + echo " export PATH=\"\$PATH:$INSTALL_DIR\"" +} + +# Run main +main diff --git a/plex_file_renamer.py b/plex_file_renamer.py new file mode 100644 index 0000000..9a74128 --- /dev/null +++ b/plex_file_renamer.py @@ -0,0 +1,1266 @@ +#!/usr/bin/env python3 +""" +Plex File Renamer - Inspect video files, fetch metadata, and rename according to Plex conventions +""" + +import os +import re +import sys +import configparser +from pathlib import Path +from typing import Optional, Dict, Any, Tuple, List + +# Video inspection - using ffmpeg-python for better compatibility +try: + import ffmpeg +except ImportError: + print("Please install ffmpeg-python: pip install ffmpeg-python") + sys.exit(1) + +# HTTP requests for API calls +try: + import requests +except ImportError: + print("Please install requests: pip install requests") + sys.exit(1) + + +class VideoInspector: + """Inspect video files to get metadata like duration""" + + @staticmethod + def get_video_duration(file_path: str) -> float: + """Get video duration in seconds using ffmpeg""" + try: + probe = ffmpeg.probe(file_path) + # Try format duration first (most reliable) + if 'format' in probe and 'duration' in probe['format']: + return float(probe['format']['duration']) + + # Fallback to stream duration + for stream in probe.get('streams', []): + if 'duration' in stream: + return float(stream['duration']) + + print("No duration information found in video file") + return 0.0 + except Exception as e: + print(f"Error getting video duration: {e}") + return 0.0 + + @staticmethod + def get_media_info(file_path: str) -> Dict[str, Any]: + """ + Get detailed media information from video file using ffmpeg probe + Returns dict with resolution, video codec, audio codec, etc. + """ + media_info = { + 'resolution': None, + 'video_codec': None, + 'audio_codec': None, + 'duration': 0.0 + } + + try: + probe = ffmpeg.probe(file_path) + + # Get duration + if 'format' in probe and 'duration' in probe['format']: + media_info['duration'] = float(probe['format']['duration']) + + # Parse streams for video and audio info + for stream in probe.get('streams', []): + if stream['codec_type'] == 'video' and not media_info['video_codec']: + # Get video codec + codec_name = stream.get('codec_name', '').lower() + if codec_name: + # Map common codec names to display format + codec_map = { + 'h264': 'H264', + 'avc': 'H264', + 'h265': 'H265', + 'hevc': 'HEVC', + 'mpeg4': 'MPEG4', + 'vp9': 'VP9', + 'vp8': 'VP8', + 'av1': 'AV1' + } + media_info['video_codec'] = codec_map.get(codec_name, codec_name.upper()) + + # Get resolution + width = stream.get('width') + height = stream.get('height') + if width and height: + # Store raw resolution for debugging + media_info['raw_resolution'] = f"{width}x{height}" + + # Determine quality based on resolution + # Use width OR height to handle different aspect ratios + # Many movies use cinematic aspect ratios (e.g., 1920x800) + if height >= 2160 or width >= 3840: + media_info['resolution'] = '4K' + elif height >= 1440 or width >= 2560: + media_info['resolution'] = '1440p' + elif height >= 1080 or width >= 1920: + media_info['resolution'] = '1080p' + elif height >= 720 or width >= 1280: + media_info['resolution'] = '720p' + elif height >= 480 or width >= 854: + media_info['resolution'] = '480p' + else: + media_info['resolution'] = f'{height}p' + + elif stream['codec_type'] == 'audio' and not media_info['audio_codec']: + # Get audio codec + codec_name = stream.get('codec_name', '').lower() + if codec_name: + # Map common audio codec names + audio_map = { + 'aac': 'AAC', + 'ac3': 'AC3', + 'eac3': 'EAC3', + 'dts': 'DTS', + 'mp3': 'MP3', + 'vorbis': 'OGG', + 'opus': 'OPUS', + 'flac': 'FLAC', + 'truehd': 'TrueHD', + 'dca': 'DTS' + } + media_info['audio_codec'] = audio_map.get(codec_name, codec_name.upper()) + + except Exception as e: + print(f"Note: Could not get detailed media info: {e}") + + return media_info + + +class TMDbAPI: + """Interface to The Movie Database (TMDb) API - free and open source""" + + BASE_URL = "https://api.themoviedb.org/3" + + def __init__(self, api_key: Optional[str] = None): + """Initialize with API key (get free key from themoviedb.org)""" + # Priority: 1. Passed parameter, 2. Environment variable, 3. Config file + self.api_key = api_key or os.environ.get('TMDB_API_KEY') + + # Try loading from config file if not found yet + if not self.api_key: + config = load_config() + self.api_key = config.get('api_key') + + if not self.api_key: + print("\nNo TMDb API key found!") + print("Please either:") + print("1. Set TMDB_API_KEY environment variable") + print("2. Pass --api-key parameter") + print("3. Create ~/.plex-renamer.conf with api_key setting") + print("\nGet a free API key at: https://www.themoviedb.org/settings/api") + sys.exit(1) + + def search_movie(self, title: str, year: Optional[int] = None) -> Optional[Dict[str, Any]]: + """Search for a movie by title and optional year""" + params = { + 'api_key': self.api_key, + 'query': title + } + if year: + params['year'] = year + + try: + response = requests.get(f"{self.BASE_URL}/search/movie", params=params) + response.raise_for_status() + data = response.json() + + if data['results']: + # Return the first (most relevant) result + return data['results'][0] + except requests.exceptions.RequestException as e: + print(f"Error searching for movie: {e}") + + return None + + def search_tv(self, title: str, year: Optional[int] = None) -> Optional[Dict[str, Any]]: + """Search for a TV show by title and optional year""" + params = { + 'api_key': self.api_key, + 'query': title + } + if year: + params['first_air_date_year'] = year + + try: + response = requests.get(f"{self.BASE_URL}/search/tv", params=params) + response.raise_for_status() + data = response.json() + + if data['results']: + return data['results'][0] + except requests.exceptions.RequestException as e: + print(f"Error searching for TV show: {e}") + + return None + + def get_movie_details(self, movie_id: int) -> Optional[Dict[str, Any]]: + """Get detailed movie information""" + params = {'api_key': self.api_key} + + try: + response = requests.get(f"{self.BASE_URL}/movie/{movie_id}", params=params) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error getting movie details: {e}") + + return None + + def get_tv_details(self, tv_id: int) -> Optional[Dict[str, Any]]: + """Get detailed TV show information""" + params = {'api_key': self.api_key} + + try: + response = requests.get(f"{self.BASE_URL}/tv/{tv_id}", params=params) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error getting TV show details: {e}") + + return None + + def get_episode_details(self, tv_id: int, season_number: int, episode_number: int) -> Optional[Dict[str, Any]]: + """Get detailed episode information including title""" + params = {'api_key': self.api_key} + + try: + response = requests.get( + f"{self.BASE_URL}/tv/{tv_id}/season/{season_number}/episode/{episode_number}", + params=params + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error getting episode details: {e}") + + return None + + +class PlexFileNamer: + """Format filenames according to Plex naming conventions""" + + @staticmethod + def detect_season_from_folder(file_path: Path) -> Optional[int]: + """ + Detect season number from folder structure + Looks for patterns like: S01, S1, Season 1, Season01, etc. + """ + parent_folder = file_path.parent.name + + season_patterns = [ + r'^[Ss](\d+)$', # S1, S01, s1, s01 + r'^[Ss]eason\s*(\d+)$', # Season 1, Season01, season 1 + r'^(\d+)$', # Just a number (1, 01) + ] + + for pattern in season_patterns: + match = re.match(pattern, parent_folder, re.IGNORECASE) + if match: + return int(match.group(1)) + + return None + + @staticmethod + def extract_show_name_from_path(file_path: Path) -> Optional[str]: + """ + Extract TV show name from folder structure + If parent folder is season folder, use grandparent as show name + """ + parent_folder = file_path.parent.name + + # Check if parent folder is a season folder + season_patterns = [ + r'^[Ss]\d+$', # S1, S01 + r'^[Ss]eason\s*\d+$', # Season 1, Season01 + r'^\d+$', # Just a number + ] + + is_season_folder = any(re.match(pattern, parent_folder, re.IGNORECASE) for pattern in season_patterns) + + if is_season_folder and file_path.parent.parent.name: + # Use grandparent folder as show name + show_name = file_path.parent.parent.name + else: + # Use parent folder as show name + show_name = parent_folder + + # Clean up the show name + show_name = re.sub(r'[._-]+', ' ', show_name).strip() + return show_name if show_name else None + + @staticmethod + def parse_filename(filename: str, parentheses_only: bool = False, skip_episode_detection: bool = False) -> Tuple[str, Optional[int], Optional[str], Optional[str]]: + """ + Parse filename to extract title, year, season, and episode + + Args: + filename: The filename to parse + parentheses_only: If True, only look for years in parentheses (2004) + skip_episode_detection: If True, skip episode/season detection (for movies) + + Returns: (title, year, season, episode) + """ + # Remove file extension + name = Path(filename).stem + + # Pre-process: Remove bracketed optional info (quality, codecs, etc.) + # This handles already-renamed files like "Movie (2019) [1080p H264].mp4" + name = re.sub(r'\s*\[.*?\]\s*', '', name) # Remove [anything] brackets + + # Pre-process: Remove common release group tags and quality info at the end + # This helps with files like "The.Matrix.1999.1080p.BluRay.x264-GROUP" + # Only remove if it's clearly a release group (uppercase, common patterns) + name = re.sub(r'-[A-Z][A-Z0-9]+$', '', name) # Remove -GROUP (uppercase release groups) + + year = None + + if parentheses_only: + # Only look for years in parentheses: " (2004)" at the end + trailing_year_patterns = [ + r'\s*\((\d{4})\)\s*$', # " (2004)" at end + ] + else: + # Look for all year patterns including dot-separated + trailing_year_patterns = [ + r'\.(\d{4})(?:\.|$)', # .1999. or .1999 at end (dot-separated) + r'\s*-\s*(\d{4})\s*$', # " - 2004" at end + r'\s+(\d{4})\s*$', # " 2004" at end + r'\s*\((\d{4})\)\s*$', # " (2004)" at end + ] + + for pattern in trailing_year_patterns: + match = re.search(pattern, name) + if match: + year = int(match.group(1)) + # Remove the year and everything after it for dot-separated files + if '.' in name and pattern == r'\.(\d{4})(?:\.|$)': + name = name[:match.start()] + else: + name = name[:match.start()].strip() + break + + season = None + episode = None + + # Skip episode detection if we're processing a movie + if not skip_episode_detection: + # Check for TV show patterns - PRIORITY ORDER (most specific first) + season_episode_patterns = [ + # Patterns with both season and episode (highest priority) + (r'[Ss](\d+)[Ee](\d+)', 'both'), # S01E01, S1E1 + (r'(\d+)[xX](\d+)', 'both'), # 1x01, 01x01 + (r'[Ss]eason\s*(\d+)\s*[Ee]pisode\s*(\d+)', 'both'), # Season 1 Episode 1 + (r'(\d+)\.(\d+)', 'both'), # 1.01, 01.01 + + # Episode-only patterns (season from folder or default to 1) + (r'[Ee](\d+)', 'episode'), # E01, E1 + (r'[Ee]pisode\s*(\d+)', 'episode'), # Episode 1, Episode 01 + # Only match 3-digit episode codes that are NOT at the start of the title + # This prevents "12 Strong" from being detected as episode 12 + (r'(? Dict[str, Any]: + """ + Comprehensive TV show analysis combining folder structure and filename + + Returns: + Dict with: show_name, season, episode, year, is_tv_show + """ + filename = file_path.name + + # Check if file is in a movie folder first (before parsing) + movie_folder_names = { + 'movies', 'movie', 'films', 'film', 'cinema', 'motion pictures', + 'feature films', 'features', 'theatrical releases' + } + + parent_lower = file_path.parent.name.lower() + grandparent_lower = file_path.parent.parent.name.lower() if file_path.parent.parent.name else '' + + in_movie_folder = (parent_lower in movie_folder_names or + grandparent_lower in movie_folder_names or + 'movie' in parent_lower or + 'film' in parent_lower) + + # Parse filename - skip episode detection if in movie folder + title_from_file, year, season_from_file, episode_from_file = PlexFileNamer.parse_filename(filename, parentheses_only, skip_episode_detection=in_movie_folder) + + # Detect season from folder structure + season_from_folder = PlexFileNamer.detect_season_from_folder(file_path) + + # Extract show name from path + show_name_from_path = PlexFileNamer.extract_show_name_from_path(file_path) + + # Episode detection (REQUIRED for TV shows) + final_episode = int(episode_from_file) if episode_from_file else None + + # Season detection - FILENAME TAKES PRIORITY + final_season = None + if season_from_file: + # Filename has season - use it + final_season = int(season_from_file) + elif final_episode and season_from_folder: + # Filename has episode, folder has season - use folder season + final_season = season_from_folder + elif final_episode: + # Episode in filename but no season anywhere - assume season 1 + final_season = 1 + + # TV show determination - MUST have episode number AND not be in movie folder + is_tv_show = bool(final_episode is not None and not in_movie_folder) + + # Warning for potential TV shows missing episode numbers + has_season_folder = season_from_folder is not None + missing_episode_warning = (has_season_folder and final_episode is None) + + # Show name priority: + # 1. If filename has show name + season/episode, use filename + # 2. If folder structure suggests show name, use that + # 3. Use filename as fallback + if is_tv_show and show_name_from_path and not (season_from_file and title_from_file): + final_show_name = show_name_from_path + else: + final_show_name = title_from_file + + return { + 'show_name': final_show_name, + 'season': final_season, + 'episode': final_episode, + 'year': year, + 'is_tv_show': is_tv_show, + 'missing_episode_warning': missing_episode_warning, + 'debug_info': { + 'title_from_file': title_from_file, + 'season_from_file': season_from_file, + 'season_from_folder': season_from_folder, + 'show_name_from_path': show_name_from_path, + 'parent_folder': file_path.parent.name, + 'grandparent_folder': file_path.parent.parent.name if file_path.parent.parent.name else None, + 'has_season_folder': has_season_folder, + 'in_movie_folder': in_movie_folder, + 'detected_episode': final_episode + } + } + + @staticmethod + def format_movie_name(title: str, year: int, optional_info: Optional[str] = None) -> str: + """ + Format movie filename according to Plex convention + Example: "Movie Title (2020) [1080p BluRay].mp4" + """ + safe_title = re.sub(r'[<>:"/\\|?*]', '', title) + filename = f"{safe_title} ({year})" + if optional_info: + filename += f" [{optional_info}]" + return filename + + @staticmethod + def format_tv_name(show_title: str, season: int, episode: int, + episode_title: Optional[str] = None, year: Optional[int] = None, + optional_info: Optional[str] = None) -> str: + """ + Format TV episode filename according to Plex convention + Example: "Show Name (2020) - s01e01 - Episode Title [1080p BluRay].mp4" + Plex format: ShowName (Year) - sXXeYY - Episode Title [Optional_Info].ext + """ + safe_title = re.sub(r'[<>:"/\\|?*]', '', show_title) + + filename = safe_title + if year: + filename += f" ({year})" + # Use lowercase 's' and 'e' as per Plex specification + filename += f" - s{season:02d}e{episode:02d}" + + if episode_title: + safe_episode_title = re.sub(r'[<>:"/\\|?*]', '', episode_title) + filename += f" - {safe_episode_title}" + + # Add optional info in brackets (ignored by Plex for matching) + if optional_info: + filename += f" [{optional_info}]" + + return filename + + @staticmethod + def combine_optional_info(filename_info: Optional[str], media_info: Dict[str, Any]) -> Optional[str]: + """ + Combine filename-extracted info with actual media info from ffmpeg probe + IMPORTANT: Always uses actual media info (resolution, codecs) over filename claims + Only uses filename for source info (BluRay, WEB-DL) that can't be detected from file + """ + info_parts = [] + + # ALWAYS use resolution from ffmpeg probe (ignores filename claims like "1080p") + if media_info.get('resolution'): + info_parts.append(media_info['resolution']) + + # Source info from filename ONLY (can't be detected from file metadata) + if filename_info: + # Extract source-related keywords from filename info + source_keywords = ['BLURAY', 'WEB-DL', 'WEBDL', 'WEBRIP', 'HDTV', 'PDTV', 'SDTV', 'BRRIP', 'BDRIP', 'DVD', 'DVDRIP'] + for keyword in source_keywords: + if keyword in filename_info.upper(): + if keyword not in ' '.join(info_parts).upper(): + info_parts.append(keyword) + break + + # ALWAYS use video codec from ffmpeg probe (ignores filename claims like "x264") + if media_info.get('video_codec'): + info_parts.append(media_info['video_codec']) + + # ALWAYS use audio codec from ffmpeg probe (ignores filename claims like "AAC") + if media_info.get('audio_codec'): + info_parts.append(media_info['audio_codec']) + + # Add special tags from filename (can't be detected from file) + if filename_info: + special_keywords = ['PROPER', 'REPACK', 'EXTENDED', 'UNRATED', 'DIRECTOR\'S CUT', 'THEATRICAL'] + for keyword in special_keywords: + if keyword in filename_info.upper(): + if keyword not in ' '.join(info_parts).upper(): + info_parts.append(keyword) + + return ' '.join(info_parts) if info_parts else None + + @staticmethod + def extract_optional_info(filename: str) -> Optional[str]: + """ + Extract optional info from filename (quality, source, etc.) + Looks for patterns like: 1080p, BluRay, WEB-DL, x264, etc. + """ + # Remove file extension + name = Path(filename).stem.lower() + + # Common quality and source indicators + optional_patterns = [ + r'1080p', r'720p', r'480p', r'4k', r'2160p', + r'bluray', r'blu-ray', r'brrip', r'bdrip', + r'web-dl', r'webdl', r'webrip', r'web', + r'hdtv', r'pdtv', r'sdtv', + r'x264', r'x265', r'h264', r'h265', r'hevc', + r'aac', r'ac3', r'dts', r'mp3', + r'proper', r'repack', r'extended', r'unrated', + r'director\'?s?\s?cut', r'theatrical' + ] + + found_info = [] + for pattern in optional_patterns: + matches = re.findall(rf'\b{pattern}\b', name, re.IGNORECASE) + for match in matches: + if match.lower() not in [info.lower() for info in found_info]: + found_info.append(match.upper()) + + return ' '.join(found_info) if found_info else None + + +def process_optional_info(file_path: Path, namer: 'PlexFileNamer', media_info: Dict[str, Any]) -> Optional[str]: + """ + Extract optional info from filename, combine with media info, and check for mismatches + + Args: + file_path: Path to the video file + namer: PlexFileNamer instance + media_info: Media information from ffmpeg probe + + Returns: + Combined optional info string or None + """ + filename_optional_info = namer.extract_optional_info(file_path.name) + combined_optional_info = namer.combine_optional_info(filename_optional_info, media_info) + + if combined_optional_info: + print(f"Optional info: {combined_optional_info}") + # Show if actual file data differs from filename claims + check_resolution_mismatch(filename_optional_info, media_info) + + return combined_optional_info + + +def check_resolution_mismatch(filename_optional_info: Optional[str], media_info: Dict[str, Any]) -> None: + """ + Check if the resolution claimed in the filename matches the actual resolution + and print a warning if they differ + + Args: + filename_optional_info: Optional info extracted from filename + media_info: Media information from ffmpeg probe + """ + if filename_optional_info and media_info.get('resolution'): + resolution_checks = [ + ('1080P', '1080p'), + ('720P', '720p'), + ('4K', '4K'), + ('2160P', '4K'), + ('480P', '480p'), + ('1440P', '1440p') + ] + + for claimed, actual in resolution_checks: + if claimed in filename_optional_info.upper() and media_info['resolution'] != actual: + print(f" Note: File is actually {media_info['resolution']}, not {actual} as filename suggests") + break + + +def load_config() -> Dict[str, Any]: + """ + Load configuration from ~/.plex-renamer.conf if it exists + + Returns: + Dict with configuration values, empty dict if no config file + """ + config_path = Path.home() / '.plex-renamer.conf' + config_values = {} + + if config_path.exists(): + try: + config = configparser.ConfigParser() + config.read(config_path) + + # Load default section if it exists + if 'default' in config: + defaults = config['default'] + + # Get all possible config values + if 'api_key' in defaults: + config_values['api_key'] = defaults['api_key'] + if 'default_type' in defaults: + config_values['media_type'] = defaults['default_type'] + if 'parentheses_only' in defaults: + config_values['parentheses_only'] = defaults.getboolean('parentheses_only') + if 'create_backups' in defaults: + config_values['create_backups'] = defaults.getboolean('create_backups') + if 'skip_confirmation' in defaults: + config_values['skip_confirmation'] = defaults.getboolean('skip_confirmation') + + print(f"๐Ÿ“ Loaded config from: {config_path}") + except Exception as e: + print(f"โš ๏ธ Warning: Could not parse config file: {e}") + + return config_values + + +def create_backup_file(old_path: Path, new_path: Path) -> None: + """ + Create or update a backup txt file with the original path/filename for reverting changes + Preserves the very first original filename even across multiple renames + + Args: + old_path: Current file path before this rename + new_path: New file path after this rename + """ + try: + # Check if there's an existing backup file for the current file + old_backup_filename = f"{old_path.stem}.original.txt" + old_backup_path = old_path.parent / old_backup_filename + + # Variables to store the true original filename + true_original_filename = None + true_original_path = None + rename_history = [] + + # If a backup already exists, read the true original filename from it + if old_backup_path.exists(): + try: + with open(old_backup_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + for line in lines: + if line.startswith("Original filename:"): + true_original_filename = line.split(":", 1)[1].strip() + elif line.startswith("Original full path:"): + true_original_path = line.split(":", 1)[1].strip() + elif line.startswith("Rename history:"): + # Parse existing rename history + for history_line in lines[lines.index(line)+1:]: + if history_line.strip() and not history_line.startswith("#"): + rename_history.append(history_line.strip()) + print(f"๐Ÿ“ Found existing backup, preserving original: {true_original_filename}") + # Delete the old backup file as we'll create a new one + old_backup_path.unlink() + except Exception as e: + print(f"โš ๏ธ Warning: Could not read existing backup: {e}") + + # If no true original found, use the current old_path as the original + if not true_original_filename: + true_original_filename = old_path.name + true_original_path = str(old_path.absolute()) + + # Add current rename to history + rename_history.append(f"{__import__('datetime').datetime.now().isoformat()}: {old_path.name} โ†’ {new_path.name}") + + # Create new backup filename based on new filename + backup_filename = f"{new_path.stem}.original.txt" + backup_path = new_path.parent / backup_filename + + # Write backup info to file + with open(backup_path, 'w', encoding='utf-8') as f: + f.write(f"Original filename: {true_original_filename}\n") + f.write(f"Original full path: {true_original_path}\n") + f.write(f"Current filename: {new_path.name}\n") + f.write(f"Last renamed on: {__import__('datetime').datetime.now().isoformat()}\n") + f.write(f"\nRename history:\n") + for entry in rename_history[-10:]: # Keep last 10 renames + f.write(f" {entry}\n") + f.write(f"\n# To revert: mv '{new_path.name}' '{true_original_filename}'\n") + + print(f"๐Ÿ“ Backup info saved: {backup_filename}") + + except Exception as e: + print(f"โš ๏ธ Warning: Could not create backup file: {e}") + + +def revert_renames(folder_path: str, dry_run: bool = False) -> None: + """ + Revert all renamed files in a folder by reading .original.txt backup files + + Args: + folder_path: Path to folder containing backup files + dry_run: If True, show what would be reverted without actually reverting + """ + folder = Path(folder_path) + + if not folder.exists() or not folder.is_dir(): + print(f"Error: Folder does not exist: {folder_path}") + return + + # Find all .original.txt backup files + backup_files = list(folder.rglob("*.original.txt")) + + if not backup_files: + print(f"No backup files found in: {folder_path}") + return + + print(f"\nFound {len(backup_files)} backup file(s)") + print("=" * 60) + + # Show what will be reverted + print("\nFiles that will be reverted:") + for backup_file in backup_files: + try: + with open(backup_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + + original_filename = None + renamed_filename = None + + for line in lines: + if line.startswith("Original filename:"): + original_filename = line.split(":", 1)[1].strip() + elif line.startswith("Current filename:"): + renamed_filename = line.split(":", 1)[1].strip() + elif line.startswith("Renamed to:") and not renamed_filename: + # Handle old format backup files + renamed_filename = line.split(":", 1)[1].strip() + + if original_filename and renamed_filename: + print(f" {renamed_filename} โ†’ {original_filename}") + except: + print(f" {backup_file.name} (couldn't read)") + + # Confirmation prompt (skip in dry-run mode) + if not dry_run: + print(f"\nโš ๏ธ This will revert {len(backup_files)} file(s) and delete their backup files.") + response = input("Are you sure you want to continue? (y/N): ").strip().lower() + + if response not in ['y', 'yes']: + print("Revert cancelled.") + return + + print("\nProcessing reversions...") + print("=" * 60) + + successful = 0 + failed = 0 + skipped = 0 + + for backup_file in backup_files: + print(f"\nProcessing: {backup_file.name}") + print("-" * 40) + + try: + # Read the backup file + with open(backup_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Parse the original filename + original_filename = None + renamed_filename = None + + for line in lines: + if line.startswith("Original filename:"): + original_filename = line.split(":", 1)[1].strip() + elif line.startswith("Current filename:"): + renamed_filename = line.split(":", 1)[1].strip() + elif line.startswith("Renamed to:") and not renamed_filename: + # Handle old format backup files + renamed_filename = line.split(":", 1)[1].strip() + + if not original_filename or not renamed_filename: + print("โŒ Invalid backup file format") + failed += 1 + continue + + # Construct file paths + current_file = backup_file.parent / renamed_filename + original_file = backup_file.parent / original_filename + + if not current_file.exists(): + print(f"โŒ Renamed file not found: {renamed_filename}") + failed += 1 + continue + + if original_file.exists(): + print(f"โš ๏ธ Original filename already exists, skipping: {original_filename}") + skipped += 1 + continue + + if dry_run: + print(f"[DRY RUN] Would revert:") + print(f" From: {renamed_filename}") + print(f" To: {original_filename}") + successful += 1 + else: + # Perform the revert + current_file.rename(original_file) + print(f"โœ“ Reverted: {renamed_filename} โ†’ {original_filename}") + + # Remove the backup file after successful revert + backup_file.unlink() + print(f"๐Ÿ“ Removed backup file: {backup_file.name}") + successful += 1 + + except Exception as e: + print(f"โŒ Error processing {backup_file.name}: {e}") + failed += 1 + + # Summary + print("\n" + "=" * 60) + print("REVERT SUMMARY") + print("=" * 60) + print(f"Total backup files processed: {len(backup_files)}") + print(f"โœ“ Successfully reverted: {successful}") + if failed > 0: + print(f"โŒ Failed: {failed}") + if skipped > 0: + print(f"โš ๏ธ Skipped: {skipped}") + + if dry_run: + print("\n[DRY RUN MODE] No files were actually reverted") + print("Run without --dry-run to perform actual reversion") + + +def process_video_file(file_path: str, api_key: Optional[str] = None, media_type: str = "auto", dry_run: bool = False, parentheses_only: bool = False) -> Optional[str]: + """ + Main function to process a video file: + 1. Extract video duration + 2. Parse filename for title and year + 3. Fetch metadata from TMDb + 4. Generate Plex-compliant filename + + Args: + file_path: Path to the video file + api_key: TMDb API key (or set TMDB_API_KEY env var) + media_type: "movie", "tv", or "auto" (auto-detect) + dry_run: If True, don't actually rename, just return the new path + parentheses_only: If True, only look for years in parentheses format + + Returns: + New filename following Plex conventions + """ + + # Initialize components + inspector = VideoInspector() + tmdb = TMDbAPI(api_key) + namer = PlexFileNamer() + + # Get video file info + file_path = Path(file_path) + if not file_path.exists(): + print(f"File not found: {file_path}") + return None + + print(f"\nProcessing: {file_path.name}") + print("-" * 50) + + # Get detailed media info from ffmpeg probe + media_info = inspector.get_media_info(str(file_path)) + duration_minutes = media_info['duration'] / 60 + + # Display media info + print(f"Video duration: {duration_minutes:.1f} minutes") + if media_info['resolution']: + print(f"Resolution: {media_info['resolution']}") + if media_info.get('raw_resolution'): + print(f"Raw resolution: {media_info['raw_resolution']}") + if media_info['video_codec']: + print(f"Video codec: {media_info['video_codec']}") + if media_info['audio_codec']: + print(f"Audio codec: {media_info['audio_codec']}") + + # Smart TV show analysis + tv_analysis = namer.analyze_tv_show(file_path, parentheses_only) + + print(f"Parsed title: {tv_analysis['show_name']}") + if tv_analysis['year']: + print(f"Parsed year: {tv_analysis['year']}") + if tv_analysis['season'] and tv_analysis['episode']: + print(f"Parsed episode: S{tv_analysis['season']:02d}E{tv_analysis['episode']:02d}") + elif tv_analysis['episode']: + print(f"Parsed episode: E{tv_analysis['episode']:02d}") + + print(f"TV show detected: {tv_analysis['is_tv_show']}") + + # Debug info + debug = tv_analysis['debug_info'] + if debug['detected_episode']: + print(f"Note: Detected episode number {debug['detected_episode']} in filename") + if debug['in_movie_folder']: + print(f"Note: File is in movie folder '{debug['parent_folder']}', treating as movie not TV show") + + # Warning for potential TV shows with missing episode numbers + if tv_analysis['missing_episode_warning']: + print(f"โš ๏ธ WARNING: File is in season folder '{debug['parent_folder']}' but has no episode number!") + print(f" This might be a TV show episode with missing episode info in filename.") + print(f" Consider renaming to include episode number (e.g., S{debug['season_from_folder']:02d}E01, E01, etc.)") + + # Additional debug info + if debug['season_from_folder']: + print(f"Season from folder: {debug['season_from_folder']}") + if debug['show_name_from_path'] and debug['show_name_from_path'] != tv_analysis['show_name']: + print(f"Show name from path: {debug['show_name_from_path']}") + + # Auto-detect media type if needed - TV analysis takes precedence + if media_type == "auto": + media_type = "tv" if tv_analysis['is_tv_show'] else "movie" + print(f"Detected type: {media_type}") + elif media_type != "tv" and tv_analysis['is_tv_show']: + # User forced movie/other type but we detected TV show - warn and override + print(f"โš ๏ธ WARNING: --type {media_type} specified but detected TV show with S{tv_analysis['season']:02d}E{tv_analysis['episode']:02d}") + print(f" Overriding to --type tv for proper TV show processing") + media_type = "tv" + + # Extract individual values for compatibility + title = tv_analysis['show_name'] + year = tv_analysis['year'] + season = tv_analysis['season'] + episode = tv_analysis['episode'] + + # Search for metadata + print(f"\nSearching TMDb for: {title}") + + if media_type == "tv": + # Search for TV show + show = tmdb.search_tv(title, year) + if show: + print(f"Found TV show: {show['name']} ({show.get('first_air_date', 'N/A')[:4]})") + + # Get detailed show info + details = tmdb.get_tv_details(show['id']) + if details: + show_year = int(details['first_air_date'][:4]) if details.get('first_air_date') else None + + # Get episode details for episode title + episode_title = None + if season and episode: + episode_details = tmdb.get_episode_details(show['id'], int(season), int(episode)) + if episode_details and episode_details.get('name'): + episode_title = episode_details['name'] + print(f"Found episode title: {episode_title}") + + # Extract optional info from filename and combine with media info + combined_optional_info = process_optional_info(file_path, namer, media_info) + + # Generate new filename + if season and episode: + new_name = namer.format_tv_name( + details['name'], + int(season), + int(episode), + episode_title=episode_title, + year=show_year, + optional_info=combined_optional_info + ) + else: + # Default to S01E01 if not detected + new_name = namer.format_tv_name( + details['name'], + 1, 1, + year=show_year, + optional_info=combined_optional_info + ) + else: + print("Could not fetch show details") + return None + else: + print("TV show not found in TMDb") + return None + + else: # movie + # Search for movie + movie = tmdb.search_movie(title, year) + if movie: + print(f"Found movie: {movie['title']} ({movie.get('release_date', 'N/A')[:4]})") + + # Get detailed movie info + details = tmdb.get_movie_details(movie['id']) + if details: + movie_year = int(details['release_date'][:4]) if details.get('release_date') else year or 0 + + # Extract optional info from filename and combine with media info + combined_optional_info = process_optional_info(file_path, namer, media_info) + + # Generate new filename + new_name = namer.format_movie_name(details['title'], movie_year, combined_optional_info) + else: + print("Could not fetch movie details") + return None + else: + print("Movie not found in TMDb") + return None + + # Add original extension + new_filename = new_name + file_path.suffix + + # Create full path + new_path = file_path.parent / new_filename + + if dry_run: + print(f"\n[DRY RUN] Would rename:") + print(f" From: {file_path.name}") + print(f" To: {new_filename}") + else: + print(f"\nSuggested Plex filename: {new_filename}") + print(f"Full path: {new_path}") + + return str(new_path) + + +def get_video_files(path: str) -> List[Path]: + """ + Get all video files from a path (file or directory) + + Args: + path: File or directory path + + Returns: + List of video file paths + """ + video_extensions = { + '.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', + '.m4v', '.mpg', '.mpeg', '.3gp', '.3g2', '.ts', '.mts', + '.m2ts', '.vob', '.ogv', '.divx', '.xvid', '.rm', '.rmvb' + } + + path_obj = Path(path) + video_files = [] + + if not path_obj.exists(): + print(f"Error: Path does not exist: {path}") + return [] + + if path_obj.is_file(): + # Single file + if path_obj.suffix.lower() in video_extensions: + video_files.append(path_obj) + elif path_obj.is_dir(): + # Directory - walk recursively using os.walk (better for network shares) + try: + for root, _, files in os.walk(path): + for file in files: + file_path = Path(root) / file + if file_path.suffix.lower() in video_extensions: + video_files.append(file_path) + except PermissionError as e: + print(f"\nโš ๏ธ Permission denied accessing directory: {path}") + print(f" Please grant Terminal/VS Code access in System Settings โ†’ Privacy & Security โ†’ Files and Folders") + return [] + except Exception as e: + print(f"Error accessing directory: {e}") + return [] + return sorted(video_files) + + +def process_path(path: str, api_key: Optional[str] = None, media_type: str = "auto", + dry_run: bool = False, rename: bool = False, parentheses_only: bool = False, + skip_confirmation: bool = False) -> None: + """ + Process a file or directory of video files + + Args: + path: File or directory path + api_key: TMDb API key + media_type: "movie", "tv", or "auto" + dry_run: Preview mode without renaming + rename: Actually rename files (ignored if dry_run is True) + skip_confirmation: Skip confirmation prompt for each rename + """ + video_files = get_video_files(path) + + if not video_files: + print(f"No video files found in: {path}") + return + + print(f"\nFound {len(video_files)} video file(s)") + print("=" * 60) + + successful = 0 + failed = 0 + skipped = 0 + + for i, video_file in enumerate(video_files, 1): + print(f"\n[{i}/{len(video_files)}] Processing: {video_file}") + print("-" * 60) + + try: + new_path = process_video_file(str(video_file), api_key, media_type, dry_run, parentheses_only) + + if new_path: + old_path = Path(video_file) + new_path = Path(new_path) + + if not dry_run and rename: + if old_path != new_path: + if new_path.exists(): + print(f"โš ๏ธ Target file already exists, skipping") + skipped += 1 + else: + # Ask for confirmation unless skip_confirmation is True + should_rename = skip_confirmation + if not skip_confirmation: + print(f"\nRename this file?") + print(f" From: {old_path.name}") + print(f" To: {new_path.name}") + response = input("Confirm rename? (y/N): ").strip().lower() + should_rename = response in ['y', 'yes'] + + if should_rename: + # Create backup file before renaming + create_backup_file(old_path, new_path) + # Perform the rename + old_path.rename(new_path) + print(f"โœ“ File renamed successfully") + successful += 1 + else: + print("โš ๏ธ Rename skipped by user") + skipped += 1 + else: + print("File already has correct name") + skipped += 1 + elif dry_run: + successful += 1 + else: + successful += 1 + else: + print("โŒ Could not determine new filename") + failed += 1 + + except Exception as e: + print(f"โŒ Error processing file: {e}") + failed += 1 + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f"Total files processed: {len(video_files)}") + print(f"โœ“ Successful: {successful}") + if failed > 0: + print(f"โŒ Failed: {failed}") + if skipped > 0: + print(f"โš ๏ธ Skipped: {skipped}") + + if dry_run: + print("\n[DRY RUN MODE] No files were actually renamed") + print("Run without --dry-run to perform actual renaming") + + +def main(): + """Command-line interface""" + import argparse + + # Load config file for defaults + config = load_config() + + parser = argparse.ArgumentParser(description="Rename video files according to Plex naming conventions") + parser.add_argument("path", help="Path to video file or directory") + parser.add_argument("--api-key", default=config.get('api_key'), + help="TMDb API key (or set TMDB_API_KEY env var or use config file)") + parser.add_argument("--type", choices=["movie", "tv", "auto"], + default=config.get('media_type', 'auto'), + help="Media type (default: auto-detect)") + parser.add_argument("--rename", action="store_true", + help="Actually rename the files (without this, only shows what would be renamed)") + parser.add_argument("--dry-run", action="store_true", + help="Preview mode - show what would be renamed without actually renaming") + parser.add_argument("--yes", "-y", action="store_true", dest="skip_confirmation", + default=config.get('skip_confirmation', False), + help="Skip confirmation prompt for each rename (auto-approve all renames)") + parser.add_argument("--parentheses-only", action="store_true", + default=config.get('parentheses_only', False), + help="Only look for years in parentheses format (2004), not - 2004 or space 2004") + parser.add_argument("--revert", action="store_true", + help="Revert all renames in the specified folder using backup files") + + args = parser.parse_args() + + # Handle revert mode + if args.revert: + revert_renames(args.path, args.dry_run) + else: + # Process the path (file or directory) + process_path(args.path, args.api_key, args.type, args.dry_run, args.rename, args.parentheses_only, + args.skip_confirmation) + + +if __name__ == "__main__": + main() + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..249c88d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +ffmpeg-python>=0.2.0 +requests>=2.31.0 \ No newline at end of file