Compare commits

..

3 Commits

Author SHA1 Message Date
cottongin
6cfa971fa6 tweak: double choice timeout 2026-04-03 22:49:02 -04:00
cottongin
f882ab9ecc chore: include LLM/AI disclaimer 2026-04-03 22:36:21 -04:00
cottongin
93f55840a5 Add explicit filtering, fuzzy matching, reply modes, bulk actions, and refresh README
- Explicit/clean track filtering with configurable explicitMode per channel
- Last.fm spell correction and smart dual-strategy iTunes search
- Configurable queuedReplyMode and announceReplyMode (channel/private/notice)
- Per-channel announceApproved/announceRejected/announceNowPlaying toggles
- Bulk select mode for mass approve/reject/mark-played in web dashboard
- Comprehensive README rewrite covering all current features and config

Made-with: Cursor
2026-04-03 22:34:42 -04:00
6 changed files with 543 additions and 38 deletions

109
README.md
View File

@@ -1,3 +1,6 @@
> [!IMPORTANT]
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
# SongRequest — Limnoria IRC Song Request Plugin
A Limnoria (Supybot) plugin that watches IRC channels for song requests, validates them against the iTunes Search API, and streams them in real time to an HTMX web dashboard via WebSocket.
@@ -7,19 +10,30 @@ A Limnoria (Supybot) plugin that watches IRC channels for song requests, validat
- **Passive detection** — recognizes `Artist - Title` patterns in chat and validates against Apple Music
- **Explicit command** — `!request Artist - Title` for direct requests
- **Disambiguation** — presents top matches when multiple results found; user picks by number
- **Feeling Lucky mode** — auto-select the first match, store the rest as alternates (per-channel)
- **Explicit/clean filtering** — prefer explicit tracks, filter out clean duplicates, or vice versa (configurable per-channel)
- **Last.fm spell correction** — optionally correct misspelled artist/track names before searching iTunes
- **Smart search** — dual-strategy iTunes queries with attribute-targeted searches and increased result limits
- **Web dashboard** — HTMX-powered UI with album art, Apple Music links, and moderation controls
- **Real-time updates** — WebSocket pushes new requests and status changes to all connected dashboards
- **Moderation** — approve, reject, or mark requests as played from the web UI
- **Bulk actions** — select multiple requests and approve, reject, or mark played in one action
- **Alternate matches** — when disambiguating, unchosen tracks appear as collapsible sub-cards with approve and mark-played buttons
- **Clickable cards** — the entire request card links to Apple Music
- **Session management** — start/stop named sessions, archive them, rename/clear/delete archived sessions
- **Channel grouping** — requests grouped by channel with tab filtering; URL-based channel routing
- **Auth system** — per-admin login via IRC-managed accounts (`addSongAdmin`), admin presence indicator
- **Theme support** — dark/light/system theme toggle
- **Rate limiting** — configurable per-user request limits
- **Ignore list** — block specific users from making requests
- **Auto-approve** — skip the pending queue and auto-approve requests (per-channel)
- **Persistence** — SQLite-backed; survives bot restarts
- **IRC announcements** — optionally announces status changes back to the originating channel
- **IRC announcements** — configurable delivery: channel, private message, or NOTICE
- **Quiet mode** — suppress the "Queued" IRC confirmation per-channel
- **Alternate matches** — when disambiguating, unchosen tracks appear as collapsible sub-cards
- **Clickable cards** — the entire request card links to Apple Music
- **Export history** — download request history as a Markdown file
- **Open/close requests** — global toggle (with per-channel override) from IRC or the web panel
- **Clear history** — wipe played/rejected entries from IRC or the web panel
- **Mobile responsive** — optimized layout for phones and tablets
## Dependencies
@@ -43,31 +57,61 @@ pip install aiohttp
@config plugins.SongRequest.enabledChannels #music #requests
```
4. Set a web dashboard auth token:
4. Add a web dashboard admin (from IRC, requires bot admin):
```
@config plugins.SongRequest.webAuthToken your-secret-token-here
@addsongadmin alice s3cretK3y
```
5. Access the dashboard at `http://<bot-host>:8888/` (default port, configurable via `webPort`).
5. (Optional) Set a Last.fm API key for spell correction:
```
@config plugins.SongRequest.lastfmApiKey YOUR_LASTFM_KEY
```
6. Access the dashboard at `http://<bot-host>:8888/` (default port, configurable via `webPort`).
## Configuration
### Global Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `enabledChannels` | Space-separated list | (empty) | Channels for passive detection |
| `ignoredUsers` | Space-separated list | (empty) | Nicks/hostmasks to ignore |
| `maxRequestsPerUser` | Integer | 10 | Max requests per rate limit window (0 = unlimited) |
| `rateLimitWindow` | Integer | 3600 | Rate limit window in seconds |
| `webAuthToken` | String (private) | (empty) | Auth token for web dashboard actions |
| `announceStatus` | Boolean | True | Announce status changes back to IRC |
| `webAuthToken` | String (private) | (empty) | (Deprecated) Legacy shared auth token; prefer per-admin accounts |
| `lastfmApiKey` | String (private) | (empty) | Last.fm API key for spell correction; empty to disable |
| `announceStatus` | Boolean | True | Master switch for all IRC status announcements |
| `maxChoices` | Integer | 3 | Disambiguation choices shown |
| `webPort` | Integer | 8888 | Port for the web dashboard server |
| `webHost` | String | 0.0.0.0 | Bind address for the web dashboard server |
| `requestsOpen` | Boolean | True | Global toggle — accept or reject new requests |
| `requestsOpenOverride` | String (per-channel) | (empty) | Per-channel override: `open`, `closed`, or empty for global |
| `quietQueued` | Boolean (per-channel) | False | Suppress the "Queued: ..." IRC confirmation |
| `passiveDetection` | Boolean (per-channel) | True | Enable passive pattern matching |
| `requestCommand` | Boolean (per-channel) | True | Enable the `!request` command |
### Per-Channel Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `requestsOpenOverride` | String | (empty) | `open`, `closed`, or empty for global default |
| `quietQueued` | Boolean | False | Suppress the "Queued: ..." IRC confirmation |
| `queuedReplyMode` | String | `channel` | Queued confirmation delivery: `channel`, `private`, or `notice` |
| `autoApprove` | Boolean | False | Auto-approve requests (skip pending queue) |
| `feelingLucky` | Boolean | False | Auto-select first match; store rest as alternates |
| `explicitMode` | String | `filter` | `off`, `prefer`, `filter`, or `clean` (see below) |
| `announceApproved` | Boolean | True | Announce approved requests in IRC |
| `announceRejected` | Boolean | True | Announce rejected requests in IRC |
| `announceNowPlaying` | Boolean | True | Announce now-playing requests in IRC |
| `announceReplyMode` | String | `channel` | Status announcement delivery: `channel`, `private`, or `notice` |
| `passiveDetection` | Boolean | True | Enable passive pattern matching |
| `requestCommand` | Boolean | True | Enable the `!request` command |
### Explicit Mode
Controls how explicit/clean track versions are handled in search results:
- **`off`** — return results as-is from iTunes
- **`prefer`** — sort explicit tracks first, keep all results
- **`filter`** (default) — drop cleaned versions when an explicit version of the same track exists, sort explicit first
- **`clean`** — drop explicit versions when a clean version exists, sort clean first
## Commands
@@ -81,31 +125,52 @@ pip install aiohttp
| `openrequests [channel]` | Open requests globally or per-channel | Admin |
| `closerequests [channel]` | Close requests globally or per-channel | Admin |
| `clearhistory` | Clear all played/rejected requests | Admin |
| `startsession [name]` | Start a new request session | Admin |
| `stopsession` | Stop and archive the active session | Admin |
| `addsongadmin <user> <key>` | Add a web dashboard admin account | Admin |
| `removesongadmin <user>` | Remove a web dashboard admin account | Admin |
| `listsongadmins` | List all web dashboard admin accounts | Admin |
## Web Dashboard
The dashboard runs on a standalone aiohttp server (separate from Limnoria's built-in HTTP server) at the configured `webPort` (default `8888`). It shows:
The dashboard runs on a standalone aiohttp server at the configured `webPort` (default `8888`).
### Authentication
Admins are managed via IRC commands (`addsongadmin`/`removesongadmin`). The dashboard has a login page at `/login`. Non-admins can view the queue and history read-only; admin controls (moderation buttons, session management, export, etc.) are only visible to logged-in admins.
A floating presence indicator shows which admins are currently online.
### Queue View
- **Pending** requests awaiting moderation
- **Approved** requests ready to play
- **History** of played/rejected requests
- **Bulk select** mode for mass approve/reject/mark-played
- Action buttons (approve/reject/mark played) positioned in the top-right corner of each card
Each request card is a clickable link to Apple Music and displays album art, song title, artist, album name, requester info, and action buttons (Approve / Reject / Mark Played). When a request had disambiguation, the alternate matches appear in a collapsible section below the main card.
### History View
The history tab includes **Export .md** (download as Markdown) and **Clear History** buttons. A toggle switch in the header opens/closes requests globally (synced in real time across all connected dashboards).
- History of played/rejected requests with **Export .md** and **Clear History** buttons
- **Session archives** — previous sessions listed with expand-to-view, plus rename/clear/delete actions
Real-time updates are delivered via WebSocket — the status indicator dot in the header shows green when connected and red when disconnected (with automatic reconnection).
### Other Features
Admin actions require the auth token, which is automatically injected into the dashboard page at serve time.
- Channel filter tabs with URL-based routing (`/#channelName` or `/channelName`)
- Dark/light/system theme toggle
- Toast notifications for new requests when viewing history
- Custom themed modals (no native browser prompts)
- Real-time WebSocket connection with auto-reconnect
## How Passive Detection Works
1. The bot watches messages in `enabledChannels` for lines matching `Something - Something`
2. Lines starting with bot command prefixes (`!`, `.`, `@`, etc.) or URLs are skipped
3. The extracted text is searched against the iTunes Search API
4. If no song matches, the message is silently ignored (primary false-positive filter)
5. If one match, it's queued automatically
6. If multiple matches, the user is presented with choices
3. If a Last.fm API key is configured, artist/track names are spell-corrected
4. The extracted text is searched against the iTunes Search API (dual-strategy: combined + attribute-targeted)
5. Results are filtered/sorted based on `explicitMode`
6. If no song matches, the message is silently ignored
7. If one match (or `feelingLucky` is on), it's queued automatically
8. If multiple matches, the user is presented with choices
## Running Tests

View File

@@ -66,6 +66,18 @@ conf.registerGlobalValue(
),
)
conf.registerGlobalValue(
SongRequest,
"lastfmApiKey",
registry.String(
"",
_("""Last.fm API key for spell-correcting artist/track names
before searching iTunes. If empty, the correction step is
skipped. Get a free key at https://www.last.fm/api/account/create"""),
private=True,
),
)
conf.registerGlobalValue(
SongRequest,
"announceStatus",
@@ -104,6 +116,18 @@ conf.registerChannelValue(
),
)
conf.registerChannelValue(
SongRequest,
"announceReplyMode",
registry.String(
"channel",
_("""How to deliver status announcements (approved/rejected/now
playing). "channel" sends to the channel (default), "private"
sends a private message to the requester, "notice" sends an
IRC NOTICE to the requester."""),
),
)
conf.registerGlobalValue(
SongRequest,
"maxChoices",
@@ -162,6 +186,18 @@ conf.registerChannelValue(
),
)
conf.registerChannelValue(
SongRequest,
"queuedReplyMode",
registry.String(
"channel",
_("""How to deliver the 'Queued: ...' confirmation when quietQueued
is False. "channel" sends to the channel (default), "private"
sends a private message to the requester, "notice" sends an
IRC NOTICE to the requester."""),
),
)
conf.registerChannelValue(
SongRequest,
"autoApprove",
@@ -183,6 +219,20 @@ conf.registerChannelValue(
),
)
conf.registerChannelValue(
SongRequest,
"explicitMode",
registry.String(
"filter",
_("""Controls explicit track preference. "off" returns results
as-is from iTunes. "prefer" sorts explicit tracks first.
"filter" (default) drops cleaned versions when an explicit
version of the same track exists, then sorts explicit first.
"clean" does the inverse: drops explicit versions when a
clean version exists, and sorts clean results first."""),
),
)
conf.registerChannelValue(
SongRequest,
"passiveDetection",

View File

@@ -2,13 +2,15 @@ import json
import urllib.request
import urllib.parse
from dataclasses import dataclass, asdict
from typing import List, Optional
from typing import List, Optional, Tuple
import supybot.log as log
SEARCH_URL = "https://itunes.apple.com/search"
LASTFM_URL = "http://ws.audioscrobbler.com/2.0/"
REQUEST_TIMEOUT = 10
LASTFM_TIMEOUT = 3
@dataclass
@@ -20,6 +22,7 @@ class Track:
artwork_url: str
apple_music_url: str
preview_url: Optional[str] = None
explicitness: str = "notExplicit"
@classmethod
def from_itunes(cls, item: dict) -> "Track":
@@ -33,6 +36,7 @@ class Track:
artwork_url=artwork_large,
apple_music_url=item.get("trackViewUrl", ""),
preview_url=item.get("previewUrl"),
explicitness=item.get("trackExplicitness", "notExplicit"),
)
def display(self) -> str:
@@ -42,15 +46,56 @@ class Track:
return asdict(self)
def search(query: str, limit: int = 5) -> List[Track]:
"""Search the iTunes Search API for songs matching the query."""
def correct_spelling(artist: str, title: str, api_key: str) -> Tuple[str, str]:
"""Use Last.fm track.getCorrection to fix misspelled artist/track names.
Returns corrected (artist, title). Falls back to originals on any error.
"""
if not api_key:
return artist, title
params = urllib.parse.urlencode({
"method": "track.getcorrection",
"artist": artist,
"track": title,
"api_key": api_key,
"format": "json",
})
url = f"{LASTFM_URL}?{params}"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=LASTFM_TIMEOUT) as resp:
data = json.loads(resp.read().decode("utf-8"))
correction = data.get("corrections", {}).get("correction", {})
track_data = correction.get("track", {})
corrected_title = track_data.get("name", "")
corrected_artist = track_data.get("artist", {}).get("name", "")
if corrected_artist:
artist = corrected_artist
if corrected_title:
title = corrected_title
log.debug("SongRequest: Last.fm corrected to %r - %r", artist, title)
except Exception:
log.debug("SongRequest: Last.fm correction failed, using originals")
return artist, title
def search(query: str, limit: int = 5, attribute: str = "") -> List[Track]:
"""Search the iTunes Search API for songs matching the query."""
params = {
"term": query,
"media": "music",
"entity": "song",
"limit": limit,
})
url = f"{SEARCH_URL}?{params}"
}
if attribute:
params["attribute"] = attribute
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
@@ -75,3 +120,71 @@ def search(query: str, limit: int = 5) -> List[Track]:
def search_artist_title(artist: str, title: str, limit: int = 5) -> List[Track]:
"""Search with both artist and title terms for better relevance."""
return search(f"{artist} {title}", limit=limit)
def search_smart(query: str, artist_hint: str = "", title_hint: str = "",
limit: int = 15) -> List[Track]:
"""Run a combined search plus an attribute-targeted search, merge and deduplicate.
If title_hint is provided, also searches with attribute=songTerm to find
results that match the title specifically. Combined results come first,
then any new results from the attribute search are appended.
"""
tracks = search(query, limit=limit)
if title_hint:
attr_query = f"{artist_hint} {title_hint}" if artist_hint else title_hint
attr_tracks = search(attr_query, limit=limit, attribute="songTerm")
seen_ids = {t.track_id for t in tracks}
for t in attr_tracks:
if t.track_id not in seen_ids:
seen_ids.add(t.track_id)
tracks.append(t)
return tracks
_EXPLICIT_SORT_ORDER = {"explicit": 0, "notExplicit": 1, "cleaned": 2}
_CLEAN_SORT_ORDER = {"cleaned": 0, "notExplicit": 1, "explicit": 2}
def filter_explicit(tracks: List[Track], mode: str = "filter") -> List[Track]:
"""Filter and/or sort tracks based on explicitness.
Modes:
off -- return tracks unchanged
prefer -- sort explicit first, keep all results
filter -- drop cleaned duplicates when an explicit version exists, then sort
clean -- drop explicit duplicates when a clean version exists, sort clean first
"""
if mode == "off" or not tracks:
return tracks
if mode == "clean":
clean_keys: set = set()
for t in tracks:
if t.explicitness in ("cleaned", "notExplicit"):
clean_keys.add((t.title.lower(), t.artist.lower()))
tracks = [
t for t in tracks
if t.explicitness != "explicit"
or (t.title.lower(), t.artist.lower()) not in clean_keys
]
tracks.sort(key=lambda t: _CLEAN_SORT_ORDER.get(t.explicitness, 1))
return tracks
if mode == "filter":
explicit_keys: set = set()
for t in tracks:
if t.explicitness == "explicit":
explicit_keys.add((t.title.lower(), t.artist.lower()))
tracks = [
t for t in tracks
if t.explicitness != "cleaned"
or (t.title.lower(), t.artist.lower()) not in explicit_keys
]
tracks.sort(key=lambda t: _EXPLICIT_SORT_ORDER.get(t.explicitness, 1))
return tracks

