Phase 4 abgeschlossen

- VOD Historie in Channelscreen implementiert
- VOD-Wiedergabe + Stop-Verhalten verifiziert
This commit is contained in:
Andre
2026-04-08 17:49:30 +02:00
parent 3e8e5c2078
commit ed1f710866
10 changed files with 390 additions and 28 deletions
+10
View File
@@ -14,8 +14,10 @@ export const IPC = {
authEvent: 'auth:event', authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos',
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
/** main → renderer */ /** main → renderer */
playbackEvent: 'playback:event' playbackEvent: 'playback:event'
@@ -41,11 +43,19 @@ export function registerIpcHandlers(
}) })
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
ipcMain.handle(
IPC.twitchGetVideos,
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
)
ipcMain.handle( ipcMain.handle(
IPC.playbackStartLive, IPC.playbackStartLive,
(_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality) (_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality)
) )
ipcMain.handle(
IPC.playbackStartVod,
(_e, vodId: string, startSeconds?: number) => playback.startVod(vodId, startSeconds)
)
ipcMain.handle(IPC.playbackStop, () => playback.stop()) ipcMain.handle(IPC.playbackStop, () => playback.stop())
playback.on('playback-event', (event: PlaybackEvent) => { playback.on('playback-event', (event: PlaybackEvent) => {
+45 -1
View File
@@ -23,7 +23,7 @@ export class PlaybackService extends EventEmitter {
} }
const sl = spawnStreamlink({ const sl = spawnStreamlink({
channelLogin, url: `twitch.tv/${channelLogin}`,
quality, quality,
mpvIpcPath: this.ipcPath mpvIpcPath: this.ipcPath
}) })
@@ -65,6 +65,50 @@ export class PlaybackService extends EventEmitter {
mpv.connect().catch(() => {}) mpv.connect().catch(() => {})
} }
async startVod(vodId: string, startSeconds = 0): Promise<void> {
if (this.current) {
await this.stop()
}
const sl = spawnStreamlink({
url: `https://www.twitch.tv/videos/${vodId}`,
quality: 'best',
mpvIpcPath: this.ipcPath,
startSeconds
})
sl.stdout?.on('data', (d: Buffer) => {
console.log('[streamlink stdout]', d.toString().trim())
})
sl.stderr?.on('data', (d: Buffer) => {
console.log('[streamlink stderr]', d.toString().trim())
})
sl.on('error', (e: Error) => {
console.error('[streamlink] spawn error:', e)
if (this.current?.streamlink === sl) this.current = null
this.emit('playback-event', {
kind: 'error',
message: `streamlink Fehler: ${e.message}`
} satisfies PlaybackEvent)
})
sl.on('exit', (code) => {
if (this.current?.streamlink === sl) this.current = null
if (code !== 0 && code !== null) {
console.warn('[streamlink] exit code', code)
}
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
})
const mpv = new MpvController(this.ipcPath)
this.current = { channelLogin: vodId, streamlink: sl, mpv }
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
mpv.connect().catch(() => {})
}
async stop(): Promise<void> { async stop(): Promise<void> {
if (!this.current) return if (!this.current) return
const { streamlink: sl, mpv } = this.current const { streamlink: sl, mpv } = this.current
+10 -5
View File
@@ -34,25 +34,30 @@ const STREAMLINK_BIN = resolveStreamlinkBin()
const MPV_BIN = resolveMpvBin() const MPV_BIN = resolveMpvBin()
export interface StreamlinkOptions { export interface StreamlinkOptions {
channelLogin: string url: string
quality: string quality: string
mpvIpcPath: string mpvIpcPath: string
startSeconds?: number
} }
export function spawnStreamlink({ export function spawnStreamlink({
channelLogin, url,
quality, quality,
mpvIpcPath mpvIpcPath,
startSeconds
}: StreamlinkOptions): ChildProcess { }: StreamlinkOptions): ChildProcess {
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi' const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
const playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}` let playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
if (startSeconds && startSeconds > 0) {
playerArgs += ` --start=${startSeconds}`
}
const args = [ const args = [
'--player', '--player',
MPV_BIN, MPV_BIN,
'--player-args', '--player-args',
playerArgs, playerArgs,
`twitch.tv/${channelLogin}`, url,
quality quality
] ]
+32 -1
View File
@@ -3,9 +3,22 @@ import type {
HelixFollowedChannel, HelixFollowedChannel,
HelixPaginatedResponse, HelixPaginatedResponse,
HelixStream, HelixStream,
HelixUser HelixUser,
HelixVideo,
VodInfo
} from './types' } from './types'
function parseDuration(d: string): number {
let s = 0
const h = d.match(/(\d+)h/)
const m = d.match(/(\d+)m/)
const sec = d.match(/(\d+)s/)
if (h) s += parseInt(h[1]) * 3600
if (m) s += parseInt(m[1]) * 60
if (sec) s += parseInt(sec[1])
return s
}
const BASE = 'https://api.twitch.tv/helix' const BASE = 'https://api.twitch.tv/helix'
export class HelixClient { export class HelixClient {
@@ -96,6 +109,24 @@ export class HelixClient {
return map return map
} }
async getVideos(broadcasterId: string, limit = 20): Promise<VodInfo[]> {
const data = await this.get<HelixPaginatedResponse<HelixVideo>>('/videos', {
user_id: broadcasterId,
type: 'archive',
first: String(limit)
})
return data.data.map((v) => ({
id: v.id,
title: v.title,
createdAt: v.created_at,
durationSeconds: parseDuration(v.duration),
viewCount: v.view_count,
thumbnailUrl: v.thumbnail_url
.replace('%{width}', '440')
.replace('%{height}', '248')
}))
}
async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> { async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
const userId = await this.getOwnUserId() const userId = await this.getOwnUserId()
const followed = await this.getAllFollowed(userId) const followed = await this.getAllFollowed(userId)
+22
View File
@@ -28,6 +28,28 @@ export interface HelixPaginatedResponse<T> {
pagination?: { cursor?: string } pagination?: { cursor?: string }
} }
export interface HelixVideo {
id: string
user_id: string
title: string
created_at: string
thumbnail_url: string
duration: string // e.g. "3h40m1s"
view_count: number
type: 'archive' | 'highlight' | 'upload'
}
/** Simplified VOD info für den Renderer. */
export interface VodInfo {
id: string
title: string
createdAt: string
durationSeconds: number
viewCount: number
/** Thumbnail URL bereits auf 440×248 aufgelöst */
thumbnailUrl: string
}
/** Merged type that the renderer receives. */ /** Merged type that the renderer receives. */
export interface FollowedChannelInfo { export interface FollowedChannelInfo {
broadcasterId: string broadcasterId: string
+16 -1
View File
@@ -9,8 +9,10 @@ const IPC = {
authEvent: 'auth:event', authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos',
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
playbackEvent: 'playback:event' playbackEvent: 'playback:event'
} as const } as const
@@ -49,6 +51,15 @@ export interface PlaybackEvent {
message?: string message?: string
} }
export interface VodInfo {
id: string
title: string
createdAt: string
durationSeconds: number
viewCount: number
thumbnailUrl: string
}
const api = { const api = {
appVersion: process.env.npm_package_version ?? '0.0.0', appVersion: process.env.npm_package_version ?? '0.0.0',
auth: { auth: {
@@ -65,11 +76,15 @@ const api = {
}, },
twitch: { twitch: {
getFollowed: (): Promise<FollowedChannelInfo[]> => getFollowed: (): Promise<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetFollowed) ipcRenderer.invoke(IPC.twitchGetFollowed),
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId)
}, },
playback: { playback: {
startLive: (channelLogin: string, quality?: string): Promise<void> => startLive: (channelLogin: string, quality?: string): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality), ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
startVod: (vodId: string, startSeconds?: number): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartVod, vodId, startSeconds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop), stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => { onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
+125 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { FollowedChannelInfo, PlaybackEvent } from '../types/t4sd' import type { FollowedChannelInfo, PlaybackEvent, VodInfo } from '../types/t4sd'
interface Props { interface Props {
channel: FollowedChannelInfo channel: FollowedChannelInfo
@@ -7,6 +7,10 @@ interface Props {
} }
type PlayState = 'idle' | 'starting' | 'playing' | 'error' type PlayState = 'idle' | 'starting' | 'playing' | 'error'
type FocusRegion = 'hero' | 'shelf'
const CARD_W = 260
const CARD_GAP = 16
function formatViewers(n: number): string { function formatViewers(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mio.` if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mio.`
@@ -14,6 +18,22 @@ function formatViewers(n: number): string {
return String(n) return String(n)
} }
function formatDuration(s: number): string {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${m}:${String(sec).padStart(2, '0')}`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined { function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined {
return url?.replace('{width}', String(w)).replace('{height}', String(h)) return url?.replace('{width}', String(w)).replace('{height}', String(h))
} }
@@ -21,12 +41,52 @@ function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string |
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [playState, setPlayState] = useState<PlayState>('idle') const [playState, setPlayState] = useState<PlayState>('idle')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [vods, setVods] = useState<VodInfo[]>([])
const [vodsLoading, setVodsLoading] = useState(true)
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
const [shelfIndex, setShelfIndex] = useState(0)
const watchBtnRef = useRef<HTMLButtonElement>(null) const watchBtnRef = useRef<HTMLButtonElement>(null)
// Escape / B → zurück (nur wenn nicht gerade Wiedergabe läuft) useEffect(() => {
window.t4sd.twitch
.getVideos(channel.broadcasterId)
.then(setVods)
.catch(() => {})
.finally(() => setVodsLoading(false))
}, [channel.broadcasterId])
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { if (focusRegion === 'hero') {
if (e.key === 'ArrowDown' && vods.length > 0) {
e.preventDefault()
setFocusRegion('shelf')
setShelfIndex(0)
} else if (e.key === 'Escape') {
e.preventDefault()
if (playState === 'playing' || playState === 'starting') {
void window.t4sd.playback.stop()
} else {
onBack()
}
}
} else {
// shelf region
if (e.key === 'ArrowLeft') {
e.preventDefault()
setShelfIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'ArrowRight') {
e.preventDefault()
setShelfIndex((i) => Math.min(vods.length - 1, i + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setFocusRegion('hero')
watchBtnRef.current?.focus()
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const vod = vods[shelfIndex]
if (vod) void handleWatchVod(vod)
} else if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
if (playState === 'playing' || playState === 'starting') { if (playState === 'playing' || playState === 'starting') {
void window.t4sd.playback.stop() void window.t4sd.playback.stop()
@@ -35,11 +95,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
} }
} }
}
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [playState, onBack]) }, [focusRegion, playState, vods, shelfIndex, onBack])
// Playback-Events aus dem Main-Prozess
useEffect(() => { useEffect(() => {
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => { const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
if (ev.kind === 'started') { if (ev.kind === 'started') {
@@ -54,7 +114,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
return unsub return unsub
}, []) }, [])
// Initial-Fokus auf "Live ansehen"-Button
useEffect(() => { useEffect(() => {
watchBtnRef.current?.focus() watchBtnRef.current?.focus()
}, []) }, [])
@@ -70,11 +129,23 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
} }
const handleWatchVod = async (vod: VodInfo): Promise<void> => {
setPlayState('starting')
setErrorMsg('')
try {
await window.t4sd.playback.startVod(vod.id)
} catch (e) {
setPlayState('error')
setErrorMsg(String(e))
}
}
const handleStop = async (): Promise<void> => { const handleStop = async (): Promise<void> => {
await window.t4sd.playback.stop() await window.t4sd.playback.stop()
} }
const thumb = resolveThumbnail(channel.thumbnailUrl) const thumb = resolveThumbnail(channel.thumbnailUrl)
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
return ( return (
<div className="channel-screen"> <div className="channel-screen">
@@ -132,6 +203,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</button> </button>
)} )}
{(playState === 'idle' || playState === 'error') && !channel.isLive && (
<button ref={watchBtnRef} className="btn btn--primary" disabled style={{ opacity: 0 }}>
placeholder
</button>
)}
{playState === 'starting' && ( {playState === 'starting' && (
<button className="btn" disabled> <button className="btn" disabled>
Starte Wiedergabe… Starte Wiedergabe…
@@ -157,6 +234,48 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</div> </div>
</div> </div>
</div> </div>
<div className="channel-screen__vods">
<h3 className="channel-screen__vods-title">
Vergangene Streams
{focusRegion === 'shelf' && (
<span className="channel-screen__vods-hint"> · ↑ zurück · Enter zum Abspielen</span>
)}
</h3>
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
{!vodsLoading && vods.length === 0 && (
<p className="channel-screen__vods-empty">Keine archivierten Streams verfügbar.</p>
)}
{vods.length > 0 && (
<div className="channel-screen__shelf-viewport">
<div
className="channel-screen__shelf-track"
style={{ transform: `translateX(-${trackOffset}px)` }}
>
{vods.map((vod, i) => (
<div
key={vod.id}
className={`channel-screen__vod-card${focusRegion === 'shelf' && shelfIndex === i ? ' channel-screen__vod-card--focused' : ''}`}
onClick={() => void handleWatchVod(vod)}
>
<div className="channel-screen__vod-thumb">
{vod.thumbnailUrl && !vod.thumbnailUrl.includes('404_processing') ? (
<img src={vod.thumbnailUrl} alt="" draggable={false} />
) : (
<div className="channel-screen__vod-thumb-placeholder" />
)}
<span className="channel-screen__vod-duration">
{formatDuration(vod.durationSeconds)}
</span>
</div>
<p className="channel-screen__vod-title">{vod.title}</p>
<p className="channel-screen__vod-meta">{formatDate(vod.createdAt)}</p>
</div>
))}
</div>
</div>
)}
</div>
</div> </div>
) )
} }
+104
View File
@@ -604,6 +604,110 @@ body,
font-size: 18px; font-size: 18px;
} }
/* VOD Shelf */
.channel-screen__vods {
margin-top: 8px;
}
.channel-screen__vods-title {
margin: 0 0 16px;
font-size: 22px;
font-weight: 700;
color: var(--color-fg-dim);
}
.channel-screen__vods-hint {
font-size: 16px;
font-weight: 400;
color: var(--color-accent);
}
.channel-screen__vods-loading,
.channel-screen__vods-empty {
margin: 0;
font-size: 18px;
color: var(--color-fg-dim);
}
.channel-screen__shelf-viewport {
overflow: hidden;
}
.channel-screen__shelf-track {
display: flex;
gap: 16px;
transition: transform 0.22s ease;
will-change: transform;
}
.channel-screen__vod-card {
flex: 0 0 260px;
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
border-radius: var(--radius);
padding: 6px;
border: 2px solid transparent;
transition: border-color 0.15s;
}
.channel-screen__vod-card--focused {
border-color: var(--color-accent);
background: var(--color-bg-elev2);
}
.channel-screen__vod-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: var(--color-bg-elev2);
border-radius: calc(var(--radius) - 4px);
overflow: hidden;
}
.channel-screen__vod-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.channel-screen__vod-thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1f1f23 0%, #2a2a31 100%);
}
.channel-screen__vod-duration {
position: absolute;
bottom: 6px;
right: 8px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 2px 7px;
border-radius: 4px;
}
.channel-screen__vod-title {
margin: 0;
font-size: 15px;
line-height: 1.35;
color: var(--color-fg);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.channel-screen__vod-meta {
margin: 0;
font-size: 13px;
color: var(--color-fg-dim);
}
/* ── Account Screen ───────────────────────────────── */ /* ── Account Screen ───────────────────────────────── */
.account { .account {
flex: 1; flex: 1;
+11
View File
@@ -34,6 +34,15 @@ export interface PlaybackEvent {
message?: string message?: string
} }
export interface VodInfo {
id: string
title: string
createdAt: string
durationSeconds: number
viewCount: number
thumbnailUrl: string
}
export interface T4sdApi { export interface T4sdApi {
appVersion: string appVersion: string
auth: { auth: {
@@ -46,9 +55,11 @@ export interface T4sdApi {
} }
twitch: { twitch: {
getFollowed: () => Promise<FollowedChannelInfo[]> getFollowed: () => Promise<FollowedChannelInfo[]>
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
} }
playback: { playback: {
startLive: (channelLogin: string, quality?: string) => Promise<void> startLive: (channelLogin: string, quality?: string) => Promise<void>
startVod: (vodId: string, startSeconds?: number) => Promise<void>
stop: () => Promise<void> stop: () => Promise<void>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void onEvent: (cb: (event: PlaybackEvent) => void) => () => void
} }
+10 -9
View File
@@ -66,17 +66,18 @@
- [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück - [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück
### Verifikation ### Verifikation
- [ ] **mpv PATH-Problem lösen** (VS Code neu starten, dann testen — siehe CLAUDE_PROGRESS.md) - [x] Stream startet: mpv-Fenster öffnet sich, Stream spielt ab (2026-04-08)
- [ ] 5 min Live-Stream ohne Werbe-Unterbrechung auf Windows (Dev-Setup) _(Fix: --twitch-api-header entfernt, resolveMpvBin() ergänzt)_
- [ ] Beim Stop: mpv-Fenster schließt sich sauber, streamlink-Prozess terminiert, - [x] **User:** Stop-Verhalten: mpv-Fenster schließt sich, keine Zombie-Prozesse (2026-04-08)
keine Zombie-Prozesse im Task-Manager - [x] **User:** Logout → Login → Stream starten funktioniert ohne App-Neustart (2026-04-08)
- [ ] Logout/Login und erneutes Starten funktioniert ohne App-Neustart ⚠️ `--twitch-disable-ads` ist von streamlink deaktiviert — Werbung wird ggf. abgespielt (Post-MVP)
## Phase 4 — VOD-Browsing + -Wiedergabe ## Phase 4 — VOD-Browsing + -Wiedergabe
- [ ] Helix `GET /videos?user_id=…&type=archive` (paginiert) - [x] Helix `GET /videos?user_id=…&type=archive` → `getVideos()` in helixClient
- [ ] VOD-Listen-Screen pro Channel - [x] VOD-Shelf im ChannelScreen (horizontal, obere Hälfte fix)
- [ ] `playback:start-vod` (mit optionaler Resume-Position) - [x] `playback:start-vod` IPC-Kanal + `PlaybackService.startVod()`
- [ ] mpv-Resume: `--start=<seconds>` beim Spawn - [x] mpv `--start=<seconds>` beim Spawn (Parameter vorbereitet für Phase 5)
- [x] **User:** VOD-Wiedergabe + Stop-Verhalten verifiziert (2026-04-08)
## Phase 5 — Lokaler VOD-Verlauf ## Phase 5 — Lokaler VOD-Verlauf
- [ ] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migrations) - [ ] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migrations)