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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user