diff --git a/Dockerfile b/Dockerfile index 7d0f196..5a9f5b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,10 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /usr/src/app +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl ffmpeg yt-dlp && \ + rm -rf /var/lib/apt/lists/* + COPY requirements.txt ./ RUN uv pip install --system --no-cache -r requirements.txt diff --git a/example.env b/example.env index fe52d75..cdcfb20 100644 --- a/example.env +++ b/example.env @@ -12,3 +12,9 @@ MATRIX_PASSWORD = "" YOUTUBE_PLAYLIST_TITLE = "" # YouTube API client secret json path. YOUTUBE_CLIENT_SECRETS_FILE = "" +# AzuraCast API key. +AZURACAST_API_KEY = "" +# AzuraCast API URL. +AZURACAST_URL = "" +# AzuraCast station ID. +AZURACAST_STATION_ID = "" diff --git a/main.py b/main.py index 65ce865..67f4e8e 100755 --- a/main.py +++ b/main.py @@ -4,7 +4,9 @@ import argparse import asyncio import datetime +import glob import html +import json import os import pickle import re @@ -30,6 +32,10 @@ MATRIX_PASSWORD = os.getenv("MATRIX_PASSWORD") YOUTUBE_CLIENT_SECRETS_FILE = os.getenv("YOUTUBE_CLIENT_SECRETS_FILE") YOUTUBE_PLAYLIST_TITLE = os.getenv("YOUTUBE_PLAYLIST_TITLE") +AZURACAST_API_KEY = os.getenv("AZURACAST_API_KEY") +AZURACAST_URL = os.getenv("AZURACAST_URL") +AZURACAST_STATION_ID = os.getenv("AZURACAST_STATION_ID") + def connect_db(): """Connect to DB and return connection and cursor.""" @@ -183,18 +189,18 @@ def get_video_info(youtube, video_id): """Check whether a YouTube video is music and return its title and channel.""" try: video_details = youtube.videos().list(id=video_id, part="snippet").execute() - + # Check if the video actually exists/is accessible if not video_details.get("items"): return False, "[Video unavailable or private]", "" snippet = video_details["items"][0]["snippet"] - + # Check if the video category is Music (10) or Entertainment (24) is_music = snippet.get("categoryId") in ("10", "24") title = snippet.get("title", "[Unknown Title]") channel = snippet.get("channelTitle", "[Unknown Channel]") - + return is_music, title, channel except errors.HttpError as error: @@ -274,10 +280,11 @@ async def message_callback(conn, cursor, youtube, client, room, event): ) timestamp_sec = datetime.datetime.fromtimestamp( - event.server_timestamp / 1000, datetime.UTC # millisec to sec + event.server_timestamp / 1000, + datetime.UTC, # millisec to sec ) current_time = datetime.datetime.now(datetime.UTC) - + # Account for up to 5 minutes of clock drift recent = abs(current_time - timestamp_sec) < datetime.timedelta(minutes=5) @@ -301,29 +308,31 @@ async def message_callback(conn, cursor, youtube, client, room, event): for link in youtube_links: video_id = link.split("v=")[-1].split("&")[0].split("/")[-1] - + # Safely fetch the category check, title, and channel is_music_vid, title, channel = get_video_info(youtube, video_id) - + # Send the title to the channel so people know what the link is # Only do this for recent messages to prevent spam during backwards-sync if recent: plain_text = f"{title}" if channel: plain_text += f" - {channel}" - + # Escape HTML characters to prevent broken rendering in Matrix escaped_text = html.escape(plain_text) - html_text = f"{escaped_text}" - + html_text = ( + f"{escaped_text}" + ) + await client.room_send( room_id=room.room_id, message_type="m.room.message", content={ - "msgtype": "m.text", + "msgtype": "m.text", "body": plain_text, "format": "org.matrix.custom.html", - "formatted_body": html_text + "formatted_body": html_text, }, ) @@ -345,6 +354,8 @@ async def message_callback(conn, cursor, youtube, client, room, event): (playlist_id, message_id, video_id), ) print(f"Added track to this week's playlist: {link}") + if recent: + asyncio.create_task(process_radio_track(link, video_id, title)) def in_playlist(cursor, video_id, playlist_id): @@ -431,6 +442,93 @@ async def backwards_sync(conn, cursor, youtube, client, room, start_token): from_token = response.end +async def process_radio_track(video_link, video_id, title): + """Downloads native audio and pushes it to AzuraCast API in the background.""" + if not AZURACAST_API_KEY: + return + + # Sanitize title for the local filesystem + safe_title = "".join(c for c in title if c.isalnum() or c in " -_").strip() + base_filename = f"{safe_title}_{video_id}" + base_filepath = os.path.join(DATA_DIR, base_filename) + + print(f"📻 Downloading audio for radio: {title}") + + # 1. Download with yt-dlp (Keeping native AAC/Opus format) + dl_cmd = [ + "yt-dlp", + "--extract-audio", + "--output", + f"{base_filepath}.%(ext)s", + video_link, + ] + + process = await asyncio.create_subprocess_exec( + *dl_cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL + ) + await process.communicate() + + # Check for the file using glob since the extension (.m4a, .webm, .opus) is dynamic + downloaded_files = glob.glob(f"{base_filepath}.*") + + if not downloaded_files: + print(f"❌ Failed to download audio for {title}") + return + + filepath = downloaded_files[0] + + print(f"📻 Uploading to AzuraCast: {title}") + + # 2. Upload to AzuraCast using curl + upload_cmd = [ + "curl", + "-s", + "-X", + "POST", + f"{AZURACAST_URL}/api/station/{AZURACAST_STATION_ID}/files", + "-H", + f"X-API-Key: {AZURACAST_API_KEY}", + "-F", + f"file=@{filepath}", + ] + + curl_proc = await asyncio.create_subprocess_exec( + *upload_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await curl_proc.communicate() + + # 3. Extract unique_id and push to the live queue + try: + response = json.loads(stdout.decode()) + unique_id = response.get("unique_id") + + if unique_id: + req_cmd = [ + "curl", + "-s", + "-X", + "POST", + f"{AZURACAST_URL}/api/station/{AZURACAST_STATION_ID}/request/{unique_id}", + "-H", + f"X-API-Key: {AZURACAST_API_KEY}", + ] + req_proc = await asyncio.create_subprocess_exec( + *req_cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await req_proc.communicate() + print(f"✅ Queued on radio: {title}") + else: + print(f"⚠️ Uploaded, but no unique_id returned. Response: {stdout.decode()}") + except json.JSONDecodeError: + print(f"❌ Failed to parse AzuraCast response: {stdout.decode()}") + + # Cleanup the local file so your container doesn't bloat + if os.path.exists(filepath): + os.remove(filepath) + + async def main(): """Get DB and Matrix client ready, and start syncing.""" args = parse_arguments()