Guest User

Untitled

a guest
May 25th, 2026
227
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 18.79 KB | Music | 0 0
  1. import curses
  2. import threading
  3. import queue
  4. import time
  5. import mpv
  6. import json
  7. import os
  8. import webbrowser
  9. import webstuff
  10. import tempfile
  11. import time
  12. import logging
  13. import util
  14. from urllib.parse import urlparse, urlunparse
  15. import yaml
  16. import setproctitle
  17. import random
  18.  
  19. logging.basicConfig(
  20.     filename='debug.log',
  21.     level=logging.INFO,
  22.     format='%(asctime)s - %(levelname)s - %(message)s'
  23. )
  24.  
  25. # Set a unique process name
  26. UNIQUE_NAME = "bandora_cli_player"
  27. setproctitle.setproctitle(UNIQUE_NAME)
  28. # Set a unique terminal window title
  29. UNIQUE_TITLE = "Bandora Player"
  30. print(f"\033]0;{UNIQUE_TITLE}\a", end="", flush=True)
  31. MPV_TITLE = UNIQUE_TITLE+" MPV"
  32.  
  33. # Global variables for thread communication
  34. current_song = ""
  35. playback_time = 0
  36. song_duration = 0
  37. is_fetching = False
  38. frontpage_albums = webstuff.scrape_album_urls()
  39. playback_lock = threading.Lock()
  40. command_queue = queue.Queue()
  41. clipboard_checkmarks = 0
  42.  
  43. config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
  44. with open(config_path, 'r') as config_file:
  45.     config = yaml.safe_load(config_file)
  46.  
  47. # Get the directory of the current script
  48. script_dir = os.path.dirname(os.path.abspath(__file__))
  49.  
  50. # File to store the current station
  51. CURRENT_STATION_FILE = os.path.join(script_dir, "current_station.txt")
  52.  
  53. # Directory to store station score files
  54. SCORES_DIR = os.path.join(script_dir, "song_scores")
  55.  
  56. # Ensure the scores directory exists
  57. os.makedirs(SCORES_DIR, exist_ok=True)
  58.  
  59. # File to store song scores
  60. # SCORES_FILE = os.path.dirname(__file__)+"/songs_scores.json"
  61.  
  62. # Access the variables from the config
  63. SCRAPE_CACHE_TIME = config.get('SCRAPE_CACHE_TIME', 60*60*24*7)  # Default value if not found
  64. INDIRECT_SCORE_WEIGHT = config.get('INDIRECT_SCORE_WEIGHT', 0.5)
  65. PROPORTION_FROM_DISCOVERY_PAGE = config.get('PROPORTION_FROM_DISCOVERY_PAGE', 0.3)
  66. RECENT_PENALTY_HALF_LIFE = config.get('RECENT_PENALTY_HALF_LIFE', 60*60)
  67. VOLUME = config.get('VOLUME', 40)
  68.  
  69. def get_available_stations():
  70.     """Get a list of all available stations"""
  71.     try:
  72.         files = os.listdir(SCORES_DIR)
  73.         # Filter for score files and extract station names
  74.         stations = []
  75.         for file in files:
  76.             if file.endswith('_scores.json'):
  77.                 station_name = file.replace('_scores.json', '')
  78.                 stations.append(station_name)
  79.         return stations
  80.     except FileNotFoundError:
  81.         return []
  82.  
  83. def create_station_if_not_exists(station_name):
  84.     """Create a station if it doesn't exist"""
  85.     scores_file = os.path.join(SCORES_DIR, f"{station_name}_scores.json")
  86.     if not os.path.exists(scores_file):
  87.         # Create empty JSON file
  88.         with open(scores_file, 'w') as f:
  89.             f.write("{}")
  90.            
  91. def get_current_station():
  92.     """Get the current station from the file"""
  93.     try:
  94.         with open(CURRENT_STATION_FILE, 'r') as f:
  95.             return f.read().strip()
  96.     except FileNotFoundError:
  97.         # If no station file exists, default to 'default'
  98.         return "default"
  99.  
  100. def set_current_station(station_name):
  101.     """Set the current station"""
  102.     with open(CURRENT_STATION_FILE, 'w') as f:
  103.         f.write(station_name)
  104.  
  105. def get_scores_file():
  106.     """Get the path to the scores file for the current station"""
  107.     station = get_current_station()
  108.     return os.path.join(SCORES_DIR, f"{station}_scores.json")
  109.  
  110. # Load or initialize song scores
  111. def load_scores():
  112.     if not os.path.exists(get_scores_file()):
  113.         # Create the file with an empty dictionary if it doesn't exist
  114.         with open(get_scores_file(), "w") as file:
  115.             json.dump({'song_scores':{},'associated_albums':{},'containing_albums':{},'last_scraped':{}, 'last_played':{}}, file)
  116.     with open(get_scores_file(), "r") as file:
  117.         return json.load(file)
  118.  
  119. #must be called outside of playback_lock
  120. def save_scores(scores):
  121.     temp_file = get_scores_file() + ".tmp"
  122.     scores_str = ""
  123.     with playback_lock:
  124.         scores_str=json.dumps(scores)
  125.     try:
  126.         # Write to a temporary file
  127.         with open(temp_file, "w") as file:
  128.             file.write(scores_str)
  129.         # Rename the temporary file to the final file
  130.         os.replace(temp_file, get_scores_file())
  131.     except Exception as e:
  132.         # Handle errors (e.g., log them)
  133.         logging.error(f"Error saving scores: {e}")
  134.         if os.path.exists(temp_file):
  135.             os.remove(temp_file)  # Clean up the temporary file
  136.         # json.dump(scores, file)
  137.  
  138. #anything that uses or modifies this should be in the thread lock because both threads might need it
  139. data = load_scores()
  140.  
  141.  
  142. def get_song_from_random_album(frontpage_albums_weighted,disliked_songs):#cache=True):
  143.     # Extract the album names and their corresponding weights
  144.     albums = list(frontpage_albums_weighted.keys())
  145.     weights = list(frontpage_albums_weighted.values())
  146.    
  147.     songs = []
  148.     album_url = None
  149.     related_albums = None
  150.     # In order to not play disliked songs, keep cycling albums until finding one with songs not in the disliked_songs list
  151.     while len(songs)==0:
  152.         # Use random.choices to select an album based on the weights
  153.         choices=random.choices(albums, weights=weights, k=1)
  154.         random.shuffle(choices) #wait is this redundant
  155.         album_url = choices[0]
  156.         songs,related_albums = webstuff.scrape_song_urls(album_url)
  157.         songs = [x for x in songs if x not in disliked_songs]
  158.     # print('songs was ',songs)
  159.     related_albums = related_albums+[album_url]
  160.     return [album_url,random.choice(songs),related_albums]
  161.  
  162. # Must be called inside playback_lock
  163. #create dict mapping the last time each album was played, based on the last time songs in them were played
  164. def get_album_last_played_time():
  165.     album_last_played_time={}
  166.     for song in data['containing_albums'].keys():
  167.         album=data['containing_albums'][song]
  168.         if album not in album_last_played_time:
  169.             if song in data['last_played']:
  170.                 album_last_played_time[album]=data['last_played'][song]
  171.             else:
  172.                 album_last_played_time[album]=0
  173.         else:
  174.             if song in data['last_played']:
  175.                 #compare the times and pick the most recent
  176.                 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]
  177.             else:
  178.                 album_last_played_time[album]=0
  179.     return album_last_played_time
  180.  
  181. # Must be called inside playback_lock
  182. def get_all_albums_weighted(extras):
  183.     extras = list(set(extras)) #remove duplicates
  184.     album_weights={}
  185.     disliked_songs=set()
  186.     for album in extras:
  187.         album_weights[album]=1
  188.     all_associated_albums = [item for sublist in data['associated_albums'].values() for item in sublist]
  189.     for album in all_associated_albums:
  190.         album_weights[album]=0
  191.     for song in data['song_scores'].keys():
  192.         if data['song_scores'][song]['score'] < 0:
  193.             disliked_songs.add(song)
  194.         if song in data['containing_albums'] and data['containing_albums'][song] in album_weights:
  195.             album_weights[data['containing_albums'][song]]+=data['song_scores'][song]['score'] #Add the score to the probability weight
  196.         if song in data['associated_albums']:
  197.             for album in data['associated_albums'][song]:
  198.                 album_weights[album]+=data['song_scores'][song]['score'] * INDIRECT_SCORE_WEIGHT #add part of the score to the probability weight of all associated albums
  199.                 pass
  200.     #remove negatives and zero values which won't be selected anyway
  201.     keys_to_delete = [key for key in album_weights if album_weights[key] <= 0]
  202.     for key in keys_to_delete:
  203.         del album_weights[key]
  204.     #normalize weights based on PROPORTION_FROM_DISCOVERY_PAGE so that a fixed number of songs from the front page vs personalized suggestions are played
  205.     score_sum=sum(album_weights.values())
  206.     score_sum_generic=0
  207.     for album in extras:
  208.         score_sum_generic+=album_weights[album]
  209.     modifier=PROPORTION_FROM_DISCOVERY_PAGE*score_sum/score_sum_generic
  210.     for album in extras:
  211.         album_weights[album]=album_weights[album]*modifier
  212.  
  213.     #penalize recently played
  214.     album_last_played_time=get_album_last_played_time()
  215.     for album in album_weights.keys():
  216.         if album in album_last_played_time:
  217.             #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.
  218.             exponent = ((time.time()-album_last_played_time[album])/RECENT_PENALTY_HALF_LIFE)
  219.             if exponent < 100:
  220.                 album_weights[album] -= album_weights[album]/(2**exponent)
  221.  
  222.     return album_weights,disliked_songs
  223.  
  224. def init_song(track_url):
  225.     pass
  226.  
  227. # Music playback thread
  228. def music_playback_thread():
  229.     global current_song, playback_time, song_duration, is_fetching,frontpage_albums
  230.     player = mpv.MPV(
  231.         ytdl=True,  # Enable youtube-dl support
  232.         force_window=True,  # Force MPV to open a window
  233.         osc=True,  # Enable the on-screen controller (playback controls)
  234.         input_default_bindings=True,  # Enable default key bindings
  235.         input_vo_keyboard=True,  # Allow keyboard input
  236.         volume=VOLUME,
  237.         title=MPV_TITLE,
  238.     )
  239.     try:
  240.         while True:
  241.             # Fetch a new song
  242.             logging.info('getting song url with selenium ')
  243.             with playback_lock:
  244.                 is_fetching = True
  245.  
  246.             weighted_albums = None
  247.             disliked_songs = None
  248.             with playback_lock:
  249.                 weighted_albums,disliked_songs = get_all_albums_weighted(frontpage_albums)
  250.             # Choose a song, getting the song url and other info
  251.             album,song,related_albums=get_song_from_random_album(weighted_albums,disliked_songs)
  252.             song=util.strip_url_params(song)
  253.             album=util.strip_url_params(album)
  254.             logging.info(f'got song url {song}, from album {album}')
  255.  
  256.             # Collect and store data associated with the chosen song before playing it
  257.             with playback_lock:
  258.                 if song in data['associated_albums']:
  259.                     related_albums += data['associated_albums'][song]
  260.             if song not in data['last_scraped'] or time.time()-data['last_scraped'][song]>SCRAPE_CACHE_TIME:
  261.                 page_linked_albums = webstuff.scrape_album_urls(album)#song)
  262.                 artist_albums = webstuff.scrape_album_urls("https://"+urlparse(song).netloc)
  263.                 related_albums +=  page_linked_albums+artist_albums#adds songs linked on artists's page
  264.                 related_albums=list(set(related_albums)) #remove any duplicates
  265.                 related_albums=list(map(util.strip_url_params, related_albums))#strip parameters from each element
  266.                 with playback_lock:
  267.                     data['associated_albums'][song] = related_albums
  268.                     data['containing_albums'][song] = album
  269.                     data['last_scraped'][song] = time.time()
  270.                     data['last_played'][song] = time.time()
  271.                     data['last_played'][album] = time.time()
  272.                 save_scores(data)
  273.  
  274.             #Log that the song was played
  275.             with open(os.path.join(os.path.dirname(__file__), 'songsplayed.txt'), 'a') as file:
  276.                 file.write(song + '\n')
  277.  
  278.             #Play the song
  279.             with playback_lock:
  280.                 current_song = song
  281.                 playback_time = 0
  282.                 is_fetching = False
  283.  
  284.             player.play(song)
  285.  
  286.             # Monitor playback status
  287.             count = 0
  288.             while player.core_idle:  # Wait for playback to start
  289.                 time.sleep(0.1)
  290.                 count+=1
  291.                 if count>100:
  292.                     break
  293.                 # if count%100==0: #it got stuck?
  294.                 #     logging.info('got stuck, trying to play the song again '+song)
  295.                 #     player.play(song)
  296.             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.
  297.                 continue
  298.             # Get the total duration of the song
  299.             with playback_lock:
  300.                 song_duration = player.duration
  301.                 # song_duration = 0
  302.             logging.info('playback active')
  303.             while True:#not player.core_idle:  # Playback is active
  304.                 time.sleep(0.1)  # Small delay to avoid busy-waiting
  305.                 with playback_lock:
  306.                     playback_time = player.time_pos or 0
  307.  
  308.                 # Check if playback has finished (not just paused)
  309.                 if player.core_idle and not player.pause:
  310.                     break
  311.  
  312.                 # Check for skip command
  313.                 try:
  314.                     command = command_queue.get_nowait()
  315.                     if command == "skip":
  316.                         player.command("stop")
  317.                         logging.info('skipped song')
  318.                         break
  319.                 except queue.Empty:
  320.                     pass
  321.             logging.info('playback completed')
  322.         print("somehow escaped the music_playback_thread loop without error, this should not happen")
  323.     except Exception as e:
  324.         logging.error("something went wrong with the music playback thread ",e)
  325.     logging.error("music_playback_thread end")
  326. lastkey = -1
  327. # Curses UI thread
  328. def curses_ui(stdscr):
  329.     global current_song, playback_time, song_duration, data, is_fetching, lastkey
  330.  
  331.     curses.curs_set(0)  # Hide cursor
  332.     stdscr.nodelay(1)   # Non-blocking input
  333.  
  334.     # Spinner animation frames
  335.     spinner_frames = ["|", "/", "-", "\\"]
  336.     spinner_index = 0
  337.  
  338.     while True:
  339.         stdscr.clear()
  340.  
  341.         # Display song info
  342.         with playback_lock:
  343.             song_display = current_song  # Strip URL parameters for display
  344.             time_display = f"Time: {playback_time:.1f}s / {song_duration:.1f}s"
  345.  
  346.         stdscr.addstr(0, 0, f"Now Playing: {song_display}")
  347.         stdscr.addstr(1, 0, time_display)
  348.  
  349.         # Get current song's score
  350.         with playback_lock:
  351.             if is_fetching:
  352.                 spinner = spinner_frames[spinner_index]
  353.                 stdscr.addstr(3, 0, f"Loading next song... {spinner}")
  354.                 spinner_index = (spinner_index + 1) % len(spinner_frames)
  355.  
  356.             if current_song in data['song_scores']:
  357.                 current_score = data['song_scores'][current_song]['score']
  358.             else:
  359.                 current_score = 0
  360.                 data['song_scores'][current_song]={'score':current_score,'time_set':time.time()}
  361.  
  362.         # Display options with visual feedback
  363.         stdscr.addstr(5, 0, "Options:")
  364.         stdscr.addstr(6, 0, f"1: Like {'✓' if current_score == 1 else ''}")
  365.         stdscr.addstr(7, 0, f"2: Dislike {'✗' if current_score == -1 else ''}")
  366.         stdscr.addstr(8, 0, "3: Open URL")
  367.         checkmarks = "✓" * clipboard_checkmarks if clipboard_checkmarks > 0 else ""
  368.         stdscr.addstr(9, 0, f"4: Add Content To Station from Clipboard {checkmarks}")
  369.         stdscr.addstr(10, 0, "s: Skip")
  370.         stdscr.addstr(11, 0, "q: Quit")
  371.  
  372.         # Handle user input
  373.         key = stdscr.getch()
  374.         # if key != lastkey:
  375.         #     print('test')
  376.         lastkey = key
  377.         if key == ord('1'):  # Like
  378.             with playback_lock:
  379.                 if current_score == 1:
  380.                     current_score = 0
  381.                     data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()}  # Undo like
  382.                 else:
  383.                     current_score = 1
  384.                     data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()} # Set like
  385.             save_scores(data)
  386.         elif key == ord('2'):  # Dislike
  387.             with playback_lock:
  388.                 if current_score == -1:
  389.                     current_score = 0
  390.                     data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()}  # Undo dislike
  391.                 else:
  392.                     current_score = -1
  393.                     data['song_scores'][current_song] = {'score':current_score,'time_set':time.time()}  # Set dislike
  394.             save_scores(data)
  395.         elif key == ord('3'):  # Open URL
  396.             with playback_lock:
  397.                 webbrowser.open(current_song)  # Open the URL in the default web browser
  398.             stdscr.addstr(10, 0, "Opening URL in browser...")
  399.         elif key == ord('4'): # Add from Clipboard
  400.             def process_clipboard_async():
  401.                 try:
  402.                     album,song,related_albums = webstuff.process_clipboard_url()
  403.                     if song:
  404.                         with playback_lock:
  405.                             current_score = 1
  406.                             data['song_scores'][song] = {'score': current_score, 'time_set': time.time()}
  407.                             data['last_played'][song] = time.time()
  408.                             data['associated_albums'][song] = related_albums
  409.                             data['containing_albums'][song] = album
  410.                             data['last_scraped'][song] = time.time()
  411.                            
  412.                         save_scores(data)
  413.                         stdscr.addstr(11, 0, "Added song from clipboard and liked it!")
  414.                         global clipboard_checkmarks
  415.                         clipboard_checkmarks += 1
  416.                     else:
  417.                         stdscr.addstr(11, 0, "No valid song found in clipboard")
  418.                 except Exception as e:
  419.                     logging.error(f"Error processing clipboard: {e}")
  420.                     stdscr.addstr(11, 0, "Error processing clipboard")
  421.                 finally:
  422.                     # Refresh the screen to show the message
  423.                     stdscr.refresh()
  424.  
  425.             thread = threading.Thread(target=process_clipboard_async)
  426.             thread.daemon = True
  427.             thread.start()
  428.            
  429.             # Show processing message immediately
  430.             stdscr.addstr(11, 0, "Processing clipboard...")
  431.             stdscr.refresh()
  432.         elif key == ord('s'):  # Skip
  433.             command_queue.put("skip")
  434.             stdscr.addstr(10, 0, "Skipping song...")
  435.         elif key == ord('q'):  # Quit
  436.             break
  437.                
  438.  
  439.         stdscr.refresh()
  440.         time.sleep(0.1)
  441.  
  442. # Main function
  443. def main():
  444.     # Start music playback thread
  445.     playback_thread = threading.Thread(target=music_playback_thread, daemon=True)
  446.     playback_thread.start()
  447.  
  448.     # Start curses UI
  449.     curses.wrapper(curses_ui)
  450.  
  451. if __name__ == "__main__":
  452.     main()
Add Comment
Please, Sign In to add comment