Skip to content
Closed
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ AI_MAX_CONTEXT_SONGS=300
# Maximum output tokens for AI response
AI_MAX_OUTPUT_TOKENS=8192

# Timeout in seconds for each AI API request (increase if you experience timeouts)
AI_REQUEST_TIMEOUT=300

# ============================================================================
# OPTIONAL - Monitoring and Metrics
# ============================================================================
Expand Down
22 changes: 21 additions & 1 deletion ENV_VARS.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,26 @@ AI_MAX_OUTPUT_TOKENS=8192

---

### AI_REQUEST_TIMEOUT
**Description**: Timeout in seconds for each AI API request. Increase this value if you experience timeout errors, especially with slower models or high `AI_MAX_OUTPUT_TOKENS` values.
**Default**: `300`
**Minimum**: `30`
**Example**:
```bash
# Default (5 minutes)
AI_REQUEST_TIMEOUT=300

# Increase for large outputs or slow connections
AI_REQUEST_TIMEOUT=600
```

**Notes**:
- Applies to OpenAI-compatible backends (`openai`, `groq`, `mistral`, `ollama`, custom)
- On timeout, OctoGen will retry up to 3 times with exponential backoff before failing
- If you consistently hit timeouts, also consider reducing `AI_MAX_OUTPUT_TOKENS`

---

## 🕐 Scheduling Configuration (Optional)

### SCHEDULE_CRON
Expand Down Expand Up @@ -1279,7 +1299,7 @@ docker logs octogen | grep "Timezone:"
| Category | Count | Variables |
|----------|-------|-----------|
| **Required** | 4 | NAVIDROME_URL, NAVIDROME_USER, NAVIDROME_PASSWORD, OCTOFIESTA_URL |
| **AI Config** | 6 | AI_API_KEY (optional), AI_MODEL, AI_BACKEND, AI_BASE_URL, AI_MAX_CONTEXT_SONGS, AI_MAX_OUTPUT_TOKENS |
| **AI Config** | 7 | AI_API_KEY (optional), AI_MODEL, AI_BACKEND, AI_BASE_URL, AI_MAX_CONTEXT_SONGS, AI_MAX_OUTPUT_TOKENS, AI_REQUEST_TIMEOUT |
| **Scheduling** | 2 | SCHEDULE_CRON, TZ, MIN_RUN_INTERVAL_HOURS |
| **Monitoring** | 4 | METRICS_ENABLED, METRICS_PORT, CIRCUIT_BREAKER_THRESHOLD, CIRCUIT_BREAKER_TIMEOUT |
| **Web UI** | 2 | WEB_ENABLED, WEB_PORT |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ LLM_SONGS_PER_MIX=5 # Songs from LLM (default: 5)
| `AI_MODEL` | `gemini-2.5-flash` | AI model to use |
| `AI_BACKEND` | `gemini` | Backend: `gemini` or `openai` |
| `AI_BASE_URL` | (none) | Custom API endpoint |
| `AI_REQUEST_TIMEOUT` | `300` | API request timeout in seconds |
| `SCHEDULE_CRON` | (none) | Cron schedule (e.g., `0 2 * * *`) |
| `TZ` | `UTC` | Timezone (e.g., `America/Chicago`) |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
Expand Down
37 changes: 32 additions & 5 deletions octogen/ai/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ def __init__(
base_url: Optional[str] = None,
max_context_songs: int = 500,
max_output_tokens: int = 65535,
data_dir: Optional[Path] = None
request_timeout: int = 300,
data_dir: Optional[Path] = None,
):
"""Initialize AI recommendation engine.

Expand All @@ -56,13 +57,15 @@ def __init__(
base_url: Optional base URL for OpenAI-compatible APIs
max_context_songs: Maximum songs to include in context
max_output_tokens: Maximum output tokens
request_timeout: Timeout in seconds for each API request
data_dir: Data directory for cache files
"""
self.api_key = api_key
self.model = model
self.backend = backend.lower()
self.max_context_songs = max_context_songs
self.max_output_tokens = max_output_tokens
self.request_timeout = request_timeout

# Set up data directory
if data_dir is None:
Expand Down Expand Up @@ -647,7 +650,7 @@ def _generate_with_openai(
temperature=0.8,
max_tokens=self.max_output_tokens,
response_format={"type": "json_object"},
timeout=120,
timeout=self.request_timeout,
)

