11 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
Abdulkadir Furkan Şanlı cba7a701bf Color 2026-04-16 12:06:58 +02:00
Abdulkadir Furkan Şanlı 884f1c6dd2 Channel name 2026-04-16 11:56:00 +02:00
Abdulkadir Furkan Şanlı 84c245b948 Titles. 2026-04-16 11:39:44 +02:00
5 changed files with 190 additions and 21 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 = ""
+167 -12
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."""
@@ -178,15 +186,30 @@ def add_video_to_playlist(youtube, playlist_id, video_id, retry_count=6):
raise error raise error
def is_music(youtube, video_id): def get_video_info(youtube, video_id):
"""Check whether a YouTube video is music.""" """Check whether a YouTube video is music and return its title and channel."""
video_details = youtube.videos().list(id=video_id, part="snippet").execute() try:
video_details = youtube.videos().list(id=video_id, part="snippet").execute()
# Check if the video category is Music (typically category ID 10) # Check if the video actually exists/is accessible
return video_details["items"][0]["snippet"]["categoryId"] in ( if not video_details.get("items"):
"10", # music return False, "[Video unavailable or private]", ""
"24", # entertainment
) 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:
print(f"YouTube API error fetching info for {video_id}: {error}")
return False, "[Error fetching title]", ""
except Exception as e:
print(f"Unexpected error fetching info for {video_id}: {e}")
return False, "[Error fetching title]", ""
async def send_intro_message(client, sender, room_id): async def send_intro_message(client, sender, room_id):
@@ -258,10 +281,13 @@ 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)
recent = current_time - timestamp_sec < datetime.timedelta(seconds=30)
# Account for up to 5 minutes of clock drift
recent = abs(current_time - timestamp_sec) < datetime.timedelta(minutes=5)
if body == "!parkerbot" and recent: if body == "!parkerbot" and recent:
await send_intro_message(client, sender, room.room_id) await send_intro_message(client, sender, room.room_id)
@@ -283,7 +309,36 @@ 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]
if is_music(youtube, video_id):
# 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"<em><span data-mx-color='#808080'>{escaped_text}</span></em>"
)
await client.room_send(
room_id=room.room_id,
message_type="m.room.message",
content={
"msgtype": "m.text",
"body": plain_text,
"format": "org.matrix.custom.html",
"formatted_body": html_text,
},
)
# Only add to the playlist if it's categorized as music/entertainment
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}")
@@ -300,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."""
@@ -386,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()
@@ -394,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