initial commit

This commit is contained in:
2026-04-12 16:27:56 -04:00
commit 407e437de1
38 changed files with 2997 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/.dockerignore
music/
data/
bot/
.git/
.gitignore
__pycache__/
*.pyc
README.md
setup.sh

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/.gitignore
data/
bot/ignores.json
bot/.env
music/
__pycache__/

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/Dockerfile
FROM python:3-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
COPY static/ ./static/
EXPOSE 7000
CMD ["python", "-u", "server.py"]

131
README.md Normal file
View File

@@ -0,0 +1,131 @@
# ACID RADIO
A self-hosted internet radio station with a web UI, vote system, and IRC bot. Built with pure Python and vanilla JavaScript — no frameworks, no build steps, no npm. Drop your music in a folder and go.
## Setup
The server requires Python 3, [mutagen](https://pypi.org/project/mutagen/) for reading genre tags, and `ffprobe` *(part of ffmpeg)* for determining track duration. Install the dependency and run:
```
pip install mutagen
python3 server.py
```
The server listens on port 7000 by default. Place your music in a `music/` directory next to `server.py`, organized as `music/Artist Name/track.mp3`. Supported formats are mp3, flac, ogg, wav, m4a, opus, and aac.
Running with `-d` or `--debug` enables admin controls in the web UI *(skip, genre jump, artist jump)* that are otherwise hidden from listeners.
```
python3 server.py --debug
```
## How It Works
The server picks a random song, streams it to all connected listeners simultaneously, and advances to the next track when the current one ends. Everyone hears the same song at the same position — there are no individual streams or playlists. The web client syncs its playback position against the server clock on each poll so late joiners pick up mid-song.
### Smart Shuffle
The shuffle system maintains a rolling history of the last 20 artists played. When selecting the next song, it filters out any artist that appears in this history, ensuring broad coverage across your library before repeating an artist. If you have fewer than 20 artists *(or all artists are in the recent history)*, it falls back to the full pool so playback never stalls.
The music library is rescanned every 5 minutes, so you can add or remove files on the fly without restarting the server. If a selected track no longer exists on disk, the server silently skips it and picks another.
### Voting
Listeners can thumbs-up or thumbs-down the current track. Votes are persisted to a SQLite database so they survive restarts. Each listener gets a unique client ID stored in their browser, and can only cast one vote per song *(toggling it off if they click again)*.
### Vote to Skip
The skip button lets listeners collectively vote to skip a song. The system uses probability scaling — the more people who vote, the higher the chance it actually skips:
| Skip Votes | Chance |
|:----------:|:------:|
| 0 | 1 in 10 |
| 1 | 1 in 9 |
| 2 | 1 in 8 |
| 3 | 1 in 7 |
| 4 | 1 in 6 |
| 5 | 1 in 5 |
| 6 | 1 in 4 |
| 7 | 1 in 3 |
| 8 | 1 in 2 |
| 9+ | guaranteed |
A global limit of 3 successful skips per hour prevents abuse. When the limit is reached, the skip button hides entirely until the cooldown passes. Each listener can only vote to skip once per song.
### Listener Count
The server tracks active listeners by session ID. Each browser tab generates a unique session on load, and the server prunes any session that hasn't polled in 15 seconds. The count is displayed on both the splash page and the player, updating every 10 seconds. No IP addresses or identifying information are ever exposed to clients — the API only returns a number.
### Volume Boost
The volume slider goes up to 200%. Values above 100% use a Web Audio API GainNode to amplify beyond the browser's native limit. This is initialized on the first "Tune In" click to satisfy browser autoplay policies.
## API Endpoints
All endpoints return JSON unless otherwise noted.
### GET
| Endpoint | Description |
|:---------|:------------|
| `/api/radio/now?sid=` | Current song state *(artist, track, genre, duration, timestamps)*. The `sid` parameter registers the session as an active listener. |
| `/api/radio/listeners` | Active listener count. Returns `{"count": N}`. |
| `/api/radio/votes?song=&client=` | Vote counts and the client's current vote for a song. |
| `/api/radio/skip-info?ts=&client=` | Skip vote count, whether the client has voted, and remaining hourly skips. |
| `/api/radio/skip` | Force skip to next song. *Debug mode only.* |
| `/api/radio/skip-to?artist=` | Skip to a random song by the given artist. *Debug mode only.* |
| `/api/radio/skip-to-genre?genre=` | Skip to a random song matching the genre. *Debug mode only.* |
| `/api/artists` | List of all artists with track counts. |
| `/api/tracks?artist=` | List of tracks for a given artist. |
| `/api/debug` | Returns `{"debug": true/false}` indicating if debug mode is active. |
| `/music/Artist/track.mp3` | Streams an audio file with range request support. |
### POST
| Endpoint | Body | Description |
|:---------|:-----|:------------|
| `/api/radio/vote` | `{"song", "client", "vote"}` | Cast a vote. `vote` is `"up"`, `"down"`, or `null` *(to remove)*. |
| `/api/radio/vote-skip` | `{"ts", "client"}` | Vote to skip the current song. `ts` is the song's `started_at` timestamp. |
## IRC Bot
The bot *(radiobot.py)* connects to IRC over SSL and announces what's playing. It uses pure asyncio with no external dependencies.
```
python3 radiobot.py
```
It connects to `irc.supernets.org` on port 6697 *(SSL)*, joins `#superbowl` 6 seconds after registration, and sits quietly until someone uses a command or the announcement timer fires.
| Command | Description |
|:--------|:------------|
| `!np` | Shows the current artist, track, genre, and listener count. |
| `@radio` | Links to the radio URL. |
Every 4 hours, the bot automatically announces the currently playing song with the listener count and a tune-in link.
## Reverse Proxy
If you're running behind nginx with SSL *(recommended)*, point your domain at the server:
```nginx
server {
listen 443 ssl;
server_name radio.acid.vegas;
ssl_certificate /etc/letsencrypt/live/radio.acid.vegas/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/radio.acid.vegas/privkey.pem;
location / {
proxy_pass http://127.0.0.1:7000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
}
}
```
Generate a cert with `sudo certbot certonly --standalone -d radio.acid.vegas`.

6
bot/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/bot/.env.example
NICKSERV_PASSWORD=changeme
SPOTIFY_CLIENT_ID=changeme
SPOTIFY_CLIENT_SECRET=changeme

378
bot/radiobot.py Normal file
View File

@@ -0,0 +1,378 @@
#!/usr/bin/env python3
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/bot/radiobot.py
import asyncio
import fnmatch
import json
import os
import re
import time
import urllib.parse
import urllib.request
try:
from dotenv import load_dotenv
except ImportError:
raise ImportError('missing dotenv library (pip install python-dotenv)')
try:
from y2mp3 import YouTubeMP3
except ImportError:
raise ImportError('missing y2mp3 locally')
load_dotenv()
BOT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(BOT_DIR)
IRC_SERVER = 'irc.supernets.org'
IRC_PORT = 6697
IRC_SSL = True
NICK = 'ACID_RADIO'
NICKSERV_PASSWORD = os.getenv('NICKSERV_PASSWORD')
USER = 'radio'
REALNAME = 'https://radio.acid.vegas'
CHANNEL = '#superbowl'
RADIO_URL = 'https://radio.acid.vegas'
ANNOUNCE_INTERVAL = 14_400 # 4 hours
COOLDOWN = 3
BOT_CLIENT_ID = 'irc-bot'
ADMIN_MASK = 'acidvegas!~stillfree@most.dangerous.motherfuck'
MUSIC_DIR = os.path.join(PROJECT_ROOT, 'music')
DOWNLOAD_DIR = os.path.join(MUSIC_DIR, 'Downloads')
IGNORE_FILE = os.path.join(BOT_DIR, 'ignores.json')
def load_ignores():
try:
with open(IGNORE_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_ignores(ignores):
with open(IGNORE_FILE, 'w') as f:
json.dump(ignores, f, indent=2)
def is_ignored(source, ignores):
for pattern in ignores:
if fnmatch.fnmatch(source.lower(), pattern.lower()):
return True
return False
def api_get(path):
with urllib.request.urlopen(f'{RADIO_URL}{path}', timeout=5) as r:
return json.loads(r.read())
def api_post(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(
f'{RADIO_URL}{path}',
data=data,
headers={'Content-Type': 'application/json'},
)
with urllib.request.urlopen(req, timeout=5) as r:
return json.loads(r.read())
def get_now_playing():
try:
data = api_get('/api/radio/now')
if not data:
return None
return {
'artist': data['artist'],
'track': data['track'],
'genre': data.get('genre') or 'unknown',
'song_key': data['folder'] + '/' + data['file'],
}
except Exception as e:
print(f'[bot] get_now_playing failed: {e}', flush=True)
return None
def get_votes(song_key):
try:
data = api_get(f'/api/radio/votes?song={urllib.parse.quote(song_key)}&client={BOT_CLIENT_ID}')
return data
except Exception as e:
print(f'[bot] get_votes failed: {e}', flush=True)
return {'up': 0, 'down': 0, 'my_vote': None}
def get_listener_count():
try:
return api_get('/api/radio/listeners').get('count', 0)
except Exception:
return 0
def cast_vote(song_key, vote):
try:
return api_post('/api/radio/vote', {
'song': song_key,
'client': BOT_CLIENT_ID,
'vote': vote,
})
except Exception as e:
print(f'[bot] cast_vote failed: {e}', flush=True)
return None
def irc_color(text, fg):
return f'\x03{fg:02d}{text}\x03'
def format_np(np, votes, listeners):
return (
f'🎵 '
f'\x02{irc_color(np["artist"], 3)}\x02 - '
f'{irc_color(np["track"], 7)} '
f'[{irc_color(np["genre"], 6)}] '
f'👍 {irc_color(str(votes["up"]), 3)} '
f'👎 {irc_color(str(votes["down"]), 4)} '
f'({irc_color(str(listeners) + " listening", 14)}) '
f'🎵 '
f'\x1f{RADIO_URL}\x1f'
)
def format_announce(np, votes, listeners):
return (
f'🎵 '
f'\x02{irc_color(np["artist"], 3)}\x02 - '
f'{irc_color(np["track"], 7)} '
f'[{irc_color(np["genre"], 6)}] '
f'👍 {irc_color(str(votes["up"]), 3)} '
f'👎 {irc_color(str(votes["down"]), 4)} '
f'({irc_color(str(listeners) + " listening", 14)}) '
f'🎵 '
f'\x1f{RADIO_URL}\x1f'
)
def parse_source(raw):
'''Parse :nick!user@host into the full mask string.'''
if raw.startswith(':'):
raw = raw[1:]
return raw.split(' ', 1)[0]
def parse_quoted_args(text):
'''Parse space-separated args where quoted strings are kept together.'''
return re.findall(r'"([^"]+)"|(\S+)', text)
async def main():
if IRC_SSL:
import ssl
ctx = ssl.create_default_context()
reader, writer = await asyncio.open_connection(IRC_SERVER, IRC_PORT, ssl=ctx)
else:
reader, writer = await asyncio.open_connection(IRC_SERVER, IRC_PORT)
downloader = YouTubeMP3(output_dir=DOWNLOAD_DIR, quality='320', verbose=True)
votes_enabled = True
ignores = load_ignores()
def send(line):
writer.write((line + '\r\n').encode())
def privmsg(text):
send(f'PRIVMSG {CHANNEL} :{text}')
send(f'NICK {NICK}')
send(f'USER {USER} 0 * :{REALNAME}')
await writer.drain()
joined = False
last_announce = 0
last_cmd = 0
while True:
line = await reader.readline()
if not line:
break
line = line.decode('utf-8', errors='replace').strip()
if line.startswith('PING'):
send('PONG' + line[4:])
await writer.drain()
continue
parts = line.split()
if not joined and len(parts) > 1 and parts[1] == '001':
if NICKSERV_PASSWORD:
send(f'PRIVMSG NickServ :IDENTIFY {NICKSERV_PASSWORD}')
await writer.drain()
await asyncio.sleep(6)
send(f'JOIN {CHANNEL}')
await writer.drain()
joined = True
last_announce = time.time()
continue
if joined and len(parts) > 3 and parts[1] == 'PRIVMSG' and parts[2] == CHANNEL:
source = parse_source(parts[0])
msg = line.split(' :', 1)[-1] if ' :' in line else ''
cmd = msg.strip()
now = time.time()
is_admin = source == ADMIN_MASK
if not is_admin and is_ignored(source, ignores):
continue
if cmd.startswith('@radio ') and is_admin:
subcmd = cmd[7:].strip()
if subcmd == 'togglevotes':
votes_enabled = not votes_enabled
state = irc_color('ENABLED', 3) if votes_enabled else irc_color('DISABLED', 4)
privmsg(f'🎵 Voting is now {state}')
await writer.drain()
continue
if subcmd == 'ignore':
if not ignores:
privmsg('ignore list is empty')
else:
privmsg('ignores: ' + ', '.join(ignores))
await writer.drain()
continue
if subcmd.startswith('ignore '):
mask = subcmd[7:].strip()
if mask.startswith('+'):
mask = mask[1:].strip()
if mask and mask not in ignores:
ignores.append(mask)
save_ignores(ignores)
privmsg(f'+ {mask}')
elif mask in ignores:
privmsg(f'{mask} already ignored')
elif mask.startswith('-'):
mask = mask[1:].strip()
if mask in ignores:
ignores.remove(mask)
save_ignores(ignores)
privmsg(f'- {mask}')
else:
privmsg(f'{mask} not in ignore list')
else:
privmsg('usage: @radio ignore [+/-]nick!user@host')
await writer.drain()
continue
if subcmd.startswith('download '):
args_str = subcmd[9:].strip()
tokens = parse_quoted_args(args_str)
flat = [quoted or unquoted for quoted, unquoted in tokens]
if len(flat) < 4:
privmsg('usage: @radio download <url> "<band>" "<song>" "<genre>"')
await writer.drain()
continue
yt_url, band, song, genre = flat[0], flat[1], flat[2], flat[3]
vid = YouTubeMP3.extract_video_id(yt_url)
if not vid:
privmsg(f'❌ cannot parse video ID from: {yt_url}')
await writer.drain()
continue
privmsg(f'⏳ downloading \x02{band}\x02 - {song} [{genre}]...')
await writer.drain()
try:
result = await downloader.download(yt_url, band, song, genre)
except Exception as e:
result = {'ok': False, 'msg': str(e)}
if result['ok']:
size_str = YouTubeMP3._human_size(result['size'])
dur_str = YouTubeMP3._human_duration(result['duration'])
privmsg(f'\x02{band}\x02 - {song} [{genre}] ({size_str}, {dur_str})')
else:
privmsg(f'{result["msg"]}')
await writer.drain()
continue
if cmd == '@radio':
if now - last_cmd < COOLDOWN:
continue
last_cmd = now
privmsg(f'🎵 {RADIO_URL}')
await writer.drain()
continue
if cmd not in ('!np', '!like', '!dislike'):
continue
if now - last_cmd < COOLDOWN:
continue
last_cmd = now
if cmd == '!np':
np = get_now_playing()
if np:
votes = get_votes(np['song_key'])
listeners = get_listener_count()
privmsg(format_np(np, votes, listeners))
else:
privmsg('nothing playing right now')
await writer.drain()
elif cmd == '!like':
if not votes_enabled:
privmsg('voting is currently disabled')
await writer.drain()
continue
np = get_now_playing()
if not np:
privmsg('nothing playing right now')
else:
result = cast_vote(np['song_key'], 'up')
if result:
privmsg(f'👍 {irc_color(str(result["up"]), 3)} 👎 {irc_color(str(result["down"]), 4)}')
else:
privmsg('❌ failed to cast vote')
await writer.drain()
elif cmd == '!dislike':
if not votes_enabled:
privmsg('voting is currently disabled')
await writer.drain()
continue
np = get_now_playing()
if not np:
privmsg('nothing playing right now')
else:
result = cast_vote(np['song_key'], 'down')
if result:
privmsg(f'👍 {irc_color(str(result["up"]), 3)} 👎 {irc_color(str(result["down"]), 4)}')
else:
privmsg('❌ failed to cast vote')
await writer.drain()
if joined and time.time() - last_announce >= ANNOUNCE_INTERVAL:
np = get_now_playing()
if np:
v = get_votes(np['song_key'])
listeners = get_listener_count()
privmsg(format_announce(np, v, listeners))
await writer.drain()
last_announce = time.time()
if __name__ == '__main__':
asyncio.run(main())

248
bot/y2mp3.py Normal file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/bot/y2mp3.py
import argparse
import asyncio
import hashlib
import json
import os
import re
import sys
import time
import urllib.request
try:
from mutagen.id3 import ID3, ID3NoHeaderError, TPE1, TIT2, TCON
except ImportError:
raise ImportError('missing mutagen library (pip install mutagen)')
API_BASE = 'https://embed.dlsrv.online'
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
QUALITY_CHOICES = ['320', '256', '128', '96', '64']
class YouTubeMP3:
def __init__(self, output_dir='music/Downloads', quality='320', verbose=False):
self.output_dir = output_dir
self.quality = quality
self.verbose = verbose
self._sign_key = None
def _log(self, msg, debug=False):
if debug and not self.verbose:
return
print(msg, flush=True)
@staticmethod
def extract_video_id(url):
for pat in [
r'(?:v=|/v/|youtu\.be/)([a-zA-Z0-9_-]{11})',
r'^([a-zA-Z0-9_-]{11})$',
]:
m = re.search(pat, url)
if m:
return m.group(1)
return None
def _fetch_sign_key(self):
if self._sign_key:
return self._sign_key
self._log('[*] Extracting signing key...', debug=True)
headers = {'User-Agent': UA, 'Accept': '*/*'}
req = urllib.request.Request(API_BASE + '/v1/full?videoId=dQw4w9WgXcQ', headers=headers)
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8', errors='replace')
for src in re.findall(r'<script[^>]+src=["\']([^"\']+)["\']', html):
if src.startswith('/cdn-cgi/') or 'turbopack' in src:
continue
if src.startswith('/'):
src = API_BASE + src
self._log(f' checking {src}', debug=True)
try:
req = urllib.request.Request(src, headers=headers)
js = urllib.request.urlopen(req, timeout=15).read().decode('utf-8', errors='replace')
except Exception:
continue
m = re.search(r'TextEncoder\(\)\.encode\(\w+\s*\+\s*["\']([^"\']{20,}?)["\']\)', js)
if m:
self._sign_key = m.group(1)
self._log(f'[*] Signing key: {self._sign_key[:12]}...', debug=True)
return self._sign_key
raise RuntimeError('could not extract signing key')
def _sign(self):
key = self._fetch_sign_key()
ts = str(int(time.time() * 1000))
sig = hashlib.sha256((ts + key).encode()).hexdigest()
return ts, sig
def _api_post(self, endpoint, payload):
url = API_BASE + endpoint
ts, sig = self._sign()
headers = {
'User-Agent': UA,
'Content-Type': 'application/json',
'Accept': '*/*',
'Origin': API_BASE,
'Referer': API_BASE + '/',
'x-app-timestamp': ts,
'x-app-signature': sig,
}
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=headers)
self._log(f' [POST] {url}', debug=True)
resp = urllib.request.urlopen(req, timeout=30)
raw = resp.read().decode('utf-8', errors='replace')
self._log(f' [{resp.status}] {raw[:300]}', debug=True)
return json.loads(raw)
def _download_file(self, url, output):
headers = {'User-Agent': UA, 'Accept': '*/*', 'Referer': API_BASE + '/'}
req = urllib.request.Request(url, headers=headers)
self._log(f' [GET] {url[:120]}', debug=True)
with urllib.request.urlopen(req, timeout=120) as resp:
with open(output, 'wb') as f:
total = 0
while True:
chunk = resp.read(65536)
if not chunk:
break
f.write(chunk)
total += len(chunk)
return os.path.getsize(output)
@staticmethod
def _set_id3(filepath, artist, title, genre):
try:
tags = ID3(filepath)
except ID3NoHeaderError:
tags = ID3()
tags.add(TPE1(encoding=3, text=[artist]))
tags.add(TIT2(encoding=3, text=[title]))
if genre:
tags.add(TCON(encoding=3, text=[genre]))
tags.save(filepath)
@staticmethod
def _human_size(nbytes):
for unit in ('B', 'KB', 'MB', 'GB'):
if nbytes < 1024:
return f'{nbytes:.1f} {unit}' if unit != 'B' else f'{nbytes} B'
nbytes /= 1024
return f'{nbytes:.1f} TB'
@staticmethod
def _human_duration(seconds):
try:
s = int(seconds)
except (TypeError, ValueError):
return '?:??'
m, s = divmod(s, 60)
return f'{m}:{s:02d}'
def _download_sync(self, url, artist, title, genre=None):
'''Synchronous download — returns dict with keys: ok, msg, path, size, duration.'''
fail = lambda m: {'ok': False, 'msg': m, 'path': None, 'size': 0, 'duration': 0}
video_id = self.extract_video_id(url)
if not video_id:
return fail(f'cannot parse video ID from: {url}')
self._log(f'[*] Video ID: {video_id}')
try:
info_resp = self._api_post('/api/info', {'videoId': video_id})
except Exception as e:
return fail(f'/api/info failed: {e}')
if info_resp.get('error'):
return fail(f'API error: {info_resp}')
info = info_resp.get('info', info_resp)
yt_title = info.get('title', video_id)
duration = info.get('duration', 0)
self._log(f'[*] YouTube title: {yt_title}')
self._log(f'[*] Duration: {duration}s')
try:
dl_resp = self._api_post('/api/download/mp3', {
'videoId': video_id,
'format': 'mp3',
'quality': self.quality,
})
except Exception as e:
return fail(f'/api/download/mp3 failed: {e}')
if dl_resp.get('error'):
return fail(f'download API error: {dl_resp}')
dl_url = dl_resp.get('url')
if not dl_url:
return fail('no download URL in response')
safe_artist = re.sub(r'[^\w\s\-\(\)\[\]&]', '', artist).strip()
safe_title = re.sub(r'[^\w\s\-\(\)\[\]&]', '', title).strip()
filename = f'{safe_artist} - {safe_title}.mp3'
os.makedirs(self.output_dir, exist_ok=True)
filepath = os.path.join(self.output_dir, filename)
self._log(f'[*] Downloading to: {filepath}')
try:
size = self._download_file(dl_url, filepath)
except Exception as e:
return fail(f'download failed: {e}')
if size < 10000:
with open(filepath, 'rb') as f:
head = f.read(500)
if b'<html' in head.lower() or b'<!doctype' in head.lower():
os.remove(filepath)
return fail('got HTML instead of audio (URL expired?)')
self._log(f'[*] Setting ID3 tags: artist={artist}, title={title}, genre={genre}')
try:
self._set_id3(filepath, artist, title, genre)
except Exception as e:
self._log(f'[!] ID3 tagging failed (file kept): {e}')
self._log(f'[+] Done! {filename} ({self._human_size(size)}, {self._human_duration(duration)})')
return {
'ok' : True,
'msg' : filename,
'path' : filepath,
'size' : size,
'duration' : duration,
}
async def download(self, url, artist, title, genre=None):
'''Async wrapper — runs the blocking download in an executor.'''
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._download_sync, url, artist, title, genre)
def main():
parser = argparse.ArgumentParser(description='Download YouTube audio as MP3 via dlsrv')
parser.add_argument('url', help='YouTube video URL or video ID')
parser.add_argument('-a', '--artist', required=True, help='Artist / band name')
parser.add_argument('-t', '--title', required=True, help='Song title')
parser.add_argument('-g', '--genre', default=None, help='Genre tag')
parser.add_argument('-o', '--output-dir', default='music/Downloads', help='Output directory')
parser.add_argument('-q', '--quality', default='320', choices=QUALITY_CHOICES)
parser.add_argument('-v', '--verbose', action='store_true')
args = parser.parse_args()
dl = YouTubeMP3(output_dir=args.output_dir, quality=args.quality, verbose=args.verbose)
result = dl._download_sync(args.url, args.artist, args.title, args.genre)
if not result['ok']:
print(f'[!] {result["msg"]}')
sys.exit(1)
print(f'[+] {result["msg"]} ({dl._human_size(result["size"])}, {dl._human_duration(result["duration"])})')
if __name__ == '__main__':
main()

39
radio.acid.vegas.conf Normal file
View File

@@ -0,0 +1,39 @@
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/radio.acid.vegas.conf
server {
if ($block_ua) { return 444; }
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name radio.acid.vegas;
location / {
proxy_pass http://127.0.0.1:7000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
}
ssl_certificate /etc/letsencrypt/live/radio.acid.vegas/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/radio.acid.vegas/privkey.pem;
}
server {
if ($block_ua) { return 444; }
listen 80;
listen [::]:80;
if ($host = radio.acid.vegas) {
return 301 https://$host$request_uri;
}
server_name radio.acid.vegas;
return 404;
}

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/requirements.txt
mutagen

618
server.py Normal file
View File

@@ -0,0 +1,618 @@
#!/usr/bin/env python3
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/server.py
import argparse
import gzip
import http.server
import json
import os
import random
import signal
import sqlite3
import subprocess
import threading
import time
import urllib.parse
try:
import mutagen
except ImportError:
raise ImportError('missing mutagen library (pip install mutagen)')
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
MUSIC_DIR = os.path.join(ROOT_DIR, 'music')
STATIC_DIR = os.path.join(ROOT_DIR, 'static')
DATA_DIR = os.path.join(ROOT_DIR, 'data')
DB_PATH = os.path.join(DATA_DIR, 'votes.db')
HOST = '0.0.0.0'
PORT = 7000
DEBUG = False
MAX_SKIPS_PER_HOUR = 3
skip_votes_lock = threading.Lock()
skip_votes = {}
skip_history = []
listeners_lock = threading.Lock()
listeners = {}
LISTENER_TIMEOUT = 15
def get_listener_count():
now = time.time()
with listeners_lock:
stale = [k for k, t in listeners.items() if now - t > LISTENER_TIMEOUT]
for k in stale:
del listeners[k]
return len(listeners)
def touch_listener(addr):
with listeners_lock:
listeners[addr] = time.time()
def get_skips_remaining():
now = time.time()
skip_history[:] = [t for t in skip_history if t > now - 3600]
return max(0, MAX_SKIPS_PER_HOUR - len(skip_history))
AUDIO_EXTS = {'.mp3', '.flac', '.ogg', '.wav', '.m4a', '.opus', '.aac'}
MIME_TYPES = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
'.opus': 'audio/opus',
'.aac': 'audio/aac',
}
STATIC_ROUTES = {
'/': 'index.html',
'/style.css': 'style.css',
'/app.js': 'app.js',
'/radio': 'index.html',
'/radio.css': 'radio.css',
'/radio.js': 'radio.js',
'/sw.js': 'sw.js',
'/manifest.json': 'manifest.json',
'/icon-192.png': 'icon-192.png',
'/icon-512.png': 'icon-512.png',
}
MIME_TYPES['.gif'] = 'image/gif'
MIME_TYPES['.png'] = 'image/png'
MIME_TYPES['.jpg'] = 'image/jpeg'
MIME_TYPES['.mp4'] = 'video/mp4'
MIME_TYPES['.json'] = 'application/json'
def init_db():
os.makedirs(DATA_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.execute('''CREATE TABLE IF NOT EXISTS votes (
song TEXT NOT NULL,
client TEXT NOT NULL,
vote TEXT NOT NULL,
ts REAL NOT NULL,
PRIMARY KEY (song, client)
)''')
conn.commit()
conn.close()
class VoteStore:
def __init__(self, db_path):
self.db_path = db_path
self.lock = threading.Lock()
def _conn(self):
return sqlite3.connect(self.db_path)
def cast(self, song, client, vote):
with self.lock:
conn = self._conn()
if vote is None:
conn.execute('DELETE FROM votes WHERE song=? AND client=?', (song, client))
else:
conn.execute(
'INSERT INTO votes (song, client, vote, ts) VALUES (?, ?, ?, ?) '
'ON CONFLICT(song, client) DO UPDATE SET vote=?, ts=?',
(song, client, vote, time.time(), vote, time.time())
)
conn.commit()
counts = self._counts(conn, song)
conn.close()
return counts
def get(self, song, client):
with self.lock:
conn = self._conn()
counts = self._counts(conn, song)
row = conn.execute(
'SELECT vote FROM votes WHERE song=? AND client=?', (song, client)
).fetchone()
conn.close()
counts['my_vote'] = row[0] if row else None
return counts
def _counts(self, conn, song):
up = conn.execute(
"SELECT COUNT(*) FROM votes WHERE song=? AND vote='up'", (song,)
).fetchone()[0]
down = conn.execute(
"SELECT COUNT(*) FROM votes WHERE song=? AND vote='down'", (song,)
).fetchone()[0]
return {'up': up, 'down': down}
class Radio:
def __init__(self, music_dir, audio_exts):
self.music_dir = music_dir
self.audio_exts = audio_exts
self.lock = threading.Lock()
self.current = None
self.next_track = None
self.songs = []
self.recent_artists = []
self._scan()
if self.songs:
self._advance()
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _scan(self):
songs = []
for folder in os.listdir(self.music_dir):
ap = os.path.join(self.music_dir, folder)
if not os.path.isdir(ap):
continue
for f in os.listdir(ap):
if os.path.splitext(f)[1].lower() in self.audio_exts:
path = os.path.join(ap, f)
tags = self._read_tags(path)
id3_artist = tags['artist'] or folder
songs.append((folder, f, id3_artist))
with self.lock:
self.songs = songs
if songs:
print(f'[radio] scanned {len(songs)} tracks')
def _read_tags(self, path):
result = {'artist': None, 'title': None, 'genre': None}
try:
tags = mutagen.File(path, easy=True)
if tags:
if 'artist' in tags:
result['artist'] = tags['artist'][0]
if 'title' in tags:
result['title'] = tags['title'][0]
if 'genre' in tags:
result['genre'] = tags['genre'][0]
except Exception:
pass
return result
def _probe_duration(self, path):
try:
r = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries',
'format=duration', '-of', 'csv=p=0', path],
capture_output=True, text=True, timeout=10
)
return float(r.stdout.strip())
except Exception:
return 240.0
def _select_next(self):
with self.lock:
candidates = list(self.songs)
recent = list(self.recent_artists)
cur = (self.current['folder'], self.current['file']) if self.current else None
fresh = [(f, t, a) for f, t, a in candidates if a not in recent]
pool = fresh if fresh else candidates
random.shuffle(pool)
for folder, filename, _artist in pool:
if cur and (folder, filename) == cur:
continue
path = os.path.join(self.music_dir, folder, filename)
if os.path.isfile(path):
tags = self._read_tags(path)
with self.lock:
self.next_track = {
'folder': folder,
'file': filename,
'artist': tags['artist'] or folder,
'track': tags['title'] or filename.rsplit('.', 1)[0],
}
return
with self.lock:
self.next_track = None
def _advance(self):
with self.lock:
nxt = self.next_track
self.next_track = None
if nxt:
path = os.path.join(self.music_dir, nxt['folder'], nxt['file'])
if os.path.isfile(path):
self._play(nxt['folder'], nxt['file'])
self._select_next()
return
with self.lock:
candidates = list(self.songs)
recent = list(self.recent_artists)
fresh = [(f, t, a) for f, t, a in candidates if a not in recent]
pool = fresh if fresh else candidates
random.shuffle(pool)
for folder, filename, _artist in pool:
path = os.path.join(self.music_dir, folder, filename)
if os.path.isfile(path):
self._play(folder, filename)
self._select_next()
return
def _loop(self):
last_scan = time.time()
while True:
time.sleep(0.5)
if time.time() - last_scan >= 300:
self._scan()
last_scan = time.time()
with self.lock:
if not self.current:
continue
elapsed = time.time() - self.current['started_at']
if elapsed < self.current['duration']:
continue
self._advance()
def skip(self):
self._advance()
def skip_to_artist(self, artist_name):
matches = [(f, t) for f, t, _a in self.songs if f == artist_name]
if not matches:
return
folder, filename = random.choice(matches)
self._play(folder, filename)
self._select_next()
def skip_to_genre(self, genre_query):
matches = []
for folder, filename, _artist in self.songs:
path = os.path.join(self.music_dir, folder, filename)
tags = self._read_tags(path)
if tags['genre'] and genre_query.lower() in tags['genre'].lower():
matches.append((folder, filename))
if not matches:
return
folder, filename = random.choice(matches)
self._play(folder, filename)
self._select_next()
def _play(self, folder, filename):
path = os.path.join(self.music_dir, folder, filename)
dur = self._probe_duration(path)
tags = self._read_tags(path)
artist = tags['artist'] or folder
title = tags['title'] or filename.rsplit('.', 1)[0]
genre = tags['genre']
with self.lock:
self.current = {
'artist': artist,
'track': title,
'folder': folder,
'file': filename,
'started_at': time.time(),
'duration': dur,
'genre': genre,
}
self.recent_artists.append(artist)
if len(self.recent_artists) > 20:
self.recent_artists.pop(0)
print(f'[radio] now playing: {artist} - {title} ({dur:.0f}s) [{genre or "unknown"}]')
def now(self):
with self.lock:
if not self.current:
return None
data = {**self.current, 'server_time': time.time()}
if self.next_track:
data['next'] = {
'folder': self.next_track['folder'],
'file': self.next_track['file'],
}
return data
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path in STATIC_ROUTES:
self.serve_file(os.path.join(STATIC_DIR, STATIC_ROUTES[path]))
elif path.startswith('/images/') or path.startswith('/video/'):
file_path = os.path.realpath(os.path.join(STATIC_DIR, path.lstrip('/')))
if not file_path.startswith(os.path.realpath(STATIC_DIR)):
self.respond(403, 'text/plain', b'forbidden')
return
if not os.path.isfile(file_path):
self.respond(404, 'text/plain', b'not found')
return
if file_path.endswith('.mp4'):
self.stream_file(file_path)
else:
self.serve_file(file_path)
elif path == '/api/debug':
self.respond(200, 'application/json', json.dumps({'debug': DEBUG}).encode())
elif path == '/api/artists':
artists = []
for d in os.listdir(MUSIC_DIR):
dp = os.path.join(MUSIC_DIR, d)
if not os.path.isdir(dp):
continue
count = sum(
1 for f in os.listdir(dp)
if os.path.isfile(os.path.join(dp, f))
and os.path.splitext(f)[1].lower() in AUDIO_EXTS
)
artists.append({'name': d, 'count': count})
artists.sort(key=lambda a: a['name'].lower())
self.respond(200, 'application/json', json.dumps(artists).encode())
elif path == '/api/tracks':
params = urllib.parse.parse_qs(parsed.query)
artist = params.get('artist', [''])[0]
artist_path = os.path.join(MUSIC_DIR, artist)
if not os.path.isdir(artist_path):
self.respond(404, 'text/plain', b'not found')
return
tracks = sorted([
f for f in os.listdir(artist_path)
if os.path.isfile(os.path.join(artist_path, f))
and os.path.splitext(f)[1].lower() in AUDIO_EXTS
], key=str.lower)
self.respond(200, 'application/json', json.dumps(tracks).encode())
elif path == '/api/radio/now':
params = urllib.parse.parse_qs(parsed.query)
sid = params.get('sid', [''])[0]
if sid:
touch_listener(sid)
state = radio.now()
self.respond(200, 'application/json', json.dumps(state).encode())
elif path == '/api/radio/listeners':
self.respond(200, 'application/json', json.dumps({'count': get_listener_count()}).encode())
elif path == '/api/radio/skip':
radio.skip()
state = radio.now()
self.respond(200, 'application/json', json.dumps(state).encode())
elif path == '/api/radio/skip-to':
params = urllib.parse.parse_qs(parsed.query)
artist = params.get('artist', [''])[0]
radio.skip_to_artist(artist)
state = radio.now()
self.respond(200, 'application/json', json.dumps(state).encode())
elif path == '/api/radio/skip-to-genre':
params = urllib.parse.parse_qs(parsed.query)
genre = params.get('genre', [''])[0]
radio.skip_to_genre(genre)
state = radio.now()
self.respond(200, 'application/json', json.dumps(state).encode())
elif path == '/api/radio/votes':
params = urllib.parse.parse_qs(parsed.query)
song = params.get('song', [''])[0]
client = params.get('client', [''])[0]
data = votes.get(song, client)
self.respond(200, 'application/json', json.dumps(data).encode())
elif path == '/api/radio/skip-info':
params = urllib.parse.parse_qs(parsed.query)
ts = float(params.get('ts', ['0'])[0])
client = params.get('client', [''])[0]
with skip_votes_lock:
voters = skip_votes.get(ts, set())
data = {
'votes': len(voters),
'voted': client in voters,
'remaining': get_skips_remaining(),
}
self.respond(200, 'application/json', json.dumps(data).encode())
elif path.startswith('/music/'):
file_path = urllib.parse.unquote(path[7:])
full_path = os.path.realpath(os.path.join(MUSIC_DIR, file_path))
if not full_path.startswith(os.path.realpath(MUSIC_DIR)):
self.respond(403, 'text/plain', b'forbidden')
return
if not os.path.isfile(full_path):
self.respond(404, 'text/plain', b'not found')
return
self.stream_file(full_path)
else:
self.respond(404, 'text/plain', b'not found')
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == '/api/radio/vote':
length = int(self.headers.get('Content-Length', 0))
body = json.loads(self.rfile.read(length))
song = body.get('song', '')
client = body.get('client', '')
vote = body.get('vote')
if vote not in ('up', 'down', None):
self.respond(400, 'text/plain', b'bad vote')
return
data = votes.cast(song, client, vote)
self.respond(200, 'application/json', json.dumps(data).encode())
elif path == '/api/radio/vote-skip':
length = int(self.headers.get('Content-Length', 0))
body = json.loads(self.rfile.read(length))
ts = body.get('ts', 0)
client = body.get('client', '')
skipped = False
with skip_votes_lock:
remaining = get_skips_remaining()
if ts not in skip_votes:
skip_votes[ts] = set()
already_voted = client in skip_votes[ts]
if already_voted or remaining <= 0:
data = {
'votes': len(skip_votes[ts]),
'voted': True,
'remaining': remaining,
'skipped': False,
}
self.respond(200, 'application/json', json.dumps(data).encode())
return
count_before = len(skip_votes[ts])
skip_votes[ts].add(client)
chance = 1.0 / max(1, 10 - count_before)
skipped = random.random() < chance
if skipped:
skip_history.append(time.time())
remaining = get_skips_remaining()
vote_count = len(skip_votes[ts])
if skipped:
radio.skip()
data = {
'votes': vote_count,
'voted': True,
'remaining': remaining,
'skipped': skipped,
}
self.respond(200, 'application/json', json.dumps(data).encode())
else:
self.respond(404, 'text/plain', b'not found')
def respond(self, code, content_type, body):
self.send_response(code)
accept_enc = self.headers.get('Accept-Encoding', '')
if 'gzip' in accept_enc and content_type in (
'text/html', 'text/css', 'text/plain',
'application/javascript', 'application/json',
):
body = gzip.compress(body)
self.send_header('Content-Encoding', 'gzip')
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', len(body))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
def serve_file(self, filepath):
if not os.path.isfile(filepath):
self.respond(404, 'text/plain', b'not found')
return
stat = os.stat(filepath)
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
if self.headers.get('If-None-Match') == etag:
self.send_response(304)
self.send_header('ETag', etag)
self.end_headers()
return
ext = os.path.splitext(filepath)[1].lower()
mime = MIME_TYPES.get(ext, 'application/octet-stream')
with open(filepath, 'rb') as f:
body = f.read()
self.send_response(200)
accept_enc = self.headers.get('Accept-Encoding', '')
if 'gzip' in accept_enc and mime in (
'text/html', 'text/css', 'text/plain',
'application/javascript', 'application/json',
):
body = gzip.compress(body)
self.send_header('Content-Encoding', 'gzip')
self.send_header('Content-Type', mime)
self.send_header('Content-Length', len(body))
cache = 'no-cache' if filepath.endswith('sw.js') else 'public, max-age=3600'
self.send_header('Cache-Control', cache)
self.send_header('ETag', etag)
self.end_headers()
self.wfile.write(body)
def stream_file(self, filepath):
ext = os.path.splitext(filepath)[1].lower()
mime = MIME_TYPES.get(ext, 'application/octet-stream')
size = os.path.getsize(filepath)
range_header = self.headers.get('Range')
if range_header:
ranges = range_header.replace('bytes=', '').split('-')
start = int(ranges[0])
end = int(ranges[1]) if ranges[1] else size - 1
length = end - start + 1
self.send_response(206)
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
self.send_header('Content-Length', length)
else:
start = 0
length = size
self.send_response(200)
self.send_header('Content-Length', size)
self.send_header('Content-Type', mime)
self.send_header('Accept-Ranges', 'bytes')
self.end_headers()
try:
with open(filepath, 'rb') as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
except (ConnectionResetError, BrokenPipeError):
pass
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('-d', '--debug', action='store_true', help='Enable debug controls (skip, genre jump)')
args = parser.parse_args()
DEBUG = args.debug
init_db()
votes = VoteStore(DB_PATH)
radio = Radio(MUSIC_DIR, AUDIO_EXTS)
server = http.server.ThreadingHTTPServer((HOST, PORT), Handler)
signal.signal(signal.SIGTERM, signal.default_int_handler)
print(f'listening on http://{HOST}:{PORT}' + (' [debug]' if DEBUG else ''))
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
print('[server] stopped')

