17 Commits
Author SHA1 Message Date
afk ad828c3605 Fix radio.
Signed-off-by: Abdulkadir Furkan Şanlı <me@afk.pm>
2026-07-19 22:53:22 +02:00
afk fac762c84d Time limit for radio.
Signed-off-by: Abdulkadir Furkan Şanlı <me@afk.pm>
2026-07-19 15:30:31 +02:00
afk 5c990752fb Add deps and enable cache dir.
Signed-off-by: Abdulkadir Furkan Şanlı <me@afk.pm>
2026-07-19 15:30:10 +02:00
afk b946f649e0 Try to fix playlist assignment and increase rotation freq.
Signed-off-by: Abdulkadir Furkan Şanlı <me@afk.pm>
2026-07-19 02:25:49 +02:00
afk 5515690416 Fix search and add JS runtime. 2026-07-16 22:13:30 +02:00
afk 1a6a0c4382 Oops 2026-07-16 21:23:59 +02:00
afk 6b12874de6 Radio album art. 2026-07-16 20:30:02 +02:00
afk ac973ba32a Fix queueing 2026-07-16 20:21:48 +02:00
afk 0a35e40e25 Playlist rotation automation, fingers crossed. 2026-07-16 20:07:02 +02:00
afk 594d619e2e Add metadata to radio 2026-07-16 19:54:04 +02:00
afk dba420ca3a Correct endpoint 2026-07-16 19:48:15 +02:00
afk 5941909646 Fix radio 2026-07-16 19:42:12 +02:00
afk 6648cfc848 Revert and debug 2026-07-16 19:29:00 +02:00
afk 13f8c962e3 Debug 2026-07-16 19:21:47 +02:00
afk f7c2e014f9 Update radio upload. 2026-07-16 19:04:54 +02:00
afk 92781e4291 Radio station track uploads. 2026-07-16 18:43:01 +02:00
afk f98f83d5df revert 512275a8b8
revert Switch to synchronous Python.

Signed-off-by: Abdulkadir Furkan Şanlı <me@abdulocra.cy>
2026-04-20 11:49:08 +02:00
4 changed files with 457 additions and 67 deletions

No files matched your search

