#!/usr/bin/env python3
"""
Playlist Overlap Finder
Copyright (c) 2025 TAPS OSS
Project: https://github.com/TAPSOSS/Walrio
Licensed under the BSD-3-Clause License (see LICENSE file for details)
This script finds songs that appear in multiple playlists (overlap) and creates
a new playlist containing only those overlapping songs.
"""
import os
import sys
import argparse
import logging
from pathlib import Path
from typing import List, Set, Dict, Tuple
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('PlaylistOverlap')
[docs]
class PlaylistOverlapFinder:
"""
Find overlapping songs between playlists and create a new playlist with the results.
"""
[docs]
def __init__(self):
"""Initialize the PlaylistOverlapFinder."""
pass
def _load_m3u_paths(self, playlist_path: str) -> List[str]:
"""
Load file paths from an M3U playlist (without metadata extraction).
Args:
playlist_path (str): Path to the M3U playlist file
Returns:
list: List of file paths from the playlist
"""
paths = []
playlist_dir = os.path.dirname(os.path.abspath(playlist_path))
try:
with open(playlist_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# Skip empty lines and comment lines (but not EXTINF)
if not line or (line.startswith('#') and not line.startswith('#EXTINF')):
continue
# Skip EXTINF lines (metadata)
if line.startswith('#EXTINF'):
continue
# This is a file path
file_path = line
# Convert relative paths to absolute
if not os.path.isabs(file_path):
file_path = os.path.normpath(os.path.join(playlist_dir, file_path))
paths.append(file_path)
logger.debug(f"Loaded {len(paths)} paths from {os.path.basename(playlist_path)}")
return paths
except Exception as e:
logger.error(f"Error loading playlist {playlist_path}: {str(e)}")
return []
def _normalize_path(self, path: str) -> str:
"""
Normalize a file path for comparison.
Args:
path (str): File path to normalize
Returns:
str: Normalized absolute path
"""
return os.path.normpath(os.path.abspath(path))
[docs]
def find_overlap(self, playlist_paths: List[str]) -> Set[str]:
"""
Find songs that appear in all provided playlists.
Args:
playlist_paths (list): List of playlist file paths
Returns:
set: Set of file paths that appear in all playlists
"""
if len(playlist_paths) < 2:
logger.error("Need at least 2 playlists to find overlap")
return set()
# Load all playlists and normalize paths
all_paths = []
for playlist_path in playlist_paths:
if not os.path.exists(playlist_path):
logger.error(f"Playlist not found: {playlist_path}")
return set()
paths = self._load_m3u_paths(playlist_path)
normalized_paths = set(self._normalize_path(p) for p in paths)
all_paths.append(normalized_paths)
logger.info(f"Loaded {len(normalized_paths)} songs from {os.path.basename(playlist_path)}")
# Find intersection of all playlists
overlap = all_paths[0]
for path_set in all_paths[1:]:
overlap = overlap.intersection(path_set)
logger.info(f"Found {len(overlap)} overlapping songs")
return overlap
[docs]
def find_unique_to_first(self, playlist_paths: List[str]) -> Set[str]:
"""
Find songs that appear only in the first playlist but not in any others.
Args:
playlist_paths (list): List of playlist file paths
Returns:
set: Set of file paths that appear only in the first playlist
"""
if len(playlist_paths) < 2:
logger.error("Need at least 2 playlists to find unique songs")
return set()
# Load all playlists and normalize paths
all_paths = []
for playlist_path in playlist_paths:
if not os.path.exists(playlist_path):
logger.error(f"Playlist not found: {playlist_path}")
return set()
paths = self._load_m3u_paths(playlist_path)
normalized_paths = set(self._normalize_path(p) for p in paths)
all_paths.append(normalized_paths)
logger.info(f"Loaded {len(normalized_paths)} songs from {os.path.basename(playlist_path)}")
# Find songs only in first playlist
unique = all_paths[0]
for path_set in all_paths[1:]:
unique = unique - path_set
logger.info(f"Found {len(unique)} songs unique to {os.path.basename(playlist_paths[0])}")
return unique
[docs]
def find_non_overlapping(self, playlist_paths: List[str]) -> Set[str]:
"""
Find songs that don't overlap (appear in only one playlist, not in all).
Args:
playlist_paths (list): List of playlist file paths
Returns:
set: Set of file paths that appear in at least one playlist but not all
"""
if len(playlist_paths) < 2:
logger.error("Need at least 2 playlists to find non-overlapping songs")
return set()
# Load all playlists and normalize paths
all_paths = []
for playlist_path in playlist_paths:
if not os.path.exists(playlist_path):
logger.error(f"Playlist not found: {playlist_path}")
return set()
paths = self._load_m3u_paths(playlist_path)
normalized_paths = set(self._normalize_path(p) for p in paths)
all_paths.append(normalized_paths)
logger.info(f"Loaded {len(normalized_paths)} songs from {os.path.basename(playlist_path)}")
# Find union of all playlists
all_songs = set()
for path_set in all_paths:
all_songs = all_songs.union(path_set)
# Find intersection (overlap)
overlap = all_paths[0]
for path_set in all_paths[1:]:
overlap = overlap.intersection(path_set)
# Non-overlapping = all songs minus overlapping songs
non_overlapping = all_songs - overlap
logger.info(f"Found {len(non_overlapping)} non-overlapping songs")
return non_overlapping
[docs]
def create_overlap_playlist(self, playlist_paths: List[str], output_path: str,
use_relative_paths: bool = True, mode: str = 'overlap') -> bool:
"""
Create a new playlist containing overlapping or non-overlapping songs.
Args:
playlist_paths (list): List of playlist file paths to compare
output_path (str): Path for the output playlist file
use_relative_paths (bool): Whether to use relative paths in output
mode (str): 'overlap', 'unique-first', or 'non-overlapping'
Returns:
bool: True if successful, False otherwise
"""
# Find songs based on mode
if mode == 'overlap':
result_songs = self.find_overlap(playlist_paths)
mode_description = "overlapping songs"
elif mode == 'unique-first':
result_songs = self.find_unique_to_first(playlist_paths)
mode_description = f"songs unique to {os.path.basename(playlist_paths[0])}"
elif mode == 'non-overlapping':
result_songs = self.find_non_overlapping(playlist_paths)
mode_description = "non-overlapping songs"
else:
logger.error(f"Invalid mode: {mode}")
return False
if not result_songs:
logger.warning(f"No {mode_description} found between playlists")
return False
# Sort paths for consistent output
sorted_songs = sorted(result_songs)
# Determine output directory for relative path calculation
output_dir = os.path.dirname(os.path.abspath(output_path))
# Create output directory if it doesn't exist
os.makedirs(output_dir, exist_ok=True)
# Write the playlist
try:
with open(output_path, 'w', encoding='utf-8') as f:
f.write("#EXTM3U\n")
f.write(f"# Playlist {mode_description} from {len(playlist_paths)} playlists\n")
f.write(f"# Contains {len(sorted_songs)} songs\n")
for file_path in sorted_songs:
# Convert to relative path if requested
if use_relative_paths:
try:
rel_path = os.path.relpath(file_path, output_dir)
f.write(f"{rel_path}\n")
except ValueError:
# Can't create relative path (different drives on Windows)
f.write(f"{file_path}\n")
else:
f.write(f"{file_path}\n")
logger.info(f"Created playlist: {output_path}")
logger.info(f" Contains {len(sorted_songs)} songs ({mode_description})")
return True
except Exception as e:
logger.error(f"Error creating playlist: {str(e)}")
return False
[docs]
def display_overlap_info(self, playlist_paths: List[str], mode: str = 'overlap') -> None:
"""
Display information about songs without creating a playlist.
Args:
playlist_paths (list): List of playlist file paths to compare
mode (str): 'overlap', 'unique-first', or 'non-overlapping'
"""
# Find songs based on mode
if mode == 'overlap':
result_songs = self.find_overlap(playlist_paths)
mode_title = "Overlapping Songs"
mode_description = "songs that appear in ALL playlists"
elif mode == 'unique-first':
result_songs = self.find_unique_to_first(playlist_paths)
mode_title = f"Songs Unique to {os.path.basename(playlist_paths[0])}"
mode_description = f"songs ONLY in {os.path.basename(playlist_paths[0])}"
elif mode == 'non-overlapping':
result_songs = self.find_non_overlapping(playlist_paths)
mode_title = "Non-Overlapping Songs"
mode_description = "songs that DON'T appear in all playlists"
else:
logger.error(f"Invalid mode: {mode}")
return
if not result_songs:
print(f"\nNo {mode_description} found between the playlists.")
return
# Sort paths for display
sorted_songs = sorted(result_songs)
print(f"\n{'='*70}")
print(f"Playlist Analysis: {mode_title}")
print(f"{'='*70}")
print(f"Playlists compared: {len(playlist_paths)}")
for i, path in enumerate(playlist_paths, 1):
print(f" {i}. {os.path.basename(path)}")
print(f"\nFound {len(sorted_songs)} {mode_description}")
print(f"{'='*70}")
# Display first 20 songs
display_count = min(20, len(sorted_songs))
print(f"\nFirst {display_count} songs:")
for i, file_path in enumerate(sorted_songs[:display_count], 1):
print(f" {i}. {os.path.basename(file_path)}")
if len(sorted_songs) > display_count:
print(f"\n... and {len(sorted_songs) - display_count} more")
print(f"\n{'='*70}")
[docs]
def parse_arguments():
"""
Parse command line arguments.
Returns:
argparse.Namespace: Parsed arguments
"""
parser = argparse.ArgumentParser(
description="Find overlapping songs between playlists",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Find overlap between 2 playlists and create a new playlist
python playlist_overlap.py playlist1.m3u playlist2.m3u -o overlap.m3u
# Find songs ONLY in first playlist (not in second)
python playlist_overlap.py playlist1.m3u playlist2.m3u -o unique.m3u --unique-first
# Find songs that are NOT in all playlists (from both/all playlists)
python playlist_overlap.py playlist1.m3u playlist2.m3u -o non_overlap.m3u --non-overlapping
# Find overlap between 3 playlists
python playlist_overlap.py playlist1.m3u playlist2.m3u playlist3.m3u -o overlap.m3u
# Show overlap information without creating a playlist
python playlist_overlap.py playlist1.m3u playlist2.m3u --info
# Show unique songs with info mode
python playlist_overlap.py playlist1.m3u playlist2.m3u --info --unique-first
# Use absolute paths in output playlist
python playlist_overlap.py playlist1.m3u playlist2.m3u -o overlap.m3u --absolute-paths
"""
)
parser.add_argument(
'playlists',
nargs='+',
help='Playlist files to compare (minimum 2)'
)
parser.add_argument(
'-o', '--output',
help='Output playlist file for overlapping songs'
)
parser.add_argument(
'--info',
action='store_true',
help='Display overlap information without creating a playlist'
)
parser.add_argument(
'--absolute-paths',
action='store_true',
help='Use absolute paths in output playlist (default: relative paths)'
)
parser.add_argument(
'--unique-first',
action='store_true',
help='Find songs ONLY in the first playlist (not in any others)'
)
parser.add_argument(
'--non-overlapping',
action='store_true',
help='Find songs that are NOT in all playlists (opposite of overlap)'
)
parser.add_argument(
'--logging',
choices=['low', 'high'],
default='low',
help='Logging level: low (default) or high (verbose)'
)
return parser.parse_args()
[docs]
def main():
"""Main entry point for the playlist overlap finder."""
args = parse_arguments()
# Set logging level
if args.logging == 'high':
logger.setLevel(logging.DEBUG)
# Validate input
if len(args.playlists) < 2:
logger.error("Need at least 2 playlists to find overlap")
sys.exit(1)
# Validate playlist files exist
for playlist_path in args.playlists:
if not os.path.exists(playlist_path):
logger.error(f"Playlist not found: {playlist_path}")
sys.exit(1)
if not playlist_path.lower().endswith('.m3u'):
logger.warning(f"File may not be a valid M3U playlist: {playlist_path}")
# Create finder
finder = PlaylistOverlapFinder()
# Determine mode
mode = 'overlap' # default
if args.unique_first:
mode = 'unique-first'
elif args.non_overlapping:
mode = 'non-overlapping'
# Check for conflicting flags
if args.unique_first and args.non_overlapping:
logger.error("Cannot use both --unique-first and --non-overlapping at the same time")
sys.exit(1)
# Display info mode
if args.info:
finder.display_overlap_info(args.playlists, mode=mode)
return
# Create overlap playlist mode
if not args.output:
logger.error("Output file required (use -o/--output or --info to just display)")
sys.exit(1)
# Create the playlist
success = finder.create_overlap_playlist(
args.playlists,
args.output,
use_relative_paths=not args.absolute_paths,
mode=mode
)
if success:
print(f"\nSuccessfully created playlist: {args.output}")
else:
logger.error("Failed to create playlist")
sys.exit(1)
if __name__ == "__main__":
main()