Skip to content

Commit 0162caf

Browse files
committed
Log pushed back to the server
1 parent 35c6ca9 commit 0162caf

File tree

5 files changed

+255
-5
lines changed

5 files changed

+255
-5
lines changed

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,10 @@ ELEVENLABS_API_KEY=
128128
ELEVENLABS_VOICE_ID=pNInz6obpgDQGcFmaJgB
129129
# Directory to store weekly summary audio files
130130
WEEKLY_SUMMARY_AUDIO_DIR=/var/audio-summaries
131+
132+
# Client-Side Logging (Optional)
133+
# Remote logging helps debug issues on mobile/car displays where console access is limited
134+
# Logs are batched and sent to the server at this interval (in milliseconds)
135+
# Lower values = more frequent updates (better for debugging), higher values = less server load
136+
# Default: 5000 (5 seconds)
137+
CLIENT_LOG_BATCH_INTERVAL=5000

config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ class Config:
107107
elevenlabs_voice_id: str
108108
weekly_summary_audio_dir: str
109109

110+
# Client-side logging settings
111+
client_log_batch_interval: int # Milliseconds between log batches
112+
110113
@classmethod
111114
def load_from_env(cls) -> "Config":
112115
"""Load configuration from environment variables."""
@@ -195,6 +198,10 @@ def load_from_env(cls) -> "Config":
195198
weekly_summary_audio_dir=os.getenv(
196199
"WEEKLY_SUMMARY_AUDIO_DIR", "/var/audio-summaries"
197200
),
201+
# Client-side logging settings
202+
client_log_batch_interval=_parse_int(
203+
os.getenv("CLIENT_LOG_BATCH_INTERVAL", "5000"), 5000, 1000, 60000
204+
), # 1-60 seconds
198205
)
199206

200207
# Validate configuration if transcription is enabled

main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def index(request: Request):
189189
and config.weekly_summary_enabled,
190190
"prefetch_threshold_seconds": config.prefetch_threshold_seconds,
191191
"trilium_url": config.trilium_url,
192+
"client_log_batch_interval": config.client_log_batch_interval,
192193
},
193194
)
194195

