Author SHA1 Message Date
Diane bd6f862c0c Drop explanatory comments 2026-09-03 13:38:32 +00:00
Diane 1ba136b91c Fix radio downloads: ship EJS solver scripts and allow runtime refresh
yt-dlp now solves YouTube JS challenges via external EJS scripts (yt-dlp-ejs package); a bare PyPI yt-dlp does not include them and remote fetching is disabled by default. Images built after the EJS migration therefore fail every download with HTTP 403 while playlists keep working. Use yt-dlp[default] so the matching yt-dlp-ejs ships in the image, and pass --remote-components ejs:github so solver scripts refresh at runtime when YouTube rotates challenges.
2026-09-03 13:31:00 +00:00
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
Abdulkadir Furkan Şanlı 611a9551df Update README.
Signed-off-by: Abdulkadir Furkan Şanlı <me@abdulocra.cy>
2026-04-20 11:42:59 +02:00
Abdulkadir Furkan Şanlı 512275a8b8 Switch to synchronous Python.
Signed-off-by: Abdulkadir Furkan Şanlı <me@abdulocra.cy>
2026-04-19 19:47:28 +02:00
Abdulkadir Furkan Şanlı 3abc37bb3c Pin versions and use uv for speed.
Signed-off-by: Abdulkadir Furkan Şanlı <me@abdulocra.cy>
2026-04-19 19:37:26 +02:00
5 changed files with 448 additions and 43 deletions

No files matched your search

+13 -3
View File
@@ -1,12 +1,22 @@
FROM python:3 FROM python:3.14
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY main.py parker.gif requirements.txt ./ RUN apt-get update && \
apt-get install -y --no-install-recommends curl ffmpeg quickjs && \
rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt COPY requirements.txt ./
RUN uv pip install --system --no-cache -r requirements.txt
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
+5 -4
View File
@@ -1,16 +1,17 @@
# ParkerBot # ParkerBot
ParkerBot is a Matrix bot that monitors a channel for YouTube links and ParkerBot is a Matrix bot that monitors a channel for YouTube links and
generates weekly playlists from them. generates weekly playlists from them. It also sends YouTube link titles to the
channel it's in.
## Running locally ## Running locally
1. Clone the repo 1. Clone the repo
2. Install the dependencies, preferably in a venv: 2. Install the dependencies, preferably in a venv:
```shell ```shell
python3 -m venv venv uv venv --python 3.14 --seed
source ./venv/bin/activate source .venv/bin/activate
pip3 install -r requirements.txt uv pip install -r requirements.txt
``` ```
3. Copy [example.env](example.env) to `.env`, customize it. 3. Copy [example.env](example.env) to `.env`, customize it.
4. Source `.env`: 4. Source `.env`:
+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 = ""
+420 -34
View File
@@ -4,12 +4,15 @@
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
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
@@ -30,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."""
@@ -76,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,
@@ -91,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())
@@ -106,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)
@@ -150,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),
) )
@@ -179,30 +183,46 @@ 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
async def send_intro_message(client, sender, room_id): async def send_intro_message(client, sender, room_id):
@@ -218,11 +238,9 @@ async def send_intro_message(client, sender, room_id):
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 = await 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
await client.room_send( await client.room_send(
room_id=room_id, room_id=room_id,
@@ -234,8 +252,6 @@ async 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}")
async def send_playlist_of_week(client, sender, room_id, playlist_id): async def send_playlist_of_week(client, sender, room_id, playlist_id):
@@ -274,11 +290,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,
) )
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:
@@ -302,19 +318,17 @@ 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 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 = 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,
@@ -323,17 +337,31 @@ async def message_callback(conn, cursor, youtube, client, room, event):
"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,
}, },
) )
# 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:
@@ -345,6 +373,14 @@ 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:
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):
@@ -415,22 +451,371 @@ async def backwards_sync(conn, cursor, youtube, client, room, start_token):
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 = await 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):
await 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
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",
"--remote-components",
"ejs:github",
"--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(): 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()
@@ -440,12 +825,13 @@ async def main():
client = await 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 = await client.sync(30000) init_sync = await client.sync(30000)
room = await client.room_resolve_alias(MATRIX_ROOM) room = await client.room_resolve_alias(MATRIX_ROOM)
await backwards_sync(conn, cursor, youtube, client, room, init_sync.next_batch) await backwards_sync(conn, cursor, youtube, client, room, init_sync.next_batch)
asyncio.create_task(automated_playlist_rotation())
await client.sync_forever(30000, full_state=True, since=sync_token) await client.sync_forever(30000, full_state=True, since=sync_token)
+4 -2
View File
@@ -1,3 +1,5 @@
matrix-nio == 0.24.0 matrix-nio == 0.24.0
google-auth-oauthlib google-auth-oauthlib == 1.3.1
google-api-python-client google-api-python-client == 2.194.0
yt-dlp[default]
mutagen