better songtracking

This commit is contained in:
2025-08-31 19:20:50 +02:00
parent 7f833cc512
commit 4a2a5b280d
+78 -60
View File
@@ -5,7 +5,7 @@ import subprocess
import time, datetime import time, datetime
import sys import sys
import threading import threading
import json import json, re
from datetime import datetime from datetime import datetime
import log95 import log95
@@ -22,10 +22,9 @@ playlist_dir = "/home/user/playlists"
name_table_path = "/home/user/mixes/name_table.txt" name_table_path = "/home/user/mixes/name_table.txt"
state_file_path = "/tmp/radioPlayer_state.json" state_file_path = "/tmp/radioPlayer_state.json"
rds_basic_base = "Gramy: {}" rds_base = "Gramy: {} - {}"
rds_base = "Gramy: radio95 - {}" rds_default_artist = "radio95"
rds_default_name = "Program Godzinny" rds_default_name = "Program Godzinny"
rds_default_rtp_data = "4,7,7,1,17"
udp_host = ("127.0.0.1", 5000) udp_host = ("127.0.0.1", 5000)
@@ -103,10 +102,10 @@ def get_audio_duration(file_path):
"""Get duration of audio file using ffprobe""" """Get duration of audio file using ffprobe"""
try: try:
result = subprocess.run([ result = subprocess.run([
'ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', 'ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', file_path '-of', 'default=noprint_wrappers=1:nokey=1', file_path
], capture_output=True, text=True) ], capture_output=True, text=True)
if result.returncode == 0: if result.returncode == 0:
duration = float(result.stdout.strip()) duration = float(result.stdout.strip())
return duration return duration
@@ -136,11 +135,11 @@ def should_resume_from_state(tracks, playlist_path):
"""Check if we should resume from saved state""" """Check if we should resume from saved state"""
if not current_state["current_file"] or not current_state["start_time"]: if not current_state["current_file"] or not current_state["start_time"]:
return False, tracks, 0 return False, tracks, 0
# Check if the saved file is still in the current playlist # Check if the saved file is still in the current playlist
if current_state["current_file"] in tracks and current_state["playlist_path"] == playlist_path: if current_state["current_file"] in tracks and current_state["playlist_path"] == playlist_path:
elapsed = time.time() - current_state["start_time"] elapsed = time.time() - current_state["start_time"]
# Only resume if less than the track duration and less than 10 minutes have passed # Only resume if less than the track duration and less than 10 minutes have passed
if (current_state["duration"] and elapsed < current_state["duration"] and elapsed < 600): if (current_state["duration"] and elapsed < current_state["duration"] and elapsed < 600):
# Find position of the current file # Find position of the current file
@@ -150,18 +149,32 @@ def should_resume_from_state(tracks, playlist_path):
return True, tracks, resume_index return True, tracks, resume_index
except ValueError: except ValueError:
pass pass
return False, tracks, 0 return False, tracks, 0
def update_rds(track_name: str): def update_rds(track_name: str):
try: try:
name_table: dict[str, str] = load_dict_from_custom_format(name_table_path) name_table: dict[str, str] = load_dict_from_custom_format(name_table_path)
try: try:
if " - " in name_table[track_name]: prt = rds_basic_base.format(name_table[track_name]) name = name_table[track_name]
else: prt = rds_base.format(name_table[track_name])
except KeyError as e: except KeyError as e:
logger.warning(f"File does not have a alias in the name table ({e})") logger.warning(f"File does not have a alias in the name table ({e})")
prt = rds_basic_base.format(track_name.split(".", 1)[0]) name = track_name.split(".",1)[0]
if " - " in name:
count = name.count(" - ")
while count != 1: # yotutube reuploads, to avoid things like ilikedick123 - Micheal Jackson - Smooth Criminal
name = name.split(" - ", 1)[1]
count = name.count(" - ")
artist = name.split(" - ", 1)[0]
title = name.split(" - ", 1)[1]
else:
artist = rds_default_artist
title = name
title = re.sub(r'\s*[\(\[][^\(\)\[\]]*[\)\]]', '', title) # there might be junk
prt = rds_base.format(artist, title)
f = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) f = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
f.settimeout(1.0) f.settimeout(1.0)
@@ -171,11 +184,16 @@ def update_rds(track_name: str):
logger.error("Could not send TEXT to RDS, timeout.") logger.error("Could not send TEXT to RDS, timeout.")
return return
rtp = [4] # type 1
rtp.append(prt.find(artist)) # start 1
rtp.append(len(artist)) # len 1
rtp.append(1) # type 2
rtp.append(prt.find(title)) # start 2
rtp.append(len(title)) # len 2
rtp = list(map(str, rtp))
try: try:
try: f.sendto(f"RTP={','.join(rtp)}\r\n".encode(), udp_host)
f.sendto(f"RTP={rds_default_rtp_data},{len(str(name_table[track_name]))-1}\r\n".encode(), udp_host)
except KeyError:
f.sendto(f"RTP={rds_default_rtp_data},{len(rds_default_name)-1}\r\n".encode(), udp_host)
except socket.timeout: except socket.timeout:
logger.error("Could not send TEXT to RDS, timeout.") logger.error("Could not send TEXT to RDS, timeout.")
return return
@@ -201,10 +219,10 @@ def load_playlist(playlist_path):
def get_newest_track(tracks): def get_newest_track(tracks):
if not tracks: if not tracks:
return None return None
newest_track = None newest_track = None
newest_time = 0 newest_time = 0
for track in tracks: for track in tracks:
track_path = os.path.abspath(os.path.expanduser(track)) track_path = os.path.abspath(os.path.expanduser(track))
try: try:
@@ -214,7 +232,7 @@ def get_newest_track(tracks):
newest_track = track newest_track = track
except OSError: except OSError:
continue continue
return newest_track return newest_track
def check_control_files(): def check_control_files():
@@ -222,11 +240,11 @@ def check_control_files():
if can_delete_file("/tmp/radioPlayer_quit"): if can_delete_file("/tmp/radioPlayer_quit"):
os.remove("/tmp/radioPlayer_quit") os.remove("/tmp/radioPlayer_quit")
return "quit" return "quit"
if can_delete_file("/tmp/radioPlayer_reload"): if can_delete_file("/tmp/radioPlayer_reload"):
os.remove("/tmp/radioPlayer_reload") os.remove("/tmp/radioPlayer_reload")
return "reload" return "reload"
return None return None
def stop_all_processes(): def stop_all_processes():
@@ -244,7 +262,7 @@ def stop_all_processes():
except (subprocess.TimeoutExpired, ProcessLookupError): except (subprocess.TimeoutExpired, ProcessLookupError):
pass pass
current_process = None current_process = None
if next_process and next_process.poll() is None: if next_process and next_process.poll() is None:
try: try:
next_process.terminate() next_process.terminate()
@@ -260,81 +278,81 @@ def stop_all_processes():
def create_audio_process(track_path, resume_seconds=0, fade_in=False, volume=1.0): def create_audio_process(track_path, resume_seconds=0, fade_in=False, volume=1.0):
"""Create ffplay process with optional fade effects and volume control""" """Create ffplay process with optional fade effects and volume control"""
cmd = ['ffplay', '-nodisp', '-hide_banner', '-autoexit', '-loglevel', 'quiet'] cmd = ['ffplay', '-nodisp', '-hide_banner', '-autoexit', '-loglevel', 'quiet']
# Build filter chain # Build filter chain
filters = [] filters = []
# Add fade in if requested # Add fade in if requested
if fade_in: if fade_in:
filters.append(f"afade=t=in:st=0:d={CROSSFADE_DURATION}") filters.append(f"afade=t=in:st=0:d={CROSSFADE_DURATION}")
# Add volume control # Add volume control
if volume != 1.0: if volume != 1.0:
filters.append(f"volume={volume}") filters.append(f"volume={volume}")
# Apply filters if any exist # Apply filters if any exist
if filters: if filters:
filter_chain = ",".join(filters) filter_chain = ",".join(filters)
cmd.extend(['-af', filter_chain]) cmd.extend(['-af', filter_chain])
# Add resume position if specified # Add resume position if specified
resume_seconds = max(0, resume_seconds - 5) # Start 5 seconds earlier for better sync resume_seconds = max(0, resume_seconds - 5) # Start 5 seconds earlier for better sync
if resume_seconds > 0: if resume_seconds > 0:
cmd.extend(['-ss', str(resume_seconds)]) cmd.extend(['-ss', str(resume_seconds)])
cmd.append(track_path) cmd.append(track_path)
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def play_single_track(track_path, resume_seconds=0): def play_single_track(track_path, resume_seconds=0):
"""Play a single track without crossfade (for first track or special cases)""" """Play a single track without crossfade (for first track or special cases)"""
global current_process global current_process
with process_lock: with process_lock:
current_process = create_audio_process(track_path, resume_seconds, fade_in=True) current_process = create_audio_process(track_path, resume_seconds, fade_in=True)
# Wait for the process to complete # Wait for the process to complete
current_process.wait() current_process.wait()
with process_lock: with process_lock:
current_process = None current_process = None
def play_audio_with_crossfade(current_track_path, next_track_path=None, resume_seconds=0): def play_audio_with_crossfade(current_track_path, next_track_path=None, resume_seconds=0):
"""Play audio file with crossfade to next track""" """Play audio file with crossfade to next track"""
global current_process, next_process global current_process, next_process
# Get duration of current track # Get duration of current track
duration = get_audio_duration(current_track_path) duration = get_audio_duration(current_track_path)
if not duration: if not duration:
logger.warning(f"Could not get duration for {current_track_path}, playing without crossfade") logger.warning(f"Could not get duration for {current_track_path}, playing without crossfade")
play_single_track(current_track_path, resume_seconds) play_single_track(current_track_path, resume_seconds)
return return
# Calculate when to start the next track (5 seconds before end) # Calculate when to start the next track (5 seconds before end)
crossfade_start_time = max(0, duration - CROSSFADE_DURATION - resume_seconds) crossfade_start_time = max(0, duration - CROSSFADE_DURATION - resume_seconds)
# Start current track with fade in # Start current track with fade in
with process_lock: with process_lock:
current_process = create_audio_process(current_track_path, resume_seconds, fade_in=True) current_process = create_audio_process(current_track_path, resume_seconds, fade_in=True)
if next_track_path and crossfade_start_time > 0: if next_track_path and crossfade_start_time > 0:
# Wait until it's time to start the crossfade # Wait until it's time to start the crossfade
time.sleep(crossfade_start_time) time.sleep(crossfade_start_time)
# Check if we need to stop due to control files # Check if we need to stop due to control files
action = check_control_files() action = check_control_files()
if action in ["quit", "reload"]: if action in ["quit", "reload"]:
stop_all_processes() stop_all_processes()
return action return action
# Start next track with fade in and reduced volume initially # Start next track with fade in and reduced volume initially
logger.info(f"Starting crossfade to: {os.path.basename(next_track_path)}") logger.info(f"Starting crossfade to: {os.path.basename(next_track_path)}")
with process_lock: with process_lock:
next_process = create_audio_process(next_track_path, fade_in=True, volume=0.8) next_process = create_audio_process(next_track_path, fade_in=True, volume=0.8)
# Wait for crossfade to complete # Wait for crossfade to complete
time.sleep(CROSSFADE_DURATION) time.sleep(CROSSFADE_DURATION)
# Stop current track (it should fade out naturally) # Stop current track (it should fade out naturally)
with process_lock: with process_lock:
if current_process and current_process.poll() is None: if current_process and current_process.poll() is None:
@@ -350,7 +368,7 @@ def play_audio_with_crossfade(current_track_path, next_track_path=None, resume_s
current_process.wait() current_process.wait()
with process_lock: with process_lock:
current_process = None current_process = None
return None return None
def play_playlist(playlist_path, custom_playlist: bool=False, play_newest_first=False, do_shuffle=True): def play_playlist(playlist_path, custom_playlist: bool=False, play_newest_first=False, do_shuffle=True):
@@ -362,12 +380,12 @@ def play_playlist(playlist_path, custom_playlist: bool=False, play_newest_first=
return return
if do_shuffle: random.seed() if do_shuffle: random.seed()
# Check if we should resume from saved state # Check if we should resume from saved state
should_resume, tracks, resume_index = should_resume_from_state(tracks, playlist_path) should_resume, tracks, resume_index = should_resume_from_state(tracks, playlist_path)
start_index = 0 start_index = 0
resume_seconds = 0 resume_seconds = 0
if should_resume: if should_resume:
start_index = resume_index start_index = resume_index
with state_lock: with state_lock:
@@ -427,16 +445,16 @@ def play_playlist(playlist_path, custom_playlist: bool=False, play_newest_first=
track_path = os.path.abspath(os.path.expanduser(track)) track_path = os.path.abspath(os.path.expanduser(track))
track_name = os.path.basename(track_path) track_name = os.path.basename(track_path)
# Update state before playing # Update state before playing
update_current_state(track_path, playlist_path, i) update_current_state(track_path, playlist_path, i)
if i == start_index and resume_seconds > 0: if i == start_index and resume_seconds > 0:
logger.info(f"Resuming: {track_name} at {resume_seconds:.0f}s") logger.info(f"Resuming: {track_name} at {resume_seconds:.0f}s")
else: else:
logger.info(f"Now playing: {track_name}") logger.info(f"Now playing: {track_name}")
resume_seconds = 0 resume_seconds = 0
update_rds(track_name) update_rds(track_name)
# Determine next track for crossfade # Determine next track for crossfade
@@ -444,14 +462,14 @@ def play_playlist(playlist_path, custom_playlist: bool=False, play_newest_first=
next_index = i + 1 next_index = i + 1
if next_index < len(tracks): if next_index < len(tracks):
next_track_path = os.path.abspath(os.path.expanduser(tracks[next_index])) next_track_path = os.path.abspath(os.path.expanduser(tracks[next_index]))
# Play with crossfade # Play with crossfade
if i == start_index and resume_seconds > 0: if i == start_index and resume_seconds > 0:
# For resumed tracks, use simpler playback # For resumed tracks, use simpler playback
result = play_audio_with_crossfade(track_path, next_track_path, resume_seconds) result = play_audio_with_crossfade(track_path, next_track_path, resume_seconds)
else: else:
result = play_audio_with_crossfade(track_path, next_track_path) result = play_audio_with_crossfade(track_path, next_track_path)
if result == "quit": if result == "quit":
stop_all_processes() stop_all_processes()
clear_state() clear_state()
@@ -460,7 +478,7 @@ def play_playlist(playlist_path, custom_playlist: bool=False, play_newest_first=
logger.info("Reload requested during playback...") logger.info("Reload requested during playback...")
stop_all_processes() stop_all_processes()
return "reload" return "reload"
# Clear state after track finishes # Clear state after track finishes
clear_current_state() clear_current_state()
@@ -489,7 +507,7 @@ def parse_arguments():
do_shuffle = True do_shuffle = True
pre_track_path = None pre_track_path = None
selected_list = None selected_list = None
if arg: if arg:
if arg.lower() == "-h": if arg.lower() == "-h":
print("Control files:") print("Control files:")
@@ -506,7 +524,7 @@ def parse_arguments():
print() print()
print("Crossfade: 5-second crossfade is automatically applied between tracks") print("Crossfade: 5-second crossfade is automatically applied between tracks")
exit(0) exit(0)
if can_delete_file("/tmp/radioPlayer_arg"): if can_delete_file("/tmp/radioPlayer_arg"):
with open("/tmp/radioPlayer_arg", "r") as f: with open("/tmp/radioPlayer_arg", "r") as f:
arg = f.read().strip() arg = f.read().strip()
@@ -536,11 +554,11 @@ def parse_arguments():
def main(): def main():
# Load state at startup # Load state at startup
state_loaded = load_state() state_loaded = load_state()
try: try:
while True: # Main reload loop while True: # Main reload loop
arg, play_newest_first, do_shuffle, pre_track_path, selected_list = parse_arguments() arg, play_newest_first, do_shuffle, pre_track_path, selected_list = parse_arguments()
if pre_track_path: if pre_track_path:
track_name = os.path.basename(pre_track_path) track_name = os.path.basename(pre_track_path)
logger.info(f"Now playing: {track_name}") logger.info(f"Now playing: {track_name}")
@@ -548,7 +566,7 @@ def main():
update_rds(track_name) update_rds(track_name)
play_single_track(pre_track_path) play_single_track(pre_track_path)
clear_current_state() clear_current_state()
action = check_control_files() action = check_control_files()
if can_delete_file("/tmp/radioPlayer_onplaylist"): action = None if can_delete_file("/tmp/radioPlayer_onplaylist"): action = None
if action == "quit": if action == "quit":
@@ -563,9 +581,9 @@ def main():
# Try to resume from the loaded state # Try to resume from the loaded state
if os.path.exists(current_state["playlist_path"]): if os.path.exists(current_state["playlist_path"]):
logger.info(f"Attempting to resume from saved state: {current_state['playlist_path']}") logger.info(f"Attempting to resume from saved state: {current_state['playlist_path']}")
result = play_playlist(current_state["playlist_path"], result = play_playlist(current_state["playlist_path"],
current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'morning') and current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'morning') and
current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'day') and current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'day') and
current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'night') and current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'night') and
current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'late_night'), current_state["playlist_path"] != os.path.join(playlist_dir, get_current_day(), 'late_night'),
play_newest_first, do_shuffle) play_newest_first, do_shuffle)
@@ -585,7 +603,7 @@ def main():
if result == "reload": if result == "reload":
playlist_loop_active = False # Break out to reload playlist_loop_active = False # Break out to reload
continue continue
current_hour = get_current_hour() current_hour = get_current_hour()
current_day = get_current_day() current_day = get_current_day()
@@ -598,7 +616,7 @@ def main():
day_dir = os.path.dirname(day_playlist) day_dir = os.path.dirname(day_playlist)
night_dir = os.path.dirname(night_playlist) night_dir = os.path.dirname(night_playlist)
late_night_dir = os.path.dirname(late_night_playlist) late_night_dir = os.path.dirname(late_night_playlist)
for dir_path in [morning_dir, day_dir, night_dir, late_night_dir]: for dir_path in [morning_dir, day_dir, night_dir, late_night_dir]:
if not os.path.exists(dir_path): if not os.path.exists(dir_path):
logger.info(f"Creating directory: {dir_path}") logger.info(f"Creating directory: {dir_path}")
@@ -622,7 +640,7 @@ def main():
else: else:
logger.info(f"Playing {current_day} night playlist...") logger.info(f"Playing {current_day} night playlist...")
result = play_playlist(night_playlist, False, play_newest_first, do_shuffle) result = play_playlist(night_playlist, False, play_newest_first, do_shuffle)
action = check_control_files() action = check_control_files()
if not can_delete_file("/tmp/radioPlayer_onplaylist"): action = None if not can_delete_file("/tmp/radioPlayer_onplaylist"): action = None
if action == "quit": if action == "quit":
@@ -635,7 +653,7 @@ def main():
os.remove("/tmp/radioPlayer_onplaylist") os.remove("/tmp/radioPlayer_onplaylist")
logger.info("Reload requested, restarting with new arguments...") logger.info("Reload requested, restarting with new arguments...")
result = "reload" result = "reload"
if result == "reload": if result == "reload":
playlist_loop_active = False # Break out to reload playlist_loop_active = False # Break out to reload