Explorar o código

Fix: Proxy book cover images through authenticated endpoint

Root cause: Audiobookshelf cover images require authentication, but
browsers cannot send Authorization headers when loading <img> tags.
The previous attempt to use full URLs failed because the images are
protected resources that return 404 without proper authentication.

Solution: Implement a cover image proxy pattern:
1. Store relative cover paths in database (e.g., /metadata/items/.../cover.jpg)
2. Created /api/cover/{book_id} endpoint that:
   - Requires user authentication
   - Fetches cover from Audiobookshelf with user's API token
   - Returns image to browser with proper content-type
3. Updated frontend to use proxy endpoint instead of direct URLs
4. Created revert-cover-urls.py to migrate 100 existing full URLs back to relative paths

Changes:
- app/main.py: Added cover proxy endpoint (lines 755-788)
- app/main.py: Simplified sync to store relative paths (lines 273-292)
- app/static/js/reading-log.js: Use /api/cover/{book_id} instead of cover_url
- revert-cover-urls.py: Migration script to fix existing data

Flow:
Browser -> /api/cover/{book_id} -> Proxy fetches from ABS with auth -> Returns image

Fixes #2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Brad Lance hai 3 meses
pai
achega
c00db3aad5
Modificáronse 4 ficheiros con 153 adicións e 3 borrados
  1. 39 2
      app/main.py
  2. 1 1
      app/static/js/reading-log.js
  3. 56 0
      fix-cover-urls.py
  4. 57 0
      revert-cover-urls.py

+ 39 - 2
app/main.py

@@ -1,7 +1,7 @@
 from fastapi import FastAPI, Request, Depends, Form, Response, HTTPException, status
 from fastapi.templating import Jinja2Templates
 from fastapi.staticfiles import StaticFiles
-from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select, func
 from contextlib import asynccontextmanager
@@ -281,13 +281,14 @@ async def sync_with_audiobookshelf(
                     genres=json.dumps(metadata.get("genres", [])),
                     tags=json.dumps(media.get("tags", [])),
                     duration=media.get("duration", 0),
-                    cover_url=media.get("coverPath")
+                    cover_url=media.get("coverPath")  # Store relative path
                 )
                 db.add(book)
             else:
                 # Update existing book
                 book.title = metadata.get("title", book.title)
                 book.author = metadata.get("authorName", book.author)
+                book.cover_url = media.get("coverPath")  # Store relative path
                 book.updated_at = datetime.now()
 
             # Update or create listening session
