Radio station track uploads.

This commit is contained in:
afk
2026-07-16 18:43:01 +02:00
parent f98f83d5df
commit 92781e4291
3 changed files with 120 additions and 12 deletions
+4
View File
@@ -6,6 +6,10 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /usr/src/app 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 ./ COPY requirements.txt ./
RUN uv pip install --system --no-cache -r requirements.txt RUN uv pip install --system --no-cache -r requirements.txt
+6
View File
@@ -12,3 +12,9 @@ MATRIX_PASSWORD = ""
YOUTUBE_PLAYLIST_TITLE = "" YOUTUBE_PLAYLIST_TITLE = ""
# YouTube API client secret json path. # YouTube API client secret json path.
YOUTUBE_CLIENT_SECRETS_FILE = "" YOUTUBE_CLIENT_SECRETS_FILE = ""
# AzuraCast API key.
AZURACAST_API_KEY = ""
# AzuraCast API URL.
AZURACAST_URL = ""
# AzuraCast station ID.
AZURACAST_STATION_ID = ""
+110 -12
View File
@@ -4,7 +4,9 @@
import argparse import argparse
import asyncio import asyncio
import datetime import datetime
import glob
import html import html
import json
import os import os
import pickle import pickle
import re import re
@@ -30,6 +32,10 @@ MATRIX_PASSWORD = os.getenv("MATRIX_PASSWORD")
YOUTUBE_CLIENT_SECRETS_FILE = os.getenv("YOUTUBE_CLIENT_SECRETS_FILE") YOUTUBE_CLIENT_SECRETS_FILE = os.getenv("YOUTUBE_CLIENT_SECRETS_FILE")
YOUTUBE_PLAYLIST_TITLE = os.getenv("YOUTUBE_PLAYLIST_TITLE") 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(): def connect_db():
"""Connect to DB and return connection and cursor.""" """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.""" """Check whether a YouTube video is music and return its title and channel."""
try: try:
video_details = youtube.videos().list(id=video_id, part="snippet").execute() video_details = youtube.videos().list(id=video_id, part="snippet").execute()
# Check if the video actually exists/is accessible # Check if the video actually exists/is accessible
if not video_details.get("items"): if not video_details.get("items"):
return False, "[Video unavailable or private]", "" return False, "[Video unavailable or private]", ""
snippet = video_details["items"][0]["snippet"] snippet = video_details["items"][0]["snippet"]
# Check if the video category is Music (10) or Entertainment (24) # Check if the video category is Music (10) or Entertainment (24)
is_music = snippet.get("categoryId") in ("10", "24") is_music = snippet.get("categoryId") in ("10", "24")
title = snippet.get("title", "[Unknown Title]") title = snippet.get("title", "[Unknown Title]")
channel = snippet.get("channelTitle", "[Unknown Channel]") channel = snippet.get("channelTitle", "[Unknown Channel]")
return is_music, title, channel return is_music, title, channel
except errors.HttpError as error: except errors.HttpError as error:
@@ -274,10 +280,11 @@ async def message_callback(conn, cursor, youtube, client, room, event):
) )
timestamp_sec = datetime.datetime.fromtimestamp( 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) current_time = datetime.datetime.now(datetime.UTC)
# Account for up to 5 minutes of clock drift # Account for up to 5 minutes of clock drift
recent = abs(current_time - timestamp_sec) < datetime.timedelta(minutes=5) 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: for link in youtube_links:
video_id = link.split("v=")[-1].split("&")[0].split("/")[-1] video_id = link.split("v=")[-1].split("&")[0].split("/")[-1]
# Safely fetch the category check, title, and channel # Safely fetch the category check, title, and channel
is_music_vid, title, channel = get_video_info(youtube, video_id) is_music_vid, title, channel = get_video_info(youtube, video_id)
# Send the title to the channel so people know what the link is # 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 # Only do this for recent messages to prevent spam during backwards-sync
if recent: if recent:
plain_text = f"{title}" plain_text = f"{title}"
if channel: if channel:
plain_text += f" - {channel}" plain_text += f" - {channel}"
# Escape HTML characters to prevent broken rendering in Matrix # Escape HTML characters to prevent broken rendering in Matrix
escaped_text = html.escape(plain_text) escaped_text = html.escape(plain_text)
html_text = f"<em><span data-mx-color='#808080'>{escaped_text}</span></em>" html_text = (
f"<em><span data-mx-color='#808080'>{escaped_text}</span></em>"
)
await client.room_send( await client.room_send(
room_id=room.room_id, room_id=room.room_id,
message_type="m.room.message", message_type="m.room.message",
content={ content={
"msgtype": "m.text", "msgtype": "m.text",
"body": plain_text, "body": plain_text,
"format": "org.matrix.custom.html", "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), (playlist_id, message_id, video_id),
) )
print(f"Added track to this week's playlist: {link}") 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): 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 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(): async def main():
"""Get DB and Matrix client ready, and start syncing.""" """Get DB and Matrix client ready, and start syncing."""
args = parse_arguments() args = parse_arguments()