routes/admin.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
"""
44

55
import logging
6+
from datetime import datetime
7+
from pathlib import Path
8+
from typing import List, Dict, Any
69
from fastapi import APIRouter, HTTPException, Request
7-
from fastapi.responses import JSONResponse, HTMLResponse
10+
from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse
811
from fastapi.templating import Jinja2Templates
12+
from pydantic import BaseModel
913

1014
from config import get_config
1115

@@ -14,6 +18,20 @@
1418
config = get_config()
1519
templates = Jinja2Templates(directory="templates")
1620

21+
# Client logs storage
22+
CLIENT_LOGS_DIR = Path("/tmp/audio-stream-client-logs")
23+
CLIENT_LOGS_FILE = CLIENT_LOGS_DIR / "client.log"
24+
MAX_LOG_SIZE = 5 * 1024 * 1024 # 5MB
25+
26+
27+
class ClientLogEntry(BaseModel):
28+
"""Client-side log entry."""
29+
30+
level: str # "log", "warn", "error", "debug"
31+
message: str
32+
timestamp: str
33+
context: Dict[str, Any] = {}
34+
1735

1836
@router.get("/stats", response_class=HTMLResponse)
1937
async def stats_page(request: Request):
@@ -183,3 +201,90 @@ def get_llm_usage_summary_endpoint(
183201
except Exception as e:
184202
logger.error(f"Error getting LLM usage summary: {e}", exc_info=True)
185203
raise HTTPException(status_code=500, detail=str(e))
204+
205+
206+
@router.post("/client-logs")
207+
async def receive_client_logs(logs: List[ClientLogEntry]) -> JSONResponse:
208+
"""
209+
Receive logs from the client browser.
210+
211+
Stores logs in a rotating log file for debugging client-side issues,
212+
especially useful for car displays and mobile devices where console
213+
access is limited.
214+
"""
215+
try:
216+
# Ensure log directory exists
217+
CLIENT_LOGS_DIR.mkdir(parents=True, exist_ok=True)
218+
219+
# Rotate log file if too large
220+
if CLIENT_LOGS_FILE.exists() and CLIENT_LOGS_FILE.stat().st_size > MAX_LOG_SIZE:
221+
backup_file = (
222+
CLIENT_LOGS_DIR
223+
/ f"client.log.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
224+
)
225+
CLIENT_LOGS_FILE.rename(backup_file)
226+
logger.info(f"Rotated client logs to {backup_file}")
227+
228+
# Append logs to file
229+
with open(CLIENT_LOGS_FILE, "a") as f:
230+
for log_entry in logs:
231+
log_line = (
232+
f"{log_entry.timestamp} [{log_entry.level.upper()}] "
233+
f"{log_entry.message}"
234+
)
235+
if log_entry.context:
236+
log_line += f" | Context: {log_entry.context}"
237+
f.write(log_line + "\n")
238+
239+
logger.debug(f"Received {len(logs)} client log entries")
240+
return JSONResponse({"status": "ok", "received": len(logs)})
241+
242+
except Exception as e:
243+
logger.error(f"Error storing client logs: {e}", exc_info=True)
244+
raise HTTPException(status_code=500, detail=str(e))
245+
246+
247+
@router.get("/client-logs", response_class=PlainTextResponse)
248+
async def get_client_logs(lines: int = 100) -> str:
249+
"""
250+
Get recent client-side logs.
251+
252+
Query parameters:
253+
- lines: Number of lines to return (default: 100, max: 1000)
254+
255+
Returns plain text log file content.
256+
"""
257+
try:
258+
# Validate lines parameter
259+
if lines > 1000:
260+
lines = 1000
261+
262+
if not CLIENT_LOGS_FILE.exists():
263+
return "No client logs available yet."
264+
265+
# Read last N lines
266+
with open(CLIENT_LOGS_FILE, "r") as f:
267+
all_lines = f.readlines()
268+
recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
269+
270+
return "".join(recent_lines)
271+
272+
except Exception as e:
273+
logger.error(f"Error reading client logs: {e}", exc_info=True)
274+
raise HTTPException(status_code=500, detail=str(e))
275+
276+
277+
@router.delete("/client-logs")
278+
async def clear_client_logs() -> JSONResponse:
279+
"""Clear all client-side logs."""
280+
try:
281+
if CLIENT_LOGS_FILE.exists():
282+
CLIENT_LOGS_FILE.unlink()
283+
logger.info("Client logs cleared")
284+
return JSONResponse({"status": "cleared", "message": "Client logs deleted"})
285+
else:
286+
return JSONResponse({"status": "ok", "message": "No logs to clear"})
287+
288+
except Exception as e:
289+
logger.error(f"Error clearing client logs: {e}", exc_info=True)
290+
raise HTTPException(status_code=500, detail=str(e))

templates/index.html

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,72 @@ <h2>
219219
const prefetchThresholdSeconds = {{ prefetch_threshold_seconds }};
220220
let prefetchTriggered = false;
221221

222+
// Remote logging for debugging on phone/car displays
223+
const REMOTE_LOGGING_ENABLED = true;
224+
const LOG_BATCH_INTERVAL = {{ client_log_batch_interval }}; // Milliseconds between log batches (configurable via .env)
225+
let logBuffer = [];
226+
let logBatchTimer = null;
227+
228+
// Remote logging function - sends logs to server
229+
function remoteLog(level, message, context = {}) {
230+
const timestamp = new Date().toISOString();
231+
232+
// Add to buffer
233+
logBuffer.push({
234+
level: level,
235+
message: message,
236+
timestamp: timestamp,
237+
context: context
238+
});
239+
240+
// Also log to console
241+
const consoleMethod = console[level] || console.log;
242+
if (Object.keys(context).length > 0) {
243+
consoleMethod(`[${level.toUpperCase()}]`, message, context);
244+
} else {
245+
consoleMethod(`[${level.toUpperCase()}]`, message);
246+
}
247+
248+
// Start batch timer if not already running
249+
if (REMOTE_LOGGING_ENABLED && !logBatchTimer) {
250+
logBatchTimer = setTimeout(flushLogs, LOG_BATCH_INTERVAL);
251+
}
252+
}
253+
254+
// Send buffered logs to server
255+
async function flushLogs() {
256+
if (logBuffer.length === 0) {
257+
logBatchTimer = null;
258+
return;
259+
}
260+
261+
const logsToSend = [...logBuffer];
262+
logBuffer = [];
263+
logBatchTimer = null;
264+
265+
try {
266+
await fetch('/admin/client-logs', {
267+
method: 'POST',
268+
headers: {
269+
'Content-Type': 'application/json',
270+
},
271+
body: JSON.stringify(logsToSend),
272+
});
273+
} catch (e) {
274+
// Silently fail - don't want logging to break the app
275+
console.debug('Failed to send remote logs:', e);
276+
}
277+
}
278+
279+
// Flush logs on page unload
280+
window.addEventListener('beforeunload', () => {
281+
if (logBuffer.length > 0) {
282+
// Use sendBeacon for reliability during page unload
283+
const blob = new Blob([JSON.stringify(logBuffer)], { type: 'application/json' });
284+
navigator.sendBeacon('/admin/client-logs', blob);
285+
}
286+
});
287+
222288
// Stream resilience configuration
223289
let retryCount = 0;
224290
let maxRetries = 50; // Increased for long network transitions (5G↔WiFi)
@@ -559,6 +625,53 @@ <h2>
559625
console.log('Stream loading suspended (browser buffered enough)');
560626
});
561627

628+
// Handle when duration becomes available
629+
player.addEventListener('loadedmetadata', function () {
630+
remoteLog('log', '[MediaSession] Metadata loaded', { duration: player.duration });
631+
632+
// Immediately set position state when duration becomes available
633+
if ('mediaSession' in navigator && navigator.mediaSession.setPositionState) {
634+
if (player.duration && !isNaN(player.duration) && isFinite(player.duration)) {
635+
try {
636+
navigator.mediaSession.setPositionState({
637+
duration: player.duration,
638+
playbackRate: player.playbackRate,
639+
position: player.currentTime
640+
});
641+
remoteLog('log', '[MediaSession] Initial position state set for car display', {
642+
duration: player.duration,
643+
position: player.currentTime
644+
});
645+
} catch (e) {
646+
remoteLog('warn', '[MediaSession] Failed to set initial position state', { error: e.message });
647+
}
648+
}
649+
}
650+
});
651+
652+
player.addEventListener('durationchange', function () {
653+
remoteLog('log', '[MediaSession] Duration changed', { duration: player.duration });
654+
655+
// Update position state when duration changes
656+
if ('mediaSession' in navigator && navigator.mediaSession.setPositionState) {
657+
if (player.duration && !isNaN(player.duration) && isFinite(player.duration)) {
658+
try {
659+
navigator.mediaSession.setPositionState({
660+
duration: player.duration,
661+
playbackRate: player.playbackRate,
662+
position: player.currentTime
663+
});
664+
remoteLog('log', '[MediaSession] Position state updated after duration change', {
665+
duration: player.duration,
666+
position: player.currentTime
667+
});
668+
} catch (e) {
669+
remoteLog('warn', '[MediaSession] Failed to update position state', { error: e.message });
670+
}
671+
}
672+
}
673+
});
674+
562675
// Prefetch next queue item when current track is nearing its end
563676
let lastPositionUpdate = 0;
564677
player.addEventListener('timeupdate', async function () {
@@ -577,6 +690,15 @@ <h2>
577690
// Some browsers don't support this or have restrictions
578691
console.debug('[MediaSession] setPositionState not supported or failed:', e.message);
579692
}
693+
} else {
694+
// Debug why position state isn't being set (only log once per 10 seconds)
695+
if (now - lastPositionUpdate > 10000) {
696+
remoteLog('warn', '[MediaSession] Cannot set position state', {
697+
duration: player.duration,
698+
isNaN: isNaN(player.duration),
699+
isFinite: isFinite(player.duration)
700+
});
701+
}
580702
}
581703
}
582704

@@ -688,7 +810,7 @@ <h2>
688810

689811
navigator.mediaSession.metadata = new MediaMetadata(metadataOptions);
690812

691-
console.log('[MediaSession] Updated metadata:', {
813+
remoteLog('log', '[MediaSession] Updated metadata', {
692814
title: metadataOptions.title,
693815
artist: metadataOptions.artist,
694816
hasArtwork: !!metadataOptions.artwork,
@@ -697,16 +819,16 @@ <h2>
697819
} catch (e) {
698820
// Fallback: set metadata without artwork
699821
// This fixes compatibility with strict car systems like Tesla
700-
console.warn('[MediaSession] Failed to set metadata with artwork, trying without:', e);
822+
remoteLog('warn', '[MediaSession] Failed to set metadata with artwork, trying without', { error: e.message });
701823
try {
702824
navigator.mediaSession.metadata = new MediaMetadata({
703825
title: trackInfo.title || 'YouTube Audio',
704826
artist: trackInfo.channel || 'YouTube',
705827
album: 'YouTube Radio'
706828
});
707-
console.log('[MediaSession] Set metadata without artwork (fallback)');
829+
remoteLog('log', '[MediaSession] Set metadata without artwork (fallback)');
708830
} catch (fallbackError) {
709-
console.error('[MediaSession] Failed to set metadata even without artwork:', fallbackError);
831+
remoteLog('error', '[MediaSession] Failed to set metadata even without artwork', { error: fallbackError.message });
710832
}
711833
}
712834

@@ -729,6 +851,14 @@ <h2>
729851
navigator.mediaSession.setActionHandler('seekforward', (details) => {
730852
player.currentTime = Math.max(0, player.currentTime + (details.seekOffset || 15));
731853
});
854+
855+
// Handle seek to specific position (for scrubber/progress bar in car displays)
856+
navigator.mediaSession.setActionHandler('seekto', (details) => {
857+
if (details.seekTime !== null && !isNaN(details.seekTime)) {
858+
player.currentTime = details.seekTime;
859+
remoteLog('log', '[MediaSession] Seeked to position', { seekTime: details.seekTime });
860+
}
861+
});
732862
}
733863

734864
// Queue Management

0 commit comments

Comments
 (0)