Source code for modules.addons.playlist_mover

#!/usr/bin/env python3
"""
Playlist Mover
Copyright (c) 2025 TAPS OSS
Project: https://github.com/TAPSOSS/Walrio
Licensed under the BSD-3-Clause License (see LICENSE file for details)

A script that moves playlist files from one directory to another while updating
all relative file paths to maintain accuracy.
"""

import os
import sys
import shutil
import argparse
from pathlib import Path


[docs] def parse_m3u_playlist(playlist_path): """ Parse an M3U playlist file and extract all entries. Args: playlist_path (str): Path to the M3U playlist file. Returns: tuple: (metadata_lines, file_paths) where metadata_lines is a list of tuples (line_number, content) for metadata lines, and file_paths is a list of tuples (line_number, path) for file paths. """ metadata_lines = [] file_paths = [] try: with open(playlist_path, 'r', encoding='utf-8') as f: lines = f.readlines() for i, line in enumerate(lines): stripped = line.strip() # Empty lines if not stripped: metadata_lines.append((i, line)) continue # Metadata/comment lines if stripped.startswith('#'): metadata_lines.append((i, line)) else: # This is a file path file_paths.append((i, stripped)) return metadata_lines, file_paths except Exception as e: print(f"Error parsing playlist '{playlist_path}': {e}") return [], []
[docs] def convert_path_to_absolute(relative_path, playlist_dir): """ Convert a relative path to an absolute path based on playlist directory. Args: relative_path (str): The relative path from the playlist file. playlist_dir (str): Directory containing the playlist file. Returns: str: Absolute path to the audio file. """ if os.path.isabs(relative_path): return relative_path return os.path.abspath(os.path.join(playlist_dir, relative_path))
[docs] def convert_absolute_to_relative(absolute_path, new_playlist_dir): """ Convert an absolute path to a relative path from the new playlist directory. Args: absolute_path (str): The absolute path to the audio file. new_playlist_dir (str): The new directory where the playlist will be located. Returns: str: Relative path from new playlist directory to the audio file. """ try: return os.path.relpath(absolute_path, new_playlist_dir) except ValueError: # If paths are on different drives (Windows), return absolute path return absolute_path
[docs] def update_playlist_paths(playlist_path, source_dir, dest_dir, dry_run=False): """ Update file paths in a playlist file for its new location. Args: playlist_path (str): Path to the playlist file in source directory. source_dir (str): Source directory where playlist currently is. dest_dir (str): Destination directory where playlist will be moved. dry_run (bool): If True, only show what would be done without making changes. Returns: list: Updated lines for the playlist file. """ metadata_lines, file_paths = parse_m3u_playlist(playlist_path) if not file_paths: print(f" Warning: No file paths found in '{os.path.basename(playlist_path)}'") return None # Reconstruct the playlist all_lines = {} # Add metadata lines for line_num, content in metadata_lines: all_lines[line_num] = content # Process file paths source_playlist_dir = os.path.dirname(playlist_path) updated_count = 0 error_count = 0 for line_num, relative_path in file_paths: # Convert to absolute path first absolute_path = convert_path_to_absolute(relative_path, source_playlist_dir) # Check if file exists if not os.path.exists(absolute_path): print(f" Warning: File not found: {absolute_path}") error_count += 1 # Keep the original path even if file doesn't exist all_lines[line_num] = relative_path + '\n' else: # Convert to relative path from new location new_relative_path = convert_absolute_to_relative(absolute_path, dest_dir) # Use forward slashes for cross-platform compatibility new_relative_path = new_relative_path.replace('\\', '/') all_lines[line_num] = new_relative_path + '\n' if not dry_run and new_relative_path != relative_path: updated_count += 1 # Sort by line number and reconstruct file sorted_lines = [all_lines[i] for i in sorted(all_lines.keys())] if not dry_run: if updated_count > 0: print(f" Updated {updated_count} path(s) in '{os.path.basename(playlist_path)}'") if error_count > 0: print(f" Found {error_count} missing file(s)") return sorted_lines
[docs] def move_playlist(playlist_path, source_dir, dest_dir, dry_run=False, overwrite=False): """ Move a single playlist file to destination directory with updated paths. Args: playlist_path (str): Full path to the playlist file. source_dir (str): Source directory. dest_dir (str): Destination directory. dry_run (bool): If True, only show what would be done. overwrite (bool): If True, overwrite existing files. Returns: bool: True if successful, False otherwise. """ playlist_name = os.path.basename(playlist_path) dest_path = os.path.join(dest_dir, playlist_name) print(f"\nProcessing: {playlist_name}") # Check if destination already exists if os.path.exists(dest_path) and not overwrite: print(f" Skipped: File already exists at destination (use --overwrite to replace)") return False # Update the playlist paths updated_lines = update_playlist_paths(playlist_path, source_dir, dest_dir, dry_run) if updated_lines is None: return False if dry_run: print(f" Would move to: {dest_path}") return True try: # Write the updated playlist to destination with open(dest_path, 'w', encoding='utf-8') as f: f.writelines(updated_lines) print(f" Successfully moved to: {dest_path}") # Remove original file after successful copy os.remove(playlist_path) print(f" Removed original file") return True except Exception as e: print(f" Error moving playlist: {e}") return False
[docs] def move_all_playlists(source_dir, dest_dir, dry_run=False, overwrite=False, recursive=False): """ Move all playlist files from source to destination directory. Args: source_dir (str): Source directory containing playlists. dest_dir (str): Destination directory for playlists. dry_run (bool): If True, only show what would be done. overwrite (bool): If True, overwrite existing files. recursive (bool): If True, search subdirectories as well. Returns: tuple: (success_count, skip_count, error_count) """ # Validate directories if not os.path.isdir(source_dir): print(f"Error: Source directory '{source_dir}' does not exist.") return 0, 0, 1 # Create destination directory if it doesn't exist if not dry_run and not os.path.exists(dest_dir): os.makedirs(dest_dir) print(f"Created destination directory: {dest_dir}") # Find all playlist files playlist_extensions = {'.m3u', '.m3u8', '.pls', '.wpl', '.asx', '.xspf'} playlist_files = [] if recursive: for root, dirs, files in os.walk(source_dir): for file in files: if os.path.splitext(file)[1].lower() in playlist_extensions: playlist_files.append(os.path.join(root, file)) else: for file in os.listdir(source_dir): file_path = os.path.join(source_dir, file) if os.path.isfile(file_path) and os.path.splitext(file)[1].lower() in playlist_extensions: playlist_files.append(file_path) if not playlist_files: print(f"No playlist files found in '{source_dir}'") return 0, 0, 0 print(f"\nFound {len(playlist_files)} playlist file(s)") if dry_run: print("\n--- DRY RUN MODE (no changes will be made) ---") # Process each playlist success_count = 0 skip_count = 0 error_count = 0 for playlist_path in playlist_files: result = move_playlist(playlist_path, source_dir, dest_dir, dry_run, overwrite) if result: success_count += 1 elif os.path.exists(os.path.join(dest_dir, os.path.basename(playlist_path))): skip_count += 1 else: error_count += 1 return success_count, skip_count, error_count
[docs] def main(): """Main entry point for the playlist mover script.""" parser = argparse.ArgumentParser( description='Move playlist files from one directory to another while updating relative paths.', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Move all playlists from ~/Music/old to ~/Music/new %(prog)s ~/Music/old ~/Music/new # Dry run to see what would happen %(prog)s ~/Music/old ~/Music/new --dry-run # Move playlists and overwrite existing files %(prog)s ~/Music/old ~/Music/new --overwrite # Move playlists from subdirectories as well %(prog)s ~/Music/old ~/Music/new --recursive """ ) parser.add_argument( 'source', help='Source directory containing playlist files' ) parser.add_argument( 'destination', help='Destination directory for playlist files' ) parser.add_argument( '-d', '--dry-run', action='store_true', help='Show what would be done without making changes' ) parser.add_argument( '-o', '--overwrite', action='store_true', help='Overwrite existing playlist files in destination' ) parser.add_argument( '-r', '--recursive', action='store_true', help='Search for playlists in subdirectories' ) parser.add_argument( '-v', '--version', action='version', version='Walrio Playlist Mover 1.0' ) args = parser.parse_args() # Expand user paths and convert to absolute source_dir = os.path.abspath(os.path.expanduser(args.source)) dest_dir = os.path.abspath(os.path.expanduser(args.destination)) # Validate that source and destination are different if source_dir == dest_dir: print("Error: Source and destination directories must be different.") return 1 print(f"Source directory: {source_dir}") print(f"Destination directory: {dest_dir}") # Move playlists success, skip, error = move_all_playlists( source_dir, dest_dir, dry_run=args.dry_run, overwrite=args.overwrite, recursive=args.recursive ) # Print summary print("\n" + "="*60) print("SUMMARY") print("="*60) if args.dry_run: print(f"Would move: {success} playlist(s)") print(f"Would skip: {skip} playlist(s) (already exist)") else: print(f"Successfully moved: {success} playlist(s)") print(f"Skipped: {skip} playlist(s) (already exist)") print(f"Errors: {error} playlist(s)") return 0 if error == 0 else 1
if __name__ == '__main__': sys.exit(main())