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
This commit is contained in:
cottongin
2026-04-03 22:34:42 -04:00
parent 3372ce77fa
commit 93f55840a5
6 changed files with 539 additions and 37 deletions

106
README.md
View File

@@ -7,19 +7,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 - **Passive detection** — recognizes `Artist - Title` patterns in chat and validates against Apple Music
- **Explicit command** — `!request Artist - Title` for direct requests - **Explicit command** — `!request Artist - Title` for direct requests
- **Disambiguation** — presents top matches when multiple results found; user picks by number - **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 - **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 - **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 - **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 - **Rate limiting** — configurable per-user request limits
- **Ignore list** — block specific users from making requests - **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 - **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 - **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 - **Export history** — download request history as a Markdown file
- **Open/close requests** — global toggle (with per-channel override) from IRC or the web panel - **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 - **Clear history** — wipe played/rejected entries from IRC or the web panel
- **Mobile responsive** — optimized layout for phones and tablets
## Dependencies ## Dependencies
@@ -43,31 +54,61 @@ pip install aiohttp
@config plugins.SongRequest.enabledChannels #music #requests @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 ## Configuration
### Global Settings
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
|---------|------|---------|-------------| |---------|------|---------|-------------|
| `enabledChannels` | Space-separated list | (empty) | Channels for passive detection | | `enabledChannels` | Space-separated list | (empty) | Channels for passive detection |
| `ignoredUsers` | Space-separated list | (empty) | Nicks/hostmasks to ignore | | `ignoredUsers` | Space-separated list | (empty) | Nicks/hostmasks to ignore |
| `maxRequestsPerUser` | Integer | 10 | Max requests per rate limit window (0 = unlimited) | | `maxRequestsPerUser` | Integer | 10 | Max requests per rate limit window (0 = unlimited) |
| `rateLimitWindow` | Integer | 3600 | Rate limit window in seconds | | `rateLimitWindow` | Integer | 3600 | Rate limit window in seconds |
| `webAuthToken` | String (private) | (empty) | Auth token for web dashboard actions | | `webAuthToken` | String (private) | (empty) | (Deprecated) Legacy shared auth token; prefer per-admin accounts |
| `announceStatus` | Boolean | True | Announce status changes back to IRC | | `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 | | `maxChoices` | Integer | 3 | Disambiguation choices shown |
| `webPort` | Integer | 8888 | Port for the web dashboard server | | `webPort` | Integer | 8888 | Port for the web dashboard server |
| `webHost` | String | 0.0.0.0 | Bind address 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 | | `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 | ### Per-Channel Settings
| `passiveDetection` | Boolean (per-channel) | True | Enable passive pattern matching |
| `requestCommand` | Boolean (per-channel) | True | Enable the `!request` command | | 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 ## Commands
@@ -81,31 +122,52 @@ pip install aiohttp
| `openrequests [channel]` | Open requests globally or per-channel | Admin | | `openrequests [channel]` | Open requests globally or per-channel | Admin |
| `closerequests [channel]` | Close requests globally or per-channel | Admin | | `closerequests [channel]` | Close requests globally or per-channel | Admin |
| `clearhistory` | Clear all played/rejected requests | 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 ## 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 - **Pending** requests awaiting moderation
- **Approved** requests ready to play - **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 ## How Passive Detection Works
1. The bot watches messages in `enabledChannels` for lines matching `Something - Something` 1. The bot watches messages in `enabledChannels` for lines matching `Something - Something`
2. Lines starting with bot command prefixes (`!`, `.`, `@`, etc.) or URLs are skipped 2. Lines starting with bot command prefixes (`!`, `.`, `@`, etc.) or URLs are skipped
3. The extracted text is searched against the iTunes Search API 3. If a Last.fm API key is configured, artist/track names are spell-corrected
4. If no song matches, the message is silently ignored (primary false-positive filter) 4. The extracted text is searched against the iTunes Search API (dual-strategy: combined + attribute-targeted)
5. If one match, it's queued automatically 5. Results are filtered/sorted based on `explicitMode`
6. If multiple matches, the user is presented with choices 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 ## 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( conf.registerGlobalValue(
SongRequest, SongRequest,
"announceStatus", "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( conf.registerGlobalValue(
SongRequest, SongRequest,
"maxChoices", "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( conf.registerChannelValue(
SongRequest, SongRequest,
"autoApprove", "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( conf.registerChannelValue(
SongRequest, SongRequest,
"passiveDetection", "passiveDetection",

View File

