Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import curses
- import threading
- import queue
- import time
- import mpv
- import json
- import os
- import webbrowser
- import webstuff
- import tempfile
- import time
- import logging
- import util
- from urllib.parse import urlparse, urlunparse
- import yaml
- import setproctitle
- import random
- logging.basicConfig(
- filename='debug.log',
- level=logging.INFO,
- format='%(asctime)s - %(levelname)s - %(message)s'
- )
- # Set a unique process name
- UNIQUE_NAME = "bandora_cli_player"
- setproctitle.setproctitle(UNIQUE_NAME)
- # Set a unique terminal window title
- UNIQUE_TITLE = "Bandora Player"
- print(f"\033]0;{UNIQUE_TITLE}\a", end="", flush=True)
- MPV_TITLE = UNIQUE_TITLE+" MPV"
- # Global variables for thread communication
- current_song = ""
- playback_time = 0
- song_duration = 0
- is_fetching = False
- frontpage_albums = webstuff.scrape_album_urls()
- playback_lock = threading.Lock()
- command_queue = queue.Queue()
- clipboard_checkmarks = 0
- config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
- with open(config_path, 'r') as config_file:
- config = yaml.safe_load(config_file)
- # Get the directory of the current script
- script_dir = os.path.dirname(os.path.abspath(__file__))
- # File to store the current station
- CURRENT_STATION_FILE = os.path.join(script_dir, "current_station.txt")
- # Directory to store station score files
- SCORES_DIR = os.path.join(script_dir, "song_scores")
- # Ensure the scores directory exists
- os.makedirs(SCORES_DIR, exist_ok=True)
- # File to store song scores
- # SCORES_FILE = os.path.dirname(__file__)+"/songs_scores.json"
- # Access the variables from the config
- SCRAPE_CACHE_TIME = config.get('SCRAPE_CACHE_TIME', 60*60*24*7) # Default value if not found
- INDIRECT_SCORE_WEIGHT = config.get('INDIRECT_SCORE_WEIGHT', 0.5)
- PROPORTION_FROM_DISCOVERY_PAGE = config.get('PROPORTION_FROM_DISCOVERY_PAGE', 0.3)
- RECENT_PENALTY_HALF_LIFE = config.get('RECENT_PENALTY_HALF_LIFE', 60*60)
- VOLUME = config.get('VOLUME', 40)
- def get_available_stations():
- """Get a list of all available stations"""
- try:
- files = os.listdir(SCORES_DIR)
- # Filter for score files and extract station names
- stations = []
- for file in files:
- if file.endswith('_scores.json'):
- station_name = file.replace('_scores.json', '')
- stations.append(station_name)
- return stations
- except FileNotFoundError:
- return []
- def create_station_if_not_exists(station_name):
- """Create a station if it doesn't exist"""
- scores_file = os.path.join(SCORES_DIR, f"{station_name}_scores.json")
- if not os.path.exists(scores_file):
- # Create empty JSON file
- with open(scores_file, 'w') as f:
- f.write("{}")
- def get_current_station():
- """Get the current station from the file"""
- try:
- with open(CURRENT_STATION_FILE, 'r') as f:
- return f.read().strip()
- except FileNotFoundError:
- # If no station file exists, default to 'default'
- return "default"
- def set_current_station(station_name):
- """Set the current station"""
- with open(CURRENT_STATION_FILE, 'w') as f:
- f.write(station_name)
- def get_scores_file():
- """Get the path to the scores file for the current station"""
- station = get_current_station()
- return os.path.join(SCORES_DIR, f"{station}_scores.json")
- # Load or initialize song scores
- def load_scores():
- if not os.path.exists(get_scores_file()):
- # Create the file with an empty dictionary if it doesn't exist
- with open(get_scores_file(), "w") as file:
- json.dump({'song_scores':{},'associated_albums':{},'containing_albums':{},'last_scraped':{}, 'last_played':{}}, file)
- with open(get_scores_file(), "r") as file:
- return json.load(file)
- #must be called outside of playback_lock
- def save_scores(scores):
- temp_file = get_scores_file() + ".tmp"
- scores_str = ""
- with playback_lock:
- scores_str=json.dumps(scores)
- try:
- # Write to a temporary file
- with open(temp_file, "w") as file:
- file.write(scores_str)
- # Rename the temporary file to the final file
- os.replace(temp_file, get_scores_file())
- except Exception as e:
- # Handle errors (e.g., log them)
- logging.error(f"Error saving scores: {e}")
- if os.path.exists(temp_file):
- os.remove(temp_file) # Clean up the temporary file
- # json.dump(scores, file)
- #anything that uses or modifies this should be in the thread lock because both threads might need it
- data = load_scores()
- def get_song_from_random_album(frontpage_albums_weighted,disliked_songs):#cache=True):
- # Extract the album names and their corresponding weights
- albums = list(frontpage_albums_weighted.keys())
- weights = list(frontpage_albums_weighted.values())
- songs = []
- album_url = None
- related_albums = None
- # In order to not play disliked songs, keep cycling albums until finding one with songs not in the disliked_songs list
- while len(songs)==0:
- # Use random.choices to select an album based on the weights
- choices=random.choices(albums, weights=weights, k=1)
- random.shuffle(choices) #wait is this redundant
- album_url = choices[0]
- songs,related_albums = webstuff.scrape_song_urls(album_url)
- songs = [x for x in songs if x not in disliked_songs]
- # print('songs was ',songs)
- related_albums = related_albums+[album_url]
- return [album_url,random.choice(songs),related_albums]
- # Must be called inside playback_lock
- #create dict mapping the last time each album was played, based on the last time songs in them were played
- def get_album_last_played_time():
- album_last_played_time={}
- for song in data['containing_albums'].keys():
- album=data['containing_albums'][song]
- if album not in album_last_played_time:
- if song in data['last_played']:
- album_last_played_time[album]=data['last_played'][song]
- else:
- album_last_played_time[album]=0
- else:
- if song in data['last_played']:
- #compare the times and pick the most recent
- album_last_played_time[album]=data['last_played'][song] if data['last_played'][song]>album_last_played_time[album] else album_last_played_time[album]
- else:
- album_last_played_time[album]=0
- return album_last_played_time
- # Must be called inside playback_lock
- def get_all_albums_weighted(extras):
- extras = list(set(extras)) #remove duplicates
- album_weights={}
- disliked_songs=set()
- for album in extras:
- album_weights[album]=1
- all_associated_albums = [item for sublist in data['associated_albums'].values() for item in sublist]
- for album in all_associated_albums:
- album_weights[album]=0
- for song in data['song_scores'].keys():
- if data['song_scores'][song]['score'] < 0:
- disliked_songs.add(song)
- if song in data['containing_albums'] and data['containing_albums'][song] in album_weights:
- album_weights[data['containing_albums'][song]]+=data['song_scores'][song]['score'] #Add the score to the probability weight
- if song in data['associated_albums']:
- for album in data['associated_albums'][song]:
- album_weights[album]+=data['song_scores'][song]['score'] * INDIRECT_SCORE_WEIGHT #add part of the score to the probability weight of all associated albums
- pass
- #remove negatives and zero values which won't be selected anyway
- keys_to_delete = [key for key in album_weights if album_weights[key] <= 0]
- for key in keys_to_delete:
- del album_weights[key]
- #normalize weights based on PROPORTION_FROM_DISCOVERY_PAGE so that a fixed number of songs from the front page vs personalized suggestions are played
- score_sum=sum(album_weights.values())
- score_sum_generic=0
- for album in extras:
- score_sum_generic+=album_weights[album]
- modifier=PROPORTION_FROM_DISCOVERY_PAGE*score_sum/score_sum_generic
- for album in extras:
- album_weights[album]=album_weights[album]*modifier
- #penalize recently played
- album_last_played_time=get_album_last_played_time()
- for album in album_weights.keys():
- if album in album_last_played_time:
- #at 0 seconds since it's been played, weight should be set to 0 (subtracts the full weight), at RECENT_PENALTY_HALF_LIFE seconds, cut in half, at 2*RECENT_PENALTY_HALF_LIFE, reduced by 1/4, etc.
- exponent = ((time.time()-album_last_played_time[album])/RECENT_PENALTY_HALF_LIFE)
- if exponent < 100:
- album_weights[album] -= album_weights[album]/(2**exponent)
- return album_weights,disliked_songs
- def init_song(track_url):
- pass
- # Music playback thread
- def music_playback_thread():
- global current_song, playback_time, song_duration, is_fetching,frontpage_albums
- player = mpv.MPV(
- ytdl=True, # Enable youtube-dl support
- force_window=True, # Force MPV to open a window
- osc=True, # Enable the on-screen controller (playback controls)
- input_default_bindings=True, # Enable default key bindings
- input_vo_keyboard=True, # Allow keyboard input
- volume=VOLUME,
- title=MPV_TITLE,
- )
- try:
- while True:
- # Fetch a new song
- logging.info('getting song url with selenium ')
- with playback_lock:
- is_fetching = True
- weighted_albums = None
- disliked_songs = None
- with playback_lock:
- weighted_albums,disliked_songs = get_all_albums_weighted(frontpage_albums)
- # Choose a song, getting the song url and other info
- album,song,related_albums=get_song_from_random_album(weighted_albums,disliked_songs)
- song=util.strip_url_params(song)
- album=util.strip_url_params(album)
- logging.info(f'got song url {song}, from album {album}')
- # Collect and store data associated with the chosen song before playing it
- with playback_lock:
- if song in data['associated_albums']:
- related_albums += data['associated_albums'][song]
- if song not in data['last_scraped'] or time.time()-data['last_scraped'][song]>SCRAPE_CACHE_TIME:
- page_linked_albums = webstuff.scrape_album_urls(album)#song)
- artist_albums = webstuff.scrape_album_urls("https://"+urlparse(song).netloc)
- related_albums += page_linked_albums+artist_albums#adds songs linked on artists's page
- related_albums=list(set(related_albums)) #remove any duplicates
- related_albums=list(map(util.strip_url_params, related_albums))#strip parameters from each element
- with playback_lock:
- data['associated_albums'][song] = related_albums
- data['containing_albums'][song] = album
- data['last_scraped'][song] = time.time()
- data['last_played'][song] = time.time()
- data['last_played'][album] = time.time()
- save_scores(data)
- #Log that the song was played
- with open(os.path.join(os.path.dirname(__file__), 'songsplayed.txt'), 'a') as file:
- file.write(song + '\n')
- #Play the song
- with playback_lock:
- current_song = song
- playback_time = 0
- is_fetching = False
- player.play(song)
- # Monitor playback status
- count = 0
- while player.core_idle: # Wait for playback to start
- time.sleep(0.1)
- count+=1
- if count>100:
- break
- # if count%100==0: #it got stuck?
- # logging.info('got stuck, trying to play the song again '+song)
- # player.play(song)
- if count>100: # The song won't play for some reason, I think MVP can't handle urls with /track/ rather than /album/ (no, probably isn't that because some like that work). Give up and go to the next one.
- continue
- # Get the total duration of the song
- with playback_lock:
- song_duration = player.duration
- # song_duration = 0
- logging.info('playback active')
- while True:#not player.core_idle: # Playback is active
- time.sleep(0.1) # Small delay to avoid busy-waiting
- with playback_lock:
- playback_time = player.time_pos or 0
- # Check if playback has finished (not just paused)
- if player.core_idle and not player.pause:
- break
- # Check for skip command
- try:
- command = command_queue.get_nowait()
- if command == "skip":
- player.command("stop")
- logging.info('skipped song')
- break
- except queue.Empty:
- pass
- logging.info('playback completed')
- print("somehow escaped the music_playback_thread loop without error, this should not happen")
- except Exception as e:
- logging.error("something went wrong with the music playback thread ",e)
- logging.error("music_playback_thread end")
- lastkey = -1
- # Curses UI thread
- def curses_ui(stdscr):
- global current_song, playback_time, song_duration, data, is_fetching, lastkey
- curses.curs_set(0) # Hide cursor
- stdscr.nodelay(1) # Non-blocking input
- # Spinner animation frames
- spinner_frames = ["|", "/", "-", "\\"]
- spinner_index = 0
- while True:
- stdscr.clear()
- # Display song info
- with playback_lock:
- song_display = current_song # Strip URL parameters for display
- time_display = f"Time: {playback_time:.1f}s / {song_duration:.1f}s"
- stdscr.addstr(0, 0, f"Now Playing: {song_display}")
- stdscr.addstr(1, 0, time_display)
- # Get current song's score
- with playback_lock:
- if is_fetching:
- spinner = spinner_frames[spinner_index]
- stdscr.addstr(3, 0, f"Loading next song... {spinner}")
- spinner_index = (spinner_index + 1) % len(spinner_frames)
- if current_song in data['song_scores']:
- current_score = data['song_scores'][current_song]['score']
- else:
- current_score = 0
- data['song_scores'][current_song]={'score':current_score,'time_set':time.time()}
- # Display options with visual feedback
- stdscr.addstr(5, 0, "Options:")
- stdscr.addstr(6, 0, f"1: Like {'✓' if current_score == 1 else ''}")
- stdscr.addstr(7, 0, f"2: Dislike {'✗' if current_score == -1 else ''}")
- stdscr.addstr(8, 0, "3: Open URL")
- checkmarks = "✓" * clipboard_checkmarks if clipboard_checkmarks > 0 else ""
- stdscr.addstr(9, 0, f"4: Add Content To Station from Clipboard {checkmarks}")
- stdscr.addstr(10, 0, "s: Skip")
- stdscr.addstr(11, 0, "q: Quit")
- # Handle user input
- key = stdscr.getch()
- # if key != lastkey:
- # print('test')
- lastkey = key
- if key == ord('1'): # Like
- with playback_lock:
- if current_score == 1:
- current_score = 0
- data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()} # Undo like
- else:
- current_score = 1
- data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()} # Set like
- save_scores(data)
- elif key == ord('2'): # Dislike
- with playback_lock:
- if current_score == -1:
- current_score = 0
- data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()} # Undo dislike
- else:
- current_score = -1
- data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()} # Set dislike
- save_scores(data)
- elif key == ord('3'): # Open URL
- with playback_lock:
- webbrowser.open(current_song) # Open the URL in the default web browser
- stdscr.addstr(10, 0, "Opening URL in browser...")
- elif key == ord('4'): # Add from Clipboard
- def process_clipboard_async():
- try:
- album,song,related_albums = webstuff.process_clipboard_url()
- if song:
- with playback_lock:
- current_score = 1
- data['song_scores'][song] = {'score': current_score, 'time_set': time.time()}
- data['last_played'][song] = time.time()
- data['associated_albums'][song] = related_albums
- data['containing_albums'][song] = album
- data['last_scraped'][song] = time.time()
- save_scores(data)
- stdscr.addstr(11, 0, "Added song from clipboard and liked it!")
- global clipboard_checkmarks
- clipboard_checkmarks += 1
- else:
- stdscr.addstr(11, 0, "No valid song found in clipboard")
- except Exception as e:
- logging.error(f"Error processing clipboard: {e}")
- stdscr.addstr(11, 0, "Error processing clipboard")
- finally:
- # Refresh the screen to show the message
- stdscr.refresh()
- thread = threading.Thread(target=process_clipboard_async)
- thread.daemon = True
- thread.start()
- # Show processing message immediately
- stdscr.addstr(11, 0, "Processing clipboard...")
- stdscr.refresh()
- elif key == ord('s'): # Skip
- command_queue.put("skip")
- stdscr.addstr(10, 0, "Skipping song...")
- elif key == ord('q'): # Quit
- break
- stdscr.refresh()
- time.sleep(0.1)
- # Main function
- def main():
- # Start music playback thread
- playback_thread = threading.Thread(target=music_playback_thread, daemon=True)
- playback_thread.start()
- # Start curses UI
- curses.wrapper(curses_ui)
- if __name__ == "__main__":
- main()
Add Comment
Please, Sign In to add comment