245 lines
9.7 KiB
Python
245 lines
9.7 KiB
Python
#!/usr/bin/python3
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import logging
|
|
from os.path import exists, basename, dirname
|
|
from shutil import which
|
|
|
|
logger = logging.getLogger("deezspot")
|
|
|
|
# Define available audio formats and their properties
|
|
AUDIO_FORMATS = {
|
|
"MP3": {
|
|
"extension": ".mp3",
|
|
"mime": "audio/mpeg",
|
|
"ffmpeg_codec": "libmp3lame",
|
|
"ffmpeg_format_flag": "mp3",
|
|
"default_bitrate": "320k",
|
|
"bitrates": ["32k", "64k", "96k", "128k", "192k", "256k", "320k"],
|
|
},
|
|
"AAC": {
|
|
"extension": ".m4a",
|
|
"mime": "audio/mp4",
|
|
"ffmpeg_codec": "aac",
|
|
"ffmpeg_format_flag": "ipod",
|
|
"default_bitrate": "256k",
|
|
"bitrates": ["32k", "64k", "96k", "128k", "192k", "256k"],
|
|
},
|
|
"OGG": {
|
|
"extension": ".ogg",
|
|
"mime": "audio/ogg",
|
|
"ffmpeg_codec": "libvorbis",
|
|
"ffmpeg_format_flag": "ogg",
|
|
"default_bitrate": "256k",
|
|
"bitrates": ["64k", "96k", "128k", "192k", "256k", "320k"],
|
|
},
|
|
"OPUS": {
|
|
"extension": ".opus",
|
|
"mime": "audio/opus",
|
|
"ffmpeg_codec": "libopus",
|
|
"ffmpeg_format_flag": "opus",
|
|
"default_bitrate": "128k",
|
|
"bitrates": ["32k", "64k", "96k", "128k", "192k", "256k"],
|
|
},
|
|
"FLAC": {
|
|
"extension": ".flac",
|
|
"mime": "audio/flac",
|
|
"ffmpeg_codec": "flac",
|
|
"ffmpeg_format_flag": "flac",
|
|
"default_bitrate": None, # Lossless, no bitrate needed
|
|
"bitrates": [],
|
|
},
|
|
"WAV": {
|
|
"extension": ".wav",
|
|
"mime": "audio/wav",
|
|
"ffmpeg_codec": "pcm_s16le",
|
|
"ffmpeg_format_flag": "wav",
|
|
"default_bitrate": None, # Lossless, no bitrate needed
|
|
"bitrates": [],
|
|
},
|
|
"ALAC": {
|
|
"extension": ".m4a",
|
|
"mime": "audio/mp4",
|
|
"ffmpeg_codec": "alac",
|
|
"ffmpeg_format_flag": "ipod",
|
|
"default_bitrate": None, # Lossless, no bitrate needed
|
|
"bitrates": [],
|
|
}
|
|
}
|
|
|
|
def check_ffmpeg_available():
|
|
"""Check if FFmpeg is installed and available."""
|
|
if which("ffmpeg") is None:
|
|
logger.error("FFmpeg is not installed or not in PATH. Audio conversion is unavailable.")
|
|
return False
|
|
return True
|
|
|
|
def get_output_path(input_path, format_name):
|
|
"""Get the output path with the new extension based on the format."""
|
|
if not format_name or format_name not in AUDIO_FORMATS:
|
|
return input_path
|
|
|
|
dir_name = dirname(input_path)
|
|
file_name = basename(input_path)
|
|
|
|
# Find the position of the last period to replace extension
|
|
dot_pos = file_name.rfind('.')
|
|
if dot_pos > 0:
|
|
new_file_name = file_name[:dot_pos] + AUDIO_FORMATS[format_name]["extension"]
|
|
else:
|
|
new_file_name = file_name + AUDIO_FORMATS[format_name]["extension"]
|
|
|
|
return os.path.join(dir_name, new_file_name)
|
|
|
|
def register_active_download(path):
|
|
"""
|
|
Register a file as being actively downloaded.
|
|
This is a placeholder that both modules implement, so we declare it here
|
|
to maintain the interface.
|
|
"""
|
|
# This function is expected to be overridden by the module
|
|
pass
|
|
|
|
def unregister_active_download(path):
|
|
"""
|
|
Unregister a file from the active downloads list.
|
|
This is a placeholder that both modules implement, so we declare it here
|
|
to maintain the interface.
|
|
"""
|
|
# This function is expected to be overridden by the module
|
|
pass
|
|
|
|
def convert_audio(input_path, format_name=None, bitrate=None, register_func=None, unregister_func=None):
|
|
"""
|
|
Convert audio file to the specified format and bitrate.
|
|
|
|
Args:
|
|
input_path: Path to the input audio file
|
|
format_name: Target format name (e.g., 'MP3', 'OGG', 'FLAC')
|
|
bitrate: Target bitrate (e.g., '320k', '128k'). If None, uses default for lossy formats.
|
|
register_func: Function to register a file as being actively downloaded
|
|
unregister_func: Function to unregister a file from the active downloads list
|
|
|
|
Returns:
|
|
Path to the converted file, or the original path if no conversion was done
|
|
"""
|
|
# Initialize the register and unregister functions
|
|
if register_func:
|
|
global register_active_download
|
|
register_active_download = register_func
|
|
|
|
if unregister_func:
|
|
global unregister_active_download
|
|
unregister_active_download = unregister_func
|
|
|
|
# If no format specified or FFmpeg not available, return the original path
|
|
if not format_name or not check_ffmpeg_available():
|
|
return input_path
|
|
|
|
# Validate format and get format details
|
|
format_name_upper = format_name.upper()
|
|
if format_name_upper not in AUDIO_FORMATS:
|
|
logger.warning(f"Unknown format: {format_name}. Using original format.")
|
|
return input_path
|
|
|
|
format_details = AUDIO_FORMATS[format_name_upper]
|
|
|
|
# Determine effective bitrate
|
|
effective_bitrate = bitrate
|
|
if format_details["default_bitrate"] is not None: # Lossy format
|
|
if effective_bitrate:
|
|
# Validate provided bitrate
|
|
if effective_bitrate.lower() not in [b.lower() for b in format_details["bitrates"]]:
|
|
logger.warning(f"Invalid bitrate {effective_bitrate} for {format_name_upper}. Using default {format_details['default_bitrate']}.")
|
|
effective_bitrate = format_details["default_bitrate"]
|
|
else: # No bitrate provided for lossy format, use default
|
|
effective_bitrate = format_details["default_bitrate"]
|
|
elif effective_bitrate: # Lossless format but bitrate was specified
|
|
logger.warning(f"Bitrate specified for lossless format {format_name_upper}. Ignoring bitrate.")
|
|
effective_bitrate = None
|
|
|
|
# Skip conversion if the file is already in the target format and bitrate matches (or not applicable)
|
|
if input_path.lower().endswith(format_details["extension"].lower()):
|
|
# For lossless, or if effective_bitrate matches (or no specific bitrate needed for format)
|
|
if format_details["default_bitrate"] is None: # Lossless
|
|
logger.info(f"File {input_path} is already in {format_name_upper} (lossless) format. Skipping conversion.")
|
|
return input_path
|
|
# For lossy, if no specific bitrate was relevant (already handled by effective_bitrate logic)
|
|
# This condition might be redundant if we always convert to ensure bitrate.
|
|
# Let's assume for now, if it's already the right extension, we don't re-encode unless bitrate implies so.
|
|
# However, the original logic converted if bitrate was specified even for same extension.
|
|
# To maintain similar behavior: if a bitrate is effectively set for a lossy format, we proceed.
|
|
# If effective_bitrate is None (e.g. for FLAC, WAV), and extension matches, skip.
|
|
if not effective_bitrate and format_details["default_bitrate"] is not None:
|
|
logger.info(f"File {input_path} is already in {format_name_upper} format with a suitable bitrate. Skipping conversion.")
|
|
return input_path
|
|
|
|
# Get the output path
|
|
output_path = get_output_path(input_path, format_name_upper)
|
|
|
|
# Use a temporary file for the conversion to avoid conflicts
|
|
temp_output = output_path + ".tmp"
|
|
|
|
# Register the temporary file
|
|
register_active_download(temp_output)
|
|
|
|
try:
|
|
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", input_path]
|
|
|
|
# Add bitrate parameter for lossy formats if an effective_bitrate is set
|
|
if effective_bitrate and format_details["bitrates"]: # format_details["bitrates"] implies lossy
|
|
cmd.extend(["-b:a", effective_bitrate])
|
|
|
|
# Add codec parameter
|
|
cmd.extend(["-c:a", format_details["ffmpeg_codec"]])
|
|
|
|
# Add format flag
|
|
if "ffmpeg_format_flag" in format_details:
|
|
cmd.extend(["-f", format_details["ffmpeg_format_flag"]])
|
|
|
|
# For some formats, add additional parameters
|
|
if format_name_upper == "MP3":
|
|
# Use high quality settings for MP3
|
|
if not effective_bitrate or int(effective_bitrate.replace('k', '')) >= 256:
|
|
cmd.extend(["-q:a", "0"])
|
|
|
|
# Add output file
|
|
cmd.append(temp_output)
|
|
|
|
# Run the conversion
|
|
logger.info(f"Converting {input_path} to {format_name_upper}" + (f" at {effective_bitrate}" if effective_bitrate else ""))
|
|
process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
|
|
if process.returncode != 0:
|
|
logger.error(f"Audio conversion failed: {process.stderr}")
|
|
if exists(temp_output):
|
|
os.remove(temp_output)
|
|
unregister_active_download(temp_output)
|
|
return input_path
|
|
|
|
# Register the output file and unregister the temp file
|
|
register_active_download(output_path)
|
|
|
|
# Rename the temporary file to the final file
|
|
os.rename(temp_output, output_path)
|
|
unregister_active_download(temp_output)
|
|
|
|
# Remove the original file if the conversion was successful and the files are different
|
|
if exists(output_path) and input_path != output_path and exists(input_path):
|
|
os.remove(input_path)
|
|
unregister_active_download(input_path)
|
|
|
|
logger.info(f"Successfully converted to {format_name_upper}" + (f" at {effective_bitrate}" if effective_bitrate else ""))
|
|
return output_path
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during audio conversion: {str(e)}")
|
|
# Clean up temp files
|
|
if exists(temp_output):
|
|
os.remove(temp_output)
|
|
unregister_active_download(temp_output)
|
|
# Return the original file path
|
|
return input_path
|