Initial commit: Filebot Media Browser

Vue 3 + FastAPI Webapp zur Anzeige von Filebot/n8n-Medienmetadaten aus PostgreSQL.
Enthält Frontend, API, Nginx-Config, SQL-Migrationen und Docker-Compose-Setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Housemann
2026-07-11 05:38:37 +02:00
co-authored by Claude Sonnet 4.6
commit 3f253ffe81
45 changed files with 4791 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
/** Cover über API-Proxy laden (Hotlink/Referrer-Probleme). */
export function coverImageSrc(url: string | null | undefined): string | null {
if (!url) return null
const cleaned = url.trim().replace(/^["']+|["']+$/g, '')
if (!cleaned) return null
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) {
return `/api/cover?url=${encodeURIComponent(cleaned)}`
}
return cleaned
}
+104
View File
@@ -0,0 +1,104 @@
export interface DateTimeBound {
date: string
hour: string
minute: string
}
export const emptyDateTimeBound = (): DateTimeBound => ({
date: '',
hour: '00',
minute: '00',
})
export function boundToIso(bound: DateTimeBound): string | undefined {
if (!bound.date) return undefined
const local = new Date(
`${bound.date}T${bound.hour.padStart(2, '0')}:${bound.minute.padStart(2, '0')}:00`
)
if (Number.isNaN(local.getTime())) return undefined
return local.toISOString()
}
export function formatBoundLabel(bound: DateTimeBound): string {
if (!bound.date) return 'Datum wählen'
return `${formatDateDe(bound.date)} ${bound.hour.padStart(2, '0')}:${bound.minute.padStart(2, '0')}`
}
export function formatDateDe(isoDate: string): string {
const [y, m, d] = isoDate.split('-').map(Number)
if (!y || !m || !d) return isoDate
return new Date(y, m - 1, d).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function pad2(n: number): string {
return String(n).padStart(2, '0')
}
export const HOURS = Array.from({ length: 24 }, (_, i) => pad2(i))
export const MINUTES = Array.from({ length: 60 }, (_, i) => pad2(i))
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
export function getWeekdayLabels(): string[] {
return WEEKDAYS
}
export function getMonthLabel(monthIndex: number): string {
return MONTHS[monthIndex] ?? ''
}
/** Kalendertage inkl. Auffüllung für Mo-start Grid */
export function getCalendarDays(year: number, month: number) {
const first = new Date(year, month, 1)
const last = new Date(year, month + 1, 0)
let startPad = first.getDay() - 1
if (startPad < 0) startPad = 6
const days: { date: string; day: number; currentMonth: boolean }[] = []
for (let i = startPad - 1; i >= 0; i--) {
const d = new Date(year, month, -i)
days.push({
date: toIsoDate(d),
day: d.getDate(),
currentMonth: false,
})
}
for (let d = 1; d <= last.getDate(); d++) {
days.push({
date: toIsoDate(new Date(year, month, d)),
day: d,
currentMonth: true,
})
}
const totalCells = Math.ceil((startPad + last.getDate()) / 7) * 7
let nextDay = 1
while (days.length < totalCells) {
const d = new Date(year, month + 1, nextDay++)
days.push({
date: toIsoDate(d),
day: d.getDate(),
currentMonth: false,
})
}
return days
}
export function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
export function isBoundActive(bound: DateTimeBound): boolean {
return Boolean(bound.date)
}
+25
View File
@@ -0,0 +1,25 @@
/** Normalisiert für groben Vergleich (Punkte, Klammern, Leerzeichen). */
export function normalizeFileName(name: string | null | undefined): string {
if (!name) return ''
return name
.toLowerCase()
.replace(/[._\-()[\]]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export function namesLikelyMatch(
original: string | null | undefined,
renamed: string | null | undefined
): boolean {
const a = normalizeFileName(original)
const b = normalizeFileName(renamed)
if (!a || !b) return a === b
if (a === b) return true
return a.includes(b) || b.includes(a)
}
export function formatDisplay(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
return String(value)
}
+22
View File
@@ -0,0 +1,22 @@
export type ObjectTypeKind = 'movie' | 'episode' | 'unknown'
export function normalizeObjectType(value: string | null | undefined): ObjectTypeKind {
const s = (value ?? '').toLowerCase().trim()
if (!s) return 'unknown'
if (s === 'movie' || s === 'film' || s === 'movies') return 'movie'
if (s === 'episode' || s === 'episodes' || s.includes('episode')) return 'episode'
return 'unknown'
}
export function objectTypeLabel(value: string | null | undefined): string {
const kind = normalizeObjectType(value)
if (kind === 'movie') return 'Film'
if (kind === 'episode') return 'Episode'
const raw = value?.trim()
return raw || '—'
}
/** Anzeige auf Filter-Buttons (DB-Wert → lesbar). */
export function objectTypeFilterLabel(dbValue: string): string {
return objectTypeLabel(dbValue)
}
+25
View File
@@ -0,0 +1,25 @@
import { nextTick } from 'vue'
export interface ScrollSnapshot {
windowY: number
tableTop: number
}
export function captureScroll(): ScrollSnapshot {
const table = document.querySelector('.table-scroll')
return {
windowY: window.scrollY,
tableTop: table instanceof HTMLElement ? table.scrollTop : 0,
}
}
export async function restoreScroll(snapshot: ScrollSnapshot): Promise<void> {
await nextTick()
requestAnimationFrame(() => {
const table = document.querySelector('.table-scroll')
if (table instanceof HTMLElement) {
table.scrollTop = snapshot.tableTop
}
window.scrollTo(0, snapshot.windowY)
})
}
+9
View File
@@ -0,0 +1,9 @@
import type { MediaRecord } from '../types'
export function isUnseen(record: MediaRecord): boolean {
return record.seen_at == null || record.seen_at === ''
}
export function countUnseen(records: MediaRecord[]): number {
return records.filter(isUnseen).length
}