@@ -2,13 +2,15 @@ import json
import urllib.request import urllib.request
import urllib.parse import urllib.parse
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import List, Optional from typing import List, Optional, Tuple
import supybot.log as log import supybot.log as log
SEARCH_URL = "https://itunes.apple.com/search" SEARCH_URL = "https://itunes.apple.com/search"
LASTFM_URL = "http://ws.audioscrobbler.com/2.0/"
REQUEST_TIMEOUT = 10 REQUEST_TIMEOUT = 10
LASTFM_TIMEOUT = 3
@dataclass @dataclass
@@ -20,6 +22,7 @@ class Track:
artwork_url: str artwork_url: str
apple_music_url: str apple_music_url: str
preview_url: Optional[str] = None preview_url: Optional[str] = None
explicitness: str = "notExplicit"
@classmethod @classmethod
def from_itunes(cls, item: dict) -> "Track": def from_itunes(cls, item: dict) -> "Track":
@@ -33,6 +36,7 @@ class Track:
artwork_url=artwork_large, artwork_url=artwork_large,
apple_music_url=item.get("trackViewUrl", ""), apple_music_url=item.get("trackViewUrl", ""),
preview_url=item.get("previewUrl"), preview_url=item.get("previewUrl"),
explicitness=item.get("trackExplicitness", "notExplicit"),
) )
def display(self) -> str: def display(self) -> str:
@@ -42,15 +46,56 @@ class Track:
return asdict(self) return asdict(self)
def search(query: str, limit: int = 5) -> List[Track]: def correct_spelling(artist: str, title: str, api_key: str) -> Tuple[str, str]:
"""Search the iTunes Search API for songs matching the query.""" """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({ 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, "term": query,
"media": "music", "media": "music",
"entity": "song", "entity": "song",
"limit": limit, "limit": limit,
}) }
url = f"{SEARCH_URL}?{params}" if attribute:
params["attribute"] = attribute
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
try: try:
req = urllib.request.Request(url, headers={"Accept": "application/json"}) 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]: def search_artist_title(artist: str, title: str, limit: int = 5) -> List[Track]:
"""Search with both artist and title terms for better relevance.""" """Search with both artist and title terms for better relevance."""
return search(f"{artist} {title}", limit=limit) 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

@@ -202,10 +202,14 @@ class SongRequest(callbacks.Plugin):
self._web_server.publish("request-new", card) self._web_server.publish("request-new", card)
if not self.registryValue("quietQueued", channel, irc.network): if not self.registryValue("quietQueued", channel, irc.network):
irc.reply( queued_text = f"Queued: {track.display()} \x02|\x02 {track.apple_music_url}"
f"Queued: {track.display()} \x02|\x02 {track.apple_music_url}", reply_mode = self.registryValue("queuedReplyMode", channel, irc.network)
prefixNick=True, 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): def _lookup_and_submit(self, irc, msg, query):
"""Search iTunes, handle disambiguation or direct submit.""" """Search iTunes, handle disambiguation or direct submit."""
@@ -218,7 +222,27 @@ class SongRequest(callbacks.Plugin):
return return
max_choices = self.registryValue("maxChoices") 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: if not tracks:
return return
@@ -259,9 +283,15 @@ class SongRequest(callbacks.Plugin):
label = status_labels[req.status] label = status_labels[req.status]
text = f"[{label}] {req.title} - {req.artist} (requested by {req.requester_nick})" 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: for irc in world.ircs:
if irc.network == req.network: if irc.network == req.network:
try: 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)) irc.queueMsg(ircmsgs.privmsg(req.channel, text))
except Exception: except Exception:
log.exception("SongRequest: Failed to announce status change") log.exception("SongRequest: Failed to announce status change")

View File

@@ -490,6 +490,73 @@
.section + .section { margin-top: 2rem; } .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 { .card-wrapper {
border-radius: var(--radius); border-radius: var(--radius);
} }
@@ -864,6 +931,9 @@
.card-actions .btn { min-height: 44px; padding: 0.5rem 1rem; } .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 { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.session-bar .btn { margin-left: 0; } .session-bar .btn { margin-left: 0; }
@@ -923,14 +993,37 @@
</div> </div>
<div id="queue-view"> <div id="queue-view">
<div class="section"> <div class="section" id="pending-section">
<div class="section-title-row">
<div class="section-title">Pending</div> <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 id="pending-list" class="request-list">
</div> </div>
</div> </div>
<div class="section"> <div class="section" id="approved-section">
<div class="section-title-row">
<div class="section-title">Approved</div> <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 id="approved-list" class="request-list">
</div> </div>
</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 ---- // ---- WebSocket ----
function connectWS() { function connectWS() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; 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)}"> 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)}" <div class="card-link request-card {esc(status_cls)}"
onclick="window.open('{esc(req.apple_music_url)}','_blank')"> 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" /> <img class="album-art" src="{esc(req.artwork_url)}" alt="Album art" loading="lazy" />
<div class="card-body"> <div class="card-body">
<div class="card-title">{esc(req.title)}</div> <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/auth/me", self._handle_auth_me)
app.router.add_get("/api/channels", self._handle_channels) app.router.add_get("/api/channels", self._handle_channels)
app.router.add_get("/api/requests", self._handle_api_get) 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}/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}/play-alt/{alt_idx}", self._handle_play_alt)
app.router.add_post("/api/requests/{request_id}/{action}", self._handle_api_action) 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) cards = "\n".join(render_request_card(r) for r in reqs)
return web.Response(text=cards, content_type="text/html") 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: async def _handle_api_action(self, request: web.Request) -> web.Response:
denied = self._require_auth(request) denied = self._require_auth(request)
if denied: if denied: