diff --git a/README.md b/README.md index 3919dfc..9eec40e 100644 --- a/README.md +++ b/README.md @@ -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 - **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 +54,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://: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://: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 +122,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 ` | Add a web dashboard admin account | Admin | +| `removesongadmin ` | 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 diff --git a/SongRequest/config.py b/SongRequest/config.py index 35ea116..65b6ab9 100644 --- a/SongRequest/config.py +++ b/SongRequest/config.py @@ -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", diff --git a/SongRequest/itunes.py b/SongRequest/itunes.py index ef03749..92c08de 100644 --- a/SongRequest/itunes.py +++ b/SongRequest/itunes.py @@ -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 diff --git a/SongRequest/plugin.py b/SongRequest/plugin.py index 68fdc7c..f97715f 100644 --- a/SongRequest/plugin.py +++ b/SongRequest/plugin.py @@ -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,10 +283,16 @@ 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: - irc.queueMsg(ircmsgs.privmsg(req.channel, text)) + 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") break diff --git a/SongRequest/templates/index.html b/SongRequest/templates/index.html index f8fd2ed..a2b7fd0 100644 --- a/SongRequest/templates/index.html +++ b/SongRequest/templates/index.html @@ -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 @@
-
-
Pending
+
+
+
Pending
+ +
+
+ + 0 selected + + +
-
-
Approved
+
+
+
Approved
+ +
+
+ + 0 selected + +
@@ -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:'; diff --git a/SongRequest/web.py b/SongRequest/web.py index e2bdaee..cab4900 100644 --- a/SongRequest/web.py +++ b/SongRequest/web.py @@ -106,6 +106,7 @@ def render_request_card(req: SongRequestModel) -> str: return f"""