Compare commits

...

12 Commits

Author SHA1 Message Date
ef3c310c08 Freeze requirements, ignore settings.py 2026-02-07 21:21:56 +00:00
0da71b7398 refactor: Remove aiodocker, use aiohttp for Docker REST API
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 14:18:54 -07:00
45c38a8496 fix: Configure aiodocker client with long timeout for log stream
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 14:16:55 -07:00
e89b578931 fix: Configure aiodocker with custom session to disable log timeouts
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 14:14:59 -07:00
81bdc71a44 fix: Revert custom aiohttp session and set aiodocker log timeout
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 13:06:56 -07:00
a411cb2b5e fix: Prevent aiodocker log stream from timing out
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 12:43:03 -07:00
811a9228c0 fix: Log full stack trace for unexpected errors
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 12:34:33 -07:00
7e28bad4df Alert tanner on song rating 2026-02-07 12:16:04 -07:00
334da4bd17 refactor: Abstract Navidrome Subsonic API calls and flatten logic
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 12:10:37 -07:00
12e35de8d9 refactor: Use Subsonic getSong API to fetch song details
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 12:07:50 -07:00
d3a4be7c01 refactor: Flatten navidrome_set_rating function
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 12:02:55 -07:00
483d3a3b26 feat: Log song title, artist, and album after setting rating
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-07 12:01:16 -07:00
3 changed files with 124 additions and 45 deletions

1
.gitignore vendored
View File

@@ -151,3 +151,4 @@ out.*
*.txt *.txt
*.json *.json
.aider* .aider*
settings.py

139
main.py
View File