return response.choices[0].message.content.strip()
Expand Down Expand Up @@ -816,7 +819,7 @@ def generate_all_playlists(
return all_playlists, None

def _generate_with_retry(self, generate_func, *args, **kwargs) -> str:
"""Retry AI generation with exponential backoff for rate limits.
"""Retry AI generation with exponential backoff for rate limits and timeouts.

Args:
generate_func: Function to call
Expand All @@ -841,10 +844,27 @@ def _generate_with_retry(self, generate_func, *args, **kwargs) -> str:
'rate limit', 'quota', 'too many requests', '429',
'resource_exhausted', 'rate_limit_exceeded'
]) or 'RateLimitError' in error_type

# Check if it's a timeout error — cover both exception type names and
# message-based detection for provider-specific exceptions (e.g. httpx,
# openai, google-genai) that don't all inherit from TimeoutError.
# ConnectionError is intentionally excluded because it covers unrelated
# failures (DNS errors, connection refused, bad AI_BASE_URL) that should
# fail immediately rather than be retried.
is_timeout = (
isinstance(e, TimeoutError)
or 'Timeout' in error_type
or any(phrase in error_msg for phrase in [
'timed out', 'timeout', 'read timeout',
'connect timeout', 'connection timeout',
])
)

if attempt == max_retries - 1:
if is_rate_limit:
logger.warning("💡 Tip: Consider using a different AI provider or model with higher limits")
elif is_timeout:
logger.warning("💡 Tip: Consider increasing AI_REQUEST_TIMEOUT if timeouts persist")
raise # Last attempt, let it fail

if is_rate_limit:
Expand All @@ -854,9 +874,16 @@ def _generate_with_retry(self, generate_func, *args, **kwargs) -> str:
error_type, attempt + 1, max_retries, delay
)
time.sleep(delay)
elif is_timeout:
delay = base_delay * (2 ** attempt)
logger.warning(
"Request timed out [%s] (attempt %d/%d). Retrying in %.1fs...",
error_type, attempt + 1, max_retries, delay
)
time.sleep(delay)
else:
# Not a rate limit error, fail immediately
logger.error("Non-rate-limit error: %s", str(e)[:200])
# Not a rate limit or timeout error, fail immediately
logger.error("Non-retryable error: %s", str(e)[:200])
raise

raise Exception("Max retries exceeded")
65 changes: 64 additions & 1 deletion octogen/api/listenbrainz.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""ListenBrainz API client for music recommendations"""

import logging
import re
from datetime import datetime
import requests
from typing import List, Dict, Optional

Expand All @@ -11,6 +13,59 @@
class ListenBrainzAPI:
"""Fetches recommendations from ListenBrainz."""

# Matches the suffix that ListenBrainz appends to all generated playlists,
# e.g. "Weekly Jams for blueion, week of 2026-04-06 Mon"
# "Exploration for blueion, week of 2026-04-06 Mon"
# Capture groups: username, week_start (YYYY-MM-DD), dow (Mon..Sun)
_GENERATED_SUFFIX_RE = re.compile(
r"^(?P<base_title>.+)\s+for\s+(?P<username>[A-Za-z0-9_\-]+),\s+week\s+of\s+"
r"(?P<week_start>\d{4}-\d{2}-\d{2})\s+(?P<dow>Mon|Tue|Wed|Thu|Fri|Sat|Sun)$"
)

# Maps three-letter day abbreviation to Python weekday index (Monday=0)
_DOW_INDEX = {"Mon": 0, "Tue": 1, "Wed": 2, "Thu": 3, "Fri": 4, "Sat": 5, "Sun": 6}

@classmethod
def parse_generated_playlist_title(cls, title: Optional[str]) -> Optional[Dict]:
"""Parse a ListenBrainz-generated playlist title with the standard suffix.

ListenBrainz appends ``for <user>, week of YYYY-MM-DD Ddd`` to every
generated playlist regardless of the playlist type (e.g. "Weekly Jams",
"Exploration", etc.).

Args:
title: Raw playlist title string.

