8 Commits
Author SHA1 Message Date
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
Abdulkadir Furkan Şanlı 9c1f18fbee Remove gay emoji 2026-04-16 12:12:41 +02:00
Abdulkadir Furkan Şanlı 98e2683b80 Fix 2026-04-16 12:09:40 +02:00
5 changed files with 148 additions and 23 deletions
+10 -3
View File
@@ -1,12 +1,19 @@
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 yt-dlp && \
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 ./
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 = ""
+125 -14
View File
@@ -4,11 +4,15 @@
import argparse import argparse
import asyncio import asyncio
import datetime import datetime
import glob
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 traceback
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
@@ -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."""
@@ -182,18 +190,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:
@@ -273,10 +281,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)
@@ -300,29 +309,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><font color='#808080'>{escaped_text}</font></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,
}, },
) )
@@ -344,7 +355,10 @@ 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:
task = asyncio.create_task(process_radio_track(link, video_id, title))
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
def in_playlist(cursor, video_id, playlist_id): def in_playlist(cursor, video_id, playlist_id):
"""Checks if video is in playlist.""" """Checks if video is in playlist."""
@@ -430,6 +444,102 @@ 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."""
try:
print(f"🚀 DEBUG: Background task started for {title}")
if not AZURACAST_API_KEY:
print("❌ Error: AZURACAST_API_KEY is empty or missing.")
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
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
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 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: {stdout.decode()}")
except json.JSONDecodeError:
print(f"❌ Failed to parse AzuraCast response: {stdout.decode()}")
if os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
print(f"💥 CRITICAL ERROR in background task: {e}")
traceback.print_exc()
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()
@@ -438,6 +548,7 @@ async def main():
youtube = get_authenticated_service() youtube = get_authenticated_service()
client = await get_client(conn, cursor, youtube) client = await get_client(conn, cursor, youtube)
sync_token = load_sync_token() sync_token = load_sync_token()
background_tasks = set()
# This is incredibly dumb and most probably will exceed your YouTube API quota. # This is incredibly dumb and most probably will exceed your YouTube API quota.
if args.backwards_sync: if args.backwards_sync:
+2 -2
View File
@@ -1,3 +1,3 @@
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