Cover-Bilder aus DB laden mit Fallback auf URL-Proxy
- API: neuer Endpunkt GET /api/records/{id}/cover liefert BYTEA direkt
aus der DB; cover_mime_type in COLUMNS aufgenommen
- Frontend: CoverImage lädt primär aus DB (/api/records/{id}/cover),
fällt bei 404 automatisch auf den URL-Proxy zurück
- types.ts: cover_mime_type zum MediaRecord-Interface hinzugefügt
- scripts/deploy.sh: einmaliges SSH-Passwort für alle scp-Transfers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
944ca9a308
commit
b5366783b1
+26
@@ -53,6 +53,7 @@ COLUMNS = [
|
|||||||
"audio_bitrate",
|
"audio_bitrate",
|
||||||
"created_at",
|
"created_at",
|
||||||
"seen_at",
|
"seen_at",
|
||||||
|
"cover_mime_type",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -452,6 +453,31 @@ async def proxy_cover(url: str = Query(..., min_length=8)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/records/{record_id}/cover")
|
||||||
|
async def get_record_cover(record_id: int):
|
||||||
|
"""Cover-Bild aus DB liefern (BYTEA). 404 wenn kein Bild gespeichert."""
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"SELECT cover_image, cover_mime_type FROM {qualified_table} WHERE id = $1",
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, detail="Eintrag nicht gefunden")
|
||||||
|
if not row["cover_image"]:
|
||||||
|
raise HTTPException(404, detail="Kein Bild gespeichert")
|
||||||
|
|
||||||
|
mime = row["cover_mime_type"] or "image/jpeg"
|
||||||
|
return Response(
|
||||||
|
content=bytes(row["cover_image"]),
|
||||||
|
media_type=mime,
|
||||||
|
headers={"Cache-Control": "public, max-age=604800"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/records")
|
@app.get("/api/records")
|
||||||
async def get_records(
|
async def get_records(
|
||||||
search: str | None = Query(None, description="Suche in Dateinamen"),
|
search: str | None = Query(None, description="Suche in Dateinamen"),
|
||||||
|
|||||||
@@ -4,17 +4,39 @@ import { coverImageSrc } from '../utils/coverUrl'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
url: string | null | undefined
|
url: string | null | undefined
|
||||||
|
id?: number | null
|
||||||
|
hasCoverInDb?: boolean | null
|
||||||
size?: 'table' | 'card'
|
size?: 'table' | 'card'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const failed = ref(false)
|
// Ob das DB-Bild bereits fehlgeschlagen ist → Fallback auf URL
|
||||||
|
const dbFailed = ref(false)
|
||||||
|
const urlFailed = ref(false)
|
||||||
|
|
||||||
const src = computed(() => coverImageSrc(props.url))
|
const src = computed(() => {
|
||||||
|
if (dbFailed.value) {
|
||||||
|
// Fallback: URL-Proxy
|
||||||
|
return coverImageSrc(props.url, null, false)
|
||||||
|
}
|
||||||
|
return coverImageSrc(props.url, props.id, props.hasCoverInDb)
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = computed(() => dbFailed.value && urlFailed.value)
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
if (!dbFailed.value && props.id && props.hasCoverInDb) {
|
||||||
|
// DB-Bild fehlgeschlagen → Fallback auf URL versuchen
|
||||||
|
dbFailed.value = true
|
||||||
|
} else {
|
||||||
|
urlFailed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.url,
|
() => [props.id, props.url, props.hasCoverInDb],
|
||||||
() => {
|
() => {
|
||||||
failed.value = false
|
dbFailed.value = false
|
||||||
|
urlFailed.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -29,7 +51,7 @@ watch(
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@error="failed = true"
|
@error="onError"
|
||||||
/>
|
/>
|
||||||
<div v-else class="cover__placeholder" aria-hidden="true">
|
<div v-else class="cover__placeholder" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const columns: ColumnDef<MediaRecord>[] = [
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
h(CoverImage, {
|
h(CoverImage, {
|
||||||
url: row.original.cover_url,
|
url: row.original.cover_url,
|
||||||
|
id: row.original.id,
|
||||||
|
hasCoverInDb: Boolean(row.original.cover_mime_type),
|
||||||
size: 'table',
|
size: 'table',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const detailFields: { key: keyof MediaRecord; label: string }[] = [
|
|||||||
@click="onCardClick(row, $event)"
|
@click="onCardClick(row, $event)"
|
||||||
>
|
>
|
||||||
<div class="card__hero">
|
<div class="card__hero">
|
||||||
<CoverImage :url="row.cover_url" size="card" />
|
<CoverImage :url="row.cover_url" :id="row.id" :has-cover-in-db="Boolean(row.cover_mime_type)" size="card" />
|
||||||
<div class="card__hero-text">
|
<div class="card__hero-text">
|
||||||
<header class="card__header">
|
<header class="card__header">
|
||||||
<div class="card__meta">
|
<div class="card__meta">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface MediaRecord {
|
|||||||
file_name_new: string | null
|
file_name_new: string | null
|
||||||
object_type: string | null
|
object_type: string | null
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
|
cover_mime_type: string | null
|
||||||
movie_rating: string | number | null
|
movie_rating: string | number | null
|
||||||
movie_votes: number | null
|
movie_votes: number | null
|
||||||
video_compression_format: string | null
|
video_compression_format: string | null
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** Cover über API-Proxy laden (Hotlink/Referrer-Probleme). */
|
/** Cover-URL aus DB-URL bereinigen (für Fallback-Proxy). */
|
||||||
export function coverImageSrc(url: string | null | undefined): string | null {
|
function cleanCoverUrl(url: string | null | undefined): string | null {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
const cleaned = url.trim().replace(/^["']+|["']+$/g, '')
|
const cleaned = url.trim().replace(/^["']+|["']+$/g, '')
|
||||||
if (!cleaned) return null
|
if (!cleaned) return null
|
||||||
@@ -8,3 +8,18 @@ export function coverImageSrc(url: string | null | undefined): string | null {
|
|||||||
}
|
}
|
||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primäre Quelle: Bild aus DB (/api/records/{id}/cover).
|
||||||
|
* Fallback-URL für den Fall dass kein Bild in DB gespeichert ist.
|
||||||
|
*/
|
||||||
|
export function coverImageSrc(
|
||||||
|
url: string | null | undefined,
|
||||||
|
id?: number | null,
|
||||||
|
hasCoverInDb?: boolean | null,
|
||||||
|
): string | null {
|
||||||
|
if (id && hasCoverInDb) {
|
||||||
|
return `/api/records/${id}/cover`
|
||||||
|
}
|
||||||
|
return cleanCoverUrl(url)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Einmal Passwort eingeben, alle Dateien übertragen, dann neu bauen.
|
||||||
|
# Aufruf: bash scripts/deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HOST="root@192.168.30.135"
|
||||||
|
REMOTE="/opt/nginx_filebot"
|
||||||
|
SOCKET="/tmp/ssh-filebot-$$"
|
||||||
|
|
||||||
|
echo "Verbinde mit $HOST …"
|
||||||
|
ssh -MNf -o ControlPath="$SOCKET" -o ControlPersist=60 "$HOST"
|
||||||
|
|
||||||
|
scp_remote() {
|
||||||
|
scp -o ControlPath="$SOCKET" "$1" "$HOST:$REMOTE/$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Übertrage Dateien …"
|
||||||
|
scp_remote "api/main.py" "api/main.py"
|
||||||
|
scp_remote "frontend/src/types.ts" "frontend/src/types.ts"
|
||||||
|
scp_remote "frontend/src/utils/coverUrl.ts" "frontend/src/utils/coverUrl.ts"
|
||||||
|
scp_remote "frontend/src/components/CoverImage.vue" "frontend/src/components/CoverImage.vue"
|
||||||
|
scp_remote "frontend/src/components/DataTable.vue" "frontend/src/components/DataTable.vue"
|
||||||
|
scp_remote "frontend/src/components/MobileCards.vue" "frontend/src/components/MobileCards.vue"
|
||||||
|
|
||||||
|
echo "Baue Docker-Container neu …"
|
||||||
|
ssh -o ControlPath="$SOCKET" "$HOST" "cd $REMOTE && docker compose up -d --build"
|
||||||
|
|
||||||
|
ssh -O exit -o ControlPath="$SOCKET" "$HOST" 2>/dev/null || true
|
||||||
|
echo "Fertig."
|
||||||
Reference in New Issue
Block a user