View File

@@ -33,7 +33,7 @@ SONG_PATTERN = re.compile(
re.UNICODE,
)
CHOICE_TIMEOUT = 60
CHOICE_TIMEOUT = 120
class SongRequest(callbacks.Plugin):
@@ -202,10 +202,14 @@ class SongRequest(callbacks.Plugin):
self._web_server.publish("request-new", card)
if not self.registryValue("quietQueued", channel, irc.network):
irc.reply(
f"Queued: {track.display()} \x02|\x02 {track.apple_music_url}",
prefixNick=True,
)
queued_text = f"Queued: {track.display()} \x02|\x02 {track.apple_music_url}"
reply_mode = self.registryValue("queuedReplyMode", channel, irc.network)
if reply_mode == "private":
irc.reply(queued_text, prefixNick=True, private=True)
elif reply_mode == "notice":
irc.queueMsg(ircmsgs.notice(msg.nick, queued_text))
else:
irc.reply(queued_text, prefixNick=True)
def _lookup_and_submit(self, irc, msg, query):
"""Search iTunes, handle disambiguation or direct submit."""
@@ -218,7 +222,27 @@ class SongRequest(callbacks.Plugin):
return
max_choices = self.registryValue("maxChoices")
tracks = itunes.search(query, limit=max_choices + 2)
search_limit = max(max_choices + 2, 15)
lastfm_key = self.registryValue("lastfmApiKey")
artist_hint = ""
title_hint = ""
m = SONG_PATTERN.match(query)
if m:
artist_hint = m.group("left").strip()
title_hint = m.group("right").strip()
if lastfm_key and artist_hint and title_hint:
artist_hint, title_hint = itunes.correct_spelling(
artist_hint, title_hint, lastfm_key
)
query = f"{artist_hint} {title_hint}"
tracks = itunes.search_smart(
query, artist_hint, title_hint, limit=search_limit
)
explicit_mode = self.registryValue("explicitMode", msg.channel, irc.network)
tracks = itunes.filter_explicit(tracks, explicit_mode)
if not tracks:
return
@@ -259,9 +283,15 @@ class SongRequest(callbacks.Plugin):
label = status_labels[req.status]
text = f"[{label}] {req.title} - {req.artist} (requested by {req.requester_nick})"
reply_mode = self.registryValue("announceReplyMode", req.channel, req.network)
for irc in world.ircs:
if irc.network == req.network:
try:
if reply_mode == "private":
irc.queueMsg(ircmsgs.privmsg(req.requester_nick, text))
elif reply_mode == "notice":
irc.queueMsg(ircmsgs.notice(req.requester_nick, text))
else:
irc.queueMsg(ircmsgs.privmsg(req.channel, text))
except Exception:
log.exception("SongRequest: Failed to announce status change")