@@ -731,3 +732,39 @@ async def delete_user(
 async def health_check():
     """Health check endpoint."""
     return {"status": "healthy"}
+
+
+@app.get("/api/cover/{book_id}")
+async def get_book_cover(
+    book_id: str,
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(get_current_user)
+):
+    """Proxy book cover images from Audiobookshelf with authentication."""
+    import httpx
+
+    # Get book from database
+    result = await db.execute(select(Book).where(Book.id == book_id))
+    book = result.scalar_one_or_none()
+
+    if not book or not book.cover_url:
+        raise HTTPException(status_code=404, detail="Cover not found")
+
+    # Get user's ABS client
+    abs_client = get_abs_client(user)
+
+    # Fetch cover from Audiobookshelf with auth
+    cover_url = f"{abs_client.base_url}{book.cover_url}"
+
+    async with httpx.AsyncClient() as client:
+        try:
+            response = await client.get(cover_url, headers=abs_client.headers)
+            response.raise_for_status()
+
+            # Return image with appropriate content type
+            return Response(
+                content=response.content,
+                media_type=response.headers.get("content-type", "image/jpeg")
+            )
+        except httpx.HTTPError:
+            raise HTTPException(status_code=404, detail="Cover image not found")

+ 1 - 1
app/static/js/reading-log.js

@@ -157,7 +157,7 @@ function renderRecentBooks(recentBooks) {
 
         return `
             <div class="book-card">
-                ${book.cover_url ? `<img src="${book.cover_url}" alt="${book.title}" class="book-cover">` : ''}
+                ${book.book_id ? `<img src="/api/cover/${book.book_id}" alt="${book.title}" class="book-cover">` : ''}
                 <div class="book-details">
                     <h3 class="book-title">${book.title}</h3>
                     <p class="book-author">by ${book.author}</p>

+ 56 - 0
fix-cover-urls.py

@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Fix broken cover URLs by prepending the Audiobookshelf server URL"""
+
+import asyncio
+import sqlite3
+from app.database import async_session
+from app.models import Book, User
+from sqlalchemy import select, update
+
+async def fix_cover_urls():
+    """Update all book cover URLs to be full URLs"""
+
+    async with async_session() as db:
+        # Get all users
+        result = await db.execute(select(User))
+        users = result.scalars().all()
+
+        if not users:
+            print("No users found")
+            return
+
+        # For each user, get their ABS URL
+        # Since books are shared, we'll use the first admin user's URL
+        admin_user = next((u for u in users if u.is_admin), users[0])
+        abs_url = admin_user.abs_url.rstrip('/')
+
+        print(f"Using Audiobookshelf URL: {abs_url}")
+
+        # Get all books with relative cover URLs
+        result = await db.execute(
+            select(Book).where(
+                Book.cover_url.isnot(None),
+                Book.cover_url != ''
+            )
+        )
+        books = result.scalars().all()
+
+        fixed_count = 0
+        for book in books:
+            if book.cover_url and not book.cover_url.startswith('http'):
+                # This is a relative URL, fix it
+                old_url = book.cover_url
+                book.cover_url = f"{abs_url}{old_url}"
+                print(f"Fixed: {book.title[:50]}")
+                print(f"  Old: {old_url}")
+                print(f"  New: {book.cover_url}")
+                print()
+                fixed_count += 1
+
+        await db.commit()
+        print(f"\nFixed {fixed_count} cover URLs")
+
+if __name__ == "__main__":
+    print("Fixing book cover URLs...")
+    print("-" * 60)
+    asyncio.run(fix_cover_urls())

+ 57 - 0
revert-cover-urls.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Revert book cover URLs from full URLs back to relative paths"""
+
+import asyncio
+import sqlite3
+from app.database import async_session
+from app.models import Book, User
+from sqlalchemy import select
+
+async def revert_cover_urls():
+    """Convert full URLs back to relative paths"""
+
+    async with async_session() as db:
+        # Get all users to find the ABS URL patterns
+        result = await db.execute(select(User))
+        users = result.scalars().all()
+
+        if not users:
+            print("No users found")
+            return
+
+        # Get unique ABS URLs
+        abs_urls = set(u.abs_url.rstrip('/') for u in users)
+        print(f"Found ABS URLs: {abs_urls}")
+
+        # Get all books with cover URLs
+        result = await db.execute(
+            select(Book).where(
+                Book.cover_url.isnot(None),
+                Book.cover_url != ''
+            )
+        )
+        books = result.scalars().all()
+
+        reverted_count = 0
+        for book in books:
+            if book.cover_url:
+                # Check if it's a full URL
+                for abs_url in abs_urls:
+                    if book.cover_url.startswith(abs_url):
+                        # Strip the ABS URL to get relative path
+                        old_url = book.cover_url
+                        book.cover_url = book.cover_url[len(abs_url):]
+                        print(f"Reverted: {book.title[:50]}")
+                        print(f"  Old: {old_url}")
+                        print(f"  New: {book.cover_url}")
+                        print()
+                        reverted_count += 1
+                        break
+
+        await db.commit()
+        print(f"\nReverted {reverted_count} cover URLs to relative paths")
+
+if __name__ == "__main__":
+    print("Reverting book cover URLs to relative paths...")
+    print("-" * 60)
+    asyncio.run(revert_cover_urls())