From 32009c9a99fd30cd9f323f9d3a1c76ba1d81e509 Mon Sep 17 00:00:00 2001 From: codey Date: Mon, 10 Feb 2025 20:29:57 -0500 Subject: [PATCH] radio changes/progress --- endpoints/radio.py | 104 ++++++++++++++++++++++++++++++++--- endpoints/radio_util.py | 119 ++++++++++++++++++++++++++++++++++++++++ gpt/__init__.py | 33 +++++++++++ util.py | 4 +- 4 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 endpoints/radio_util.py create mode 100644 gpt/__init__.py diff --git a/endpoints/radio.py b/endpoints/radio.py index bbb0b06..2f6591d 100644 --- a/endpoints/radio.py +++ b/endpoints/radio.py @@ -6,12 +6,14 @@ import traceback import os import aiosqlite as sqlite3 import time +import random import asyncio import regex import music_tag +from . import radio_util from uuid import uuid4 as uuid from pydantic import BaseModel -from fastapi import FastAPI, Request, Response, HTTPException +from fastapi import FastAPI, BackgroundTasks, Request, Response, HTTPException from fastapi.responses import RedirectResponse from aiohttp import ClientSession, ClientTimeout @@ -39,16 +41,45 @@ class ValidRadioSongRequest(BaseModel): class ValidRadioNextRequest(BaseModel): """ - **key**: API Key + - **skipTo**: UUID to skip to [optional] """ key: str + skipTo: str|None = None + +class ValidRadioReshuffleRequest(ValidRadioNextRequest): + """ + - **key**: API Key + """ + +class ValidRadioQueueShiftRequest(BaseModel): + """ + - **key**: API Key + - **uuid**: UUID to shift + - **next**: Play next if true, immediately if false, default False + """ + key: str + uuid: str + next: bool = False + +class ValidRadioQueueRemovalRequest(BaseModel): + """ + - **key**: API Key + - **uuid**: UUID to remove + """ + key: str + uuid: str + + + class Radio(FastAPI): """Radio Endpoints""" - def __init__(self, app: FastAPI, my_util, constants, glob_state): # pylint: disable=super-init-not-called + def __init__(self, app: FastAPI, my_util, constants, glob_state) -> None: # pylint: disable=super-init-not-called self.app = app self.util = my_util self.constants = constants + self.radio_util = radio_util.RadioUtil(self.constants) self.glob_state = glob_state self.ls_uri = "http://10.10.10.101:29000" self.sqlite_exts: list[str] = ['/home/singer/api/solibs/spellfix1.cpython-311-x86_64-linux-gnu.so'] @@ -70,6 +101,9 @@ class Radio(FastAPI): "radio/request": self.radio_request, "radio/get_queue": self.radio_get_queue, "radio/skip": self.radio_skip, + "radio/queue_shift": self.radio_queue_shift, + "radio/reshuffle": self.radio_reshuffle, + "radio/queue_remove": self.radio_queue_remove, # "widget/sqlite": self.homepage_sqlite_widget, # "widget/lyrics": self.homepage_lyrics_widget, # "widget/radio": self.homepage_radio_widget, @@ -88,7 +122,7 @@ class Radio(FastAPI): asyncio.get_event_loop().run_until_complete(self.load_playlist()) - async def get_queue_item_by_uuid(self, uuid: str) -> tuple[int, dict] | None: + def get_queue_item_by_uuid(self, uuid: str) -> tuple[int, dict] | None: """ Get queue item by UUID Args: @@ -99,7 +133,7 @@ class Radio(FastAPI): for x, item in enumerate(self.active_playlist): if item.get('uuid') == uuid: return (x, item) - return False + return None async def _ls_skip(self) -> bool: async with ClientSession() as session: @@ -111,24 +145,40 @@ class Radio(FastAPI): async def radio_skip(self, data: ValidRadioNextRequest, request: Request) -> bool: """ - Skip to the next track in the queue + Skip to the next track in the queue, or to uuid specified in skipTo if provided """ try: if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): raise HTTPException(status_code=403, detail="Unauthorized") + if data.skipTo: + (x, _) = self.get_queue_item_by_uuid(data.skipTo) + self.active_playlist = self.active_playlist[x:] + if not self.active_playlist: + await self.load_playlist() return await self._ls_skip() except Exception as e: traceback.print_exc() return False + + async def radio_reshuffle(self, data: ValidRadioReshuffleRequest, request: Request) -> dict: + """ + Reshuffle the play queue + """ + if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): + raise HTTPException(status_code=403, detail="Unauthorized") + random.shuffle(self.active_playlist) + return { + 'ok': True + } - async def radio_get_queue(self, request: Request, limit: int = 100) -> dict: + async def radio_get_queue(self, request: Request, limit: int = 20_000) -> dict: """ - Get current play queue, up to limit n [default: 100] + Get current play queue, up to limit n [default: 20k] Args: - limit (int): Number of results to return (default 100) + limit (int): Number of results to return (default 20k) Returns: dict """ @@ -146,6 +196,37 @@ class Radio(FastAPI): 'items': queue_out } + async def radio_queue_shift(self, data: ValidRadioQueueShiftRequest, request: Request) -> dict: + """Shift position of a UUID within the queue [currently limited to playing next or immediately]""" + if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): + raise HTTPException(status_code=403, detail="Unauthorized") + + (x, item) = self.get_queue_item_by_uuid(data.uuid) + self.active_playlist.pop(x) + self.active_playlist.insert(0, item) + if not data.next: + await self._ls_skip() + return { + 'ok': True, + } + + async def radio_queue_remove(self, data: ValidRadioQueueRemovalRequest, request: Request) -> dict: + """Remove an item from the current play queue""" + if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): + raise HTTPException(status_code=403, detail="Unauthorized") + + (x, found_item) = self.get_queue_item_by_uuid(data.uuid) + if not found_item: + return { + 'ok': False, + 'err': 'UUID not found in play queue', + } + self.active_playlist.pop(x) + return { + 'ok': True, + } + + async def search_playlist(self, artistsong: str|None = None, artist: str|None = None, song: str|None = None) -> bool: if artistsong and (artist or song): raise RadioException("Cannot search using combination provided") @@ -250,7 +331,8 @@ class Radio(FastAPI): return ret_obj - async def radio_get_next(self, data: ValidRadioNextRequest, request: Request) -> dict: + async def radio_get_next(self, data: ValidRadioNextRequest, request: Request, + background_tasks: BackgroundTasks) -> dict: """ Get next track Args: @@ -285,6 +367,10 @@ class Radio(FastAPI): self.now_playing = next next['start'] = time_started next['end'] = time_ends + try: + background_tasks.add_task(self.radio_util.webhook_song_change, next) + except Exception as e: + traceback.print_exc() return next else: return await self.radio_pop_track(request, recursion_type="not list: self.active_playlist") diff --git a/endpoints/radio_util.py b/endpoints/radio_util.py new file mode 100644 index 0000000..cb746e5 --- /dev/null +++ b/endpoints/radio_util.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3.12 + +""" +Radio Utils +""" + +import logging +import traceback +import time +import datetime +from aiohttp import ClientSession, ClientTimeout +import gpt + +class RadioUtil: + def __init__(self, constants) -> None: + self.constants = constants + self.gpt = gpt.GPT(self.constants) + self.webhooks = { + 'gpt': { + 'hook': self.constants.GPT_WEBHOOK, + }, + 'sfm': { + 'hook': self.constants.SFM_WEBHOOK, + } + } + def duration_conv(self, s: int|float) -> str: + """ + Convert duration given in seconds to hours, minutes, and seconds (h:m:s) + Args: + s (int|float): seconds to convert + Returns: + str + """ + return str(datetime.timedelta(seconds=s)).split(".", maxsplit=1)[0] + + async def get_ai_song_info(self, artist: str, song: str) -> str|None: + """ + Get AI Song Info + Args: + artist (str) + song (str) + Returns: + str|None + """ + response = await self.gpt.get_completion(prompt=f"I am going to listen to {song} by {artist}.") + if not response: + logging.critical("No response received from GPT?") + return + return response + + async def webhook_song_change(self, track: dict) -> None: + try: + """ + Handles Song Change Outbounds (Webhooks) + Args: + track (dict) + Returns: + None + """ + + # First, send track info + friendly_track_start = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(track['start'])) + friendly_track_end = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(track['end'])) + hook_data = { + 'username': 'serious.FM', + "embeds": [{ + "title": "Now Playing", + "description": f'# {track['song']}\nby **{track['artist']}**', + "color": 0x30c56f, + "fields": [ + { + "name": "Duration", + "value": self.duration_conv(track['duration']), + "inline": True, + }, + { + "name": "Filetype", + "value": track['file_path'].rsplit(".", maxsplit=1)[1], + "inline": True, + }, + { + "name": "Higher Res", + "value": "[stream/icecast](https://relay.sfm.codey.lol/aces.ogg) || [web player](https://codey.lol/radio)", + "inline": True, + } + ] + }] + } + + sfm_hook = self.webhooks['gpt'].get('hook') + async with ClientSession() as session: + async with await session.post(sfm_hook, json=hook_data, + timeout=ClientTimeout(connect=5, sock_read=5), headers={ + 'content-type': 'application/json; charset=utf-8',}) as request: + request.raise_for_status() + + # Next, AI feedback + + ai_response = await self.get_ai_song_info(track['artist'], + track['song']) + hook_data = { + 'username': 'GPT', + "embeds": [{ + "title": "AI Feedback", + "color": 0x35d0ff, + "description": ai_response.strip(), + }] + } + + ai_hook = self.webhooks['gpt'].get('hook') + async with ClientSession() as session: + async with await session.post(ai_hook, json=hook_data, + timeout=ClientTimeout(connect=5, sock_read=5), headers={ + 'content-type': 'application/json; charset=utf-8',}) as request: + request.raise_for_status() + except Exception as e: + traceback.print_exc() + + \ No newline at end of file diff --git a/gpt/__init__.py b/gpt/__init__.py new file mode 100644 index 0000000..a14bf86 --- /dev/null +++ b/gpt/__init__.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3.12 +import os +import asyncio +from typing import Optional +from openai import AsyncOpenAI + +class GPT: + def __init__(self, constants): + self.constants = constants + self.api_key = self.constants.OPENAI_API_KEY + self.client = AsyncOpenAI( + api_key=self.api_key, + timeout=10.0, + ) + self.default_system_prompt = "You are a helpful assistant who will provide tidbits of info on songs the user may listen to." + + async def get_completion(self, prompt: str, system_prompt: Optional[str] = None) -> None: + if not system_prompt: + system_prompt = self.default_system_prompt + chat_completion = await self.client.chat.completions.create( + messages=[ + { + "role": "system", + "content": system_prompt, + }, + { + "role": "user", + "content": prompt, + } + ], + model="gpt-4o-mini", + ) + return chat_completion.choices[0].message.content \ No newline at end of file diff --git a/util.py b/util.py index c836dd8..bb02b67 100644 --- a/util.py +++ b/util.py @@ -44,4 +44,6 @@ class Utilities: if path.lower().startswith("/xc/") and not key.startswith("XC-"): return False - return True \ No newline at end of file + return True + + \ No newline at end of file