Files
LEDMatrix/src/music_manager.py
2025-05-25 17:31:32 -05:00

660 lines
33 KiB
Python

import time
import threading
from enum import Enum, auto
import logging
import json
import os
from io import BytesIO
import requests
from PIL import Image, ImageEnhance
# Use relative imports for clients within the same package (src)
from .spotify_client import SpotifyClient
from .ytm_client import YTMClient
# Removed: import config
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Define paths relative to this file's location
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json')
# SECRETS_PATH is handled within SpotifyClient
class MusicSource(Enum):
NONE = auto()
SPOTIFY = auto()
YTM = auto()
class MusicManager:
def __init__(self, display_manager, config, update_callback=None):
self.display_manager = display_manager
self.config = config
self.spotify = None
self.ytm = None
self.current_track_info = None
self.current_source = MusicSource.NONE
self.update_callback = update_callback
self.polling_interval = 2 # Default
self.enabled = False # Default
self.preferred_source = "auto" # Default
self.stop_event = threading.Event()
self.track_info_lock = threading.Lock() # Added lock
# Display related attributes moved from DisplayController
self.album_art_image = None
self.last_album_art_url = None
self.scroll_position_title = 0
self.scroll_position_artist = 0
self.title_scroll_tick = 0
self.artist_scroll_tick = 0
self.is_music_display_active = False # New state variable
self._load_config() # Load config first
self._initialize_clients() # Initialize based on loaded config
self.poll_thread = None
def _load_config(self):
default_interval = 2
default_preferred_source = "auto"
self.enabled = False # Assume disabled until config proves otherwise
if not os.path.exists(CONFIG_PATH):
logging.warning(f"Config file not found at {CONFIG_PATH}. Music manager disabled.")
return
try:
with open(CONFIG_PATH, 'r') as f:
config_data = json.load(f)
music_config = config_data.get("music", {})
self.enabled = music_config.get("enabled", False)
self.polling_interval = music_config.get("POLLING_INTERVAL_SECONDS", default_interval)
self.preferred_source = music_config.get("preferred_source", default_preferred_source).lower()
if not self.enabled:
logging.info("Music manager is disabled in config.json.")
return # Don't proceed further if disabled
logging.info(f"Music manager enabled. Polling interval: {self.polling_interval}s. Preferred source: {self.preferred_source}")
except json.JSONDecodeError:
logging.error(f"Error decoding JSON from {CONFIG_PATH}. Music manager disabled.")
self.enabled = False
except Exception as e:
logging.error(f"Error loading music config: {e}. Music manager disabled.")
self.enabled = False
def _initialize_clients(self):
# Only initialize if the manager is enabled
if not self.enabled:
self.spotify = None
self.ytm = None
return
logging.info("Initializing music clients...")
# Initialize Spotify Client if needed
if self.preferred_source in ["auto", "spotify"]:
try:
self.spotify = SpotifyClient()
if not self.spotify.is_authenticated():
logging.warning("Spotify client initialized but not authenticated. Please run src/authenticate_spotify.py if you want to use Spotify.")
# The SpotifyClient will log more details if cache loading failed.
# No need to attempt auth URL generation here.
else:
logging.info("Spotify client authenticated.")
except Exception as e:
logging.error(f"Failed to initialize Spotify client: {e}")
self.spotify = None
else:
logging.info("Spotify client initialization skipped due to preferred_source setting.")
self.spotify = None
# Initialize YTM Client if needed
if self.preferred_source in ["auto", "ytm"]:
try:
self.ytm = YTMClient(update_callback=self._handle_ytm_direct_update)
# We no longer check is_available() or connect here. Connection is on-demand.
logging.info(f"YTMClient initialized. Connection will be managed on-demand. Configured URL: {self.ytm.base_url}")
except Exception as e:
logging.error(f"Failed to initialize YTM client: {e}")
self.ytm = None
else:
logging.info("YTM client initialization skipped due to preferred_source setting.")
self.ytm = None
def activate_music_display(self):
logger.info("Music display activated.")
self.is_music_display_active = True
if self.ytm and self.preferred_source in ["auto", "ytm"]:
if not self.ytm.is_connected:
logger.info("Attempting to connect YTM client due to music display activation.")
# Pass a reasonable timeout for on-demand connection
if self.ytm.connect_client(timeout=10):
logger.info("YTM client connected successfully on display activation.")
else:
logger.warning("YTM client failed to connect on display activation.")
else:
logger.debug("YTM client already connected during music display activation.")
def deactivate_music_display(self):
logger.info("Music display deactivated.")
self.is_music_display_active = False
if self.ytm and self.ytm.is_connected:
logger.info("Disconnecting YTM client due to music display deactivation.")
self.ytm.disconnect_client()
def _handle_ytm_direct_update(self, ytm_data):
"""Handles a direct state update from YTMClient."""
logger.debug(f"MusicManager received direct YTM update: {ytm_data.get('track', {}).get('title') if ytm_data else 'No Data'}")
if not self.enabled or not self.is_music_display_active: # Check if display is active
logger.debug("Skipping YTM direct update: Manager disabled or music display not active.")
return
# Only process if YTM is the preferred source, or if auto and Spotify isn't actively playing.
spotify_is_playing_flag = False
with self.track_info_lock:
if self.current_source == MusicSource.SPOTIFY and self.current_track_info and self.current_track_info.get('is_playing'):
spotify_is_playing_flag = True
if not (self.preferred_source == "ytm" or (self.preferred_source == "auto" and not spotify_is_playing_flag)):
logger.debug("Skipping YTM direct update due to preferred_source/Spotify state.")
return
player_info = ytm_data.get('player', {})
is_actually_playing_ytm = (player_info.get('trackState') == 1) and not player_info.get('adPlaying', False)
simplified_info = self.get_simplified_track_info(ytm_data if is_actually_playing_ytm else None,
MusicSource.YTM if is_actually_playing_ytm else MusicSource.NONE)
has_changed = False
with self.track_info_lock:
if simplified_info != self.current_track_info:
has_changed = True
old_album_art_url = self.current_track_info.get('album_art_url') if self.current_track_info else None
new_album_art_url = simplified_info.get('album_art_url') if simplified_info else None
self.current_track_info = simplified_info
self.current_source = MusicSource.YTM if is_actually_playing_ytm and simplified_info.get('source') == 'YouTube Music' else self.current_source
if not is_actually_playing_ytm and self.current_source == MusicSource.YTM:
self.current_source = MusicSource.NONE
if new_album_art_url != old_album_art_url:
self.album_art_image = None
self.last_album_art_url = new_album_art_url
display_title = self.current_track_info.get('title', 'None') if self.current_track_info else 'None'
logger.debug(f"YTM Direct Update: Track change detected. Source: {self.current_source.name}. Track: {display_title}")
else:
logger.debug("YTM Direct Update: No change in simplified track info.")
if has_changed and self.update_callback:
try:
# Pass a copy of the track info to the callback
with self.track_info_lock:
track_info_copy = self.current_track_info.copy() if self.current_track_info else None
self.update_callback(track_info_copy)
except Exception as e:
logger.error(f"Error executing DisplayController update callback from YTM direct update: {e}")
def _fetch_and_resize_image(self, url: str, target_size: tuple[int, int]) -> Image.Image | None:
"""Fetches an image from a URL, resizes it, and returns a PIL Image object."""
if not url:
return None
try:
response = requests.get(url, timeout=5) # 5-second timeout for image download
response.raise_for_status() # Raise an exception for bad status codes
img_data = BytesIO(response.content)
img = Image.open(img_data)
# Ensure image is RGB for compatibility with the matrix
img = img.convert("RGB")
img.thumbnail(target_size, Image.Resampling.LANCZOS)
# Enhance contrast
enhancer_contrast = ImageEnhance.Contrast(img)
img = enhancer_contrast.enhance(1.3) # Adjust 1.3 as needed
# Enhance saturation (Color)
enhancer_saturation = ImageEnhance.Color(img)
img = enhancer_saturation.enhance(1.3) # Adjust 1.3 as needed
final_img = Image.new("RGB", target_size, (0,0,0)) # Black background
paste_x = (target_size[0] - img.width) // 2
paste_y = (target_size[1] - img.height) // 2
final_img.paste(img, (paste_x, paste_y))
return final_img
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching image from {url}: {e}")
return None
except IOError as e:
logger.error(f"Error processing image from {url}: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error fetching/processing image {url}: {e}")
return None
def _poll_music_data(self):
"""Continuously polls music sources for updates, respecting preferences."""
if not self.enabled:
logging.warning("Polling attempted while music manager is disabled. Stopping polling thread.")
return
while not self.stop_event.is_set():
polled_track_info_data = None
polled_source = MusicSource.NONE
is_playing_from_poll = False # Renamed to avoid conflict
poll_spotify = self.preferred_source in ["auto", "spotify"] and self.spotify and self.spotify.is_authenticated()
poll_ytm = self.preferred_source in ["auto", "ytm"] and self.ytm
if poll_spotify:
try:
spotify_track = self.spotify.get_current_track()
if spotify_track and spotify_track.get('is_playing'):
polled_track_info_data = spotify_track
polled_source = MusicSource.SPOTIFY
is_playing_from_poll = True
logging.debug(f"Polling Spotify: Active track - {spotify_track.get('item', {}).get('name')}")
else:
logging.debug("Polling Spotify: No active track or player paused.")
except Exception as e:
logging.error(f"Error polling Spotify: {e}")
if "token" in str(e).lower():
logging.warning("Spotify auth token issue detected during polling.")
should_poll_ytm_now = poll_ytm and (self.preferred_source == "ytm" or (self.preferred_source == "auto" and not is_playing_from_poll))
if should_poll_ytm_now:
if self.ytm and self.ytm.is_connected:
try:
ytm_track_data = self.ytm.get_current_track() # Data from YTMClient's cache
if ytm_track_data and ytm_track_data.get('player') and not ytm_track_data.get('player', {}).get('isPaused') and not ytm_track_data.get('player',{}).get('adPlaying', False):
if self.preferred_source == "ytm" or not is_playing_from_poll:
polled_track_info_data = ytm_track_data
polled_source = MusicSource.YTM
is_playing_from_poll = True # YTM is now considered playing
logger.debug(f"Polling YTM: Active track - {ytm_track_data.get('track', {}).get('title')}")
else:
logging.debug("Polling YTM: No active track or player paused (or track data missing player info).")
except Exception as e:
logging.error(f"Error polling YTM: {e}")
else:
logging.debug("Skipping YTM poll: Client not initialized or not connected.")
# Consider setting self.ytm = None if it becomes unavailable repeatedly?
simplified_info_poll = self.get_simplified_track_info(polled_track_info_data, polled_source)
has_changed_poll = False
with self.track_info_lock:
if simplified_info_poll != self.current_track_info:
has_changed_poll = True
old_album_art_url_poll = self.current_track_info.get('album_art_url') if self.current_track_info else None
new_album_art_url_poll = simplified_info_poll.get('album_art_url') if simplified_info_poll else None
self.current_track_info = simplified_info_poll
self.current_source = polled_source
if new_album_art_url_poll != old_album_art_url_poll:
self.album_art_image = None
self.last_album_art_url = new_album_art_url_poll
display_title_poll = self.current_track_info.get('title', 'None') if self.current_track_info else 'None'
logger.debug(f"Poll Update: Track change detected. Source: {self.current_source.name}. Track: {display_title_poll}")
else:
logger.debug("Poll Update: No change in simplified track info.")
if has_changed_poll and self.update_callback:
try:
with self.track_info_lock:
track_info_copy_poll = self.current_track_info.copy() if self.current_track_info else None
self.update_callback(track_info_copy_poll)
except Exception as e:
logger.error(f"Error executing update callback from poll: {e}")
time.sleep(self.polling_interval)
# Modified to accept data and source, making it more testable/reusable
def get_simplified_track_info(self, track_data, source):
"""Provides a consistent format for track info regardless of source."""
# Default "Nothing Playing" structure
nothing_playing_info = {
'source': 'None',
'title': 'Nothing Playing',
'artist': '',
'album': '',
'album_art_url': None,
'duration_ms': 0,
'progress_ms': 0,
'is_playing': False,
}
if source == MusicSource.SPOTIFY and track_data:
item = track_data.get('item', {})
is_playing_spotify = track_data.get('is_playing', False)
if not item or not is_playing_spotify:
return nothing_playing_info.copy()
return {
'source': 'Spotify',
'title': item.get('name'),
'artist': ', '.join([a['name'] for a in item.get('artists', [])]),
'album': item.get('album', {}).get('name'),
'album_art_url': item.get('album', {}).get('images', [{}])[0].get('url') if item.get('album', {}).get('images') else None,
'duration_ms': item.get('duration_ms'),
'progress_ms': track_data.get('progress_ms'),
'is_playing': is_playing_spotify, # Should be true here
}
elif source == MusicSource.YTM and track_data:
video_info = track_data.get('video', {})
player_info = track_data.get('player', {})
title = video_info.get('title') # Check if title exists
artist = video_info.get('author')
# Play state: player_info.trackState: -1 Unknown, 0 Paused, 1 Playing, 2 Buffering
track_state = player_info.get('trackState')
is_playing_ytm = (track_state == 1) # 1 means Playing
if player_info.get('adPlaying', False):
is_playing_ytm = False
logging.debug("YTM: Ad is playing, reporting track as not actively playing.")
if not title or not artist or not is_playing_ytm: # If no title/artist, or not truly playing
return nothing_playing_info.copy()
album = video_info.get('album')
duration_seconds = video_info.get('durationSeconds')
duration_ms = int(duration_seconds * 1000) if duration_seconds is not None else 0
progress_seconds = player_info.get('videoProgress')
progress_ms = int(progress_seconds * 1000) if progress_seconds is not None else 0
thumbnails = video_info.get('thumbnails', [])
album_art_url = thumbnails[0].get('url') if thumbnails else None
return {
'source': 'YouTube Music',
'title': title,
'artist': artist,
'album': album if album else '',
'album_art_url': album_art_url,
'duration_ms': duration_ms,
'progress_ms': progress_ms,
'is_playing': is_playing_ytm, # Should be true here
}
else:
# This covers cases where source is NONE, or track_data is None for Spotify/YTM
return nothing_playing_info.copy()
def get_current_display_info(self):
"""Returns the currently stored track information for display."""
with self.track_info_lock:
return self.current_track_info.copy() if self.current_track_info else None
def start_polling(self):
# Only start polling if enabled
if not self.enabled:
logging.info("Music manager disabled, polling not started.")
return
if not self.poll_thread or not self.poll_thread.is_alive():
# Ensure at least one client is potentially available
if not self.spotify and not self.ytm:
logging.warning("Cannot start polling: No music clients initialized or available.")
return
self.stop_event.clear()
self.poll_thread = threading.Thread(target=self._poll_music_data, daemon=True)
self.poll_thread.start()
logging.info("Music polling started.")
def stop_polling(self):
"""Stops the music polling thread."""
logger.info("Music manager: Stopping polling thread...")
self.stop_event.set()
if self.poll_thread and self.poll_thread.is_alive():
self.poll_thread.join(timeout=self.polling_interval + 1) # Wait for thread to finish
if self.poll_thread and self.poll_thread.is_alive():
logger.warning("Music manager: Polling thread did not terminate cleanly.")
else:
logger.info("Music manager: Polling thread stopped.")
self.poll_thread = None # Clear the thread object
# Also ensure YTM client is disconnected when polling stops completely
self.deactivate_music_display() # This will also handle YTM disconnect if needed
# Method moved from DisplayController and renamed
def display(self, force_clear: bool = False):
if force_clear:
self.display_manager.clear()
self.activate_music_display()
with self.track_info_lock:
current_display_info = self.current_track_info.copy() if self.current_track_info else None
# We also need self.last_album_art_url and self.album_art_image under the same lock if they are read here and written elsewhere
# For now, last_album_art_url is updated along with current_track_info under the lock
# album_art_image is assigned here or if it's None and last_album_art_url exists, fetched.
# Let's assume current_display_info correctly snapshots necessary parts or that album art fetching uses locked members.
local_last_album_art_url = self.last_album_art_url
local_album_art_image = self.album_art_image
# Simplified condition to prevent "Nothing Playing" flicker:
# Show screen if we have any track info, unless it's explicitly "Nothing Playing".
# The 'is_playing' check within get_simplified_track_info should handle ads/paused state correctly by returning 'Nothing Playing' title if appropriate.
if not current_display_info or current_display_info.get('title') == 'Nothing Playing':
if not hasattr(self, '_last_nothing_playing_log_time') or \
time.time() - getattr(self, '_last_nothing_playing_log_time', 0) > 30:
logger.info("Music Screen (MusicManager): Nothing playing or info explicitly 'Nothing Playing'.")
self._last_nothing_playing_log_time = time.time()
if not force_clear:
self.display_manager.clear()
text_width = self.display_manager.get_text_width("Nothing Playing", self.display_manager.regular_font)
x_pos = (self.display_manager.matrix.width - text_width) // 2
y_pos = (self.display_manager.matrix.height // 2) - 4
self.display_manager.draw_text("Nothing Playing", x=x_pos, y=y_pos, font=self.display_manager.regular_font)
self.display_manager.update_display()
with self.track_info_lock: # Protect writes to shared state
self.scroll_position_title = 0
self.scroll_position_artist = 0
self.title_scroll_tick = 0
self.artist_scroll_tick = 0
self.album_art_image = None # Clear any fetched art if we are showing Nothing Playing
# self.last_album_art_url = None # Keep last_album_art_url so we don't re-fetch if it was a brief flicker
return
# If we've reached here, it means we are about to display actual music info.
if not self.is_music_display_active:
self.activate_music_display()
if not force_clear:
self.display_manager.draw.rectangle([0, 0, self.display_manager.matrix.width, self.display_manager.matrix.height], fill=(0, 0, 0))
matrix_height = self.display_manager.matrix.height
album_art_size = matrix_height - 2
album_art_target_size = (album_art_size, album_art_size)
album_art_x = 1
album_art_y = 1
text_area_x_start = album_art_x + album_art_size + 2
text_area_width = self.display_manager.matrix.width - text_area_x_start - 1
# Fetch and display album art using local_last_album_art_url and potentially updating self.album_art_image
if local_last_album_art_url and not local_album_art_image: # Check local_album_art_image
logger.info(f"MusicManager: Fetching album art from: {local_last_album_art_url}")
fetched_image = self._fetch_and_resize_image(local_last_album_art_url, album_art_target_size)
if fetched_image:
logger.info(f"MusicManager: Album art fetched and processed successfully.")
with self.track_info_lock:
self.album_art_image = fetched_image # Update shared state
local_album_art_image = fetched_image # Update local copy for current render
else:
logger.warning(f"MusicManager: Failed to fetch or process album art.")
# Do not clear self.album_art_image here, might be a temporary glitch
if local_album_art_image: # Use local_album_art_image for display
self.display_manager.image.paste(local_album_art_image, (album_art_x, album_art_y))
else:
self.display_manager.draw.rectangle([album_art_x, album_art_y,
album_art_x + album_art_size -1, album_art_y + album_art_size -1],
outline=(50,50,50), fill=(10,10,10))
# Use current_display_info for text, which is a snapshot from the beginning of the method
title = current_display_info.get('title', ' ')
artist = current_display_info.get('artist', ' ')
album = current_display_info.get('album', ' ')
font_title = self.display_manager.small_font
font_artist_album = self.display_manager.bdf_5x7_font
line_height_title = 8
line_height_artist_album = 7
padding_between_lines = 1
TEXT_SCROLL_DIVISOR = 5
# --- Title ---
y_pos_title = 2
title_width = self.display_manager.get_text_width(title, font_title)
current_title_display_text = title
if title_width > text_area_width:
if self.scroll_position_title >= len(title):
self.scroll_position_title = 0
current_title_display_text = title[self.scroll_position_title:] + " " + title[:self.scroll_position_title]
self.display_manager.draw_text(current_title_display_text,
x=text_area_x_start, y=y_pos_title, color=(255, 255, 255), font=font_title)
if title_width > text_area_width:
self.title_scroll_tick += 1
if self.title_scroll_tick % TEXT_SCROLL_DIVISOR == 0:
self.scroll_position_title = (self.scroll_position_title + 1) % len(title)
self.title_scroll_tick = 0
else:
self.scroll_position_title = 0
self.title_scroll_tick = 0
# --- Artist ---
y_pos_artist = y_pos_title + line_height_title + padding_between_lines
artist_width = self.display_manager.get_text_width(artist, font_artist_album)
current_artist_display_text = artist
if artist_width > text_area_width:
if self.scroll_position_artist >= len(artist):
self.scroll_position_artist = 0
current_artist_display_text = artist[self.scroll_position_artist:] + " " + artist[:self.scroll_position_artist]
self.display_manager.draw_text(current_artist_display_text,
x=text_area_x_start, y=y_pos_artist, color=(180, 180, 180), font=font_artist_album)
if artist_width > text_area_width:
self.artist_scroll_tick += 1
if self.artist_scroll_tick % TEXT_SCROLL_DIVISOR == 0:
self.scroll_position_artist = (self.scroll_position_artist + 1) % len(artist)
self.artist_scroll_tick = 0
else:
self.scroll_position_artist = 0
self.artist_scroll_tick = 0
# --- Album ---
y_pos_album = y_pos_artist + line_height_artist_album + padding_between_lines
if (matrix_height - y_pos_album - 5) >= line_height_artist_album :
album_width = self.display_manager.get_text_width(album, font_artist_album)
if album_width <= text_area_width:
self.display_manager.draw_text(album, x=text_area_x_start, y=y_pos_album, color=(150, 150, 150), font=font_artist_album)
# --- Progress Bar ---
progress_bar_height = 3
progress_bar_y = matrix_height - progress_bar_height - 1
duration_ms = current_display_info.get('duration_ms', 0)
progress_ms = current_display_info.get('progress_ms', 0)
if duration_ms > 0:
bar_total_width = text_area_width
filled_ratio = progress_ms / duration_ms
filled_width = int(filled_ratio * bar_total_width)
self.display_manager.draw.rectangle([
text_area_x_start, progress_bar_y,
text_area_x_start + bar_total_width -1, progress_bar_y + progress_bar_height -1
], outline=(60, 60, 60), fill=(30,30,30))
if filled_width > 0:
self.display_manager.draw.rectangle([
text_area_x_start, progress_bar_y,
text_area_x_start + filled_width -1, progress_bar_y + progress_bar_height -1
], fill=(200, 200, 200))
self.display_manager.update_display()
# Example usage (for testing this module standalone, if needed)
# def print_update(track_info):
# logging.info(f"Callback: Track update received by dummy callback: {track_info}")
if __name__ == '__main__':
# This is a placeholder for testing.
# To test properly, you'd need a mock DisplayManager and ConfigManager.
logging.basicConfig(level=logging.DEBUG)
logger.info("Running MusicManager standalone test (limited)...")
# Mock DisplayManager and Config objects
class MockDisplayManager:
def __init__(self):
self.matrix = type('Matrix', (), {'width': 64, 'height': 32})() # Mock matrix
self.image = Image.new("RGB", (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) # Requires ImageDraw
self.regular_font = None # Needs font loading
self.small_font = None
self.extra_small_font = None
# Add other methods/attributes DisplayManager uses if they are called by MusicManager's display
# For simplicity, we won't fully mock font loading here.
# self.regular_font = ImageFont.truetype("path/to/font.ttf", 8)
def clear(self): logger.debug("MockDisplayManager: clear() called")
def get_text_width(self, text, font): return len(text) * 5 # Rough mock
def draw_text(self, text, x, y, color=(255,255,255), font=None): logger.debug(f"MockDisplayManager: draw_text '{text}' at ({x},{y})")
def update_display(self): logger.debug("MockDisplayManager: update_display() called")
class MockConfig:
def get(self, key, default=None):
if key == "music":
return {"enabled": True, "POLLING_INTERVAL_SECONDS": 2, "preferred_source": "auto"}
return default
# Need to import ImageDraw for the mock to work if draw_text is complex
try: from PIL import ImageDraw, ImageFont
except ImportError: ImageDraw = None; ImageFont = None; logger.warning("Pillow ImageDraw/ImageFont not fully available for mock")
mock_display = MockDisplayManager()
mock_config_main = {"music": {"enabled": True, "POLLING_INTERVAL_SECONDS": 2, "preferred_source": "auto"}}
# The MusicManager expects the overall config, not just the music part directly for its _load_config
# So we simulate a config object that has a .get('music', {}) method.
# However, MusicManager's _load_config reads from CONFIG_PATH.
# For a true standalone test, we might need to mock file IO or provide a test config file.
# Simplified test:
# manager = MusicManager(display_manager=mock_display, config=mock_config_main) # This won't work due to file reading
# To truly test, you'd point CONFIG_PATH to a test config.json or mock open()
# For now, this __main__ block is mostly a placeholder.
logger.info("MusicManager standalone test setup is complex due to file dependencies for config.")
logger.info("To test: run the main application and observe logs from MusicManager.")
# if manager.enabled:
# manager.start_polling()
# try:
# while True:
# time.sleep(1)
# # In a real test, you might manually call manager.display() after setting some track info
# except KeyboardInterrupt:
# logger.info("Stopping standalone test...")
# finally:
# if manager.enabled:
# manager.stop_polling()
# logger.info("Test finished.")