Compare commits
2 Commits
3372ce77fa
...
f882ab9ecc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f882ab9ecc
|
||
|
|
93f55840a5
|
109
README.md
109
README.md
@@ -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
|
# 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.
|
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
|
- **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 +57,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 +125,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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:';
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user