View File

@@ -490,6 +490,73 @@
.section + .section { margin-top: 2rem; }
/* ---- Bulk select mode ---- */
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-select {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 0.2rem 0.6rem;
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.btn-select:hover { border-color: var(--text-muted); color: var(--text); }
.btn-select.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.bulk-bar {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
flex-wrap: wrap;
}
.bulk-bar.visible { display: flex; }
.bulk-bar .bulk-count {
font-size: 0.8125rem;
color: var(--text-muted);
margin-right: auto;
}
.bulk-bar .btn { font-size: 0.75rem; padding: 0.3rem 0.65rem; }
.section.selecting .card-wrapper { cursor: pointer; }
.section.selecting .card-wrapper .card-link { pointer-events: none; }
.select-checkbox {
display: none;
position: absolute;
top: 0.625rem;
left: 0.625rem;
width: 22px;
height: 22px;
accent-color: var(--accent);
cursor: pointer;
z-index: 2;
pointer-events: none;
}
.section.selecting .select-checkbox { display: block; }
.card-wrapper.selected > .card-link {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.card-wrapper {
border-radius: var(--radius);
}
@@ -864,6 +931,9 @@
.card-actions .btn { min-height: 44px; padding: 0.5rem 1rem; }
.bulk-bar .btn { min-height: 44px; padding: 0.5rem 0.75rem; }
.select-checkbox { width: 28px; height: 28px; }
.session-bar { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.session-bar .btn { margin-left: 0; }
@@ -923,14 +993,37 @@
</div>
<div id="queue-view">
<div class="section">
<div class="section" id="pending-section">
<div class="section-title-row">
<div class="section-title">Pending</div>
<button class="btn-select admin-only" onclick="toggleSelectMode('pending')">Select</button>
</div>
<div class="bulk-bar" id="pending-bulk-bar">
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;">
<input type="checkbox" onchange="toggleSelectAll('pending', this.checked)" />
<span style="font-size:0.8125rem;">Select All</span>
</label>
<span class="bulk-count" id="pending-bulk-count">0 selected</span>
<button class="btn btn-approve" onclick="bulkAction('pending','approve')">Approve All</button>
<button class="btn btn-reject" onclick="bulkAction('pending','reject')">Reject All</button>
</div>
<div id="pending-list" class="request-list">
</div>
</div>
<div class="section">
<div class="section" id="approved-section">
<div class="section-title-row">
<div class="section-title">Approved</div>
<button class="btn-select admin-only" onclick="toggleSelectMode('approved')">Select</button>
</div>
<div class="bulk-bar" id="approved-bulk-bar">
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;">
<input type="checkbox" onchange="toggleSelectAll('approved', this.checked)" />
<span style="font-size:0.8125rem;">Select All</span>
</label>
<span class="bulk-count" id="approved-bulk-count">0 selected</span>
<button class="btn btn-played" onclick="bulkAction('approved','played')">Mark All Played</button>
</div>
<div id="approved-list" class="request-list">
</div>
</div>
@@ -1587,6 +1680,121 @@
});
}
// ---- Bulk select ----
var selectModeState = {};
function toggleSelectMode(section) {
var sectionEl = document.getElementById(section + '-section');
var bar = document.getElementById(section + '-bulk-bar');
var btn = sectionEl.querySelector('.btn-select');
var isActive = sectionEl.classList.contains('selecting');
if (isActive) {
exitSelectMode(section);
} else {
sectionEl.classList.add('selecting');
bar.classList.add('visible');
btn.classList.add('active');
btn.textContent = 'Cancel';
selectModeState[section] = true;
updateBulkCount(section);
}
}
function exitSelectMode(section) {
var sectionEl = document.getElementById(section + '-section');
var bar = document.getElementById(section + '-bulk-bar');
var btn = sectionEl.querySelector('.btn-select');
sectionEl.classList.remove('selecting');
bar.classList.remove('visible');
btn.classList.remove('active');
btn.textContent = 'Select';
selectModeState[section] = false;
var list = document.getElementById(section + '-list');
list.querySelectorAll('.card-wrapper.selected').forEach(function(w) {
w.classList.remove('selected');
var cb = w.querySelector('.select-checkbox');
if (cb) cb.checked = false;
});
var selectAllCb = bar.querySelector('input[type="checkbox"]');
if (selectAllCb) selectAllCb.checked = false;
updateBulkCount(section);
}
function toggleSelectAll(section, checked) {
var list = document.getElementById(section + '-list');
list.querySelectorAll('.card-wrapper').forEach(function(w) {
if (checked) {
w.classList.add('selected');
} else {
w.classList.remove('selected');
}
var cb = w.querySelector('.select-checkbox');
if (cb) cb.checked = checked;
});
updateBulkCount(section);
}
function updateBulkCount(section) {
var list = document.getElementById(section + '-list');
var count = list.querySelectorAll('.card-wrapper.selected').length;
var countEl = document.getElementById(section + '-bulk-count');
countEl.textContent = count + ' selected';
}
function toggleCardSelection(wrapper) {
wrapper.classList.toggle('selected');
var cb = wrapper.querySelector('.select-checkbox');
if (cb) cb.checked = wrapper.classList.contains('selected');
var section = wrapper.closest('.section');
var sectionName = section.id.replace('-section', '');
updateBulkCount(sectionName);
}
function bulkAction(section, action) {
var list = document.getElementById(section + '-list');
var ids = [];
list.querySelectorAll('.card-wrapper.selected').forEach(function(w) {
var idStr = w.id.replace('request-', '');
ids.push(parseInt(idStr, 10));
});
if (ids.length === 0) return;
var label = action === 'approve' ? 'approve' : action === 'reject' ? 'reject' : 'mark as played';
showModal({
title: 'Bulk ' + label.charAt(0).toUpperCase() + label.slice(1),
body: label.charAt(0).toUpperCase() + label.slice(1) + ' ' + ids.length + ' selected request' + (ids.length !== 1 ? 's' : '') + '?',
confirmText: label.charAt(0).toUpperCase() + label.slice(1),
confirmClass: action === 'approve' ? 'btn-approve' : action === 'reject' ? 'btn-reject' : 'btn-played',
onConfirm: function() {
fetch('/api/requests/bulk', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ ids: ids, action: action })
}).then(function(r) {
if (r.ok) {
exitSelectMode(section);
reloadLists();
}
});
}
});
}
document.addEventListener('click', function(e) {
var wrapper = e.target.closest('.section.selecting .card-wrapper');
if (!wrapper) return;
if (e.target.closest('.card-actions') || e.target.closest('.alternates')) return;
e.preventDefault();
e.stopPropagation();
toggleCardSelection(wrapper);
}, true);
// ---- WebSocket ----
function connectWS() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';

