initial commit
12
.dockerignore
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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> — ' + track.replace(/\.[^.]+$/, '');
|
||||
playBtn.innerHTML = '▮▮';
|
||||
|
||||
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 = '▮▮';
|
||||
} else {
|
||||
audio.pause();
|
||||
playBtn.innerHTML = '▶';
|
||||
}
|
||||
};
|
||||
|
||||
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
|
After Width: | Height: | Size: 26 KiB |
BIN
static/icon-512.png
Normal file
|
After Width: | Height: | Size: 810 KiB |
4
static/icon.svg
Normal 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 |
BIN
static/images/hardcore/1.gif
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
static/images/hardcore/2.gif
Normal file
|
After Width: | Height: | Size: 982 KiB |
BIN
static/images/hardcore/3.gif
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
static/images/indie/arnold.gif
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
static/images/indie/bart.gif
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
static/images/indie/drum.gif
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
static/images/tonyhawks/2.gif
Normal file
|
After Width: | Height: | Size: 6.0 MiB |
BIN
static/images/tonyhawks/3.gif
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
static/images/tonyhawks/4.gif
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
static/images/tonyhawks/5.gif
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
static/images/tonyhawks/6.gif
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
static/images/tonyhawks/background.gif
Normal file
|
After Width: | Height: | Size: 14 MiB |
84
static/index.html
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
);
|
||||
});
|
||||