@@ -7,7 +7,6 @@ logging.basicConfig(
import asyncio import asyncio
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import aiodocker
import aiohttp import aiohttp
import random import random
import string import string
@@ -25,6 +24,15 @@ LOG_PATTERN = re.compile(
r'time="(?P<ts>[^"]+)".*msg="Changing starred" ids="\[`(?P<id>[^`]+)`\]".*starred=(?P<starred>true|false)' r'time="(?P<ts>[^"]+)".*msg="Changing starred" ids="\[`(?P<id>[^`]+)`\]".*starred=(?P<starred>true|false)'
) )
async def alert_tanner(message):
try:
params = {'navidrome': message}
async with aiohttp.ClientSession() as session:
async with session.get('https://tbot.tannercollin.com/message', params=params, timeout=4) as response:
response.raise_for_status()
except BaseException as e:
logging.error('Problem alerting Tanner: ' + str(e))
def parse_log_line(line): def parse_log_line(line):
""" """
@@ -46,13 +54,16 @@ def parse_log_line(line):
return timestamp, song_id, is_starred return timestamp, song_id, is_starred
async def navidrome_set_rating(song_id): async def _call_subsonic_api(endpoint, **kwargs):
"""
A generic helper to call the Navidrome Subsonic API.
"""
navidrome_url = settings.NAVIDROME_URL navidrome_url = settings.NAVIDROME_URL
username = settings.NAVIDROME_USER username = settings.NAVIDROME_USER
if not all([navidrome_url, username]): if not all([navidrome_url, username]):
logging.error("NAVIDROME_URL and NAVIDROME_USER must be set in settings.py.") logging.error("NAVIDROME_URL and NAVIDROME_USER must be set in settings.py.")
return return None
salt = settings.SUBSONIC_SALT salt = settings.SUBSONIC_SALT
token = settings.SUBSONIC_TOKEN token = settings.SUBSONIC_TOKEN
@@ -61,7 +72,7 @@ async def navidrome_set_rating(song_id):
password = settings.NAVIDROME_PASSWORD password = settings.NAVIDROME_PASSWORD
if not password: if not password:
logging.error("Either (SUBSONIC_SALT and SUBSONIC_TOKEN) or NAVIDROME_PASSWORD must be set in settings.py.") logging.error("Either (SUBSONIC_SALT and SUBSONIC_TOKEN) or NAVIDROME_PASSWORD must be set in settings.py.")
return return None
# Subsonic API requires a salt and a token (md5(password + salt)) # Subsonic API requires a salt and a token (md5(password + salt))
salt = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10)) salt = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10))
@@ -74,43 +85,108 @@ async def navidrome_set_rating(song_id):
'v': '1.16.1', 'v': '1.16.1',
'c': 'heart-monitor', 'c': 'heart-monitor',
'f': 'json', 'f': 'json',
'id': song_id,
'rating': 1,
} }
params.update(kwargs)
api_url = f"{navidrome_url.rstrip('/')}/rest/setRating" api_url = f"{navidrome_url.rstrip('/')}/rest/{endpoint}"
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(api_url, params=params) as response: async with session.get(api_url, params=params) as response:
response.raise_for_status() response.raise_for_status()
data = await response.json() data = await response.json()
if data.get('subsonic-response', {}).get('status') == 'ok':
logging.info(f"Successfully set rating for song {song_id} to 1.")
else:
logging.error(f"Failed to set rating for song {song_id}. Response: {data}")
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logging.error(f"Error calling Navidrome API for song {song_id}: {e}") logging.error(f"Error calling Navidrome API endpoint {endpoint} for song {kwargs.get('id')}: {e}")
return None
if data.get('subsonic-response', {}).get('status') != 'ok':
logging.error(f"Failed to call Navidrome API endpoint {endpoint} for song {kwargs.get('id')}. Response: {data}")
return None
return data.get('subsonic-response', {})
async def navidrome_get_song_details(song_id):
"""
Gets song details from Navidrome's Subsonic API.
"""
response = await _call_subsonic_api('getSong', id=song_id)
if response:
return response.get('song')
return None
async def navidrome_set_rating(song_id):
"""
Sets a 1-star rating for a song using Navidrome's Subsonic API.
"""
response = await _call_subsonic_api('setRating', id=song_id, rating=1)
return response is not None
async def handle_star_unstar_event(song_id):
"""
Sets song rating to 1 and logs the song details.
"""
success = await navidrome_set_rating(song_id)
if not success:
# _call_subsonic_api already logs errors
return
details = await navidrome_get_song_details(song_id)
if not details:
logging.info(f"Successfully set rating for song {song_id} to 1, but couldn't get song details.")
return
title = details.get('title', 'Unknown Title')
artist = details.get('artist', 'Unknown Artist')
album = details.get('album', 'Unknown Album')
msg = f'Set song "{title}" - {artist} ({album}) rating to 1.'
logging.info(msg)
await alert_tanner(msg)
async def main(): async def main():
""" """
Monitors Navidrome container logs for rapid star/unstar events. Monitors Navidrome container logs for rapid star/unstar events.
""" """
docker = None session = None
try: try:
docker = aiodocker.Docker() connector = aiohttp.UnixConnector(path="/var/run/docker.sock")
container = await docker.containers.get(settings.NAVIDROME_CONTAINER) # Disable all timeouts for the long-polling log stream
timeout = aiohttp.ClientTimeout(total=None, sock_read=None)
session = aiohttp.ClientSession(connector=connector, timeout=timeout)
logging.info(f"Monitoring logs for container '{settings.NAVIDROME_CONTAINER}'...") container_name = settings.NAVIDROME_CONTAINER
logs = container.log(
stdout=True,
stderr=True,
follow=True,
since=datetime.now(timezone.utc).timestamp()
)
async for line in logs: # First, check if the container exists.
inspect_url = f"http://localhost/containers/{container_name}/json"
async with session.get(inspect_url) as response:
if response.status == 404:
logging.error(f"Container '{container_name}' not found.")
return
response.raise_for_status()
logging.info(f"Monitoring logs for container '{container_name}'...")
since = int(datetime.now(timezone.utc).timestamp())
params = {
'stdout': 'true',
'stderr': 'true',
'follow': 'true',
'since': str(since),
}
logs_url = f"http://localhost/containers/{container_name}/logs"
async with session.get(logs_url, params=params) as response:
response.raise_for_status()
while True:
line_bytes = await response.content.readline()
if not line_bytes:
break
# Docker's log stream is multiplexed. The first 8 bytes are a header.
# We strip it to get the raw log line.
if len(line_bytes) > 8:
line = line_bytes[8:].decode('utf-8', errors='replace').strip()
parsed = parse_log_line(line) parsed = parse_log_line(line)
if not parsed: if not parsed:
continue continue
@@ -125,20 +201,23 @@ async def main():
logging.info( logging.info(
f"Song {song_id} was starred and then unstarred within {STAR_UNSTAR_WINDOW.seconds} seconds." f"Song {song_id} was starred and then unstarred within {STAR_UNSTAR_WINDOW.seconds} seconds."
) )
await navidrome_set_rating(song_id) await handle_star_unstar_event(song_id)
# Remove song from tracking after it has been unstarred # Remove song from tracking after it has been unstarred
del starred_songs[song_id] del starred_songs[song_id]
except aiodocker.exceptions.DockerError as e: except aiohttp.ClientResponseError as e:
if e.status == 404: if e.status == 404:
logging.error(f"Container '{settings.NAVIDROME_CONTAINER}' not found.") logging.error(f"Container '{settings.NAVIDROME_CONTAINER}' not found.")
else: else:
logging.error(f"Error connecting to Docker or getting container: {e}") logging.error(f"Error with Docker API: {e}")
except Exception as e: except aiohttp.ClientError as e:
logging.error(f"An unexpected error occurred: {e}") # For other client errors like connection issues
logging.error(f"Error connecting to Docker: {e}")
except Exception:
logging.exception("An unexpected error occurred")
finally: finally:
if docker: if session:
await docker.close() await session.close()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,4 +1,3 @@
aiodocker==0.24.0
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
aiohttp==3.13.3 aiohttp==3.13.3
aiosignal==1.4.0 aiosignal==1.4.0