Last active
August 27, 2025 05:04
-
-
Save SingularReza/c2063c017364564b165447147341ca12 to your computer and use it in GitHub Desktop.
A bash script to import "LOVE RATING" values from FLAC metadata to jellyfin favorites and vice versa. Generated from pseudocode using AI so use it at your own risk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| ################################################################################### | |
| # Jellyfin FLAC Favorites and Play Count Bidirectional Sync Script | |
| ################################################################################### | |
| # | |
| # DESCRIPTION: | |
| # This script provides bidirectional synchronization between FLAC files and | |
| # Jellyfin favorites/play counts based on Vorbis comment tags. | |
| # | |
| # WHAT IT DOES: | |
| # DIRECTION 1: FLAC → Jellyfin | |
| # - Scans FLAC files with "LOVE RATING" = "L" and marks as favorites | |
| # - Updates play counts from "PLAYCOUNT" vorbis comment tags | |
| # - Can control whether to sync favorites, playcount, or both | |
| # - Can limit to N most recently modified files | |
| # | |
| # DIRECTION 2: Jellyfin → FLAC | |
| # - Gets favorite songs from Jellyfin and sets "LOVE RATING" = "L" | |
| # - Can sort by "last played" or "last added" and limit results | |
| # | |
| # UNICODE FILENAME SUPPORT: | |
| # Sets locale specifically for metaflac commands to handle UTF-8 metadata. | |
| # | |
| ################################################################################### | |
| # | |
| # USAGE INSTRUCTIONS AND EXAMPLES | |
| # | |
| ################################################################################### | |
| # | |
| # BASIC SYNTAX: | |
| # ./script.sh <FOLDER> <API_KEY> [SYNC_DIRECTION] [SORT_CRITERIA] [ITEM_LIMIT] [FLAC_LIMIT] [PLAYCOUNT_LIMIT] [FLAC_SYNC_TYPE] | |
| # | |
| # PARAMETERS EXPLAINED: | |
| # FOLDER - Full path to your music directory containing FLAC files | |
| # Script searches recursively through all subdirectories | |
| # Use quotes if path contains spaces | |
| # | |
| # API_KEY - Your Jellyfin API key (32+ character string) | |
| # Get this from: Jellyfin Admin → Advanced → API Keys | |
| # | |
| # SYNC_DIRECTION - Optional, defaults to "both" | |
| # "both" = Sync in both directions | |
| # "to-jellyfin" = Only FLAC → Jellyfin (favorites + play counts) | |
| # "to-flac" = Only Jellyfin → FLAC (favorites only) | |
| # | |
| # SORT_CRITERIA - Optional, for Jellyfin → FLAC sync only | |
| # "last-played" = Sort favorites by last played date | |
| # "last-added" = Sort favorites by date added to library | |
| # Leave empty ("") if not needed | |
| # | |
| # ITEM_LIMIT - Optional, requires SORT_CRITERIA | |
| # Number of most recent Jellyfin favorites to process | |
| # Only affects Jellyfin → FLAC direction | |
| # | |
| # FLAC_LIMIT - Optional, for FLAC → Jellyfin favorites sync | |
| # Number of most recently modified FLAC files to process | |
| # Only affects favorites sync, not play count sync | |
| # | |
| # PLAYCOUNT_LIMIT - Optional, for FLAC → Jellyfin play count sync | |
| # Number of most recently modified FLAC files to process | |
| # Independent from FLAC_LIMIT | |
| # | |
| # FLAC_SYNC_TYPE - Optional, defaults to "both", controls FLAC → Jellyfin sync | |
| # "both" = Sync both favorites and play counts | |
| # "favorites" = Sync only favorites (ignore PLAYCOUNT tags) | |
| # "playcount" = Sync only play counts (ignore LOVE RATING tags) | |
| # Only affects "to-jellyfin" and "both" sync directions | |
| # | |
| ################################################################################### | |
| # | |
| # USAGE EXAMPLES: | |
| # | |
| # 1. BASIC USAGE - Full bidirectional sync (all files, both favorites and play counts): | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-jellyfin-api-key" | |
| # | |
| # This will: | |
| # - Process ALL FLAC files for favorites and play counts → Jellyfin | |
| # - Process ALL Jellyfin favorites → FLAC files | |
| # | |
| # 2. FAVORITES ONLY - Sync only favorites, ignore play counts completely: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "both" "" "" "" "" "favorites" | |
| # | |
| # This will: | |
| # - Sync favorites in both directions | |
| # - Skip play count sync completely | |
| # - Faster than full sync | |
| # | |
| # 3. PLAY COUNTS ONLY - Sync only play counts, ignore favorites: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "to-jellyfin" "" "" "" 100 "playcount" | |
| # | |
| # This will: | |
| # - Read FLAC "PLAYCOUNT" tags → update Jellyfin play counts (100 recent files) | |
| # - Ignore FLAC "LOVE RATING" tags completely | |
| # - NOT modify any FLAC files or sync favorites | |
| # | |
| # 4. ONE-WAY SYNC - FLAC to Jellyfin only (both types): | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "to-jellyfin" | |
| # | |
| # This will: | |
| # - Read FLAC "LOVE RATING" tags → mark Jellyfin favorites | |
| # - Read FLAC "PLAYCOUNT" tags → update Jellyfin play counts | |
| # - NOT modify any FLAC files | |
| # | |
| # 5. ONE-WAY SYNC - Jellyfin to FLAC only: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "to-flac" | |
| # | |
| # This will: | |
| # - Read Jellyfin favorites → set FLAC "LOVE RATING" = "L" | |
| # - NOT read FLAC files or update Jellyfin | |
| # - FLAC_SYNC_TYPE parameter is ignored for this direction | |
| # | |
| # 6. LIMITED RECENT FILES - Process only recently modified files with type control: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "to-jellyfin" "" "" 50 100 "both" | |
| # | |
| # This will: | |
| # - Process 50 most recently modified FLAC files for favorites | |
| # - Process 100 most recently modified FLAC files for play counts | |
| # - Useful for regular incremental updates | |
| # | |
| # 7. FAVORITES ONLY WITH LIMITS - Fast favorites-only sync: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "both" "last-played" 25 50 "" "favorites" | |
| # | |
| # This will: | |
| # - FLAC → Jellyfin: 50 recent files for favorites only | |
| # - Jellyfin → FLAC: 25 most recently played favorites | |
| # - Skip all play count processing (faster) | |
| # | |
| # 8. PLAY COUNTS MAINTENANCE - Update only play counts from recent files: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "to-jellyfin" "" "" "" 200 "playcount" | |
| # | |
| # This will: | |
| # - Process 200 most recently modified FLAC files for play counts only | |
| # - Skip favorites processing entirely | |
| # - Good for daily play count updates | |
| # | |
| # 9. TESTING - Process only a few files for testing: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "to-jellyfin" "" "" 5 10 "both" | |
| # | |
| # This will: | |
| # - Process only 5 recent FLAC files for favorites | |
| # - Process only 10 recent FLAC files for play counts | |
| # - Good for testing the script before full sync | |
| # | |
| # 10. DAILY SYNC - Typical daily maintenance (favorites only for speed): | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "both" "last-played" 100 50 "" "favorites" | |
| # | |
| # This will: | |
| # - Sync 50 most recently modified FLAC files (favorites only) | |
| # - Sync 100 most recently played Jellyfin favorites | |
| # - Good for daily cron job (faster than full sync) | |
| # | |
| # 11. WEEKLY PLAY COUNT UPDATE - Update play counts from all recent activity: | |
| # ./jellyfin_sync.sh "/home/user/Music" "your-api-key" "to-jellyfin" "" "" "" 500 "playcount" | |
| # | |
| # This will: | |
| # - Update play counts from 500 most recently modified FLAC files | |
| # - Skip favorites (if you sync those daily) | |
| # - Good for weekly cron job | |
| # | |
| # 12. MUSIC PATH WITH SPACES - Handle paths containing spaces: | |
| # ./jellyfin_sync.sh "/home/user/My Music Collection" "your-api-key" "both" "" "" "" "" "favorites" | |
| # | |
| # Always use quotes around paths with spaces | |
| # | |
| ################################################################################### | |
| # | |
| # COMMON USE CASES: | |
| # | |
| # INITIAL SETUP - First time running the script: | |
| # 1. Backup your FLAC files first! | |
| # 2. Start with a test: ./script.sh "/path/to/music" "api-key" "to-jellyfin" "" "" 10 10 "both" | |
| # 3. Check results, then run full sync: ./script.sh "/path/to/music" "api-key" "both" | |
| # | |
| # REGULAR MAINTENANCE - Daily/weekly updates: | |
| # Daily favorites: ./script.sh "/path/to/music" "api-key" "both" "last-played" 100 50 "" "favorites" | |
| # Weekly play counts: ./script.sh "/path/to/music" "api-key" "to-jellyfin" "" "" "" 500 "playcount" | |
| # | |
| # PERFORMANCE OPTIMIZATION - For large libraries: | |
| # Fast daily sync: Use "favorites" only with small limits (50-100 files) | |
| # Weekly full sync: Use "both" with larger limits or no limits | |
| # Play count only: Use "playcount" when you only need statistics updates | |
| # | |
| # AFTER IMPORTING NEW MUSIC - Sync only recent files: | |
| # ./script.sh "/path/to/music" "api-key" "to-jellyfin" "" "" 100 100 "both" | |
| # | |
| # BACKUP JELLYFIN FAVORITES - Save favorites to FLAC metadata: | |
| # ./script.sh "/path/to/music" "api-key" "to-flac" "last-added" 1000 | |
| # | |
| # MIGRATE FROM OTHER PLAYER - Import existing ratings and play counts: | |
| # ./script.sh "/path/to/music" "api-key" "to-jellyfin" "" "" "" "" "both" | |
| # | |
| # SELECTIVE SYNC - Choose what to sync based on your needs: | |
| # Only care about favorites: Use "favorites" type | |
| # Only care about statistics: Use "playcount" type | |
| # Want everything: Use "both" type (default) | |
| # | |
| ################################################################################### | |
| # | |
| # IMPORTANT NOTES: | |
| # | |
| # 1. BACKUP FIRST! This script modifies FLAC file metadata | |
| # cp -r "/your/music/folder" "/backup/location/" | |
| # | |
| # 2. FLAC METADATA REQUIREMENTS: | |
| # - "LOVE RATING" tag must be exactly "L" (case sensitive) | |
| # - "PLAYCOUNT" tag must be a positive integer (1, 2, 3, etc.) | |
| # - Files must have "TITLE" and "ALBUM" tags for matching | |
| # | |
| # 3. JELLYFIN SETUP: | |
| # - Get API key from: Admin Dashboard → Advanced → API Keys | |
| # - Ensure your music library is fully scanned | |
| # - Update JELLYFIN_URL variable in script if not localhost:8096 | |
| # | |
| # 4. PERFORMANCE TIPS: | |
| # - Use "favorites" type for faster daily syncs | |
| # - Use "playcount" type for statistics-only updates | |
| # - Use limits for large libraries (10,000+ files) | |
| # - Play count sync is slower (multiple API calls per file) | |
| # - Run with smaller limits first to test | |
| # | |
| # 5. SYNC TYPE USAGE: | |
| # - "favorites": Fast, only processes LOVE RATING tags | |
| # - "playcount": Slower, only processes PLAYCOUNT tags | |
| # - "both": Complete sync, processes both tag types | |
| # - FLAC_SYNC_TYPE only affects FLAC → Jellyfin direction | |
| # | |
| # 6. LIMITATIONS: | |
| # - Cannot decrease play counts in Jellyfin (API limitation) | |
| # - Exact TITLE/ALBUM matching required | |
| # - Unicode filenames require proper locale support | |
| # - FLAC_SYNC_TYPE is ignored for "to-flac" sync direction | |
| # | |
| # 7. TROUBLESHOOTING: | |
| # - Check /tmp/jellyfin_favorites_debug.json for API responses | |
| # - Run with small limits first (5-10 files) to debug issues | |
| # - Ensure FLAC files are writable for metadata updates | |
| # - Use "favorites" type first to test basic functionality | |
| # | |
| ################################################################################### | |
| # | |
| # CRON JOB EXAMPLES: | |
| # | |
| # Fast daily favorites sync at 2 AM: | |
| # 0 2 * * * /path/to/jellyfin_sync.sh "/home/user/Music" "api-key" "both" "last-played" 100 50 "" "favorites" >> /var/log/jellyfin_sync_daily.log 2>&1 | |
| # | |
| # Weekly full sync on Sunday at 3 AM: | |
| # 0 3 * * 0 /path/to/jellyfin_sync.sh "/home/user/Music" "api-key" "both" "last-played" 500 200 300 "both" >> /var/log/jellyfin_sync_weekly.log 2>&1 | |
| # | |
| # Play count only sync every 6 hours: | |
| # 0 */6 * * * /path/to/jellyfin_sync.sh "/home/user/Music" "api-key" "to-jellyfin" "" "" "" 100 "playcount" >> /var/log/jellyfin_sync_playcount.log 2>&1 | |
| # | |
| # Hourly favorites only (very recent files): | |
| # 0 * * * * /path/to/jellyfin_sync.sh "/home/user/Music" "api-key" "both" "" "" 10 "" "favorites" >> /var/log/jellyfin_sync_hourly.log 2>&1 | |
| # | |
| ################################################################################### | |
| # Check if required arguments are provided | |
| if [ $# -lt 2 ] || [ $# -gt 8 ]; then | |
| echo "Usage: $0 <FOLDER> <API_KEY> [SYNC_DIRECTION] [SORT_CRITERIA] [ITEM_LIMIT] [FLAC_LIMIT] [PLAYCOUNT_LIMIT] [FLAC_SYNC_TYPE]" | |
| echo " FOLDER: Directory path containing FLAC files" | |
| echo " API_KEY: Jellyfin API key" | |
| echo " SYNC_DIRECTION: 'both' (default), 'to-jellyfin', or 'to-flac'" | |
| echo " SORT_CRITERIA: 'last-played' or 'last-added' (for to-flac/both modes)" | |
| echo " ITEM_LIMIT: Number of most recent items to process (requires SORT_CRITERIA)" | |
| echo " FLAC_LIMIT: Number of most recently modified FLAC files for favorites sync" | |
| echo " PLAYCOUNT_LIMIT: Number of most recently modified FLAC files for play count sync" | |
| echo " FLAC_SYNC_TYPE: 'both' (default), 'favorites', or 'playcount' (for FLAC→Jellyfin sync)" | |
| echo "" | |
| echo "Examples:" | |
| echo " $0 \"/home/user/Music\" \"your-api-key\"" | |
| echo " $0 \"/mnt/music\" \"your-api-key\" \"both\" \"\" \"\" \"\" \"\" \"favorites\"" | |
| echo " $0 \"/home/user/Music\" \"your-api-key\" \"to-jellyfin\" \"\" \"\" 100 50 \"both\"" | |
| echo " $0 \"/home/user/Music\" \"your-api-key\" \"both\" \"last-played\" 25 50 100 \"playcount\"" | |
| echo "" | |
| echo "See script comments for detailed usage instructions and examples" | |
| exit 1 | |
| fi | |
| FOLDER="$1" | |
| KEY="$2" | |
| SYNC_DIRECTION="${3:-both}" | |
| SORT_CRITERIA="$4" | |
| ITEM_LIMIT="$5" | |
| FLAC_LIMIT="$6" | |
| PLAYCOUNT_LIMIT="$7" | |
| FLAC_SYNC_TYPE="${8:-both}" | |
| # Validate sync direction | |
| if [[ ! "$SYNC_DIRECTION" =~ ^(both|to-jellyfin|to-flac)$ ]]; then | |
| echo "Error: Invalid sync direction. Use 'both', 'to-jellyfin', or 'to-flac'" | |
| exit 1 | |
| fi | |
| # Validate sort criteria if provided | |
| if [ -n "$SORT_CRITERIA" ]; then | |
| if [[ ! "$SORT_CRITERIA" =~ ^(last-played|last-added)$ ]]; then | |
| echo "Error: Invalid sort criteria. Use 'last-played' or 'last-added'" | |
| exit 1 | |
| fi | |
| fi | |
| # Validate item limit if provided | |
| if [ -n "$ITEM_LIMIT" ]; then | |
| if [ -z "$SORT_CRITERIA" ]; then | |
| echo "Error: ITEM_LIMIT requires SORT_CRITERIA to be specified" | |
| exit 1 | |
| fi | |
| if ! [[ "$ITEM_LIMIT" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "Error: ITEM_LIMIT must be a positive integer" | |
| exit 1 | |
| fi | |
| fi | |
| # Validate FLAC limit if provided | |
| if [ -n "$FLAC_LIMIT" ]; then | |
| if ! [[ "$FLAC_LIMIT" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "Error: FLAC_LIMIT must be a positive integer" | |
| exit 1 | |
| fi | |
| fi | |
| # Validate play count limit if provided | |
| if [ -n "$PLAYCOUNT_LIMIT" ]; then | |
| if ! [[ "$PLAYCOUNT_LIMIT" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "Error: PLAYCOUNT_LIMIT must be a positive integer" | |
| exit 1 | |
| fi | |
| fi | |
| # Validate FLAC sync type if provided | |
| if [[ ! "$FLAC_SYNC_TYPE" =~ ^(both|favorites|playcount)$ ]]; then | |
| echo "Error: Invalid FLAC sync type. Use 'both', 'favorites', or 'playcount'" | |
| exit 1 | |
| fi | |
| # Check if folder exists | |
| if [ ! -d "$FOLDER" ]; then | |
| echo "Error: Directory '$FOLDER' does not exist" | |
| echo "Please check the path and try again." | |
| exit 1 | |
| fi | |
| # Check if required tools are available | |
| if ! command -v metaflac &> /dev/null; then | |
| echo "Error: metaflac command not found." | |
| echo "Install with: sudo apt install flac (Ubuntu/Debian) or brew install flac (macOS)" | |
| exit 1 | |
| fi | |
| if ! command -v jq &> /dev/null; then | |
| echo "Error: jq command not found." | |
| echo "Install with: sudo apt install jq (Ubuntu/Debian) or brew install jq (macOS)" | |
| exit 1 | |
| fi | |
| if ! command -v curl &> /dev/null; then | |
| echo "Error: curl command not found." | |
| echo "Install with: sudo apt install curl (Ubuntu/Debian) or brew install curl (macOS)" | |
| exit 1 | |
| fi | |
| # Jellyfin server URL - MODIFY THIS if your server is not on localhost:8096 | |
| JELLYFIN_URL="http://localhost:8096" | |
| # Function to run metaflac with proper UTF-8 locale | |
| run_metaflac() { | |
| # Try different locale options for UTF-8 support | |
| local locale_options=("en_US.UTF-8" "C.UTF-8" "en_GB.UTF-8") | |
| for loc in "${locale_options[@]}"; do | |
| if locale -a 2>/dev/null | grep -q "^${loc}$"; then | |
| LC_ALL="$loc" LANG="$loc" metaflac "$@" | |
| return $? | |
| fi | |
| done | |
| # Fallback to system default | |
| metaflac "$@" | |
| return $? | |
| } | |
| # Function to get user ID (needed for favorites API) | |
| get_user_id() { | |
| curl -s -H "X-Emby-Token: $KEY" \ | |
| "$JELLYFIN_URL/Users" | \ | |
| jq -r '.[0].Id' 2>/dev/null | |
| } | |
| # Enhanced function to get all favorite items with additional debugging | |
| get_favorites_with_paths() { | |
| local user_id="$1" | |
| local sort_criteria="$2" | |
| local item_limit="$3" | |
| # Build URL with base parameters | |
| local url="$JELLYFIN_URL/Users/$user_id/Items?isFavorite=true&includeItemTypes=Audio&recursive=true&fields=Path,MediaSources" | |
| # Add sorting parameters if specified | |
| if [ -n "$sort_criteria" ]; then | |
| case "$sort_criteria" in | |
| "last-played") | |
| url="${url}&sortBy=DatePlayed&sortOrder=Descending" | |
| ;; | |
| "last-added") | |
| url="${url}&sortBy=DateCreated&sortOrder=Descending" | |
| ;; | |
| esac | |
| fi | |
| # Add limit if specified | |
| if [ -n "$item_limit" ]; then | |
| url="${url}&limit=$item_limit" | |
| fi | |
| echo "[DEBUG] API URL: $url" >&2 | |
| # Get raw response and save for debugging | |
| local raw_response | |
| raw_response=$(curl -s -H "X-Emby-Token: $KEY" "$url") | |
| # Save raw response to file for debugging | |
| echo "$raw_response" > "/tmp/jellyfin_favorites_debug.json" | |
| echo "[DEBUG] Raw API response saved to /tmp/jellyfin_favorites_debug.json" >&2 | |
| # Try different approaches to get file paths | |
| echo "$raw_response" | jq -r '.Items[] | | |
| if .Path then | |
| "\(.Name)|\(.Album)|\(.Path)" | |
| elif (.MediaSources | length > 0) then | |
| "\(.Name)|\(.Album)|\(.MediaSources[0].Path)" | |
| else | |
| "\(.Name)|\(.Album)|NO_PATH_AVAILABLE" | |
| end' | |
| } | |
| # Function to get FLAC files sorted by modification time | |
| get_recent_flac_files() { | |
| local folder="$1" | |
| local limit="$2" | |
| echo "Getting FLAC files sorted by modification time..." >&2 | |
| if [ -n "$limit" ]; then | |
| echo "Limiting to $limit most recently modified files" >&2 | |
| # Use find with printf to get modification time and path, sort by time (newest first), take top N | |
| find "$folder" -type f -iname "*.flac" -printf '%T@ %p\n' 2>/dev/null | \ | |
| sort -nr | \ | |
| head -n "$limit" | \ | |
| cut -d' ' -f2- | |
| else | |
| echo "Processing all FLAC files" >&2 | |
| find "$folder" -type f -iname "*.flac" 2>/dev/null | |
| fi | |
| } | |
| # Function to search for item in Jellyfin | |
| search_item() { | |
| local album="$1" | |
| local title="$2" | |
| local user_id="$3" | |
| # URL encode the search terms | |
| local encoded_title=$(printf '%s' "$title" | jq -sRr @uri) | |
| # Search for the item by title first, then filter by album | |
| curl -s -H "X-Emby-Token: $KEY" \ | |
| "$JELLYFIN_URL/Users/$user_id/Items?searchTerm=$encoded_title&includeItemTypes=Audio&recursive=true" | \ | |
| jq -r --arg album "$album" --arg title "$title" \ | |
| '.Items[] | select(.Album == $album and .Name == $title) | .Id' | head -n 1 | |
| } | |
| # Function to find FLAC file by filename and verify album (FIXED exclusion) | |
| find_flac_by_filename() { | |
| local filename="$1" | |
| local expected_album="$2" | |
| MATCH_ERROR="" | |
| local excluded_files=() | |
| local max_attempts=5 | |
| local attempt=1 | |
| echo " Debug: Searching for filename: '$filename'" >&2 | |
| echo " Debug: Expected album: '$expected_album'" >&2 | |
| while [ $attempt -le $max_attempts ]; do | |
| echo " Debug: Attempt $attempt" >&2 | |
| if [ ${#excluded_files[@]} -gt 0 ]; then | |
| echo " Debug: Excluding ${#excluded_files[@]} files: ${excluded_files[*]}" >&2 | |
| fi | |
| local found_file="" | |
| local search_method="" | |
| local all_candidates=() | |
| # First try: exact filename match (case-sensitive) | |
| if [ ${#all_candidates[@]} -eq 0 ]; then | |
| mapfile -t all_candidates < <(find "$FOLDER" -type f -name "$filename" 2>/dev/null) | |
| search_method="exact match" | |
| fi | |
| # Second try: case-insensitive search if exact match fails | |
| if [ ${#all_candidates[@]} -eq 0 ]; then | |
| echo " Debug: Exact match failed, trying case-insensitive search..." >&2 | |
| mapfile -t all_candidates < <(find "$FOLDER" -type f -iname "$filename" 2>/dev/null) | |
| search_method="case-insensitive match" | |
| fi | |
| # Third try: wildcard search in case there are encoding issues | |
| if [ ${#all_candidates[@]} -eq 0 ]; then | |
| echo " Debug: Case-insensitive failed, trying wildcard search..." >&2 | |
| local basename_without_ext="${filename%.*}" | |
| mapfile -t all_candidates < <(find "$FOLDER" -type f -name "*${basename_without_ext}*" -iname "*.flac" 2>/dev/null) | |
| search_method="wildcard search" | |
| fi | |
| # Filter out excluded files | |
| for candidate in "${all_candidates[@]}"; do | |
| local is_excluded=false | |
| # Check if this candidate is in our exclusion list | |
| for excluded in "${excluded_files[@]}"; do | |
| if [ "$candidate" = "$excluded" ]; then | |
| is_excluded=true | |
| break | |
| fi | |
| done | |
| # If not excluded, use this candidate | |
| if [ "$is_excluded" = false ]; then | |
| found_file="$candidate" | |
| break | |
| fi | |
| done | |
| # If no file found at all, exit with error | |
| if [ -z "$found_file" ]; then | |
| if [ ${#excluded_files[@]} -eq 0 ]; then | |
| MATCH_ERROR="Filename '$filename' not found in directory (tried exact, case-insensitive, and wildcard searches)" | |
| else | |
| MATCH_ERROR="Filename '$filename' not found in directory after excluding ${#excluded_files[@]} files with wrong album tags" | |
| fi | |
| return 1 | |
| fi | |
| echo " Debug: Found file via $search_method: '$found_file'" >&2 | |
| # Check if it's a FLAC file | |
| if [[ ! "$found_file" =~ \.(flac|FLAC)$ ]]; then | |
| echo " Debug: File is not FLAC, excluding and retrying..." >&2 | |
| excluded_files+=("$found_file") | |
| ((attempt++)) | |
| continue | |
| fi | |
| # Get ALBUM tag from the file using UTF-8 locale | |
| local file_album | |
| file_album=$(run_metaflac --show-tag=ALBUM "$found_file" 2>/dev/null | cut -d'=' -f2-) | |
| if [ -z "$file_album" ]; then | |
| echo " Debug: File has no ALBUM tag, excluding and retrying..." >&2 | |
| excluded_files+=("$found_file") | |
| ((attempt++)) | |
| continue | |
| fi | |
| echo " Debug: File album tag: '$file_album'" >&2 | |
| # Check if album matches | |
| if [ "$file_album" != "$expected_album" ]; then | |
| echo " Debug: ALBUM tag mismatch (expected: '$expected_album', found: '$file_album'), excluding and retrying..." >&2 | |
| excluded_files+=("$found_file") | |
| ((attempt++)) | |
| continue | |
| fi | |
| # Success - both filename found and album matches | |
| echo " Debug: Album matches! Returning file: '$found_file'" >&2 | |
| echo "$found_file" | |
| return 0 | |
| done | |
| # If we get here, we've exhausted all attempts | |
| MATCH_ERROR="Filename '$filename' found ${#excluded_files[@]} candidate files but none had matching album tag '$expected_album' after $max_attempts attempts" | |
| return 1 | |
| } | |
| # Function to mark item as favorite | |
| mark_favorite() { | |
| local item_id="$1" | |
| local user_id="$2" | |
| local response=$(curl -s -w "%{http_code}" -X POST -H "X-Emby-Token: $KEY" \ | |
| "$JELLYFIN_URL/Users/$user_id/FavoriteItems/$item_id") | |
| local http_code="${response: -3}" | |
| if [ "$http_code" = "200" ]; then | |
| return 0 | |
| else | |
| return 1 | |
| fi | |
| } | |
| # Function to update play count in Jellyfin | |
| update_play_count() { | |
| local item_id="$1" | |
| local user_id="$2" | |
| local play_count="$3" | |
| echo " [DEBUG] Updating play count for item $item_id to $play_count" >&2 | |
| # First, get current play count to avoid unnecessary updates | |
| local current_stats | |
| current_stats=$(curl -s -H "X-Emby-Token: $KEY" \ | |
| "$JELLYFIN_URL/Users/$user_id/Items/$item_id") | |
| local current_play_count | |
| current_play_count=$(echo "$current_stats" | jq -r '.UserData.PlayCount // 0') | |
| echo " [DEBUG] Current play count in Jellyfin: $current_play_count" >&2 | |
| # Only update if the play count is different | |
| if [ "$current_play_count" != "$play_count" ]; then | |
| # Jellyfin doesn't have a direct "set play count" API, so we need to simulate plays | |
| # We'll use the reporting API to mark items as played | |
| if [ "$play_count" -gt "$current_play_count" ]; then | |
| local plays_to_add=$((play_count - current_play_count)) | |
| echo " [DEBUG] Adding $plays_to_add plays to reach target count" >&2 | |
| # Add the required number of plays | |
| for ((i=1; i<=plays_to_add; i++)); do | |
| local response | |
| response=$(curl -s -w "%{http_code}" -X POST -H "X-Emby-Token: $KEY" \ | |
| "$JELLYFIN_URL/Users/$user_id/PlayedItems/$item_id") | |
| local http_code="${response: -3}" | |
| if [ "$http_code" != "200" ]; then | |
| echo " [DEBUG] Failed to add play #$i (HTTP $http_code)" >&2 | |
| return 1 | |
| fi | |
| done | |
| return 0 | |
| elif [ "$play_count" -lt "$current_play_count" ]; then | |
| echo " [DEBUG] Cannot decrease play count (FLAC: $play_count < Jellyfin: $current_play_count)" >&2 | |
| echo " [DEBUG] Jellyfin API doesn't support decreasing play counts" >&2 | |
| return 2 # Special return code for "cannot decrease" | |
| else | |
| echo " [DEBUG] Play counts already match" >&2 | |
| return 0 | |
| fi | |
| else | |
| echo " [DEBUG] Play counts already match ($play_count)" >&2 | |
| return 0 | |
| fi | |
| } | |
| # Function to set LOVE RATING tag in FLAC file | |
| set_love_rating() { | |
| local flac_file="$1" | |
| local rating="$2" | |
| local temp_error_file=$(mktemp) | |
| echo " [DEBUG] Processing file: '$flac_file'" >&2 | |
| # Check basic file access | |
| if [ ! -f "$flac_file" ]; then | |
| echo " ✗ File does not exist: '$flac_file'" >&2 | |
| rm "$temp_error_file" | |
| return 1 | |
| fi | |
| # Check if metaflac can read the file | |
| if ! run_metaflac --show-md5sum "$flac_file" >/dev/null 2>&1; then | |
| echo " ✗ metaflac cannot read the file (may be corrupt or wrong format)" >&2 | |
| rm "$temp_error_file" | |
| return 1 | |
| fi | |
| # Check basic file permissions | |
| if [ ! -w "$flac_file" ]; then | |
| echo " ✗ File is not writable" >&2 | |
| echo " Try: chmod +w \"$flac_file\"" >&2 | |
| rm "$temp_error_file" | |
| return 1 | |
| fi | |
| echo " Attempting to remove existing LOVE RATING tag..." >&2 | |
| # Remove existing LOVE RATING tag (don't fail if tag doesn't exist) | |
| run_metaflac --remove-tag="LOVE RATING" "$flac_file" 2>"$temp_error_file" | |
| echo " Attempting to set new LOVE RATING tag..." >&2 | |
| # Add new LOVE RATING tag | |
| if ! run_metaflac --set-tag="LOVE RATING=$rating" "$flac_file" 2>"$temp_error_file"; then | |
| echo " ✗ Failed to set new tag:" >&2 | |
| cat "$temp_error_file" >&2 | |
| rm "$temp_error_file" | |
| return 1 | |
| fi | |
| # Verify the tag was actually set | |
| echo " Verifying tag was set correctly..." >&2 | |
| local verify_rating | |
| verify_rating=$(run_metaflac --show-tag="LOVE RATING" "$flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| if [ "$verify_rating" != "$rating" ]; then | |
| echo " ✗ Tag verification failed. Expected: '$rating', Found: '$verify_rating'" >&2 | |
| rm "$temp_error_file" | |
| return 1 | |
| fi | |
| echo " ✓ Tag set and verified successfully" >&2 | |
| rm "$temp_error_file" | |
| return 0 | |
| } | |
| # Validation and connection test | |
| echo "============================================" | |
| echo "Jellyfin FLAC Sync Script with Play Counts" | |
| echo "============================================" | |
| echo "Folder: $FOLDER" | |
| echo "Sync Direction: $SYNC_DIRECTION" | |
| if [ -n "$SORT_CRITERIA" ]; then | |
| echo "Sort Criteria: $SORT_CRITERIA" | |
| fi | |
| if [ -n "$ITEM_LIMIT" ]; then | |
| echo "Item Limit: $ITEM_LIMIT" | |
| fi | |
| if [ -n "$FLAC_LIMIT" ]; then | |
| echo "FLAC Limit (favorites): $FLAC_LIMIT (most recently modified files)" | |
| fi | |
| if [ -n "$PLAYCOUNT_LIMIT" ]; then | |
| echo "FLAC Limit (play counts): $PLAYCOUNT_LIMIT (most recently modified files)" | |
| fi | |
| echo "FLAC Sync Type: $FLAC_SYNC_TYPE" | |
| echo "Jellyfin URL: $JELLYFIN_URL" | |
| echo "" | |
| # Get user ID and test connection | |
| echo "Connecting to Jellyfin server..." | |
| USER_ID=$(get_user_id) | |
| if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then | |
| echo "Error: Could not get user ID." | |
| echo "Please check:" | |
| echo " - Your API key is correct" | |
| echo " - Jellyfin server is running at $JELLYFIN_URL" | |
| echo " - Network connectivity to Jellyfin server" | |
| exit 1 | |
| fi | |
| echo "Successfully connected to Jellyfin" | |
| echo "Using User ID: $USER_ID" | |
| echo "" | |
| # Count FLAC files for progress indication | |
| flac_count=$(find "$FOLDER" -type f -iname "*.flac" | wc -l) | |
| echo "Found $flac_count FLAC files in directory" | |
| echo "" | |
| # Counters | |
| flac_processed=0 | |
| flac_to_jellyfin=0 | |
| playcount_processed=0 | |
| playcount_updated=0 | |
| jellyfin_processed=0 | |
| jellyfin_to_flac=0 | |
| # DIRECTION 1: FLAC → Jellyfin (Modified to support FLAC_SYNC_TYPE) | |
| if [[ "$SYNC_DIRECTION" == "both" || "$SYNC_DIRECTION" == "to-jellyfin" ]]; then | |
| echo "==========================================" | |
| echo "PHASE 1: Syncing FLAC → Jellyfin" | |
| echo "==========================================" | |
| echo "Sync type: $FLAC_SYNC_TYPE" | |
| echo "" | |
| # PART 1A: Favorites sync (only if FLAC_SYNC_TYPE is "both" or "favorites") | |
| if [[ "$FLAC_SYNC_TYPE" == "both" || "$FLAC_SYNC_TYPE" == "favorites" ]]; then | |
| echo "--- Part 1A: Favorites Sync ---" | |
| # Create temporary file for FLAC files (sorted by modification time if limit specified) | |
| temp_file=$(mktemp) | |
| get_recent_flac_files "$FOLDER" "$FLAC_LIMIT" > "$temp_file" | |
| # FIXED: Remove 'local' keyword - this is not inside a function | |
| files_to_process=$(wc -l < "$temp_file") | |
| echo "Processing $files_to_process FLAC files for favorites$([ -n "$FLAC_LIMIT" ] && echo " (limited to $FLAC_LIMIT most recent)")" | |
| echo "" | |
| # Process each FLAC file for favorites | |
| while IFS= read -r flac_file; do | |
| echo "Processing: $(basename "$flac_file")" | |
| echo " Last modified: $(date -r "$flac_file" '+%Y-%m-%d %H:%M:%S')" | |
| flac_processed=$((flac_processed + 1)) | |
| # Read LOVE RATING tag using UTF-8 locale | |
| love_rating=$(run_metaflac --show-tag="LOVE RATING" "$flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| # Check if LOVE RATING is "L" | |
| if [ "$love_rating" = "L" ]; then | |
| echo " ✓ Found LOVE RATING = L" | |
| # Get ALBUM and TITLE tags using UTF-8 locale | |
| album=$(run_metaflac --show-tag=ALBUM "$flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| title=$(run_metaflac --show-tag=TITLE "$flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| if [ -n "$album" ] && [ -n "$title" ]; then | |
| echo " Album: $album" | |
| echo " Title: $title" | |
| echo " Searching in Jellyfin..." | |
| # Search for item in Jellyfin | |
| item_id=$(search_item "$album" "$title" "$USER_ID") | |
| if [ -n "$item_id" ] && [ "$item_id" != "null" ] && [ "$item_id" != "" ]; then | |
| echo " Found item ID: $item_id" | |
| echo " Marking as favorite..." | |
| # Mark as favorite | |
| if mark_favorite "$item_id" "$USER_ID"; then | |
| echo " ✓ Successfully marked as favorite" | |
| flac_to_jellyfin=$((flac_to_jellyfin + 1)) | |
| else | |
| echo " ✗ Failed to mark as favorite" | |
| fi | |
| else | |
| echo " ✗ Item not found in Jellyfin library" | |
| fi | |
| else | |
| echo " ✗ Missing ALBUM or TITLE tag in file metadata" | |
| fi | |
| else | |
| echo " LOVE RATING is not 'L' (found: '$love_rating')" | |
| fi | |
| echo "" | |
| done < "$temp_file" | |
| # Clean up temporary file | |
| rm "$temp_file" | |
| else | |
| echo "--- Part 1A: Favorites Sync (SKIPPED) ---" | |
| echo "FLAC_SYNC_TYPE is '$FLAC_SYNC_TYPE', skipping favorites sync" | |
| echo "" | |
| fi | |
| # PART 1B: Play count sync (only if FLAC_SYNC_TYPE is "both" or "playcount") | |
| if [[ "$FLAC_SYNC_TYPE" == "both" || "$FLAC_SYNC_TYPE" == "playcount" ]]; then | |
| echo "" | |
| echo "--- Part 1B: Play Count Sync ---" | |
| # Create temporary file for play count sync (different limit) | |
| temp_file=$(mktemp) | |
| get_recent_flac_files "$FOLDER" "$PLAYCOUNT_LIMIT" > "$temp_file" | |
| # FIXED: Remove 'local' keyword - this is not inside a function | |
| playcount_files_to_process=$(wc -l < "$temp_file") | |
| echo "Processing $playcount_files_to_process FLAC files for play counts$([ -n "$PLAYCOUNT_LIMIT" ] && echo " (limited to $PLAYCOUNT_LIMIT most recent)")" | |
| echo "" | |
| # Process each FLAC file for play counts | |
| while IFS= read -r flac_file; do | |
| echo "Processing: $(basename "$flac_file")" | |
| echo " Last modified: $(date -r "$flac_file" '+%Y-%m-%d %H:%M:%S')" | |
| playcount_processed=$((playcount_processed + 1)) | |
| # Read PLAYCOUNT tag using UTF-8 locale | |
| play_count=$(run_metaflac --show-tag="PLAYCOUNT" "$flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| # Check if PLAYCOUNT exists and is a valid number | |
| if [ -n "$play_count" ] && [[ "$play_count" =~ ^[0-9]+$ ]] && [ "$play_count" -gt 0 ]; then | |
| echo " ✓ Found PLAYCOUNT = $play_count" | |
| # Get ALBUM and TITLE tags using UTF-8 locale | |
| album=$(run_metaflac --show-tag=ALBUM "$flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| title=$(run_metaflac --show-tag=TITLE "$flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| if [ -n "$album" ] && [ -n "$title" ]; then | |
| echo " Album: $album" | |
| echo " Title: $title" | |
| echo " Searching in Jellyfin..." | |
| # Search for item in Jellyfin | |
| item_id=$(search_item "$album" "$title" "$USER_ID") | |
| if [ -n "$item_id" ] && [ "$item_id" != "null" ] && [ "$item_id" != "" ]; then | |
| echo " Found item ID: $item_id" | |
| echo " Updating play count to $play_count..." | |
| # Update play count | |
| local update_result | |
| update_play_count "$item_id" "$USER_ID" "$play_count" | |
| update_result=$? | |
| case $update_result in | |
| 0) | |
| echo " ✓ Successfully updated play count" | |
| playcount_updated=$((playcount_updated + 1)) | |
| ;; | |
| 1) | |
| echo " ✗ Failed to update play count" | |
| ;; | |
| 2) | |
| echo " ! Cannot decrease play count (FLAC < Jellyfin)" | |
| ;; | |
| esac | |
| else | |
| echo " ✗ Item not found in Jellyfin library" | |
| fi | |
| else | |
| echo " ✗ Missing ALBUM or TITLE tag in file metadata" | |
| fi | |
| else | |
| if [ -n "$play_count" ]; then | |
| echo " PLAYCOUNT is invalid or zero (found: '$play_count')" | |
| else | |
| echo " No PLAYCOUNT tag found" | |
| fi | |
| fi | |
| echo "" | |
| done < "$temp_file" | |
| # Clean up temporary file | |
| rm "$temp_file" | |
| else | |
| echo "" | |
| echo "--- Part 1B: Play Count Sync (SKIPPED) ---" | |
| echo "FLAC_SYNC_TYPE is '$FLAC_SYNC_TYPE', skipping play count sync" | |
| echo "" | |
| fi | |
| fi | |
| # DIRECTION 2: Jellyfin → FLAC (No changes from previous version) | |
| if [[ "$SYNC_DIRECTION" == "both" || "$SYNC_DIRECTION" == "to-flac" ]]; then | |
| echo "==========================================" | |
| echo "PHASE 2: Syncing Jellyfin → FLAC" | |
| echo "==========================================" | |
| echo "" | |
| echo "Getting favorites from Jellyfin..." | |
| if [ -n "$SORT_CRITERIA" ]; then | |
| echo "Sort criteria: $SORT_CRITERIA" | |
| fi | |
| if [ -n "$ITEM_LIMIT" ]; then | |
| echo "Item limit: $ITEM_LIMIT" | |
| fi | |
| echo "" | |
| # Get favorites with paths and process each line correctly | |
| get_favorites_with_paths "$USER_ID" "$SORT_CRITERIA" "$ITEM_LIMIT" | while IFS= read -r line; do | |
| # Split the line properly to handle trailing empty fields | |
| IFS='|' read -r title album path <<< "$line" | |
| # Trim whitespace from all variables | |
| title=$(echo "$title" | xargs) | |
| album=$(echo "$album" | xargs) | |
| path=$(echo "$path" | xargs) | |
| echo " [DEBUG] Raw line: '$line'" >&2 | |
| echo " [DEBUG] Parsed - Title: '$title' | Album: '$album' | Path: '$path'" >&2 | |
| if [ -n "$title" ] && [ -n "$album" ]; then | |
| echo "Processing favorite: $title - $album" | |
| jellyfin_processed=$((jellyfin_processed + 1)) | |
| if [ -n "$path" ] && [ "$path" != "NO_PATH_AVAILABLE" ]; then | |
| # Use Path from Jellyfin API with retry approach | |
| filename=$(basename "$path") | |
| echo " Using Jellyfin path: $path" | |
| echo " Filename: $filename" | |
| echo " Expected album: $album" | |
| echo " Searching for file in directory..." | |
| matched_flac_file=$(find_flac_by_filename "$filename" "$album") | |
| if [ -n "$matched_flac_file" ]; then | |
| echo " ✓ Found matching FLAC file: $(basename "$matched_flac_file")" | |
| # Check current LOVE RATING using UTF-8 locale | |
| current_rating=$(run_metaflac --show-tag="LOVE RATING" "$matched_flac_file" 2>/dev/null | cut -d'=' -f2-) | |
| if [ "$current_rating" = "L" ]; then | |
| echo " LOVE RATING already set to 'L'" | |
| else | |
| echo " Setting LOVE RATING to 'L'..." | |
| if set_love_rating "$matched_flac_file" "L"; then | |
| echo " ✓ Successfully updated LOVE RATING" | |
| jellyfin_to_flac=$((jellyfin_to_flac + 1)) | |
| else | |
| echo " ✗ Failed to update LOVE RATING (see details above)" | |
| fi | |
| fi | |
| else | |
| echo " ✗ $MATCH_ERROR" | |
| fi | |
| else | |
| echo " ✗ No path available from Jellyfin API, cannot search for file" | |
| fi | |
| echo "" | |
| else | |
| echo "Skipping incomplete entry: Title=[$title] Album=[$album] Path=[$path]" | |
| echo "" | |
| fi | |
| done | |
| fi | |
| echo "============================================" | |
| echo "Script completed successfully!" | |
| echo "============================================" | |
| echo "" | |
| if [[ "$SYNC_DIRECTION" == "both" || "$SYNC_DIRECTION" == "to-jellyfin" ]]; then | |
| echo "FLAC → Jellyfin Results (Sync Type: $FLAC_SYNC_TYPE):" | |
| if [[ "$FLAC_SYNC_TYPE" == "both" || "$FLAC_SYNC_TYPE" == "favorites" ]]; then | |
| echo " 📁 Processed $flac_processed FLAC files for favorites" | |
| if [ -n "$FLAC_LIMIT" ]; then | |
| echo " 📅 Limited to $FLAC_LIMIT most recently modified files for favorites" | |
| fi | |
| echo " ⭐ Marked $flac_to_jellyfin items as favorites in Jellyfin" | |
| else | |
| echo " ⭐ Favorites sync skipped (sync type: $FLAC_SYNC_TYPE)" | |
| fi | |
| echo "" | |
| if [[ "$FLAC_SYNC_TYPE" == "both" || "$FLAC_SYNC_TYPE" == "playcount" ]]; then | |
| echo " 🎵 Processed $playcount_processed FLAC files for play counts" | |
| if [ -n "$PLAYCOUNT_LIMIT" ]; then | |
| echo " 📅 Limited to $PLAYCOUNT_LIMIT most recently modified files for play counts" | |
| fi | |
| echo " 📊 Updated $playcount_updated play counts in Jellyfin" | |
| else | |
| echo " 📊 Play count sync skipped (sync type: $FLAC_SYNC_TYPE)" | |
| fi | |
| echo "" | |
| fi | |
| if [[ "$SYNC_DIRECTION" == "both" || "$SYNC_DIRECTION" == "to-flac" ]]; then | |
| echo "Jellyfin → FLAC Results:" | |
| if [ -n "$SORT_CRITERIA" ] || [ -n "$ITEM_LIMIT" ]; then | |
| echo " 🎯 Used filtering:" | |
| [ -n "$SORT_CRITERIA" ] && echo " - Sort: $SORT_CRITERIA" | |
| [ -n "$ITEM_LIMIT" ] && echo " - Limit: $ITEM_LIMIT items" | |
| fi | |
| echo " ⭐ Processed favorites from Jellyfin" | |
| echo " 📁 Updated $jellyfin_to_flac FLAC files with LOVE RATING = 'L'" | |
| fi | |
| echo "" | |
| echo "Sync completed! Your favorites and play counts are now synchronized." | |
| echo "Debug information saved to /tmp/jellyfin_favorites_debug.json" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment