Skip to content

Commit 62d54df

Browse files
committed
History marker reminder for coming back and having the track to about 10 seconds from last play
1 parent 652c787 commit 62d54df

File tree

12 files changed

+383
-98
lines changed

12 files changed

+383
-98
lines changed

.claude/rules/python-code.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
paths:
3+
- "*.py"
4+
- "services/**/*.py"
5+
- "routes/**/*.py"
6+
- "tests/**/test_*.py"
7+
---
8+
9+
- Python code must have all their imports at the top of the files and never inside a function<
10+
- Python code must respect the ruff format
11+
- Python code must be typed and respect mypy
12+
- Python code must be unit tested with proper mock

.claude/rules/readme-update.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- Make sure that when you add new features that "Features" section in the readme.md is updated
2+
- Make sure when architecture change that the "Architecture" section of the readme.md is updated

README.md

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,55 +9,54 @@ A powerful FastAPI application that streams audio from YouTube videos as MP3 ove
99
## Features
1010

1111
### Core Streaming
12-
- **YouTube Audio Streaming**: Stream any YouTube video as MP3 audio in real-time
13-
- **Multi-Client Support**: Multiple users can listen to the same stream simultaneously with individual replay buffers
14-
- **Smart Queue System**: Build playlists with persistent queue that survives page refreshes
15-
- **Auto-Play**: Automatically plays next track when current track completes
16-
- **Prefetching**: Pre-downloads next queue item before current track ends for seamless transitions
17-
- **Play History**: Track all played videos with play counts, timestamps, and rich metadata
18-
- **Mobile-First Web UI**: Responsive interface optimized for phones, tablets, and desktops
19-
- **MediaSession API**: Rich media controls in car entertainment systems, lock screens, and notification panels
12+
- **YouTube Audio Streaming**: Stream any YouTube video as MP3 audio; supports full YouTube URLs, short `youtu.be` links, and raw video IDs
13+
- **Smart Queue System**: Build playlists with a persistent queue (survives page refreshes) that supports both YouTube videos and weekly summary audio as mixed items
14+
- **Auto-Play & Prefetching**: Automatically plays the next track when the current one finishes; pre-downloads the upcoming track in the background for seamless transitions
15+
- **Play History**: Tracks all played videos with play counts, relative timestamps, and rich metadata (title, channel, thumbnail); click any entry to replay
16+
- **Stream Resilience**: Automatic stalling detection with up to 50 reconnection retries and adaptive back-off delay
17+
18+
### Playback Controls
19+
- **Audio Player**: HTML5 player with Play/Pause, Rewind (−15 s), Fast-Forward (+15 s), and Stop
20+
- **Speed Controls**: Switch between 1×, 1.15×, 1.3×, and 1.5× playback speed
21+
- **Keyboard Shortcuts**: Space to play/pause; ↑/↓ to skip forward/back 15 s
22+
- **MediaSession API**: Rich media controls in car entertainment systems (Tesla tested), lock screens, and notification panels — displays title, channel, and album art
23+
24+
### Web Interface
25+
- **Mobile-First Design**: Responsive layout optimised for phones, tablets, and desktops
26+
- **Dark / Light Theme**: Automatic system-preference detection with manual toggle; preference saved in local storage
27+
- **Drag-and-Drop Queue Reordering**: Reorder queue items by dragging (mouse and touch); order saved instantly to the database
2028

2129
### AI-Powered Features
2230

23-
#### Automatic Transcription
24-
- **Multi-Provider Support**: Choose between OpenAI Whisper, Mistral Voxtral, or Google Gemini for transcription
25-
- **Audio Optimization**: Automatic compression and speed-up to reduce API costs by ~33%
26-
- **Background Processing**: Transcription happens asynchronously without blocking playback
27-
- **Smart Caching**: Transcripts cached locally to avoid re-processing
31+
#### Automatic Transcription (`TRANSCRIPTION_ENABLED=true`)
32+
- **Multi-Provider**: OpenAI Whisper, Mistral Voxtral, or Google Gemini — switch via config
33+
- **Audio Optimisation**: Automatic compression (1.5× speed, mono, 32 kbps, 16 kHz) reduces Whisper API costs ~33 %
34+
- **Background Processing**: Runs asynchronously; transcription status visible in UI with polling every 5 s
35+
- **Smart Caching**: Transcripts cached locally; Trilium deduplication prevents re-processing the same video
2836

29-
#### Intelligent Summarization
30-
- **Video Summaries**: AI-generated summaries of each video's content
37+
#### Intelligent Summarisation
38+
- **Video Summaries**: AI-generated summaries posted to Trilium Notes with video title, channel, thumbnail, and YouTube link
3139
- **Multi-Provider**: OpenAI GPT or Google Gemini (Gemini recommended for free tier)
32-
- **Knowledge Management**: Automatic posting to Trilium Notes with deduplication
33-
- **Rich Metadata**: Includes video title, channel, thumbnail, and YouTube link
34-
35-
#### Weekly Summaries
36-
- **Automated Scheduling**: Runs every Sunday at 11 PM Pacific to summarize the week (Monday-Sunday)
37-
- **Comprehensive Analysis**: Synthesizes all videos watched during the week
38-
- **Key Learnings**: Extracts 15 most important insights across all content
39-
- **Theme Detection**: Identifies common themes and patterns in your viewing
40-
- **Text-to-Speech**: Optional TTS generation (OpenAI or ElevenLabs) for listening to summaries
41-
42-
#### Smart Video Suggestions
43-
- **AI Content Discovery**: Analyzes your viewing history to suggest similar videos
44-
- **Theme-Based**: Identifies patterns in what you watch to find relevant content
45-
- **YouTube Integration**: Direct search with working YouTube links
46-
- **Configurable**: Control how many videos to analyze and suggestions to generate
40+
41+
#### Weekly Summaries (`WEEKLY_SUMMARY_ENABLED=true`)
42+
- **Automated Scheduling**: Configurable schedule (default: Fridays at 11 PM) to summarise the week's viewing
43+
- **Comprehensive Analysis**: Synthesises all videos watched during the week; extracts key learnings and common themes
44+
- **Text-to-Speech**: Optional TTS (OpenAI or ElevenLabs) converts the written summary to audio you can queue and play
45+
46+
#### Smart Video Suggestions (`BOOK_SUGGESTIONS_ENABLED=true`)
47+
- **AI Content Discovery**: Analyses viewing history and Trilium summaries to find thematically related YouTube videos
48+
- **Configurable**: Control how many recent videos to analyse and how many suggestions to generate
4749

4850
### Data & Analytics
49-
- **LLM Usage Tracking**: Detailed logging of all API calls with token counts
50-
- **Cost Monitoring**: Track spending across providers, models, and features
51-
- **Performance Metrics**: Audio duration, file sizes, processing times
52-
- **Flexible Filtering**: Query by date range, provider, model, or feature
51+
- **LLM Usage Dashboard**: HTML dashboard at `/admin/stats` plus JSON API with filtering by date range, provider, model, and feature
52+
- **Cost Monitoring**: Token counts and audio-duration tracking for accurate per-minute pricing (Whisper, Voxtral)
53+
- **Client-Side Logging**: Browser logs batched and forwarded to the server — essential for debugging on car displays and mobile devices without a console
5354

5455
### Developer Experience
55-
- **Modern Stack**: FastAPI, Python 3.12, SQLite with type-safe models
56-
- **Comprehensive Testing**: 76%+ test coverage with pytest
57-
- **CI/CD**: Automated testing and linting with GitHub Actions
58-
- **Pre-commit Hooks**: Automatic code formatting with Ruff and type checking with mypy
59-
- **Type Safety**: Full type annotations with mypy strict mode
60-
- **Docker Ready**: Easy deployment with systemd service support
56+
- **Modern Stack**: FastAPI, Python 3.12, SQLite with type-safe models and dataclasses
57+
- **Comprehensive Testing**: 76 %+ test coverage with pytest and pre-commit hooks
58+
- **CI/CD**: Automated testing with GitHub Actions on every push and pull request
59+
- **Type Safety**: Full return-type annotations; mypy-compatible throughout
6160