19
setup.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
# ACID RADIO - Developed by acidvegas in Python (https://git.supernets.org/acidvegas/acid-radio)
# acid-radio/setup.sh
# Set xtrace, exit on error, & verbose mode
set -xev
# Ensure persistent mount targets exist
mkdir -p music data
# Remove existing docker container and clean up old images/cache
docker rm -f acid-radio 2>/dev/null || true
docker system prune -af
# Build the Docker image
docker build --no-cache -t acid-radio .
# Run the Docker container
docker run -d --name acid-radio --restart unless-stopped -p 127.0.0.1:7000:7000 -v "$PWD/music:/app/music" -v "$PWD/data:/app/data" acid-radio

153
static/app.js Normal file
View File

@@ -0,0 +1,153 @@
const audio = document.getElementById('audio');
const listEl = document.getElementById('list');
const nowEl = document.getElementById('now');
const timeEl = document.getElementById('time');
const seekEl = document.getElementById('seek');
const volEl = document.getElementById('vol');
const playBtn = document.getElementById('play');
let currentArtist = null;
let trackList = [];
let trackIdx = -1;
let seeking = false;
audio.volume = 0.8;
function fmt(s) {
s = Math.floor(s);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function toggleArtist(header, tracksDiv, name) {
const wasOpen = header.classList.contains('open');
if (wasOpen) {
header.classList.remove('open');
tracksDiv.classList.remove('open');
return;
}
header.classList.add('open');
if (tracksDiv.children.length === 0) {
fetch('/api/tracks?artist=' + encodeURIComponent(name))
.then(r => r.json())
.then(list => {
list.forEach((t, i) => {
const d = document.createElement('div');
d.className = 'track';
d.textContent = t.replace(/\.[^.]+$/, '');
d.onclick = e => {
e.stopPropagation();
selectArtistTracks(name, list);
playTrack(i);
};
tracksDiv.appendChild(d);
});
tracksDiv.classList.add('open');
});
} else {
tracksDiv.classList.add('open');
}
}
function selectArtistTracks(name, list) {
currentArtist = name;
trackList = list;
}
function playTrack(i) {
trackIdx = i;
const track = trackList[i];
audio.src = '/music/' + encodeURIComponent(currentArtist) + '/' + encodeURIComponent(track);
audio.play();
nowEl.innerHTML = '<span>' + currentArtist + '</span> &mdash; ' + track.replace(/\.[^.]+$/, '');
playBtn.innerHTML = '&#9646;&#9646;';
listEl.querySelectorAll('.track').forEach(d => d.classList.remove('active'));
const artistSections = listEl.querySelectorAll('.artist-section');
artistSections.forEach(section => {
const header = section.querySelector('.artist-header');
if (header.dataset.name === currentArtist) {
const tracks = section.querySelectorAll('.track');
tracks.forEach((d, j) => d.classList.toggle('active', j === i));
}
});
}
fetch('/api/artists')
.then(r => r.json())
.then(artists => {
artists.forEach(a => {
const section = document.createElement('div');
section.className = 'artist-section';
const header = document.createElement('div');
header.className = 'artist-header';
header.dataset.name = a.name;
const nameSpan = document.createElement('span');
nameSpan.className = 'artist-name';
nameSpan.textContent = a.name;
const countSpan = document.createElement('span');
countSpan.className = 'artist-count';
countSpan.textContent = a.count;
header.appendChild(nameSpan);
header.appendChild(countSpan);
const tracksDiv = document.createElement('div');
tracksDiv.className = 'track-list';
header.onclick = () => toggleArtist(header, tracksDiv, a.name);
section.appendChild(header);
section.appendChild(tracksDiv);
listEl.appendChild(section);
});
});
playBtn.onclick = () => {
if (audio.paused) {
audio.play();
playBtn.innerHTML = '&#9646;&#9646;';
} else {
audio.pause();
playBtn.innerHTML = '&#9654;';
}
};
document.getElementById('prev').onclick = () => {
if (trackIdx > 0) playTrack(trackIdx - 1);
};
document.getElementById('next').onclick = () => {
if (trackIdx < trackList.length - 1) playTrack(trackIdx + 1);
};
audio.onended = () => {
if (trackIdx < trackList.length - 1) playTrack(trackIdx + 1);
};
audio.ontimeupdate = () => {
if (!audio.duration) return;
timeEl.textContent = fmt(audio.currentTime) + ' / ' + fmt(audio.duration);
if (!seeking) seekEl.value = (audio.currentTime / audio.duration) * 100;
};
seekEl.oninput = () => { seeking = true; };
seekEl.onchange = () => { audio.currentTime = (seekEl.value / 100) * audio.duration; seeking = false; };
volEl.oninput = () => { audio.volume = volEl.value / 100; };
document.onkeydown = e => {
if (e.code === 'Space') { e.preventDefault(); playBtn.click(); }
if (e.code === 'ArrowRight' && e.shiftKey) document.getElementById('next').click();
if (e.code === 'ArrowLeft' && e.shiftKey) document.getElementById('prev').click();
};

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

4
static/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#080808"/>
<text x="256" y="310" text-anchor="middle" font-family="'Impact','Arial Black',sans-serif" font-weight="bold" font-size="240" fill="#0daa1e" letter-spacing="15">AR</text>
</svg>

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

84
static/index.html Normal file
View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RADIO</title>
<meta name="theme-color" content="#0daa1e">
<link rel="manifest" href="/manifest.json">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Rubik+Glitch&family=Bebas+Neue&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/radio.css">
</head>
<body>
<div id="reconnect" class="hidden">DISCONNECTED — RECONNECTING...</div>
<div id="buffering" class="hidden">BUFFERING...</div>
<div id="top-bar">
<div id="hxc-btn" class="debug-btn hidden" title="HXC">🤘</div>
<div id="thps-btn" class="debug-btn hidden" title="THPS">🛹</div>
<div id="skip-btn" class="debug-btn hidden" title="skip">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 2l8 6-8 6V2z" fill="currentColor"/>
<rect x="12" y="2" width="2.5" height="12" rx="0.5" fill="currentColor"/>
</svg>
</div>
<div id="car-btn" title="car mode">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 9l1.5-4h7L13 9" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<rect x="1.5" y="9" width="13" height="3.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
<circle cx="4.5" cy="12.5" r="1" fill="currentColor"/>
<circle cx="11.5" cy="12.5" r="1" fill="currentColor"/>
</svg>
</div>
<div id="vol-btn" title="volume">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 5.5h2.5L8 2v12L4.5 10.5H2a1 1 0 01-1-1v-3a1 1 0 011-1z" fill="currentColor"/>
<path d="M10.5 4.5c.8.8 1.3 2 1.3 3.5s-.5 2.7-1.3 3.5M12.5 2.5c1.3 1.3 2 3.2 2 5.5s-.7 4.2-2 5.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
</div>
<div id="vol-dropdown" class="hidden">
<input type="range" id="vol" min="0" max="200" value="80" orient="vertical">
</div>
<div id="splash">
<div id="freq">66.67 FM</div>
<div id="title" data-text="ACID RADIO">ACID<br>RADIO</div>
<div id="tagline">HARDCORE x HEAVY x PUNK</div>
<button id="tunein">TUNE IN</button>
<div id="listener-count">0 listening</div>
</div>
<div id="radio" class="hidden">
<div id="radio-header">ACID RADIO</div>
<div id="now-artist"></div>
<div id="now-track"></div>
<div id="now-genre"></div>
<div id="progress-row">
<span id="time-elapsed">0:00</span>
<div id="progress-wrap">
<div id="progress-bar"></div>
</div>
<span id="time-total">0:00</span>
</div>
<div id="votes">
<button id="vote-up" class="vote-btn" title="thumbs up">
<span class="vote-emoji">🤘</span>
<span id="count-up">0</span>
</button>
<button id="vote-down" class="vote-btn" title="thumbs down">
<span class="vote-emoji">👎</span>
<span id="count-down">0</span>
</button>
<button id="vote-skip" class="vote-btn" title="vote to skip">
<span class="vote-emoji"></span>
<span id="skip-vote-count">0</span>
</button>
</div>
<div id="listener-count-radio">0 listening</div>
</div>
<video id="bg-video" muted playsinline></video>
<video id="bg-video2" muted playsinline></video>
<audio id="audio"></audio>
<script src="/radio.js"></script>
</body>
</html>

12
static/manifest.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "ACID RADIO",
"short_name": "ACID RADIO",
"start_url": "/",
"display": "standalone",
"background_color": "#080808",
"theme_color": "#0daa1e",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

533
static/radio.css Normal file
View File

@@ -0,0 +1,533 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #080808;
color: #c8c8c8;
font-family: 'Rubik Glitch', 'Impact', 'Arial Black', sans-serif;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#bg-video, #bg-video2 {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: -1;
display: none;
}
#bg-video.active, #bg-video2.active {
display: block;
}
body.artist-bg {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
body.shake {
animation: screen-shake 0.15s linear;
}
@keyframes screen-shake {
0% { transform: translate(0, 0); }
20% { transform: translate(-3px, 2px); }
40% { transform: translate(3px, -2px); }
60% { transform: translate(-2px, -3px); }
80% { transform: translate(2px, 3px); }
100% { transform: translate(0, 0); }
}
::selection {
background: #10e020;
color: #fff;
}
.hidden {
display: none !important;
}
/* ── top bar ── */
#top-bar {
position: fixed;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 10;
}
#hxc-btn, #thps-btn, #skip-btn, #car-btn, #vol-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid #222;
background: #111;
color: #555;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
#hxc-btn:hover, #thps-btn:hover, #skip-btn:hover, #car-btn:hover, #vol-btn:hover {
border-color: #444;
color: #999;
}
#vol-dropdown {
position: fixed;
top: 62px;
right: 16px;
background: #111;
border: 1px solid #222;
border-radius: 6px;
padding: 14px 10px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
#vol {
-webkit-appearance: none;
appearance: none;
writing-mode: vertical-lr;
direction: rtl;
width: 20px;
height: 120px;
background: transparent;
outline: none;
cursor: pointer;
}
#vol::-webkit-slider-runnable-track {
width: 4px;
background: #1a1a1a;
border-radius: 2px;
}
#vol::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #555;
border-radius: 50%;
cursor: pointer;
}
#vol::-webkit-slider-thumb:hover {
background: #888;
}
#vol::-moz-range-track {
width: 4px;
background: #1a1a1a;
border-radius: 2px;
border: none;
}
#vol::-moz-range-thumb {
width: 14px;
height: 14px;
background: #555;
border-radius: 50%;
border: none;
cursor: pointer;
}
#vol::-moz-range-thumb:hover {
background: #888;
}
#reconnect {
position: fixed;
top: 0;
left: 0;
width: 100%;
padding: 10px;
background: #c22;
color: #fff;
text-align: center;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
letter-spacing: 3px;
z-index: 100;
}
/* ── splash ── */
#splash {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
#freq {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
color: #333;
letter-spacing: 4px;
}
#title {
font-size: 100px;
line-height: 1;
color: #fff;
letter-spacing: 4px;
text-shadow: 0 0 40px rgba(16, 224, 32, 0.3);
position: relative;
}
#title::before,
#title::after {
content: 'ACID\ARADIO';
white-space: pre;
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
}
#title::before {
color: #fff;
text-shadow: -2px 0 #10e020;
animation: glitch-top 1.2s infinite linear alternate-reverse;
clip-path: inset(0 0 65% 0);
}
#title::after {
color: #fff;
text-shadow: 2px 0 #0daa1e;
animation: glitch-bottom 1s infinite linear alternate-reverse;
clip-path: inset(60% 0 0 0);
}
@keyframes glitch-top {
0% { transform: translate(0); }
2% { transform: translate(3px, -1px); }
4% { transform: translate(-2px, 1px); }
6% { transform: translate(0); }
40% { transform: translate(0); }
42% { transform: translate(-4px, 0); }
44% { transform: translate(2px, 1px); }
46% { transform: translate(0); }
80% { transform: translate(0); }
82% { transform: translate(5px, 0); }
84% { transform: translate(-3px, -1px); }
86% { transform: translate(0); }
100% { transform: translate(0); }
}
@keyframes glitch-bottom {
0% { transform: translate(0); }
3% { transform: translate(-3px, 1px); }
6% { transform: translate(2px, 0); }
9% { transform: translate(0); }
50% { transform: translate(0); }
53% { transform: translate(4px, 1px); }
56% { transform: translate(-2px, 0); }
59% { transform: translate(0); }
100% { transform: translate(0); }
}
#tagline {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: #555;
letter-spacing: 6px;
margin-top: 8px;
}
#listener-count {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #333;
letter-spacing: 2px;
margin-top: 20px;
}
#listener-count-radio {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 10px;
color: #333;
letter-spacing: 2px;
margin-top: 20px;
}
#tunein {
margin-top: 40px;
font-family: 'Bebas Neue', 'Impact', sans-serif;
font-size: 30px;
letter-spacing: 8px;
color: #fff;
background: #0daa1e;
border: none;
padding: 16px 60px;
cursor: pointer;
transition: all 0.2s;
animation: btn-pulse 2s ease-in-out infinite;
}
#tunein:hover {
background: #10e020;
transform: scale(1.05);
}
@keyframes btn-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(16, 224, 32, 0.4); }
50% { box-shadow: 0 0 50px rgba(16, 224, 32, 0.8), 0 0 100px rgba(16, 224, 32, 0.3); }
}
/* ── radio player ── */
#radio {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 100%;
max-width: 600px;
padding: 20px;
}
#radio-header {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
font-size: 20px;
letter-spacing: 6px;
color: #333;
white-space: nowrap;
}
#now-artist {
font-size: clamp(28px, 10vw, 64px);
color: #fff;
line-height: 1.1;
text-transform: uppercase;
letter-spacing: 2px;
text-shadow: 0 0 30px rgba(16, 224, 32, 0.25);
overflow-wrap: break-word;
word-break: normal;
max-width: 100%;
position: relative;
display: inline-block;
}
#now-artist::before,
#now-artist::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
}
#now-artist::before {
color: #fff;
text-shadow: -2px 0 #10e020;
animation: glitch-top 1.5s infinite linear alternate-reverse;
clip-path: inset(0 0 55% 0);
}
#now-artist::after {
color: #fff;
text-shadow: 2px 0 #0daa1e;
animation: glitch-bottom 1.3s infinite linear alternate-reverse;
clip-path: inset(50% 0 0 0);
}
#now-track {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 14px;
color: #666;
margin-top: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#now-genre {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #0daa1e;
letter-spacing: 3px;
text-transform: uppercase;
margin-top: 6px;
}
/* ── progress ── */
#progress-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-top: 24px;
}
#time-elapsed, #time-total {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #444;
min-width: 36px;
}
#time-elapsed {
text-align: right;
}
#time-total {
text-align: left;
}
#progress-wrap {
flex: 1;
height: 3px;
background: #1a1a1a;
overflow: hidden;
}
#progress-bar {
height: 100%;
width: 0%;
background: #0daa1e;
transition: width 0.5s linear;
}
/* ── votes ── */
#votes {
display: flex;
gap: 20px;
margin-top: 24px;
}
.vote-btn {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid #1a1a1a;
color: #444;
padding: 8px 16px;
cursor: pointer;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
border-radius: 4px;
transition: all 0.15s;
}
.vote-btn:hover {
border-color: #333;
color: #888;
}
.vote-btn.voted {
border-color: #0daa1e;
color: #10e020;
}
#vote-skip.voted {
border-color: #c44;
color: #e55;
}
.vote-emoji {
font-size: 16px;
line-height: 1;
}
/* ── car mode ── */
#car-btn.active {
border-color: #0daa1e;
color: #10e020;
}
body.car-mode {
background: #000 !important;
background-image: none !important;
}
body.car-mode.shake {
animation: none !important;
}
body.car-mode #bg-video,
body.car-mode #bg-video2 {
display: none !important;
}
body.car-mode #radio-header,
body.car-mode #now-genre,
body.car-mode #votes,
body.car-mode #listener-count-radio {
display: none !important;
}
body.car-mode #now-artist {
font-size: clamp(36px, 14vw, 100px);
overflow-wrap: normal;
}
body.car-mode #now-artist,
body.car-mode #now-artist::before,
body.car-mode #now-artist::after {
animation: none !important;
}
body.car-mode #now-artist::before,
body.car-mode #now-artist::after {
display: none;
}
body.car-mode #now-track {
font-size: clamp(16px, 5vw, 28px);
color: #888;
}
/* ── buffering ── */
#buffering {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #666;
letter-spacing: 3px;
z-index: 50;
pointer-events: none;
}

518
static/radio.js Normal file
View File

@@ -0,0 +1,518 @@
const audio = document.getElementById('audio');
const splash = document.getElementById('splash');
const radioEl = document.getElementById('radio');
const artistEl = document.getElementById('now-artist');
const trackEl = document.getElementById('now-track');
const genreEl = document.getElementById('now-genre');
const bgVideo = document.getElementById('bg-video');
const bgVideo2 = document.getElementById('bg-video2');
const progressB = document.getElementById('progress-bar');
const elapsedEl = document.getElementById('time-elapsed');
const totalEl = document.getElementById('time-total');
const volBtn = document.getElementById('vol-btn');
const volDrop = document.getElementById('vol-dropdown');
const volEl = document.getElementById('vol');
const tuneinBtn = document.getElementById('tunein');
const skipBtn = document.getElementById('skip-btn');
const thpsBtn = document.getElementById('thps-btn');
const hxcBtn = document.getElementById('hxc-btn');
const voteUpBtn = document.getElementById('vote-up');
const voteDownBtn = document.getElementById('vote-down');
const countUpEl = document.getElementById('count-up');
const countDownEl = document.getElementById('count-down');
const voteSkipBtn = document.getElementById('vote-skip');
const skipCountEl = document.getElementById('skip-vote-count');
const listenerEl = document.getElementById('listener-count');
const listenerEl2 = document.getElementById('listener-count-radio');
let currentStartedAt = null;
let syncElapsed = 0;
let syncLocalTime = 0;
let songDuration = 0;
let pollTimer = null;
let firstSong = true;
let currentSongKey = null;
let myVote = null;
let audioCtx = null;
let gainNode = null;
let hasVotedSkip = false;
const sessionId = Math.random().toString(36).slice(2);
const reconnectEl = document.getElementById('reconnect');
const carBtn = document.getElementById('car-btn');
const bufferingEl = document.getElementById('buffering');
let carMode = localStorage.getItem('acid_radio_car') === 'on';
let disconnected = false;
let preloadedUrl = null;
let preloadedKey = null;
let activeBlobUrl = null;
audio.volume = 0.8;
audio.addEventListener('waiting', () => bufferingEl.classList.remove('hidden'));
audio.addEventListener('playing', () => bufferingEl.classList.add('hidden'));
audio.addEventListener('canplay', () => bufferingEl.classList.add('hidden'));
function initAudioBoost() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const source = audioCtx.createMediaElementSource(audio);
gainNode = audioCtx.createGain();
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
gainNode.gain.value = volEl.value / 100;
audio.volume = 1.0;
}
function getClientId() {
let id = localStorage.getItem('acid_radio_id');
if (!id) {
try { id = crypto.randomUUID(); } catch (e) {
id = Array.from(crypto.getRandomValues(new Uint8Array(16)),
b => b.toString(16).padStart(2, '0')).join('');
}
localStorage.setItem('acid_radio_id', id);
}
return id;
}
const clientId = getClientId();
function fmt(s) {
s = Math.max(0, Math.floor(s));
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
async function fetchNow() {
const r = await fetch('/api/radio/now?sid=' + sessionId);
return await r.json();
}
async function fetchListeners() {
try {
const r = await fetch('/api/radio/listeners');
const data = await r.json();
const txt = data.count + ' listening';
listenerEl.textContent = txt;
listenerEl2.textContent = txt;
} catch (e) {}
}
function notify(artist, track) {
try {
if (Notification.permission !== 'granted') return;
new Notification('ACID RADIO', {
body: artist + ' \u2014 ' + track,
silent: true,
});
} catch (e) {}
}
function preloadNext(folder, file) {
const key = folder + '/' + file;
if (key === preloadedKey) return;
if (preloadedUrl) URL.revokeObjectURL(preloadedUrl);
preloadedUrl = null;
preloadedKey = key;
fetch('/music/' + encodeURIComponent(folder) + '/' + encodeURIComponent(file))
.then(r => r.blob())
.then(blob => {
if (preloadedKey !== key) return;
preloadedUrl = URL.createObjectURL(blob);
})
.catch(() => { if (preloadedKey === key) preloadedKey = null; });
}
function updateMediaSession(artist, track, genre) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track,
artist: artist,
album: genre || 'ACID RADIO',
});
}
async function fetchVotes() {
if (!currentSongKey) return;
try {
const r = await fetch('/api/radio/votes?song=' + encodeURIComponent(currentSongKey) + '&client=' + encodeURIComponent(clientId));
const data = await r.json();
countUpEl.textContent = data.up;
countDownEl.textContent = data.down;
myVote = data.my_vote;
voteUpBtn.classList.toggle('voted', myVote === 'up');
voteDownBtn.classList.toggle('voted', myVote === 'down');
} catch (e) {}
}
async function castVote(direction) {
if (!currentSongKey) return;
const newVote = (myVote === direction) ? null : direction;
try {
const r = await fetch('/api/radio/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
song: currentSongKey,
client: clientId,
vote: newVote,
}),
});
const data = await r.json();
countUpEl.textContent = data.up;
countDownEl.textContent = data.down;
myVote = newVote;
voteUpBtn.classList.toggle('voted', myVote === 'up');
voteDownBtn.classList.toggle('voted', myVote === 'down');
} catch (e) {}
}
async function fetchSkipInfo() {
if (!currentStartedAt) return;
try {
const r = await fetch('/api/radio/skip-info?ts=' + currentStartedAt + '&client=' + encodeURIComponent(clientId));
const data = await r.json();
skipCountEl.textContent = data.votes;
hasVotedSkip = data.voted;
voteSkipBtn.classList.toggle('voted', data.voted);
voteSkipBtn.classList.toggle('hidden', data.remaining <= 0);
} catch (e) {}
}
async function castSkipVote() {
if (!currentStartedAt || hasVotedSkip) return;
try {
const r = await fetch('/api/radio/vote-skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ts: currentStartedAt, client: clientId }),
});
const data = await r.json();
skipCountEl.textContent = data.votes;
hasVotedSkip = true;
voteSkipBtn.classList.add('voted');
voteSkipBtn.classList.toggle('hidden', data.remaining <= 0);
if (data.skipped) {
await syncSong();
}
} catch (e) {}
}
async function syncSong() {
let state;
try {
state = await fetchNow();
} catch (e) {
if (!disconnected) {
disconnected = true;
reconnectEl.classList.remove('hidden');
}
return;
}
if (!state) return;
if (disconnected) {
disconnected = false;
reconnectEl.classList.add('hidden');
}
const songChanged = currentStartedAt !== null && state.started_at !== currentStartedAt;
currentStartedAt = state.started_at;
syncElapsed = state.server_time - state.started_at;
syncLocalTime = Date.now() / 1000;
songDuration = state.duration;
artistEl.textContent = state.artist;
artistEl.setAttribute('data-text', state.artist);
trackEl.textContent = state.track;
genreEl.textContent = state.genre || '';
updateMediaSession(state.artist, state.track, state.genre);
const artistBgs = {
'Tony Hawks': [
'/images/tonyhawks/background.gif',
'/images/tonyhawks/2.gif',
'/images/tonyhawks/3.gif',
'/images/tonyhawks/4.gif',
'/images/tonyhawks/5.gif',
],
};
const genreBgs = {
'indie': [
'/images/indie/arnold.gif',
'/images/indie/bart.gif',
'/images/indie/drum.gif',
],
};
if (window._bgInterval) {
clearInterval(window._bgInterval);
window._bgInterval = null;
}
bgVideo.ontimeupdate = null;
bgVideo.onloadedmetadata = null;
bgVideo.onended = null;
bgVideo2.onended = null;
bgVideo2.classList.remove('active');
bgVideo2.pause();
bgVideo2.removeAttribute('src');
const genre = (state.genre || '').toLowerCase();
const isHardcore = genre === 'hardcore';
const isPostHardcore = genre === 'post hardcore' || genre === 'post-hardcore';
const isFolkPunk = genre === 'folk punk' || genre === 'folk-punk';
const isPunk = genre === 'punk';
const bgs = artistBgs[state.folder];
if (isHardcore) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
if (bgVideo.getAttribute('src') !== '/video/crowdkill.mp4') {
bgVideo.src = '/video/crowdkill.mp4';
}
bgVideo.onended = () => { bgVideo.currentTime = 0; bgVideo.play(); };
bgVideo.classList.add('active');
bgVideo.play();
} else if (isPostHardcore) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
if (bgVideo.getAttribute('src') !== '/video/posthardcore.mp4') {
bgVideo.src = '/video/posthardcore.mp4';
}
bgVideo.onended = () => { bgVideo.currentTime = 0; bgVideo.play(); };
bgVideo.classList.add('active');
bgVideo.play();
} else if (isFolkPunk) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
if (bgVideo.getAttribute('src') !== '/video/folkpunk.mp4') {
bgVideo.src = '/video/folkpunk.mp4';
}
bgVideo.onended = () => { bgVideo.currentTime = 0; bgVideo.play(); };
bgVideo.classList.add('active');
bgVideo.play();
} else if (isPunk) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
const jayClips = ['/video/jay.mp4', '/video/jay2.mp4'];
const vids = [bgVideo, bgVideo2];
let cur = 0;
vids[0].src = jayClips[0];
vids[1].src = jayClips[1];
vids[1].load();
function jaySwap() {
const next = 1 - cur;
vids[next].classList.add('active');
vids[next].play();
vids[cur].classList.remove('active');
cur = next;
const preloadIdx = 1 - cur;
vids[preloadIdx].src = jayClips[preloadIdx];
vids[preloadIdx].load();
}
vids[0].onended = jaySwap;
vids[1].onended = jaySwap;
vids[0].classList.add('active');
vids[0].play();
} else if (bgs) {
bgVideo.classList.remove('active');
bgVideo.pause();
let idx = 0;
document.body.style.backgroundImage = 'url(' + bgs[idx] + ')';
document.body.classList.add('artist-bg');
window._bgInterval = setInterval(() => {
idx = (idx + 1) % bgs.length;
document.body.style.backgroundImage = 'url(' + bgs[idx] + ')';
}, 5000);
} else if (genreBgs[genre]) {
bgVideo.classList.remove('active');
bgVideo.pause();
let idx = 0;
const gBgs = genreBgs[genre];
document.body.style.backgroundImage = 'url(' + gBgs[idx] + ')';
document.body.classList.add('artist-bg');
window._bgInterval = setInterval(() => {
idx = (idx + 1) % gBgs.length;
document.body.style.backgroundImage = 'url(' + gBgs[idx] + ')';
}, 5000);
} else {
bgVideo.classList.remove('active');
bgVideo.pause();
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
}
currentSongKey = state.folder + '/' + state.file;
myVote = null;
hasVotedSkip = false;
voteUpBtn.classList.remove('voted');
voteDownBtn.classList.remove('voted');
voteSkipBtn.classList.remove('voted');
skipCountEl.textContent = '0';
fetchVotes();
fetchSkipInfo();
if (activeBlobUrl) {
URL.revokeObjectURL(activeBlobUrl);
activeBlobUrl = null;
}
const songPath = state.folder + '/' + state.file;
if (preloadedKey === songPath && preloadedUrl) {
audio.src = preloadedUrl;
activeBlobUrl = preloadedUrl;
preloadedUrl = null;
preloadedKey = null;
} else {
audio.src = '/music/' + encodeURIComponent(state.folder) + '/' + encodeURIComponent(state.file);
}
if (state.next) preloadNext(state.next.folder, state.next.file);
audio.onloadedmetadata = () => {
const nowLocal = Date.now() / 1000;
const elapsed = syncElapsed + (nowLocal - syncLocalTime);
audio.currentTime = Math.min(elapsed, audio.duration - 0.5);
audio.play().catch(() => {});
};
audio.onerror = () => {};
if (songChanged || firstSong) {
firstSong = false;
notify(state.artist, state.track);
}
}
async function checkForChange() {
try {
const state = await fetchNow();
if (!state) return;
if (disconnected) {
disconnected = false;
reconnectEl.classList.add('hidden');
}
if (state.started_at !== currentStartedAt) {
await syncSong();
}
} catch (e) {
if (!disconnected) {
disconnected = true;
reconnectEl.classList.remove('hidden');
}
}
}
function updateProgress() {
if (!songDuration) {
requestAnimationFrame(updateProgress);
return;
}
const elapsed = syncElapsed + (Date.now() / 1000 - syncLocalTime);
const pct = Math.min((elapsed / songDuration) * 100, 100);
progressB.style.width = pct + '%';
elapsedEl.textContent = fmt(elapsed);
totalEl.textContent = fmt(songDuration);
requestAnimationFrame(updateProgress);
}
tuneinBtn.onclick = async () => {
try {
if (Notification.permission === 'default')
await Notification.requestPermission();
} catch (e) {}
initAudioBoost();
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => audio.play());
navigator.mediaSession.setActionHandler('pause', () => audio.pause());
}
splash.classList.add('hidden');
radioEl.classList.remove('hidden');
await syncSong();
pollTimer = setInterval(checkForChange, 3000);
setInterval(() => { fetchVotes(); fetchSkipInfo(); }, 10000);
requestAnimationFrame(updateProgress);
};
skipBtn.onclick = async () => {
try { await fetch('/api/radio/skip'); } catch (e) {}
try { await syncSong(); } catch (e) {}
};
thpsBtn.onclick = async () => {
try { await fetch('/api/radio/skip-to?artist=' + encodeURIComponent('Tony Hawks')); } catch (e) {}
try { await syncSong(); } catch (e) {}
};
hxcBtn.onclick = async () => {
try { await fetch('/api/radio/skip-to-genre?genre=hardcore'); } catch (e) {}
try { await syncSong(); } catch (e) {}
};
volBtn.onclick = e => {
e.stopPropagation();
volDrop.classList.toggle('hidden');
};
document.addEventListener('click', e => {
if (!volDrop.contains(e.target) && e.target !== volBtn && !volBtn.contains(e.target)) {
volDrop.classList.add('hidden');
}
});
volEl.oninput = () => {
if (gainNode) {
gainNode.gain.value = volEl.value / 100;
} else {
audio.volume = Math.min(volEl.value / 100, 1.0);
}
};
voteUpBtn.onclick = () => castVote('up');
voteDownBtn.onclick = () => castVote('down');
voteSkipBtn.onclick = () => castSkipVote();
carBtn.onclick = () => {
carMode = !carMode;
localStorage.setItem('acid_radio_car', carMode ? 'on' : 'off');
document.body.classList.toggle('car-mode', carMode);
carBtn.classList.toggle('active', carMode);
};
if (carMode) {
document.body.classList.add('car-mode');
carBtn.classList.add('active');
}
function scheduleShake() {
const delay = 1500 + Math.random() * 4000;
setTimeout(() => {
document.body.classList.add('shake');
setTimeout(() => document.body.classList.remove('shake'), 150);
scheduleShake();
}, delay);
}
scheduleShake();
fetchListeners();
setInterval(fetchListeners, 10000);
fetch('/api/debug').then(r => r.json()).then(data => {
if (data.debug) {
document.querySelectorAll('.debug-btn').forEach(el => el.classList.remove('hidden'));
}
}).catch(() => {});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}

179
static/style.css Normal file
View File

@@ -0,0 +1,179 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #c8c8c8;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
::selection {
background: #333;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #222;
border-radius: 3px;
}
#list {
flex: 1;
overflow-y: auto;
padding: 12px 0;
}
.artist-header {
display: flex;
justify-content: space-between;
padding: 5px 16px;
cursor: pointer;
white-space: nowrap;
transition: background 0.1s;
user-select: none;
}
.artist-header:hover {
background: #141414;
}
.artist-header.open {
color: #fff;
background: #131313;
}
.artist-name {
overflow: hidden;
text-overflow: ellipsis;
}
.artist-count {
color: #444;
margin-left: 20px;
flex-shrink: 0;
}
.track-list {
display: none;
}
.track-list.open {
display: block;
}
.track {
padding: 4px 16px 4px 32px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #777;
transition: background 0.1s;
}
.track:hover {
background: #141414;
color: #aaa;
}
.track.active {
color: #fff;
background: #161616;
}
#player {
border-top: 1px solid #1a1a1a;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 14px;
min-height: 52px;
background: #0d0d0d;
}
#now {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: #888;
font-size: 12px;
}
#now span {
color: #c8c8c8;
}
#controls {
display: flex;
align-items: center;
gap: 10px;
}
#controls button {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
font-family: inherit;
}
#controls button:hover {
color: #fff;
}
#time {
color: #555;
font-size: 11px;
min-width: 90px;
text-align: center;
}
#seek {
flex: 2;
max-width: 400px;
}
#vol {
width: 80px;
}
input[type=range] {
-webkit-appearance: none;
appearance: none;
background: #1a1a1a;
height: 3px;
border-radius: 2px;
outline: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
background: #555;
border-radius: 50%;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb:hover {
background: #888;
}

31
static/sw.js Normal file
View File

@@ -0,0 +1,31 @@
const CACHE = 'acid-radio-v1';
const ASSETS = ['/', '/radio.css', '/radio.js'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/music/') ||
url.pathname.startsWith('/video/') || url.pathname.startsWith('/images/')) {
return;
}
e.respondWith(
fetch(e.request).then(r => {
const clone = r.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
return r;
}).catch(() => caches.match(e.request))
);
});

BIN
static/video/crowdkill.mp4 Normal file

Binary file not shown.

BIN
static/video/folkpunk.mp4 Normal file

Binary file not shown.

BIN
static/video/jay.mp4 Normal file

Binary file not shown.

BIN
static/video/jay2.mp4 Normal file

Binary file not shown.

Binary file not shown.