Add Audiobook support to IRC (#1136)

This commit is contained in:
CaliBrain
2026-07-24 18:34:12 -04:00
committed by GitHub
parent e9f3b9a3ad
commit 7fdaf3f67d
4 changed files with 210 additions and 3 deletions
+22 -2
View File
@@ -1395,9 +1395,11 @@ Delay between requests in seconds to avoid rate limiting (0-10).
| `IRC_SERVER` | IRC server hostname | string | _none_ |
| `IRC_PORT` | IRC server port (usually 6697 for TLS, 6667 for plain) | number | `6697` |
| `IRC_USE_TLS` | Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. | boolean | `true` |
| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ |
| `IRC_CHANNEL` | Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below. | string | _none_ |
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). | string | _none_ |
| `IRC_AUDIOBOOK_CHANNEL` | Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too. | string | _none_ |
| `IRC_AUDIOBOOK_SEARCH_BOT` | Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set. | string | _none_ |
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
<details>
@@ -1435,7 +1437,7 @@ Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't
**Channel**
Channel name without the # prefix
Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below.
- **Type:** string
- **Default:** _none_
@@ -1461,6 +1463,24 @@ The search bot to address queries to (required). Searches are sent as "@<bot> <q
- **Default:** _none_
- **Required:** Yes
#### `IRC_AUDIOBOOK_CHANNEL`
**Audiobook channel**
Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too.
- **Type:** string
- **Default:** _none_
#### `IRC_AUDIOBOOK_SEARCH_BOT`
**Audiobook search bot**
Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set.
- **Type:** string
- **Default:** _none_
#### `IRC_CACHE_TTL`
**Cache Duration**
+36 -1
View File
@@ -72,7 +72,10 @@ def irc_settings() -> list[SettingsField]:
key="IRC_CHANNEL",
label="Channel",
placeholder="e.g. ebooks",
description="Channel name without the # prefix",
description=(
"Channel name without the # prefix. Used for all searches unless a "
"separate audiobook channel is configured below."
),
required=True,
env_supported=True,
),
@@ -95,6 +98,38 @@ def irc_settings() -> list[SettingsField]:
required=True,
env_supported=True,
),
HeadingField(
key="audiobook_heading",
title="Audiobooks",
description=(
"Some networks index audiobooks in a separate channel from ebooks "
"(for example #ebooks for ebooks and #bookz for audiobooks). "
"Configure that channel here to search it for audiobook requests. "
"Leave these blank to search the main channel above for both."
),
),
TextField(
key="IRC_AUDIOBOOK_CHANNEL",
label="Audiobook channel",
placeholder="e.g. bookz",
description=(
"Optional. Channel name (without the # prefix) to use for audiobook "
"searches. Leave blank to use the main channel above for audiobooks too."
),
required=False,
env_supported=True,
),
TextField(
key="IRC_AUDIOBOOK_SEARCH_BOT",
label="Audiobook search bot",
placeholder="e.g. search",
description=(
"Optional. Search bot for the audiobook channel. Leave blank to reuse "
"the main search bot above. Only used when an audiobook channel is set."
),
required=False,
env_supported=True,
),
HeadingField(
key="cache_heading",
title="Search Cache",
+13
View File
@@ -240,6 +240,19 @@ class IRCReleaseSource(ReleaseSource):
nick = _config_text("IRC_NICK")
search_bot = _config_text("IRC_SEARCH_BOT")
# Audiobooks may be indexed in a separate channel from ebooks on some networks
# (e.g. #ebooks for ebooks, #bookz for audiobooks). When an audiobook channel is
# configured and an audiobook was requested, route the search there (with its own
# search bot if set). Otherwise fall back to the main channel/bot, which keeps the
# single-channel networks that index both formats working unchanged.
if is_audiobook(content_type):
audiobook_channel = _config_text("IRC_AUDIOBOOK_CHANNEL")
if audiobook_channel:
channel = audiobook_channel
audiobook_search_bot = _config_text("IRC_AUDIOBOOK_SEARCH_BOT")
if audiobook_search_bot:
search_bot = audiobook_search_bot
# Never post an unaddressed query to the channel. A bare book title looks like
# spam to everyone else in the channel and gets the nick banned. Searches must
# be addressed to a search bot ("@<bot> <query>").
+139
View File
@@ -161,6 +161,145 @@ def test_search_no_dcc_offer_releases_connection_and_caches_empty_result(monkeyp
]
def test_audiobook_search_routes_to_configured_audiobook_channel_and_bot(monkeypatch):
"""An audiobook request uses the audiobook channel/bot when configured."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
connection_kwargs: dict[str, object] = {}
cache_calls: list[dict[str, object]] = []
class FakeClient:
online_servers: set[str] = set()
def send_message(self, channel: str, message: str) -> None:
self.channel = channel
self.message = message
def wait_for_dcc(
self, *, timeout: float, result_type: bool, expected_senders: object = None
) -> None:
return None
client = FakeClient()
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
"IRC_AUDIOBOOK_CHANNEL": "bookz",
"IRC_AUDIOBOOK_SEARCH_BOT": "audiosearch",
}.get(key, ""),
)
irc_source._recent_message_sends.clear()
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda cache_key, *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda cache_key, title, releases, *, online_servers=None: cache_calls.append(
{"cache_key": cache_key}
),
)
def fake_get_connection(**kwargs: object) -> FakeClient:
connection_kwargs.update(kwargs)
return client
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
fake_get_connection,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
book = BookMetadata(provider="hardcover", provider_id="ab", title="Audio Book")
plan = SimpleNamespace(primary_query="Audio Book")
source.search(book, plan, content_type="audiobook")
# Joined the audiobook channel, addressed the audiobook search bot, and the cache
# key is namespaced to the audiobook channel (so it won't collide with the ebook one).
assert connection_kwargs["channel"] == "bookz"
assert client.channel == "#bookz"
assert client.message == "@audiosearch Audio Book"
assert cache_calls == [{"cache_key": "irc.example.net:bookz:audio book"}]
def test_audiobook_search_reuses_main_bot_when_only_channel_configured(monkeypatch):
"""With an audiobook channel but no audiobook bot, reuse the main search bot."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
class FakeClient:
online_servers: set[str] = set()
def send_message(self, channel: str, message: str) -> None:
self.channel = channel
self.message = message
def wait_for_dcc(
self, *, timeout: float, result_type: bool, expected_senders: object = None
) -> None:
return None
client = FakeClient()
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
"IRC_AUDIOBOOK_CHANNEL": "bookz",
"IRC_AUDIOBOOK_SEARCH_BOT": "",
}.get(key, ""),
)
irc_source._recent_message_sends.clear()
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda cache_key, *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: client,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
book = BookMetadata(provider="hardcover", provider_id="ab2", title="Audio Book")
plan = SimpleNamespace(primary_query="Audio Book")
source.search(book, plan, content_type="audiobook")
assert client.channel == "#bookz"
assert client.message == "@search Audio Book"
def test_search_without_search_bot_never_posts_to_channel(monkeypatch):
"""A bare (unaddressed) query must never reach the channel; refuse to connect."""
import shelfmark.release_sources.irc.source as irc_source