Returns:
A dict with keys ``base_title``, ``username``, ``week_start``, and
``dow`` when the title matches and the date/day pair is valid;
``None`` otherwise.
"""
if not title:
return None

m = cls._GENERATED_SUFFIX_RE.match(title.strip())
if not m:
return None

week_start_str = m.group("week_start")
dow = m.group("dow")

try:
dt = datetime.strptime(week_start_str, "%Y-%m-%d")
except ValueError:
return None

if dt.weekday() != cls._DOW_INDEX[dow]:
return None

return {
"base_title": m.group("base_title").strip(),
"username": m.group("username"),
"week_start": week_start_str,
"dow": dow,
}

def __init__(self, username: str, token: str = None):
"""Initialize ListenBrainz API client.

Expand Down Expand Up @@ -76,7 +131,15 @@ def get_created_for_you_playlists(self, count: int = 25, offset: int = 0) -> Lis
p for p in playlists
if p.get("playlist", {}).get("title") and p.get("playlist", {}).get("identifier")
]


# Enrich generated playlists with parsed metadata so callers can identify
# playlist type, owning user, and week without re-parsing the title.
for p in playlists:
title = p["playlist"].get("title", "")
parsed = self.parse_generated_playlist_title(title)
if parsed:
p["playlist"]["octogen_listenbrainz_generated"] = parsed

# DEBUG
if playlists:
logger.info("First playlist keys: %s", list(playlists[0].keys()))
Expand Down
1 change: 1 addition & 0 deletions octogen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def load_config_from_env() -> Dict:
"base_url": os.getenv("AI_BASE_URL"),
"max_context_songs": int(os.getenv("AI_MAX_CONTEXT_SONGS", "500")),
"max_output_tokens": int(os.getenv("AI_MAX_OUTPUT_TOKENS", "65535")),
"request_timeout": int(os.getenv("AI_REQUEST_TIMEOUT", "300")),
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI_REQUEST_TIMEOUT is documented/configured with a minimum of 30s (and AIConfig.request_timeout enforces ge=30), but this env loader allows values <30. This creates inconsistent behavior vs OctoGenEngine._load_config_from_env() (which clamps to 30) and can cause validation failures depending on which config path is used. Clamp to at least 30 here as well (or centralize the parsing) to keep config handling consistent.

Suggested change
"request_timeout": int(os.getenv("AI_REQUEST_TIMEOUT", "300")),
"request_timeout": max(30, int(os.getenv("AI_REQUEST_TIMEOUT", "300"))),

