diff --git a/README.md b/README.md index c241165..c9b4636 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ Python 3.8+ Python packages: `gunicorn aiohttp aiohttp_jinja2 uvloop mutagen` ## Install +``` +$ psql +postgres=# CREATE DATABASE "scorch"; +postgres=# CREATE USER "scorch" WITH PASSWORD 'password'; +postgres=# ALTER ROLE "scorch" SET client_encoding TO 'utf8'; +postgres=# ALTER ROLE "scorch" SET default_transaction_isolation TO 'read committed'; +postgres=# ALTER ROLE "scorch" SET timezone TO 'UTC'; +postgres=# GRANT ALL PRIVILEGES ON DATABASE "scorch" TO "scorch"; +postgres=# \q +``` 1. Get on the floor 2. Walk the dinosaur diff --git a/config.py.template b/config.py.template index 70d3056..1a20f3b 100644 --- a/config.py.template +++ b/config.py.template @@ -6,9 +6,18 @@ eg. https://example.com/scorch. `buckler` specifies settings pertaining to the Buckler server. """ url_prefix = '/scorch' +music_dir = "/home/iou1name/music/Music" +db = { + 'database': 'scorch', + 'user': 'scorch', + 'password': """password""", + 'host': 'localhost', + 'port': 5432, +} + buckler = { 'url': "http://127.0.0.1:5400/buckler", - 'app_id': 2, - 'app_key': """lol""", + 'app_id': 1, + 'app_key': """password""", 'login_url': "/buckler/login", } diff --git a/database.py b/database.py new file mode 100644 index 0000000..d1f207e --- /dev/null +++ b/database.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Database operations for Scorch. +""" +import os +import multiprocessing +from datetime import datetime, timezone + +import asyncpg +import mutagen +import mutagen.mp3 + +import config + +MUSIC_EXT = ['flac', 'mp3'] + +async def build_library(root_dir): + """Walks the music directory and builds a library of tracks.""" + print("Building library") + filepaths = [] + for dir_name, sub_dirs, files in os.walk(root_dir): + for file in files: + if not os.path.splitext(file)[1][1:] in MUSIC_EXT: + continue + filepath = os.path.join(root_dir, dir_name, file) + last_modified = datetime.fromtimestamp( + os.path.getmtime(filepath), timezone.utc) + filepaths.append((filepath, last_modified)) + db_pool = await asyncpg.create_pool(**config.db) + async with db_pool.acquire() as conn: + tracks_prev = await conn.fetch("SELECT * FROM track") + tracks_prev = {track['filepath']: track for track in tracks_prev} + + global worker + def worker(tup): + """Worker for multi-processing tracks.""" + filepath, last_modified = tup + track_prev = tracks_prev.get(filepath) + if track_prev: + if track_prev['last_modified'] >= last_modified: + return tuple(track_prev) + data = read_track(filepath) + return data + + with multiprocessing.Pool() as pool: + mapping = pool.imap(worker, filepaths) + tracks = [] + prev_percent = 0 + while True: + try: + tracks.append(mapping.next()) + except StopIteration: + break + percent = round(len(tracks) / len(filepaths) * 100, 2) + if percent >= prev_percent + 2.5: + print(f"{percent}%") + prev_percent = percent + async with db_pool.acquire() as conn: + await conn.execute("DELETE FROM track") + await conn.executemany( + "INSERT INTO track VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + tracks + ) + print("Done") + + +def read_track(filepath): + """ + Reads the specified file and extracts relevant information from it. + Returns a tuple. + """ + if filepath.endswith("mp3"): + m = mutagen.mp3.EasyMP3(filepath) + else: + m = mutagen.File(filepath) + artist = m.get('artist', [''])[0] + if m.get('albumartist'): + albumartist = m.get('albumartist', [''])[0] + else: + albumartist = m.get('artist', [''])[0] + date = m.get('date', [''])[0] + album = m.get('album', [''])[0] + discnumber = m.get('discnumber', [''])[0] + tracknumber = m.get('tracknumber', [''])[0] + title = m.get('title', [''])[0] + genre = m.get('genre', [''])[0] + length = m.info.length + last_modified = datetime.fromtimestamp( + os.path.getmtime(filepath), timezone.utc) + + d = locals() + d.pop('m') + return tuple(d.values()) diff --git a/scorch.py b/scorch.py index 9a78fe1..3e73a6c 100644 --- a/scorch.py +++ b/scorch.py @@ -4,34 +4,27 @@ A music streaming application. """ import asyncio +import aiohttp from aiohttp import web, WSMsgType import jinja2 import aiohttp_jinja2 from aiohttp_jinja2 import render_template import uvloop +import asyncpg import config +import database import buckler_aiohttp uvloop.install() routes = web.RouteTableDef() -with open ('test.opus', 'rb') as file: - test_data = file.read() - -def chunker(seq, size): - return (seq[pos:pos + size] for pos in range(0, len(seq), size)) - -async def send_files(ws): - for n, chunk in enumerate(chunker(test_data, 200*1024)): - print(f"sending packet #{n}") - await ws.send_bytes(chunk) - await asyncio.sleep(5) - - @routes.get('/', name='index') async def index(request): """The index page.""" + async with request.app['pool'].acquire() as conn: + artists = await conn.execute( + "SELECT DISTINCT albumartist FROM track ORDER BY albumartist ASC") return render_template('index.html', request, {}) @@ -52,11 +45,6 @@ async def websocket_handler(request): print('ping') await ws.send_str("pong") - if msg.data == 'test': - print('initiating test') - asyncio.create_task(send_files(ws)) - - await ws.close() return ws @@ -71,6 +59,10 @@ async def init_app(): undefined=jinja2.StrictUndefined, loader=jinja2.FileSystemLoader('templates'), ) + app['pool'] = await asyncpg.create_pool(**config.db) + async with app['pool'].acquire() as conn: + with open('scorch.sql', 'r') as file: + await conn.execute(file.read()) app.router.add_routes(routes) @@ -80,5 +72,8 @@ async def init_app(): if __name__ == "__main__": - app = init_app() - aiohttp.web.run_app(app, host='0.0.0.0', port=5500) + import sys + if len(sys.argv) > 1: + asyncio.run(database.build_library(config.music_dir)) + #app = asyncio.run(init_app()) + #aiohttp.web.run_app(app, host='0.0.0.0', port=5500) diff --git a/scorch.sql b/scorch.sql new file mode 100644 index 0000000..4b4eae4 --- /dev/null +++ b/scorch.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS track ( + filepath TEXT PRIMARY KEY, + artist TEXT, + albumartist TEXT, + date TEXT, + album TEXT, + discnumber TEXT, + tracknumber TEXT, + title TEXT, + genre TEXT, + length FLOAT, + last_modified TIMESTAMP WITH TIME ZONE +) diff --git a/static/scorch.js b/static/scorch.js index ec33c4e..0865802 100644 --- a/static/scorch.js +++ b/static/scorch.js @@ -1,27 +1,4 @@ var socket; -var audioCtx = new window.AudioContext(); -var source = audioCtx.createBufferSource(); -var total_raw; - -function appendBuffer(buffer1, buffer2) { - let numberOfChannels = Math.min( buffer1.numberOfChannels, buffer2.numberOfChannels ); - let tmp = audioCtx.createBuffer( numberOfChannels, (buffer1.length + buffer2.length), buffer1.sampleRate ); - for (let i=0; i