Jelajahi Sumber

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 3 bulan lalu
induk
melakukan
c00db3aad5
4 mengubah file dengan 153 tambahan dan 3 penghapusan
  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 import FastAPI, Request, Depends, Form, Response, HTTPException, status
 from fastapi.templating import Jinja2Templates
 from fastapi.templating import Jinja2Templates
 from fastapi.staticfiles import StaticFiles
 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.ext.asyncio import AsyncSession
 from sqlalchemy import select, func
 from sqlalchemy import select, func
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
@@ -281,13 +281,14 @@ async def sync_with_audiobookshelf(
                     genres=json.dumps(metadata.get("genres", [])),
                     genres=json.dumps(metadata.get("genres", [])),
                     tags=json.dumps(media.get("tags", [])),
                     tags=json.dumps(media.get("tags", [])),
                     duration=media.get("duration", 0),
                     duration=media.get("duration", 0),
-                    cover_url=media.get("coverPath")
+                    cover_url=media.get("coverPath")  # Store relative path
                 )
                 )
                 db.add(book)
                 db.add(book)
             else:
             else:
                 # Update existing book
                 # Update existing book
                 book.title = metadata.get("title", book.title)
                 book.title = metadata.get("title", book.title)
                 book.author = metadata.get("authorName", book.author)
                 book.author = metadata.get("authorName", book.author)
+                book.cover_url = media.get("coverPath")  # Store relative path
                 book.updated_at = datetime.now()
                 book.updated_at = datetime.now()
 
 
             # Update or create listening session
             # Update or create listening session
@@ -731,3 +732,39 @@ async def delete_user(
 async def health_check():
 async def health_check():
     """Health check endpoint."""
     """Health check endpoint."""
     return {"status": "healthy"}
     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 `
         return `
             <div class="book-card">
             <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">
                 <div class="book-details">
                     <h3 class="book-title">${book.title}</h3>
                     <h3 class="book-title">${book.title}</h3>
                     <p class="book-author">by ${book.author}</p>
                     <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())