6261
## Quick Start
6362

routes/stream.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@
44

55
import logging
66
import threading
7+
from typing import Optional
78
from fastapi import APIRouter, HTTPException
89
from fastapi.responses import FileResponse, JSONResponse, Response
910
from pydantic import BaseModel
1011
from config import get_config
1112
from services.background_tasks import get_transcription_queue, TranscriptionJob
12-
from services.database import add_to_history, get_history, clear_history
13+
from services.database import (
14+
add_to_history,
15+
get_history,
16+
clear_history,
17+
save_playback_position,
18+
get_playback_position,
19+
clear_playback_position,
20+
)
1321
from services.path_utils import expand_path
1422
from services.streaming import (
1523
get_audio_duration,
@@ -269,3 +277,31 @@ def clear_play_history() -> JSONResponse:
269277
except Exception as e:
270278
logger.error(f"Error clearing history: {e}")
271279
raise HTTPException(status_code=500, detail=str(e))
280+
281+
282+
class SavePositionRequest(BaseModel):
283+
position_seconds: float
284+
duration_seconds: Optional[float] = None
285+
286+
287+
@router.get("/playback-position/{video_id}")
288+
def get_position(video_id: str) -> JSONResponse:
289+
"""Get the saved playback position for a video."""
290+
pos = get_playback_position(video_id)
291+
if pos is None:
292+
return JSONResponse({"position_seconds": 0, "duration_seconds": None})
293+
return JSONResponse(pos.to_dict())
294+
295+
296+
@router.post("/playback-position/{video_id}")
297+
def save_position(video_id: str, request: SavePositionRequest) -> JSONResponse:
298+
"""Save or update the playback position for a video."""
299+
save_playback_position(video_id, request.position_seconds, request.duration_seconds)
300+
return JSONResponse({"status": "saved"})
301+
302+
303+
@router.delete("/playback-position/{video_id}")
304+
def delete_position(video_id: str) -> JSONResponse:
305+
"""Delete the saved playback position for a video."""
306+
clear_playback_position(video_id)
307+
return JSONResponse({"status": "cleared"})

services/database.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from queue import Queue, Empty
88
import os
99

10-
from services.models import PlayHistoryItem, QueueItem, WeeklySummary
10+
from services.models import PlayHistoryItem, PlaybackPosition, QueueItem, WeeklySummary
1111

1212
logger = logging.getLogger(__name__)
1313

@@ -193,6 +193,22 @@ def init_database():
193193
ON llm_usage_stats(feature)
194194
""")
195195

196+
# Playback positions table
197+
cursor.execute("""
198+
CREATE TABLE IF NOT EXISTS playback_positions (
199+
id INTEGER PRIMARY KEY AUTOINCREMENT,
200+
youtube_id TEXT NOT NULL UNIQUE,
201+
position_seconds REAL NOT NULL,
202+
duration_seconds REAL,
203+
last_updated_at TEXT NOT NULL
204+
)
205+
""")
206+
207+
cursor.execute("""
208+
CREATE INDEX IF NOT EXISTS idx_playback_positions_youtube_id
209+
ON playback_positions (youtube_id)
210+
""")
211+
196212
logger.info(f"Database initialized at {DB_PATH}")
197213

198214

@@ -881,3 +897,50 @@ def get_llm_usage_summary(
881897
results["totals"]["total_tokens"] += stat["total_tokens"]
882898

883899
return results
900+
901+
902+
# Playback position functions
903+
904+
905+
def save_playback_position(
906+
youtube_id: str,
907+
position_seconds: float,
908+
duration_seconds: Optional[float] = None,
909+
) -> None:
910+
"""Save or update the playback position for a video."""
911+
timestamp = datetime.now(timezone.utc).isoformat()
912+
with get_db_connection() as conn:
913+
cursor = conn.cursor()
914+
cursor.execute(
915+
"""
916+
INSERT INTO playback_positions (youtube_id, position_seconds, duration_seconds, last_updated_at)
917+
VALUES (?, ?, ?, ?)
918+
ON CONFLICT(youtube_id) DO UPDATE SET
919+
position_seconds = excluded.position_seconds,
920+
duration_seconds = excluded.duration_seconds,
921+
last_updated_at = excluded.last_updated_at
922+
""",
923+
(youtube_id, position_seconds, duration_seconds, timestamp),
924+
)
925+
926+
927+
def get_playback_position(youtube_id: str) -> Optional[PlaybackPosition]:
928+
"""Get the saved playback position for a video, or None if not found."""
929+
with get_db_connection() as conn:
930+
cursor = conn.cursor()
931+
cursor.execute(
932+
"SELECT * FROM playback_positions WHERE youtube_id = ?", (youtube_id,)
933+
)
934+
row = cursor.fetchone()
935+
if row is None:
936+
return None
937+
return PlaybackPosition.from_db_row(row)
938+
939+
940+
def clear_playback_position(youtube_id: str) -> None:
941+
"""Delete the saved playback position for a video."""
942+
with get_db_connection() as conn:
943+
cursor = conn.cursor()
944+
cursor.execute(
945+
"DELETE FROM playback_positions WHERE youtube_id = ?", (youtube_id,)
946+
)

services/models.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Data models for database entities and API responses."""
22

33
from dataclasses import dataclass
4-
from typing import Optional
4+
from typing import Any, Optional
55

66

77
@dataclass
@@ -161,6 +161,35 @@ def to_dict(self) -> dict:
161161
return result
162162

163163

164+
@dataclass
165+
class PlaybackPosition:
166+
"""Represents a saved playback position for a video."""
167+
168+
youtube_id: str
169+
position_seconds: float
170+
duration_seconds: Optional[float]
171+
last_updated_at: str
172+
173+
@classmethod
174+
def from_db_row(cls, row: Any) -> "PlaybackPosition":
175+
"""Create instance from database row."""
176+
return cls(
177+
youtube_id=row["youtube_id"],
178+
position_seconds=row["position_seconds"],
179+
duration_seconds=row["duration_seconds"],
180+
last_updated_at=row["last_updated_at"],
181+
)
182+
183+
def to_dict(self) -> dict:
184+
"""Convert to dictionary."""
185+
return {
186+
"youtube_id": self.youtube_id,
187+
"position_seconds": self.position_seconds,
188+
"duration_seconds": self.duration_seconds,
189+
"last_updated_at": self.last_updated_at,
190+
}
191+
192+
164193
@dataclass
165194
class BookInfo:
166195
"""Represents book information for weekly summaries."""

services/weekly_summary.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -544,25 +544,23 @@ def _check_audio_already_attached(note_id: str, filename: str) -> bool:
544544
"""
545545
Check if audio file is already attached to a Trilium note.
546546
547+
Uses the local database as the source of truth: audio_file_path is only
548+
set in weekly_summaries after a successful attach_audio_to_note call,
549+
so its presence means the audio is already attached.
550+
547551
Args:
548-
note_id: The Trilium note ID
552+
note_id: The Trilium note ID (unused, kept for signature compatibility)
549553
filename: Expected filename (e.g., "2024-W01.mp3")
550554
551555
Returns:
552556
True if audio is already attached, False otherwise
553557
"""
554558
try:
555-
client = get_httpx_client()
556-
children_url = _build_url(config.trilium_url, f"etapi/notes/{note_id}/children")
557-
response = client.get(
558-
children_url, headers=_get_trilium_headers(), timeout=10.0
559-
)
560-
response.raise_for_status()
561-
562-
children = response.json()
563-
return any(child.get("title") == filename for child in children)
559+
week_year = filename[:-4] if filename.endswith(".mp3") else filename
560+
summary = get_summary_by_week_year(week_year)
561+
return summary is not None and summary.audio_file_path is not None
564562
except Exception as e:
565-
logger.warning(f"Could not check Trilium children: {e}")
563+
logger.warning(f"Could not check audio attachment status: {e}")
566564
return False
567565

568566

0 commit comments

Comments
 (0)