View File

@@ -106,6 +106,7 @@ def render_request_card(req: SongRequestModel) -> str:
return f"""<div id="request-{req.id}" class="card-wrapper" data-status="{esc(req.status)}" data-channel="{esc(req.channel)}">
<div class="card-link request-card {esc(status_cls)}"
onclick="window.open('{esc(req.apple_music_url)}','_blank')">
<input type="checkbox" class="select-checkbox" onclick="event.stopPropagation();" />
<img class="album-art" src="{esc(req.artwork_url)}" alt="Album art" loading="lazy" />
<div class="card-body">
<div class="card-title">{esc(req.title)}</div>
@@ -189,6 +190,7 @@ class WebServer:
app.router.add_get("/api/auth/me", self._handle_auth_me)
app.router.add_get("/api/channels", self._handle_channels)
app.router.add_get("/api/requests", self._handle_api_get)
app.router.add_post("/api/requests/bulk", self._handle_bulk_action)
app.router.add_post("/api/requests/{request_id}/approve-alt/{alt_idx}", self._handle_approve_alt)
app.router.add_post("/api/requests/{request_id}/play-alt/{alt_idx}", self._handle_play_alt)
app.router.add_post("/api/requests/{request_id}/{action}", self._handle_api_action)
@@ -385,6 +387,43 @@ class WebServer:
cards = "\n".join(render_request_card(r) for r in reqs)
return web.Response(text=cards, content_type="text/html")
async def _handle_bulk_action(self, request: web.Request) -> web.Response:
denied = self._require_auth(request)
if denied:
return denied
try:
data = await request.json()
except Exception:
return web.Response(text="Bad request", status=400)
ids = data.get("ids", [])
action = data.get("action", "")
status_map = {"approve": "approved", "reject": "rejected", "played": "played"}
new_status = status_map.get(action)
if not new_status or not isinstance(ids, list) or not ids:
return web.json_response({"error": "Invalid action or empty ids"}, status=400)
processed = 0
for rid in ids:
try:
rid = int(rid)
except (ValueError, TypeError):
continue
req = self._store.update_status(rid, new_status)
if not req:
continue
processed += 1
card_html = render_request_card(req)
await self._broadcast("request-update", card_html)
if self._on_status_change:
try:
self._on_status_change(req)
except Exception:
log.exception("SongRequest: on_status_change callback failed")
return web.json_response({"processed": processed})
async def _handle_api_action(self, request: web.Request) -> web.Response:
denied = self._require_auth(request)
if denied: