diff --git a/ircradio.pid b/ircradio.pid index 02fa967..9a8d31d 100644 --- a/ircradio.pid +++ b/ircradio.pid @@ -1 +1 @@ -1797 \ No newline at end of file +1846 \ No newline at end of file diff --git a/ircradio/irc.py b/ircradio/irc.py index 6ffe844..da7b922 100644 --- a/ircradio/irc.py +++ b/ircradio/irc.py @@ -68,7 +68,7 @@ def reconnect(**kwargs): class Commands: LOOKUP = ['np', 'tune', 'boo', 'request', 'dj', 'skip', 'listeners', 'queue', - 'queue_user', 'pop', 'search', 'stats', + 'queue_user', 'pop', 'search', 'searchq', 'stats', 'rename', 'ban', 'whoami'] @staticmethod @@ -116,51 +116,106 @@ class Commands: async def request(*args, target=None, nick=None, **kwargs): """request a song by title or YouTube id""" from ircradio.models import Song - if not args: - send_message(target=target, message="usage: !request ") + await send_message(target=target, message="usage: !request ") + + songs = await Commands._return_song_results(*args, target=target, nick=nick, **kwargs) + + if songs and len(songs) == 1: + song = songs[0] + Radio.queue(song) + msg = f"Added {song.title} to the queue" + + return await send_message(target, msg) + + if songs: + return await Commands._print_song_results(*args, target=target, nick=nick, songs=songs, **kwargs) + + @staticmethod + async def search(*args, target=None, nick=None, **kwargs): + from ircradio.models import Song + if not args: + return await send_message(target=target, message="usage: !search ") + + return await Commands._search(*args, target=target, nick=nick, **kwargs) + + @staticmethod + async def searchq(*args, target=None, nick=None, **kwargs): + from ircradio.models import Song + if not args: + return await send_message(target=target, message="usage: !searchq ") + + return await Commands._search(*args, target=target, nick=nick, report_quality=True, **kwargs) + + @staticmethod + async def _search(*args, target=None, nick=None, **kwargs) -> Optional[List['Song']]: + """search for a title""" + from ircradio.models import Song + + report_quality = kwargs.get('report_quality') + + songs = await Commands._return_song_results(*args, target=target, nick=nick, **kwargs) + + if songs: + return await Commands._print_song_results(*args, target=target, nick=nick, report_quality=report_quality, songs=songs, **kwargs) + + @staticmethod + async def _return_song_results(*args, target=None, nick=None, **kwargs) -> Optional[List['Song']]: + from ircradio.models import Song needle = " ".join(args) + + # https://git.wownero.com/dsc/ircradio/issues/1 + needle_2nd = None + if "|" in needle: + spl = needle.split('|', 1) + a = spl[0].strip() + b = spl[1].strip() + needle = a + needle_2nd = b + try: songs = Song.search(needle) except Exception as ex: return await send_message(target, f"{ex}") - if not songs: - return await send_message(target, "Not found!") - - if len(songs) >= 2: - random.shuffle(songs) - await send_message(target, "Multiple found:") - for s in songs[:4]: - await send_message(target, f"{s.utube_id} | {s.title}") - return - - song = songs[0] - msg = f"Added {song.title} to the queue" - Radio.queue(song) - return await send_message(target, msg) - - @staticmethod - async def search(*args, target=None, nick=None, **kwargs): - """search for a title""" - from ircradio.models import Song - - if not args: - return await send_message(target=target, message="usage: !search ") - - needle = " ".join(args) - songs = Song.search(needle) if not songs: return await send_message(target, "No song(s) found!") - if len(songs) == 1: - song = songs[0] - await send_message(target, f"{song.utube_id} | {song.title}") - else: - random.shuffle(songs) + if songs and needle_2nd: + songs = [s for s in songs if s.title and needle_2nd in s.title.lower()] + + if not songs: + return await send_message(target, "No song(s) found after '|'!") + + return songs + + @staticmethod + async def _print_song_results(*args, target=None, nick=None, report_quality=None, songs=None, **kwargs): + from ircradio.models import Song + + len_songs = len(songs) + max_songs = 6 + moar = len_songs > max_songs + if len_songs > 1: await send_message(target, "Multiple found:") - for s in songs[:8]: - await send_message(target, f"{s.utube_id} | {s.title}") + + random.shuffle(songs) + + for s in songs[:max_songs]: + msg = f"{s.utube_id} | {s.title}" + await s.scan(s.path or s.filepath) + + if report_quality and s.meta: + if s.meta.bitrate: + msg += f" ({s.meta.bitrate / 1000}kbps)" + if s.meta.channels: + msg += f" (channels: {s.meta.channels}) " + if s.meta.sample_rate: + msg += f" (sample_rate: {s.meta.sample_rate}) " + await send_message(target, msg) + + if moar: + await send_message(target, "[...]") @staticmethod async def dj(*args, target=None, nick=None, **kwargs): diff --git a/ircradio/models.py b/ircradio/models.py index d26a609..52f48a8 100644 --- a/ircradio/models.py +++ b/ircradio/models.py @@ -1,12 +1,18 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2021, dsc@xmr.pm +import logging +import json import os import re from typing import Optional, List from datetime import datetime +from dataclasses import dataclass + import mutagen +import aiofiles + from peewee import SqliteDatabase, SQL import peewee as pw @@ -23,6 +29,20 @@ class Ban(pw.Model): class Meta: database = db +@dataclass +class SongMeta: + title: Optional[str] = None + description: Optional[str] = None + artist: Optional[str] = None + album: Optional[str] = None + album_cover: Optional[str] = None # path to image + bitrate: Optional[int] = None + length: Optional[float] = None + sample_rate: Optional[int] = None + channels: Optional[int] = None + mime: Optional[str] = None + + class Song(pw.Model): id = pw.AutoField() date_added = pw.DateTimeField(default=datetime.now) @@ -34,6 +54,10 @@ class Song(pw.Model): karma = pw.IntegerField(default=5, index=True) banned = pw.BooleanField(default=False) + meta: SongMeta = None # directly from file (exif) or metadata json + path: Optional[str] = None + remaining: int = None # liquidsoap playing status in seconds + @staticmethod def delete_song(utube_id: str) -> bool: from ircradio.factory import app @@ -110,6 +134,73 @@ class Song(pw.Model): app.logger.error(f"{ex}") pass + + async def scan(self, path: str = None): + # update with metadata, etc. + if path is None: + path = self.filepath + + if not os.path.exists(path): + raise Exception(f"filepath {path} does not exist") + basename = os.path.splitext(os.path.basename(path))[0] + + self.meta = SongMeta() + self.path = path + + # EXIF direct + from ircradio.utils import mutagen_file + _m = await mutagen_file(path) + + if _m: + if _m.info: + self.meta.channels = _m.info.channels + self.meta.length = int(_m.info.length) + if hasattr(_m.info, 'bitrate'): + self.meta.bitrate = _m.info.bitrate + if hasattr(_m.info, 'sample_rate'): + self.meta.sample_rate = _m.info.sample_rate + if _m.tags: + if hasattr(_m.tags, 'as_dict'): + _tags = _m.tags.as_dict() + else: + try: + _tags = {k: v for k, v in _m.tags.items()} + except: + _tags = {} + + for k, v in _tags.items(): + if isinstance(v, list): + v = v[0] + elif isinstance(v, (str, int, float)): + pass + else: + continue + + if k in ["title", "description", "language", "date", "purl", "artist"]: + if hasattr(self.meta, k): + setattr(self.meta, k, v) + + # yt-dlp metadata json file + fn_utube_meta = os.path.join(settings.dir_meta, f"{basename}.info.json") + utube_meta = {} + if os.path.exists(fn_utube_meta): + async with aiofiles.open(fn_utube_meta, mode="r") as f: + try: + utube_meta = json.loads(await f.read()) + except Exception as ex: + logging.error(f"could not parse {fn_utube_meta}, {ex}") + + if utube_meta: + # utube_meta file does not have anything we care about + pass + + if not self.title and not self.meta.title: + # just adopt filename + self.title = os.path.basename(self.path) + + return self + + @property def filepath(self): """Absolute""" diff --git a/ircradio/templates/index.html b/ircradio/templates/index.html index 561c96a..180f80d 100644 --- a/ircradio/templates/index.html +++ b/ircradio/templates/index.html @@ -16,7 +16,7 @@
- +

Now playing:

diff --git a/ircradio/utils.py b/ircradio/utils.py index abf221e..fc949d8 100644 --- a/ircradio/utils.py +++ b/ircradio/utils.py @@ -12,10 +12,14 @@ import asyncio from asyncio.subprocess import Process from io import TextIOWrapper +import mutagen import aiofiles import aiohttp import jinja2 from jinja2 import Environment, PackageLoader, select_autoescape +from aiocache import cached, Cache +from aiocache.serializers import PickleSerializer + import settings @@ -207,6 +211,17 @@ class Price: return blob.get('usd', 0) +#@cached(ttl=3600, cache=Cache.MEMORY, +# key_builder=lambda *args, **kw: f"mutagen_file_{args[1]}", +# serializer=PickleSerializer()) +async def mutagen_file(path): + from quart import current_app + if current_app: + return await current_app.sync_to_async(mutagen.File)(path) + else: + return mutagen.File(path) + + def print_banner(): print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪ ██ ▀▄ █·▐█ ▌▪▀▄ █·▐█ ▀█ ██▪ ██ ██ ▪ diff --git a/ircradio/youtube.py b/ircradio/youtube.py index 90bdf79..8c03016 100644 --- a/ircradio/youtube.py +++ b/ircradio/youtube.py @@ -118,8 +118,9 @@ class YouTube: title = 'Unknown' app.logger.warning(f"could not detect artist/title from metadata for {filepath}") + title = title if '-' in title else f"{artist} - {title}" return { - "name": f"{artist} - {title}", + "name": f"{title}", "data": metadata, "duration": duration, "path": filepath diff --git a/requirements.txt b/requirements.txt index 359f909..21439c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ quart yt-dlp aiofiles aiohttp +aiocache bottom tinytag python-dateutil