Copilot uses AI. Check for mistakes.
},
"lastfm": {
"enabled": os.getenv("LASTFM_ENABLED", "false").lower() == "true",
Expand Down
5 changes: 4 additions & 1 deletion octogen/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def __init__(self, dry_run: bool = False):
max_context = int(self.config.get("ai", {}).get("max_context_songs", 500))
max_output = int(self.config.get("ai", {}).get("max_output_tokens", 65535))
backend = self.config.get("ai", {}).get("backend", "gemini")
request_timeout = int(self.config.get("ai", {}).get("request_timeout", 300))

self.ai = AIRecommendationEngine(
api_key=self.config["ai"]["api_key"],
Expand All @@ -110,6 +111,7 @@ def __init__(self, dry_run: bool = False):
base_url=self.config.get("ai", {}).get("base_url"),
max_context_songs=max_context,
max_output_tokens=max_output,
request_timeout=request_timeout,
)
logger.info("✓ AI engine initialized")
else:
Expand Down Expand Up @@ -243,7 +245,8 @@ def _load_config_from_env(self) -> dict:
"backend": os.getenv("AI_BACKEND", "gemini"),
"base_url": os.getenv("AI_BASE_URL"),
"max_context_songs": self._get_env_int("AI_MAX_CONTEXT_SONGS", 500),
"max_output_tokens": self._get_env_int("AI_MAX_OUTPUT_TOKENS", 65535)
"max_output_tokens": self._get_env_int("AI_MAX_OUTPUT_TOKENS", 65535),
"request_timeout": max(30, self._get_env_int("AI_REQUEST_TIMEOUT", 300)),
},
"performance": {
"album_batch_size": self._get_env_int("PERF_ALBUM_BATCH_SIZE", 500),
Expand Down
1 change: 1 addition & 0 deletions octogen/models/config_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class AIConfig(BaseModel):
model: str = Field(..., description="Model name")
backend: str = Field("gemini", description="AI backend")
base_url: Optional[str] = None
request_timeout: int = Field(300, ge=30, description="Request timeout in seconds")

@field_validator('api_key')
@classmethod
Expand Down
95 changes: 95 additions & 0 deletions tests/test_listenbrainz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Tests for ListenBrainz generated playlist title parser."""

from octogen.api.listenbrainz import ListenBrainzAPI


parse = ListenBrainzAPI.parse_generated_playlist_title
Comment on lines +1 to +6
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test module is written in pytest style (plain assert statements and pytest-style test discovery), but the repo doesn’t appear to include pytest in requirements.txt or any other documented/dev dependency. Add pytest as a (dev) dependency and/or document how to run the test suite so these tests can actually be executed in CI/local setups.

Copilot uses AI. Check for mistakes.


class TestParseGeneratedPlaylistTitle:
"""Unit tests for parse_generated_playlist_title."""

# --- Valid cases --------------------------------------------------------

def test_valid_weekly_jams(self):
result = parse("Weekly Jams for blueion, week of 2026-04-06 Mon")
assert result == {
"base_title": "Weekly Jams",
"username": "blueion",
"week_start": "2026-04-06",
"dow": "Mon",
}

def test_valid_arbitrary_prefix(self):
"""Any generated playlist prefix is supported, not just 'Weekly Jams'."""
result = parse("Exploration for alice, week of 2026-04-07 Tue")
assert result is not None
assert result["base_title"] == "Exploration"
assert result["username"] == "alice"
assert result["week_start"] == "2026-04-07"
assert result["dow"] == "Tue"

def test_valid_multi_word_prefix(self):
result = parse("Top Discoveries for bob_99, week of 2026-04-04 Sat")
assert result is not None
assert result["base_title"] == "Top Discoveries"
assert result["username"] == "bob_99"

def test_prefix_containing_for(self):
"""Greedy base_title group splits on the final 'for <user>, week of' suffix."""
result = parse("Songs for Sleep for blueion, week of 2026-04-06 Mon")
assert result is not None
assert result["base_title"] == "Songs for Sleep"
assert result["username"] == "blueion"

def test_base_title_trimmed(self):
"""Trailing whitespace between base title and 'for' is stripped."""
result = parse("Weekly Jams for blueion, week of 2026-04-06 Mon")
# Extra spaces before 'for' are accepted; the parsed base_title should
# still be trimmed.
assert result is not None
assert result["base_title"] == result["base_title"].rstrip()

# --- Day mismatch -------------------------------------------------------

def test_invalid_day_mismatch(self):
"""2026-04-06 is a Monday; 'Tue' must be rejected."""
assert parse("Weekly Jams for blueion, week of 2026-04-06 Tue") is None

def test_invalid_day_mismatch_saturday(self):
"""2026-04-07 is a Tuesday; 'Sat' must be rejected."""
assert parse("Weekly Jams for blueion, week of 2026-04-07 Sat") is None

# --- Malformed date -----------------------------------------------------

def test_malformed_date_invalid_month(self):
assert parse("Weekly Jams for blueion, week of 2026-13-01 Mon") is None

def test_malformed_date_invalid_day(self):
assert parse("Weekly Jams for blueion, week of 2026-02-30 Mon") is None

def test_malformed_date_not_a_date(self):
assert parse("Weekly Jams for blueion, week of abcd-ef-gh Mon") is None

# --- Non-matching titles ------------------------------------------------

def test_empty_string(self):
assert parse("") is None

def test_none_input(self):
assert parse(None) is None

def test_no_suffix_at_all(self):
assert parse("My Custom Playlist") is None

def test_missing_week_of(self):
assert parse("Weekly Jams for blueion, 2026-04-06 Mon") is None

def test_missing_username(self):
assert parse("Weekly Jams for , week of 2026-04-06 Mon") is None

def test_missing_comma(self):
assert parse("Weekly Jams for blueion week of 2026-04-06 Mon") is None

def test_unknown_day_abbreviation(self):
assert parse("Weekly Jams for blueion, week of 2026-04-06 Monday") is None
Loading