Compare commits

..

5 Commits

Author SHA1 Message Date
2982d40811 Format settings example 2026-02-04 12:16:30 -07:00
d9194dcd76 fix: Sanitize playlist names to prevent directory traversal
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-04 12:14:12 -07:00
6e9f348089 chore: Remove redundant playlists_changed assignment 2026-02-04 12:14:10 -07:00
c5fbad8ad6 feat: Add 5-second timeout to all network requests
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-04 12:10:32 -07:00
196767001c feat: Add Mopidy RPC call to refresh playlists on changes
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-04 12:09:39 -07:00
2 changed files with 44 additions and 5 deletions

45
main.py
View File

@@ -38,9 +38,14 @@ def run_pls_command(playlist_id):
return "" return ""
def sanitize_for_filename(name):
"""Sanitizes a string to be safe as a filename component."""
return name.replace('/', '_').replace('\\', '_')
def save_playlist_file(playlist_dir, playlist_name, content): def save_playlist_file(playlist_dir, playlist_name, content):
"""Saves the transformed playlist content to a file.""" """Saves the transformed playlist content to a file."""
filename = f"{playlist_name}.m3u8" filename = f"{sanitize_for_filename(playlist_name)}.m3u8"
filepath = os.path.join(playlist_dir, filename) filepath = os.path.join(playlist_dir, filename)
try: try:
with open(filepath, 'w', encoding='utf-8') as f: with open(filepath, 'w', encoding='utf-8') as f:
@@ -52,7 +57,7 @@ def save_playlist_file(playlist_dir, playlist_name, content):
def delete_playlist_file(playlist_dir, playlist_name): def delete_playlist_file(playlist_dir, playlist_name):
"""Deletes a playlist file.""" """Deletes a playlist file."""
filename = f"{playlist_name}.m3u8" filename = f"{sanitize_for_filename(playlist_name)}.m3u8"
filepath = os.path.join(playlist_dir, filename) filepath = os.path.join(playlist_dir, filename)
if os.path.exists(filepath): if os.path.exists(filepath):
try: try:
@@ -62,6 +67,27 @@ def delete_playlist_file(playlist_dir, playlist_name):
logging.error(f"Error deleting file {filepath}: {e}") logging.error(f"Error deleting file {filepath}: {e}")
def call_mopidy_rpc(method, params=None):
"""Calls a Mopidy RPC method."""
if not settings.MOPIDY_RPC_URL:
return
data = {
'jsonrpc': '2.0',
'id': 1,
'method': method,
}
if params:
data['params'] = params
try:
response = requests.post(settings.MOPIDY_RPC_URL, json=data, timeout=5)
response.raise_for_status()
logging.info(f"Successfully called Mopidy RPC method: {method}")
except requests.exceptions.RequestException as e:
logging.error(f"Error calling Mopidy RPC: {e}")
def main(): def main():
"""Get playlists from a Navidrome server using the Subsonic API.""" """Get playlists from a Navidrome server using the Subsonic API."""
parser = argparse.ArgumentParser(description="Sync Navidrome playlists to Mopidy.") parser = argparse.ArgumentParser(description="Sync Navidrome playlists to Mopidy.")
@@ -108,7 +134,7 @@ def main():
logging.info("Starting one-time force sync of all playlists...") logging.info("Starting one-time force sync of all playlists...")
api_url = f"{navidrome_url.rstrip('/')}/rest/getPlaylists.view" api_url = f"{navidrome_url.rstrip('/')}/rest/getPlaylists.view"
try: try:
response = requests.get(api_url, params=params) response = requests.get(api_url, params=params, timeout=5)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.error(f"Error connecting to Navidrome: {e}") logging.error(f"Error connecting to Navidrome: {e}")
@@ -127,6 +153,7 @@ def main():
return return
logging.info(f"Found {len(playlists)} playlists to sync.") logging.info(f"Found {len(playlists)} playlists to sync.")
synced_a_playlist = False
for playlist in playlists: for playlist in playlists:
playlist_id = playlist.get('id') playlist_id = playlist.get('id')
playlist_name = playlist.get('name') playlist_name = playlist.get('name')
@@ -136,17 +163,22 @@ def main():
transformed_output = transform_m3u_to_m3u8(raw_output) transformed_output = transform_m3u_to_m3u8(raw_output)
if transformed_output: if transformed_output:
save_playlist_file(mopidy_playlist_dir, playlist_name, transformed_output) save_playlist_file(mopidy_playlist_dir, playlist_name, transformed_output)
synced_a_playlist = True
if synced_a_playlist:
call_mopidy_rpc('core.playlists.refresh')
logging.info("Force sync complete.") logging.info("Force sync complete.")
return return
known_playlists = {} known_playlists = {}
while True: while True:
playlists_changed = False
api_url = f"{navidrome_url.rstrip('/')}/rest/getPlaylists.view" api_url = f"{navidrome_url.rstrip('/')}/rest/getPlaylists.view"
try: try:
response = requests.get(api_url, params=params) response = requests.get(api_url, params=params, timeout=5)
response.raise_for_status() # Raise an exception for bad status codes response.raise_for_status() # Raise an exception for bad status codes
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.error(f"Error connecting to Navidrome: {e}") logging.error(f"Error connecting to Navidrome: {e}")
@@ -201,6 +233,7 @@ def main():
playlist_name = playlist_data.get('name') playlist_name = playlist_data.get('name')
logging.info(f"Playlist deleted: '{playlist_name}' ({playlist_id}). Removing file.") logging.info(f"Playlist deleted: '{playlist_name}' ({playlist_id}). Removing file.")
delete_playlist_file(mopidy_playlist_dir, playlist_name) delete_playlist_file(mopidy_playlist_dir, playlist_name)
playlists_changed = True
# Check for song count or name changes in existing playlists # Check for song count or name changes in existing playlists
for playlist_id, playlist_data in current_playlists.items(): for playlist_id, playlist_data in current_playlists.items():
@@ -215,6 +248,7 @@ def main():
name_changed = previous_name != current_name name_changed = previous_name != current_name
if song_count_changed or name_changed: if song_count_changed or name_changed:
playlists_changed = True
log_msg = f"Playlist '{previous_name}' ({playlist_id}) changed." log_msg = f"Playlist '{previous_name}' ({playlist_id}) changed."
if name_changed: if name_changed:
log_msg += f" Name: '{previous_name}' -> '{current_name}'." log_msg += f" Name: '{previous_name}' -> '{current_name}'."
@@ -233,6 +267,9 @@ def main():
# Update the state for the next iteration. # Update the state for the next iteration.
known_playlists = current_playlists known_playlists = current_playlists
if playlists_changed:
call_mopidy_rpc('core.playlists.refresh')
time.sleep(5) time.sleep(5)

View File

@@ -1,6 +1,7 @@
MOPIDY_PLAYLIST_DIR = '/var/lib/mopidy/m3u' MOPIDY_PLAYLIST_DIR = '/var/lib/mopidy/m3u'
NAVIDROME_URL = '' MOPIDY_RPC_URL = 'http://127.0.0.1:6680/mopidy/rpc'
NAVIDROME_URL = 'https://navidrome.example.com'
NAVIDROME_USER = '' NAVIDROME_USER = ''
@@ -9,3 +10,4 @@ NAVIDROME_PASSWORD = ''
# or these two: # or these two:
SUBSONIC_SALT = '' SUBSONIC_SALT = ''
SUBSONIC_TOKEN = '' SUBSONIC_TOKEN = ''