+7
View File
@@ -6,11 +6,18 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ffmpeg quickjs && \
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
COPY main.py parker.gif ./ COPY main.py parker.gif ./
RUN mkdir -p /usr/src/app/.cache && \
chown -R 1000:1000 /usr/src/app/.cache
USER 1000:1000 USER 1000:1000
CMD ["python", "./main.py"] CMD ["python", "./main.py"]
+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 = ""
+442 -67
View File
@@ -2,19 +2,23 @@
"""ParkerBot""" """ParkerBot"""
import argparse import argparse
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
import sqlite3 import sqlite3
import time import time
import urllib.parse
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build from googleapiclient.discovery import build
from googleapiclient import errors from googleapiclient import errors
from nio import HttpClient, RoomMessageText, SyncResponse, UploadResponse from nio import AsyncClient, RoomMessageText, SyncResponse, UploadResponse
DATA_DIR = os.getenv("DATA_DIR", "./") DATA_DIR = os.getenv("DATA_DIR", "./")
DB_PATH = os.path.join(DATA_DIR, "parkerbot.sqlite3") DB_PATH = os.path.join(DATA_DIR, "parkerbot.sqlite3")
@@ -29,6 +33,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."""
@@ -75,7 +83,7 @@ def define_tables(conn, cursor):
playlist_id TEXT UNIQUE, playlist_id TEXT UNIQUE,
creation_date DATE)""" creation_date DATE)"""
) )
cursor.execute( # TODO: Write migration script to add video_id. cursor.execute(
"""CREATE TABLE IF NOT EXISTS playlist_tracks ( """CREATE TABLE IF NOT EXISTS playlist_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER, playlist_id INTEGER,
@@ -90,12 +98,10 @@ def define_tables(conn, cursor):
def get_authenticated_service(): def get_authenticated_service():
"""Get an authentivated YouTube service.""" """Get an authentivated YouTube service."""
credentials = None credentials = None
# Stores the user's access and refresh tokens.
if os.path.exists(PICKLE_PATH): if os.path.exists(PICKLE_PATH):
with open(PICKLE_PATH, "rb") as token: with open(PICKLE_PATH, "rb") as token:
credentials = pickle.load(token) credentials = pickle.load(token)
# If there are no valid credentials available, let the user log in.
if not credentials or not credentials.valid: if not credentials or not credentials.valid:
if credentials and credentials.expired and credentials.refresh_token: if credentials and credentials.expired and credentials.refresh_token:
credentials.refresh(Request()) credentials.refresh(Request())
@@ -105,7 +111,6 @@ def get_authenticated_service():
scopes=["https://www.googleapis.com/auth/youtube.force-ssl"], scopes=["https://www.googleapis.com/auth/youtube.force-ssl"],
) )
credentials = flow.run_local_server(port=8080) credentials = flow.run_local_server(port=8080)
# Save the credentials for the next run
with open(PICKLE_PATH, "wb") as token: with open(PICKLE_PATH, "wb") as token:
pickle.dump(credentials, token) pickle.dump(credentials, token)
@@ -149,7 +154,7 @@ def get_or_make_playlist(conn, cursor, youtube, playlist_date):
playlist_id = make_playlist(youtube, title) playlist_id = make_playlist(youtube, title)
with conn: with conn:
cursor.execute( # TODO: https://docs.python.org/3/library/sqlite3.html#default-adapters-and-converters-deprecated cursor.execute(
"INSERT INTO playlists (title, playlist_id, creation_date) VALUES (?, ?, ?)", "INSERT INTO playlists (title, playlist_id, creation_date) VALUES (?, ?, ?)",
(title, playlist_id, playlist_date), (title, playlist_id, playlist_date),
) )
@@ -178,52 +183,66 @@ def add_video_to_playlist(youtube, playlist_id, video_id, retry_count=6):
raise error raise error
def parse_iso8601_duration(duration_str):
"""Parse ISO 8601 duration string into seconds."""
pattern = re.compile(r'^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$')
match = pattern.match(duration_str)
if not match:
return 0
weeks = int(match.group(1)) if match.group(1) else 0
days = int(match.group(2)) if match.group(2) else 0
hours = int(match.group(3)) if match.group(3) else 0
minutes = int(match.group(4)) if match.group(4) else 0
seconds = int(match.group(5)) if match.group(5) else 0
return weeks * 604800 + days * 86400 + hours * 3600 + minutes * 60 + seconds
def get_video_info(youtube, video_id): 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, channel, and duration."""
try: try:
video_details = youtube.videos().list(id=video_id, part="snippet").execute() video_details = youtube.videos().list(id=video_id, part="snippet,contentDetails").execute()
# 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]", "", 0
snippet = video_details["items"][0]["snippet"] item = video_details["items"][0]
snippet = item["snippet"]
content_details = item.get("contentDetails", {})
duration_str = content_details.get("duration", "PT0S")
duration = parse_iso8601_duration(duration_str)
# 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, duration
except errors.HttpError as error: except errors.HttpError as error:
print(f"YouTube API error fetching info for {video_id}: {error}") print(f"YouTube API error fetching info for {video_id}: {error}")
return False, "[Error fetching title]", "" return False, "[Error fetching title]", "", 0
except Exception as e: except Exception as e:
print(f"Unexpected error fetching info for {video_id}: {e}") print(f"Unexpected error fetching info for {video_id}: {e}")
return False, "[Error fetching title]", "" return False, "[Error fetching title]", "", 0
def send_intro_message(client, sender, room_id): async def send_intro_message(client, sender, room_id):
"""Sends introduction message in reply to sender, in room with room_id.""" """Sends introduction message in reply to sender, in room with room_id."""
intro_message = ( intro_message = (
f"Hi {sender}, I'm ParkerBot! I generate YouTube playlists from links " f"Hi {sender}, I'm ParkerBot! I generate YouTube playlists from links "
"sent to this channel. You can find my source code here: " "sent to this channel. You can find my source code here: "
"https://git.abdulocra.cy/abdulocracy/parkerbot" "https://git.abdulocra.cy/abdulocracy/parkerbot"
) )
client.room_send( await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": intro_message}, content={"msgtype": "m.text", "body": intro_message},
) )
# TODO: Figure out how to properly send GIF, this is broken as shit.
with open("./parker.gif", "rb") as gif_file: with open("./parker.gif", "rb") as gif_file:
response = client.upload(gif_file, content_type="image/gif") response = await client.upload(gif_file, content_type="image/gif")
if isinstance(response, UploadResponse): if isinstance(response, UploadResponse):
print("Image was uploaded successfully to server. ")
gif_uri = response.content_uri gif_uri = response.content_uri
client.room_send( await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={ content={
@@ -233,33 +252,31 @@ def send_intro_message(client, sender, room_id):
"info": {"mimetype": "image/gif"}, "info": {"mimetype": "image/gif"},
}, },
) )
else:
print(f"Failed to upload image. Failure response: {response}")
def send_playlist_of_week(client, sender, room_id, playlist_id): async def send_playlist_of_week(client, sender, room_id, playlist_id):
"""Sends playlist of the week in reply to sender, in room with room_id.""" """Sends playlist of the week in reply to sender, in room with room_id."""
playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}" playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}"
reply_msg = f"{sender}, here's the playlist of the week: {playlist_link}" reply_msg = f"{sender}, here's the playlist of the week: {playlist_link}"
client.room_send( await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": reply_msg}, content={"msgtype": "m.text", "body": reply_msg},
) )
def send_playlist_of_all(client, sender, room_id, playlist_id): async def send_playlist_of_all(client, sender, room_id, playlist_id):
"""Sends playlist of all time in reply to sender, in room with room_id.""" """Sends playlist of all time in reply to sender, in room with room_id."""
playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}" playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}"
reply_msg = f"{sender}, here's the playlist of all time: {playlist_link}" reply_msg = f"{sender}, here's the playlist of all time: {playlist_link}"
client.room_send( await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": reply_msg}, content={"msgtype": "m.text", "body": reply_msg},
) )
def message_callback(conn, cursor, youtube, client, room, event): async def message_callback(conn, cursor, youtube, client, room, event):
"""Event handler for received messages.""" """Event handler for received messages."""
sender = event.sender sender = event.sender
if sender != MATRIX_USER: if sender != MATRIX_USER:
@@ -274,23 +291,22 @@ def message_callback(conn, cursor, youtube, client, room, event):
timestamp_sec = datetime.datetime.fromtimestamp( timestamp_sec = datetime.datetime.fromtimestamp(
event.server_timestamp / 1000, event.server_timestamp / 1000,
datetime.UTC, # millisec to sec datetime.UTC,
) )
current_time = datetime.datetime.now(datetime.UTC) 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) recent = abs(current_time - timestamp_sec) < datetime.timedelta(minutes=5)
if body == "!parkerbot" and recent: if body == "!parkerbot" and recent:
send_intro_message(client, sender, room.room_id) await send_intro_message(client, sender, room.room_id)
return return
if body == "!week" and recent: if body == "!week" and recent:
send_playlist_of_week(client, sender, room.room_id, playlist_id) await send_playlist_of_week(client, sender, room.room_id, playlist_id)
return return
if body == "!all" and recent: if body == "!all" and recent:
send_playlist_of_all(client, sender, room.room_id, all_playlist_id) await send_playlist_of_all(client, sender, room.room_id, all_playlist_id)
return return
youtube_link_pattern = ( youtube_link_pattern = (
@@ -302,23 +318,19 @@ 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 is_music_vid, title, channel, duration = 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
# 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
escaped_text = html.escape(plain_text) escaped_text = html.escape(plain_text)
html_text = ( html_text = (
f"<em><span data-mx-color='#808080'>{escaped_text}</span></em>" f"<em><span data-mx-color='#808080'>{escaped_text}</span></em>"
) )
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={
@@ -329,13 +341,27 @@ def message_callback(conn, cursor, youtube, client, room, event):
}, },
) )
# Only add to the playlist if it's categorized as music/entertainment if is_music_vid and duration > 1200:
warning_text = f"⚠️ Track is too long for the radio ({(duration // 60)}m {duration % 60}s). Skipped radio upload."
await client.room_send(
room_id=room.room_id,
message_type="m.room.message",
content={
"msgtype": "m.text",
"body": warning_text,
"format": "org.matrix.custom.html",
"formatted_body": f"<em><span data-mx-color='#808080'>{html.escape(warning_text)}</span></em>",
},
)
elif not is_music_vid:
print(f"📻 Skipping radio upload for {title} (Not categorized as Music/Entertainment)")
if is_music_vid: if is_music_vid:
message_id = record_message(conn, cursor, sender, link, timestamp) message_id = record_message(conn, cursor, sender, link, timestamp)
if in_playlist(cursor, video_id, playlist_id): if in_playlist(cursor, video_id, playlist_id):
print(f"Track is already in this week's playlist: {link}") print(f"Track is already in this week's playlist: {link}")
else: else:
# Add video to playlists and record it in the database
add_video_to_playlist(youtube, playlist_id, video_id) add_video_to_playlist(youtube, playlist_id, video_id)
add_video_to_playlist(youtube, all_playlist_id, video_id) add_video_to_playlist(youtube, all_playlist_id, video_id)
with conn: with conn:
@@ -347,6 +373,14 @@ 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:
if 0 < duration <= 1200:
asyncio.create_task(process_radio_track(link, video_id, title))
else:
if duration == 0:
print(f"📻 Skipping radio upload for {title} (duration unknown or live stream)")
else:
print(f"📻 Skipping radio upload for {title} (duration: {duration}s > 1200s)")
def in_playlist(cursor, video_id, playlist_id): def in_playlist(cursor, video_id, playlist_id):
@@ -382,7 +416,7 @@ def record_message(conn, cursor, sender, link, timestamp):
return cursor.fetchone()[0] return cursor.fetchone()[0]
def sync_callback(response): async def sync_callback(response):
"""Saves Matrix sync token.""" """Saves Matrix sync token."""
with open(TOKEN_PATH, "w", encoding="utf-8") as f: with open(TOKEN_PATH, "w", encoding="utf-8") as f:
f.write(response.next_batch) f.write(response.next_batch)
@@ -397,9 +431,9 @@ def load_sync_token():
return None return None
def get_client(conn, cursor, youtube): async def get_client(conn, cursor, youtube):
"""Returns configured and logged in Matrix client.""" """Returns configured and logged in Matrix client."""
client = HttpClient(MATRIX_SERVER, MATRIX_USER) client = AsyncClient(MATRIX_SERVER, MATRIX_USER)
client.add_event_callback( client.add_event_callback(
lambda room, event: message_callback( lambda room, event: message_callback(
conn, cursor, youtube, client, room, event conn, cursor, youtube, client, room, event
@@ -407,56 +441,397 @@ def get_client(conn, cursor, youtube):
RoomMessageText, RoomMessageText,
) )
client.add_response_callback(sync_callback, SyncResponse) client.add_response_callback(sync_callback, SyncResponse)
print(client.login(MATRIX_PASSWORD)) print(await client.login(MATRIX_PASSWORD))
return client return client
def backwards_sync(conn, cursor, youtube, client, room, start_token): async def backwards_sync(conn, cursor, youtube, client, room, start_token):
"""Fetch and process historical messages from a given room.""" """Fetch and process historical messages from a given room."""
print("Starting to process channel log...") print("Starting to process channel log...")
from_token = start_token from_token = start_token
room_id = room.room_id room_id = room.room_id
while True: while True:
# Fetch room messages response = await client.room_messages(room_id, from_token, direction="b")
response = client.room_messages(room_id, from_token, direction="b")
# Process each message
for event in response.chunk: for event in response.chunk:
if isinstance(event, RoomMessageText): if isinstance(event, RoomMessageText):
message_callback(conn, cursor, youtube, client, room, event) await message_callback(conn, cursor, youtube, client, room, event)
# Break if there are no more messages to fetch
if not response.end or response.end == from_token: if not response.end or response.end == from_token:
break break
# Update the from_token for the next iteration
from_token = response.end from_token = response.end
def main(): 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
base_url = AZURACAST_URL.rstrip("/")
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}")
dl_cmd = [
"yt-dlp",
"--cache-dir",
"/usr/src/app/.cache",
"--extract-audio",
"--embed-metadata",
"--embed-thumbnail",
"--convert-thumbnails",
"jpg",
"--js-runtimes",
"quickjs",
"--output",
f"{base_filepath}.%(ext)s",
video_link,
]
process = await asyncio.create_subprocess_exec(
*dl_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
print(f"❌ yt-dlp crashed! Error:\n{stderr.decode().strip()}")
return
downloaded_files = glob.glob(f"{base_filepath}.*")
if not downloaded_files:
print(f"❌ Failed to download audio for {title}")
return
filepath = downloaded_files[0]
filename = os.path.basename(filepath)
print(f"📻 Uploading to AzuraCast: {title}")
encoded_path = urllib.parse.quote(filename)
upload_cmd = [
"curl",
"-s",
"-X",
"POST",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/files/upload?path={encoded_path}",
"-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()
unique_id = None
azura_path = None
try:
response = json.loads(stdout.decode())
unique_id = response.get("unique_id")
azura_path = response.get("path") or response.get("file")
except json.JSONDecodeError:
print("❌ Failed to parse AzuraCast upload response")
if not unique_id or not azura_path:
try:
search_cmd = [
"curl",
"-s",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/files",
"-H",
f"X-API-Key: {AZURACAST_API_KEY}",
]
search_proc = await asyncio.create_subprocess_exec(
*search_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
search_out, _ = await search_proc.communicate()
search_data = json.loads(search_out.decode())
file_list = (
search_data
if isinstance(search_data, list)
else search_data.get("rows", [])
)
target_suffix = f"_{video_id}."
for item in file_list:
item_path = item.get("path", "")
if target_suffix.lower() in item_path.lower():
unique_id = item.get("unique_id")
azura_path = item.get("path")
break
except Exception as e:
print(f"❌ Failed to search for uploaded track on AzuraCast: {e}")
# 1. Add the track to the "Currents" playlist FIRST
# AzuraCast rejects requests for files that aren't assigned to any active playlist.
target_path = azura_path if azura_path else filename
try:
pl_cmd = [
"curl",
"-s",
"-X",
"GET",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/playlists",
"-H",
f"X-API-Key: {AZURACAST_API_KEY}",
]
pl_proc = await asyncio.create_subprocess_exec(
*pl_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
)
pl_stdout, _ = await pl_proc.communicate()
playlists = json.loads(pl_stdout.decode())
currents_id = next(
(p.get("id") for p in playlists if p.get("name", "").lower() == "currents"),
None,
)
if currents_id:
payload = {
"do": "playlist",
"playlists": [currents_id],
"files": [target_path],
}
json_path = os.path.join(DATA_DIR, f"assign_{filename}.json")
with open(json_path, "w") as f:
json.dump(payload, f)
batch_cmd = [
"curl",
"-s",
"-X",
"PUT",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/files/batch",
"-H",
f"X-API-Key: {AZURACAST_API_KEY}",
"-H",
"Content-Type: application/json",
"-d",
f"@{json_path}",
]
b_proc = await asyncio.create_subprocess_exec(
*batch_cmd, stdout=asyncio.subprocess.DEVNULL
)
await b_proc.communicate()
if os.path.exists(json_path):
os.remove(json_path)
print(f"✅ Auto-assigned to 'Currents' (Path: {target_path})")
except Exception as e:
print(f"❌ Failed to auto-assign playlist for {title}: {e}")
# 2. Issue the Play Next Request SECOND
# Now that the file is legally in a playlist, AzuraCast will accept the request.
if target_path:
q_payload = {
"do": "queue",
"files": [target_path],
}
q_json_path = os.path.join(DATA_DIR, f"queue_{filename}.json")
with open(q_json_path, "w") as f:
json.dump(q_payload, f)
q_batch_cmd = [
"curl",
"-s",
"-X",
"PUT",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/files/batch",
"-H",
f"X-API-Key: {AZURACAST_API_KEY}",
"-H",
"Content-Type: application/json",
"-d",
f"@{q_json_path}",
]
q_proc = await asyncio.create_subprocess_exec(
*q_batch_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
q_stdout, _ = await q_proc.communicate()
if os.path.exists(q_json_path):
os.remove(q_json_path)
try:
q_resp = json.loads(q_stdout.decode())
if q_resp.get("success") or q_resp.get("code") == 200:
print(f"✅ Queued on radio to play next: {title}")
else:
print(
f"⚠️ AzuraCast rejected the queue request for {title}: {q_stdout.decode()}"
)
except json.JSONDecodeError:
print(f"⚠️ Failed to queue {title}. API returned: {q_stdout.decode()}")
else:
print("⚠️ Uploaded, but no path returned to make the request.")
if os.path.exists(filepath):
os.remove(filepath)
async def automated_playlist_rotation():
"""Runs continuously, sorting AzuraCast files by age once an hour."""
while True:
try:
if not AZURACAST_API_KEY:
await asyncio.sleep(3600)
continue
base_url = AZURACAST_URL.rstrip("/")
pl_cmd = [
"curl",
"-s",
"-X",
"GET",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/playlists",
"-H",
f"X-API-Key: {AZURACAST_API_KEY}",
]
pl_proc = await asyncio.create_subprocess_exec(
*pl_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
pl_stdout, _ = await pl_proc.communicate()
try:
playlists = json.loads(pl_stdout.decode())
pl_map = {p.get("name", "").lower(): p.get("id") for p in playlists}
except json.JSONDecodeError:
await asyncio.sleep(3600)
continue
req_playlists = ["currents", "recurrents", "gold"]
if not all(p in pl_map for p in req_playlists):
print(
"⚠️ Missing required playlists. Please create 'Currents', 'Recurrents', and 'Gold'."
)
await asyncio.sleep(3600)
continue
currents_id = pl_map["currents"]
recurrents_id = pl_map["recurrents"]
gold_id = pl_map["gold"]
files_cmd = [
"curl",
"-s",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/files",
"-H",
f"X-API-Key: {AZURACAST_API_KEY}",
]
f_proc = await asyncio.create_subprocess_exec(
*files_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
f_stdout, _ = await f_proc.communicate()
try:
files_data = json.loads(f_stdout.decode())
except json.JSONDecodeError:
files_data = []
file_list = (
files_data.get("rows", [])
if isinstance(files_data, dict)
else files_data
)
currents = []
recurrents = []
gold = []
current_time = time.time()
for item in file_list:
if item.get("type") == "dir":
continue
path = item.get("path")
mtime = item.get("mtime", current_time)
age_days = (current_time - mtime) / 86400
if age_days <= 7:
currents.append(path)
elif age_days <= 14:
recurrents.append(path)
else:
gold.append(path)
async def batch_assign(files, pl_id):
if not files:
return
for i in range(0, len(files), 50):
chunk = files[i : i + 50]
payload = {"do": "playlist", "playlists": [pl_id], "files": chunk}
json_path = os.path.join(DATA_DIR, f"batch_{pl_id}_{i}.json")
with open(json_path, "w") as f:
json.dump(payload, f)
batch_cmd = [
"curl",
"-s",
"-X",
"PUT",
f"{base_url}/api/station/{AZURACAST_STATION_ID}/files/batch",
"-H",
f"X-API-Key: {AZURACAST_API_KEY}",
"-H",
"Content-Type: application/json",
"-d",
f"@{json_path}",
]
b_proc = await asyncio.create_subprocess_exec(
*batch_cmd, stdout=asyncio.subprocess.DEVNULL
)
await b_proc.communicate()
if os.path.exists(json_path):
os.remove(json_path)
await batch_assign(currents, currents_id)
await batch_assign(recurrents, recurrents_id)
await batch_assign(gold, gold_id)
print(
f"✅ Playlist rotation: {len(currents)} Currents, {len(recurrents)} Recurrents, {len(gold)} Gold."
)
except Exception as e:
print(f"💥 Error in automated_playlist_rotation: {e}")
await asyncio.sleep(3600)
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()
conn, cursor = connect_db() conn, cursor = connect_db()
define_tables(conn, cursor) define_tables(conn, cursor)
youtube = get_authenticated_service() youtube = get_authenticated_service()
client = get_client(conn, cursor, youtube) client = await get_client(conn, cursor, youtube)
sync_token = load_sync_token() sync_token = load_sync_token()
# This is incredibly dumb and most probably will exceed your YouTube API quota.
if args.backwards_sync: if args.backwards_sync:
init_sync = client.sync(30000) init_sync = await client.sync(30000)
room = client.room_resolve_alias(MATRIX_ROOM) room = await client.room_resolve_alias(MATRIX_ROOM)
backwards_sync(conn, cursor, youtube, client, room, init_sync.next_batch) await backwards_sync(conn, cursor, youtube, client, room, init_sync.next_batch)
print("Started syncing...") asyncio.create_task(automated_playlist_rotation())
while True:
try: await client.sync_forever(30000, full_state=True, since=sync_token)
client.sync(timeout=30000, full_state=True, since=sync_token)
sync_token = load_sync_token()
except Exception as e:
print(f"Sync error: {e}")
time.sleep(5)
if __name__ == "__main__": if __name__ == "__main__":
main() asyncio.run(main())
+2
View File
@@ -1,3 +1,5 @@
matrix-nio == 0.24.0 matrix-nio == 0.24.0
google-auth-oauthlib == 1.3.1 google-auth-oauthlib == 1.3.1
google-api-python-client == 2.194.0 google-api-python-client == 2.194.0
yt-dlp
mutagen