#!/usr/bin/env python3
"""
Playlist Manager
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 creates and manages M3U playlists from the audio library database.
"""
import sys
import os
import sqlite3
import argparse
from pathlib import Path
from . import metadata
# Default database path
DEFAULT_DB_PATH = "walrio_library.db"
# Supported audio file extensions
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.wav', '.m4a', '.aac', '.wma', '.opus', '.ape', '.mpc'}
[docs]
def connect_to_database(db_path):
"""
Connect to the SQLite database and return connection.
Args:
db_path (str): Path to the SQLite database file.
Returns:
sqlite3.Connection or None: Database connection object, or None if connection fails.
"""
if not os.path.exists(db_path):
print(f"Error: Database file '{db_path}' not found.")
print("Please run database.py first to create the database.")
return None
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row # Enable dict-like access to rows
return conn
except sqlite3.Error as e:
print(f"Error connecting to database: {e}")
return None
[docs]
def get_songs_from_database(conn, filters=None):
"""
Get songs from database based on filters.
Args:
conn (sqlite3.Connection): Database connection object.
filters (dict, optional): Dictionary with filter criteria.
Supported keys: 'artist', 'album', 'genre' (all use partial matching).
Returns:
list: List of song records as sqlite3.Row objects.
"""
cursor = conn.cursor()
# Base query
query = """
SELECT id, title, artist, album, albumartist, url, length, track, disc, year, genre
FROM songs
WHERE unavailable = 0
"""
params = []
# Apply filters if provided
if filters:
if filters.get('artist'):
query += " AND (artist LIKE ? OR albumartist LIKE ?)"
artist_filter = f"%{filters['artist']}%"
params.extend([artist_filter, artist_filter])
if filters.get('album'):
query += " AND album LIKE ?"
params.append(f"%{filters['album']}%")
if filters.get('genre'):
query += " AND genre LIKE ?"
params.append(f"%{filters['genre']}%")
# Order by artist, album, disc, track for logical playback order
query += " ORDER BY artist, album, disc, track"
try:
cursor.execute(query, params)
return cursor.fetchall()
except sqlite3.Error as e:
print(f"Error querying database: {e}")
return []
[docs]
def get_relative_path(file_path, playlist_path):
"""
Convert file path to relative path from playlist location.
Args:
file_path (str): Path to the audio file.
playlist_path (str): Path to the playlist file.
Returns:
str: Relative path from playlist directory to audio file.
"""
# Remove file:// prefix if present
if file_path.startswith('file://'):
file_path = file_path[7:]
try:
file_path = Path(file_path).resolve()
playlist_dir = Path(playlist_path).parent.resolve()
return os.path.relpath(file_path, playlist_dir)
except (ValueError, OSError):
# If relative path calculation fails, return original path
return file_path
[docs]
def create_m3u_playlist(songs, playlist_path, use_absolute_paths=False, playlist_name="Playlist"):
"""
Create M3U playlist file from a list of songs.
Args:
songs (list): List of song dictionaries or database records.
playlist_path (str): Path where the playlist file will be saved.
use_absolute_paths (bool, optional): Use absolute paths instead of relative. Defaults to False.
playlist_name (str, optional): Name of the playlist. Defaults to "Playlist".
Returns:
bool: True if playlist created successfully, False otherwise.
"""
try:
# Ensure directory exists
playlist_dir = Path(playlist_path).parent
playlist_dir.mkdir(parents=True, exist_ok=True)
with open(playlist_path, 'w', encoding='utf-8') as f:
# Write M3U header
f.write("#EXTM3U\n")
f.write(f"#PLAYLIST:{playlist_name}\n")
f.write(f"#EXTENC:UTF-8\n")
f.write(f"# Generated by Walrio Playlist Manager\n")
f.write(f"# Total tracks: {len(songs)}\n\n")
for song in songs:
# Get file path
file_path = song['url']
if file_path.startswith('file://'):
file_path = file_path[7:] # Remove 'file://' prefix
# Use absolute or relative path based on option
if use_absolute_paths:
path_to_write = os.path.abspath(file_path)
else:
path_to_write = get_relative_path(song['url'], playlist_path)
# Write song info
artist = song['artist'] or "Unknown Artist"
title = song['title'] or "Unknown Title"
length = song['length'] or -1
f.write(f"#EXTINF:{length},{artist} - {title}\n")
f.write(f"{path_to_write}\n")
return True
except Exception as e:
print(f"Error creating playlist: {e}")
return False
[docs]
def load_m3u_playlist(playlist_path):
"""
Load songs from M3U playlist file.
Args:
playlist_path (str): Path to the M3U playlist file.
Returns:
list: List of song dictionaries parsed from the playlist.
"""
songs = []
try:
with open(playlist_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
current_info = {}
for line in lines:
line = line.strip()
# Skip empty lines and comments (except EXTINF)
if not line or (line.startswith('#') and not line.startswith('#EXTINF')):
continue
# Parse EXTINF line
if line.startswith('#EXTINF:'):
# Format: #EXTINF:duration,artist - title
try:
parts = line[8:].split(',', 1) # Remove #EXTINF: and split on first comma
duration = int(parts[0]) if parts[0].isdigit() else 0
if len(parts) > 1 and ' - ' in parts[1]:
artist, title = parts[1].split(' - ', 1)
current_info = {
'artist': artist.strip(),
'title': title.strip(),
'length': duration
}
except (ValueError, IndexError):
current_info = {}
else:
# This should be a file path
file_path = line
# Convert relative path to absolute if needed
if not os.path.isabs(file_path):
playlist_dir = Path(playlist_path).parent
file_path = os.path.abspath(os.path.join(playlist_dir, file_path))
# Create song entry
song = {
'url': file_path,
'artist': current_info.get('artist', 'Unknown Artist'),
'title': current_info.get('title', 'Unknown Title'),
'album': 'Unknown Album',
'albumartist': current_info.get('artist', 'Unknown Artist'),
'length': current_info.get('length', 0),
'track': 0,
'disc': 0,
'year': 0,
'genre': 'Unknown'
}
songs.append(song)
current_info = {}
return songs
except Exception as e:
print(f"Error loading playlist: {e}")
return []
[docs]
def scan_directory(directory_path):
"""
Scan directory recursively for audio files.
Args:
directory_path (str): Path to the directory to scan.
Returns:
list: List of audio file paths found in the directory.
"""
audio_files = []
try:
directory = Path(directory_path)
if not directory.exists():
print(f"Error: Directory '{directory_path}' does not exist.")
return []
if not directory.is_dir():
print(f"Error: '{directory_path}' is not a directory.")
return []
# Recursively find audio files
for file_path in directory.rglob('*'):
if file_path.is_file() and file_path.suffix.lower() in AUDIO_EXTENSIONS:
audio_files.append(str(file_path))
# Sort files for consistent ordering
audio_files.sort()
return audio_files
except Exception as e:
print(f"Error scanning directory '{directory_path}': {e}")
return []
[docs]
def main():
"""
Main function for playlist management command-line interface.
Parses command-line arguments and performs playlist operations including
creating playlists from database queries, files/directories, or loading
existing playlists.
Examples:
Create playlist from database with artist filter:
python playlist.py --name "My Playlist" --artist "Pink Floyd" --output playlists/
Create playlist from files and directories:
python playlist.py --name "Files" --inputs song1.mp3 song2.flac /path/to/music/
Create playlist from input file:
python playlist.py --name "From File" --input-file mylist.txt --output playlists/
Load and display existing playlist:
python playlist.py --load existing_playlist.m3u
"""
parser = argparse.ArgumentParser(
description="Playlist Manager - Create and manage M3U playlists",
epilog="Examples:\n"
" python playlist.py --name \"My Playlist\" --artist \"Pink Floyd\" --output playlists/\n"
" python playlist.py --name \"Files\" --inputs song1.mp3 song2.flac /path/to/music/\n"
" python playlist.py --name \"From File\" --input-file mylist.txt --output playlists/",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--db-path",
default=DEFAULT_DB_PATH,
help=f"Path to the SQLite database file (default: {DEFAULT_DB_PATH})"
)
parser.add_argument(
"--name",
help="Name of the playlist (required for database mode)"
)
parser.add_argument(
"--output",
default="./",
help="Directory to save the playlist file (default: current directory)"
)
parser.add_argument(
"--absolute",
action="store_true",
help="Use absolute paths instead of relative paths"
)
parser.add_argument(
"--artist",
help="Filter songs by artist name (partial match)"
)
parser.add_argument(
"--album",
help="Filter songs by album name (partial match)"
)
parser.add_argument(
"--genre",
help="Filter songs by genre (partial match)"
)
parser.add_argument(
"--load",
help="Load and display contents of an existing M3U playlist"
)
parser.add_argument(
"--inputs",
nargs='+',
help="List of audio files and/or directories to include in the playlist"
)
parser.add_argument(
"--input-file",
help="Text file containing list of audio files/directories (one per line)"
)
args = parser.parse_args()
# Load playlist mode
if args.load:
if not os.path.exists(args.load):
print(f"Error: Playlist file '{args.load}' not found.")
sys.exit(1)
songs = load_m3u_playlist(args.load)
if songs:
print(f"Loaded {len(songs)} songs from playlist '{args.load}':")
for i, song in enumerate(songs):
print(f" {i+1:3d}. {format_song_info(song)}")
else:
print("No songs found in playlist.")
return
# File/folder input mode
if args.inputs or args.input_file:
if not args.name:
print("Error: --name is required when creating playlists from inputs.")
sys.exit(1)
inputs = []
# Get inputs from command line
if args.inputs:
inputs.extend(args.inputs)
# Get inputs from file
if args.input_file:
if not os.path.exists(args.input_file):
print(f"Error: Input file '{args.input_file}' not found.")
sys.exit(1)
try:
with open(args.input_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'): # Skip empty lines and comments
inputs.append(line)
except Exception as e:
print(f"Error reading input file: {e}")
sys.exit(1)
if not inputs:
print("Error: No inputs provided.")
sys.exit(1)
# Create playlist filename
safe_name = "".join(c if c.isalnum() or c in (' ', '-', '_') else '' for c in args.name)
safe_name = safe_name.replace(' ', '_')
playlist_filename = f"{safe_name}.m3u"
playlist_path = os.path.join(args.output, playlist_filename)
# Create playlist from inputs
success = create_playlist_from_inputs(inputs, playlist_path, args.absolute, args.name)
if success:
print(f"Playlist '{args.name}' created successfully!")
print(f"Saved to: {playlist_path}")
print(f"Path format: {'Absolute' if args.absolute else 'Relative'}")
else:
print("Failed to create playlist.")
sys.exit(1)
return
# Database mode (existing functionality)
if not args.name:
print("Error: --name is required.")
sys.exit(1)
# Connect to database
conn = connect_to_database(args.db_path)
if not conn:
sys.exit(1)
try:
# Build filters from command line arguments
filters = {}
if args.artist:
filters['artist'] = args.artist
if args.album:
filters['album'] = args.album
if args.genre:
filters['genre'] = args.genre
# Get songs from database
songs = get_songs_from_database(conn, filters)
if not songs:
print("No songs found matching the specified criteria.")
sys.exit(1)
print(f"Found {len(songs)} songs matching criteria.")
# Create playlist filename
safe_name = "".join(c if c.isalnum() or c in (' ', '-', '_') else '' for c in args.name)
safe_name = safe_name.replace(' ', '_')
playlist_filename = f"{safe_name}.m3u"
playlist_path = os.path.join(args.output, playlist_filename)
# Create playlist
success = create_m3u_playlist(songs, playlist_path, args.absolute, args.name)
if success:
print(f"Playlist '{args.name}' created successfully!")
print(f"Saved to: {playlist_path}")
print(f"Contains {len(songs)} songs")
print(f"Path format: {'Absolute' if args.absolute else 'Relative'}")
else:
print("Failed to create playlist.")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
finally:
conn.close()
# Run file
if __name__ == "__main__":
main()