Compare commits
3 Commits
97fb289fe7
...
48fb0c9487
| Author | SHA1 | Date | |
|---|---|---|---|
| 48fb0c9487 | |||
| c0841aab20 | |||
| 5c496277f2 |
@@ -31,6 +31,7 @@ RATE_LIMIT_MAX_REQUESTS=30
|
||||
API_KEY_WEB=web-frontend-key-change-me-in-production
|
||||
API_KEY_MOBILE=mobile-app-key-change-me-in-production
|
||||
API_KEY_ADMIN=admin-key-super-secret-change-me
|
||||
# Frontend Vite env'de kullanilacak key'ler icin frontend/.env.example dosyasina bak.
|
||||
|
||||
# === TMDB API Configuration ===
|
||||
# Get your API key from https://www.themoviedb.org/settings/api
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
date: 2026-02-28
|
||||
topic: realtime-db-redis-ui-sync
|
||||
---
|
||||
|
||||
# Realtime DB/Redis UI Sync
|
||||
|
||||
## What We're Building
|
||||
Hem `Yonetici Dashboard` hem de `Icerik Katalogu`, backend tarafında oluşan veri değişimlerini Socket.IO ile anlık alacak. Kapsam yalnız job progress değil; DB write, Redis write, TTL bilgisi, cache hit/miss ve source (cache/database/netflix) telemetrisi de canlı akacak.
|
||||
|
||||
Bağlantı kopup geri geldiğinde istemci yalnız yeni eventleri dinlemekle kalmayacak; otomatik snapshot çekip state'i yeniden senkronlayacak. Böylece UI drift ve event kaçırma riski pratikte ortadan kalkacak.
|
||||
|
||||
## Why This Approach
|
||||
Üç model değerlendirildi:
|
||||
|
||||
1. Event-only (lightweight): sadece socket eventleriyle state güncelleme.
|
||||
2. Polling fallback: belirli aralıkla REST çekme + eventlerle destekleme.
|
||||
3. Event + reconnect snapshot (seçilen): normalde event akışı, reconnect sonrası tam snapshot senkronu.
|
||||
|
||||
Seçim sebebi: Kullanıcı ihtiyacı "anlık" ve "doğru"; reconnect senaryosu kritik. Event+snapshot, replay/offset kadar karmaşık olmadan güçlü tutarlılık sağlar.
|
||||
|
||||
## Key Decisions
|
||||
- Kapsam: canlı güncelleme hem Dashboard hem Katalog için açık olacak.
|
||||
- Event ailesi genişletilecek:
|
||||
- `content:created|updated|deleted`
|
||||
- `cache:written|expired|cleared`
|
||||
- `metrics:updated` (hit/miss, source dağılımı)
|
||||
- mevcut `job:*` korunacak.
|
||||
- Snapshot endpointleri:
|
||||
- admin panel için `/api/admin/overview`
|
||||
- katalog için `/api/content` (gerekirse hafif summary endpoint)
|
||||
- Reconnect davranışı: socket reconnect olduğunda her iki görünüm kendi snapshot'unu otomatik yenileyecek.
|
||||
- YAGNI: event replay/offset ilk sürümde yok; gerekirse v2.
|
||||
|
||||
## Open Questions
|
||||
- Katalogda tüm değişimler anında kart gridine mi işlenecek, yoksa "yeni içerik geldi" toast + soft refresh mi tercih edilecek?
|
||||
- Dashboard eventleri için throttling (ör. 250ms batch) gerekli mi?
|
||||
|
||||
## Next Steps
|
||||
- `/workflows:plan` ile implementasyon adımlarını ve dosya değişimlerini netleştir.
|
||||
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# Frontend (Vite) environment variables
|
||||
# Copy to frontend/.env.local for local development
|
||||
|
||||
# Must match backend API_KEY_WEB value
|
||||
VITE_WEB_API_KEY=web-frontend-key-change-me-in-production
|
||||
|
||||
# Must match backend API_KEY_ADMIN value (for admin dashboard endpoints)
|
||||
VITE_ADMIN_API_KEY=admin-key-super-secret-change-me
|
||||
@@ -1,2 +1 @@
|
||||
/* App styles - Mantine handles most styling */
|
||||
|
||||
/* Page-level styles are collocated in route-level css files. */
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #1a1a1b;
|
||||
min-height: 100vh;
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;700;800&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-base: #090a0e;
|
||||
--bg-alt: #10131d;
|
||||
--surface: rgba(19, 22, 33, 0.72);
|
||||
--surface-strong: rgba(27, 33, 48, 0.94);
|
||||
--text-main: #f4f5f8;
|
||||
--text-muted: #adb4c2;
|
||||
--line-soft: rgba(255, 255, 255, 0.12);
|
||||
--accent-main: #eb2338;
|
||||
--accent-warm: #ff9b42;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% -8%, rgba(235, 35, 56, 0.22), transparent 58%),
|
||||
radial-gradient(900px 640px at 90% 10%, rgba(255, 155, 66, 0.14), transparent 58%),
|
||||
linear-gradient(140deg, var(--bg-base), var(--bg-alt));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.22;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 42px 42px, 42px 42px;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,28 @@ import { createRoot } from 'react-dom/client'
|
||||
import { MantineProvider, createTheme } from '@mantine/core'
|
||||
import '@mantine/core/styles.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import App from './App'
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: 'red',
|
||||
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
headings: {
|
||||
fontFamily: '"Bricolage Grotesque", "IBM Plex Sans", sans-serif',
|
||||
sizes: {
|
||||
h1: { fontSize: 'clamp(2rem, 4vw, 3.6rem)', lineHeight: '1.02', fontWeight: '700' },
|
||||
h2: { fontSize: 'clamp(1.4rem, 2.6vw, 2.1rem)', lineHeight: '1.1', fontWeight: '700' },
|
||||
h3: { fontSize: '1.12rem', lineHeight: '1.2', fontWeight: '650' },
|
||||
},
|
||||
},
|
||||
radius: {
|
||||
md: '14px',
|
||||
lg: '20px',
|
||||
xl: '28px',
|
||||
},
|
||||
shadows: {
|
||||
md: '0 18px 45px rgba(0, 0, 0, 0.28)',
|
||||
xl: '0 30px 70px rgba(0, 0, 0, 0.36)',
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
431
frontend/src/pages/movies-page.css
Normal file
431
frontend/src/pages/movies-page.css
Normal file
@@ -0,0 +1,431 @@
|
||||
.catalog-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.catalog-hero {
|
||||
background:
|
||||
linear-gradient(130deg, rgba(235, 35, 56, 0.14), rgba(13, 17, 28, 0.96) 48%),
|
||||
radial-gradient(circle at 80% 18%, rgba(255, 155, 66, 0.2), transparent 45%);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.stats-wrap {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stat-pill {
|
||||
min-width: 88px;
|
||||
text-align: right;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
z-index: 20;
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(14, 18, 28, 0.82);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
background: linear-gradient(150deg, rgba(15, 20, 32, 0.88), rgba(11, 14, 23, 0.92));
|
||||
border-color: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
.view-nav {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
z-index: 30;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(10, 15, 26, 0.84);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.catalog-view-shell {
|
||||
padding-inline: clamp(0.5rem, 1.4vw, 1.35rem);
|
||||
}
|
||||
|
||||
.nav-pill {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
transition: transform 180ms ease, border-color 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
.nav-pill .mantine-Button-label,
|
||||
.nav-pill .mantine-Button-section {
|
||||
transition: color 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.nav-pill.is-active {
|
||||
border-color: rgba(235, 35, 56, 0.95);
|
||||
box-shadow: 0 0 0 1px rgba(235, 35, 56, 0.26) inset;
|
||||
}
|
||||
|
||||
.nav-pill.is-active .mantine-Button-label,
|
||||
.nav-pill.is-active .mantine-Button-section {
|
||||
color: #f8fbff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-pill.is-inactive {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.nav-pill.is-inactive .mantine-Button-label,
|
||||
.nav-pill.is-inactive .mantine-Button-section {
|
||||
color: rgba(218, 226, 240, 0.86);
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.nav-pill:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(255, 155, 66, 0.45);
|
||||
}
|
||||
|
||||
.nav-pill.is-inactive:hover .mantine-Button-label,
|
||||
.nav-pill.is-inactive:hover .mantine-Button-section {
|
||||
color: rgba(246, 250, 255, 0.96);
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.list-row {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-key-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.genre-chip {
|
||||
cursor: pointer;
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.genre-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.genre-chip:focus-visible {
|
||||
outline: 2px solid rgba(255, 155, 66, 0.95);
|
||||
outline-offset: 2px;
|
||||
border-color: rgba(255, 155, 66, 0.95);
|
||||
}
|
||||
|
||||
.catalog-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(165deg, rgba(29, 33, 44, 0.95), rgba(15, 19, 30, 0.96));
|
||||
border-color: rgba(255, 255, 255, 0.11);
|
||||
transition: transform 220ms ease, box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.catalog-card:hover {
|
||||
transform: translateY(-7px);
|
||||
box-shadow: 0 28px 65px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.catalog-card:focus-visible {
|
||||
outline: 2px solid rgba(255, 155, 66, 0.92);
|
||||
outline-offset: 3px;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 24px 54px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.live-fade-in {
|
||||
animation: live-fade-in 1500ms ease;
|
||||
}
|
||||
|
||||
@keyframes live-fade-in {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
box-shadow: 0 0 0 rgba(235, 35, 56, 0), 0 0 0 rgba(255, 214, 102, 0);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1.004);
|
||||
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.56), 0 0 28px rgba(255, 214, 102, 0.42);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
box-shadow: 0 0 0 2px rgba(235, 35, 56, 0.35), 0 0 20px rgba(235, 35, 56, 0.22);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
box-shadow: 0 0 0 rgba(235, 35, 56, 0), 0 0 0 rgba(255, 214, 102, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.live-ttl-flash {
|
||||
animation: ttl-flash 1050ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes ttl-flash {
|
||||
0% {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 rgba(0, 0, 0, 0);
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
38% {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.34), inset 0 0 28px rgba(0, 0, 0, 0.42);
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.32), rgba(255, 255, 255, 0.012));
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 rgba(0, 0, 0, 0);
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
.media-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-shell {
|
||||
position: relative;
|
||||
height: 190px;
|
||||
}
|
||||
|
||||
.image-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lazy-image {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.lazy-image img {
|
||||
filter: blur(14px);
|
||||
transform: scale(1.05);
|
||||
opacity: 0.86;
|
||||
transition: filter 320ms ease, transform 420ms ease, opacity 240ms ease;
|
||||
}
|
||||
|
||||
.lazy-image.is-loaded img {
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-fallback {
|
||||
background: linear-gradient(160deg, rgba(35, 38, 52, 0.95), rgba(24, 28, 39, 0.95));
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
|
||||
.media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to top, rgba(8, 10, 16, 0.86), rgba(8, 10, 16, 0.05) 55%);
|
||||
}
|
||||
|
||||
.detail-modal-content {
|
||||
background: linear-gradient(150deg, rgba(16, 20, 31, 0.97), rgba(11, 14, 24, 0.97));
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.detail-modal-header {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-modal-body {
|
||||
padding-top: 0.6rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-media-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.detail-media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(8, 10, 16, 0.92), rgba(8, 10, 16, 0.1) 58%),
|
||||
radial-gradient(circle at 76% 22%, rgba(235, 35, 56, 0.22), transparent 42%);
|
||||
}
|
||||
|
||||
.detail-title-group {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 14px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
color: #f5f8fc;
|
||||
text-shadow: 0 5px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.detail-plot {
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.detail-brand-stamp {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 16px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
opacity: 0.96;
|
||||
filter: drop-shadow(0 7px 16px rgba(0, 0, 0, 0.44));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'Bricolage Grotesque', 'IBM Plex Sans', sans-serif;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.brand-stamp {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
opacity: 0.92;
|
||||
filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.38));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: linear-gradient(145deg, rgba(22, 26, 37, 0.9), rgba(14, 17, 26, 0.95));
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.admin-toast {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
z-index: 80;
|
||||
max-width: min(420px, calc(100vw - 24px));
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(84, 224, 187, 0.4);
|
||||
background: linear-gradient(145deg, rgba(8, 40, 42, 0.95), rgba(10, 20, 32, 0.95));
|
||||
color: #dffcf2;
|
||||
font-size: 0.92rem;
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.35);
|
||||
animation: toast-in 180ms ease;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: reveal-in 420ms ease forwards;
|
||||
}
|
||||
|
||||
@keyframes reveal-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.view-nav {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.view-nav > div {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.catalog-view-shell {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.stats-wrap {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.admin-toast {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal,
|
||||
.catalog-card,
|
||||
.genre-chip,
|
||||
.lazy-image img,
|
||||
.live-fade-in,
|
||||
.live-ttl-flash {
|
||||
animation: none;
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export default defineConfig({
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Server as HttpServer } from 'http';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import logger from '../utils/logger.js';
|
||||
import type { DataSource, GetInfoResponse } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Socket.IO Server singleton
|
||||
@@ -11,6 +12,32 @@ export interface SocketData {
|
||||
subscribedJobs: Set<string>;
|
||||
}
|
||||
|
||||
export interface ContentRealtimeEvent {
|
||||
action: 'created' | 'updated' | 'deleted';
|
||||
url: string;
|
||||
content?: GetInfoResponse;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface CacheRealtimeEvent {
|
||||
action: 'written' | 'deleted' | 'cleared';
|
||||
key?: string;
|
||||
ttlSeconds?: number;
|
||||
count?: number;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface MetricsRealtimeEvent {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
sourceCounts: {
|
||||
cache: number;
|
||||
database: number;
|
||||
netflix: number;
|
||||
};
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Socket.IO server
|
||||
*/
|
||||
@@ -86,8 +113,8 @@ export function emitJobProgress(
|
||||
*/
|
||||
export function emitJobCompleted(
|
||||
jobId: string,
|
||||
data: unknown,
|
||||
source: string
|
||||
data: GetInfoResponse,
|
||||
source: DataSource
|
||||
): void {
|
||||
if (io) {
|
||||
io.to(`job:${jobId}`).emit('job:completed', {
|
||||
@@ -98,6 +125,33 @@ export function emitJobCompleted(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit realtime content mutation event to all clients
|
||||
*/
|
||||
export function emitContentEvent(event: ContentRealtimeEvent): void {
|
||||
if (io) {
|
||||
io.emit('content:event', event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit realtime cache event to all clients
|
||||
*/
|
||||
export function emitCacheEvent(event: CacheRealtimeEvent): void {
|
||||
if (io) {
|
||||
io.emit('cache:event', event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit realtime metrics event to all clients
|
||||
*/
|
||||
export function emitMetricsEvent(event: MetricsRealtimeEvent): void {
|
||||
if (io) {
|
||||
io.emit('metrics:updated', event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit job error event
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { getValidApiKeys } from '../config/env.js';
|
||||
import { env, getValidApiKeys } from '../config/env.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import type { ApiResponse } from '../types/index.js';
|
||||
|
||||
@@ -61,8 +61,6 @@ export function authMiddleware(
|
||||
* Optional: Identify which client made the request
|
||||
*/
|
||||
export function identifyClient(apiKey: string): string {
|
||||
const { env } = require('../config/env.js');
|
||||
|
||||
if (apiKey === env.API_KEY_WEB) return 'web';
|
||||
if (apiKey === env.API_KEY_MOBILE) return 'mobile';
|
||||
if (apiKey === env.API_KEY_ADMIN) return 'admin';
|
||||
@@ -70,4 +68,46 @@ export function identifyClient(apiKey: string): string {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict admin API key guard.
|
||||
* Use for admin-only operational endpoints.
|
||||
*/
|
||||
export function adminOnlyMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_API_KEY',
|
||||
message: 'API key is required. Include X-API-Key header.',
|
||||
},
|
||||
} satisfies ApiResponse<never>);
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiKey !== env.API_KEY_ADMIN) {
|
||||
logger.warn('Admin endpoint access denied', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
keyPrefix: apiKey.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'ADMIN_API_KEY_REQUIRED',
|
||||
message: 'Admin API key required.',
|
||||
},
|
||||
} satisfies ApiResponse<never>);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default authMiddleware;
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware } from '../middleware/auth.middleware.js';
|
||||
import { adminOnlyMiddleware, authMiddleware } from '../middleware/auth.middleware.js';
|
||||
import { scrapeRateLimiter } from '../middleware/rateLimit.middleware.js';
|
||||
import { validateGetInfo } from '../middleware/validation.middleware.js';
|
||||
import { JobService } from '../services/job.service.js';
|
||||
import { ContentService } from '../services/content.service.js';
|
||||
import type { ApiResponse, GetInfoRequest, GetInfoResponse } from '../types/index.js';
|
||||
import { AdminService } from '../services/admin.service.js';
|
||||
import type {
|
||||
AdminOverviewResponse,
|
||||
AdminActionResponse,
|
||||
ApiResponse,
|
||||
GetInfoRequest,
|
||||
GetInfoResponse,
|
||||
} from '../types/index.js';
|
||||
|
||||
const router = Router();
|
||||
const listContentSchema = z.object({
|
||||
type: z.enum(['movie', 'tvshow']).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
const retryFailedJobsSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(50).default(10),
|
||||
});
|
||||
const refreshStaleSchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(365).default(30),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/getinfo
|
||||
@@ -118,6 +132,160 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/admin/overview
|
||||
* Admin dashboard summary metrics (cache, db, jobs)
|
||||
*
|
||||
* Headers: X-API-Key: <api_key>
|
||||
*/
|
||||
router.get(
|
||||
'/admin/overview',
|
||||
adminOnlyMiddleware,
|
||||
async (_req: Request, res: Response<ApiResponse<AdminOverviewResponse>>) => {
|
||||
try {
|
||||
const overview = await AdminService.getOverview();
|
||||
res.json({
|
||||
success: true,
|
||||
data: overview,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'ADMIN_OVERVIEW_ERROR',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch admin overview',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/cache/clear
|
||||
* Delete all content cache keys from Redis.
|
||||
*/
|
||||
router.post(
|
||||
'/admin/cache/clear',
|
||||
adminOnlyMiddleware,
|
||||
async (_req: Request, res: Response<ApiResponse<AdminActionResponse>>) => {
|
||||
try {
|
||||
const result = await AdminService.clearCache();
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'ADMIN_CACHE_CLEAR_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Failed to clear cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/cache/warmup
|
||||
* Warm Redis cache from all DB content.
|
||||
*/
|
||||
router.post(
|
||||
'/admin/cache/warmup',
|
||||
adminOnlyMiddleware,
|
||||
async (_req: Request, res: Response<ApiResponse<AdminActionResponse>>) => {
|
||||
try {
|
||||
const result = await AdminService.warmupCacheFromDatabase();
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'ADMIN_CACHE_WARMUP_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Failed to warm cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/retry-failed
|
||||
* Requeue recent failed jobs.
|
||||
*/
|
||||
router.post(
|
||||
'/admin/jobs/retry-failed',
|
||||
adminOnlyMiddleware,
|
||||
async (req: Request, res: Response<ApiResponse<AdminActionResponse>>) => {
|
||||
const parsed = retryFailedJobsSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid retry request',
|
||||
details: { errors: parsed.error.issues },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await AdminService.retryFailedJobs(parsed.data.limit);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'ADMIN_RETRY_FAILED_ERROR',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Failed to retry failed jobs',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/content/refresh-stale
|
||||
* Queue refresh jobs for stale content entries.
|
||||
*/
|
||||
router.post(
|
||||
'/admin/content/refresh-stale',
|
||||
adminOnlyMiddleware,
|
||||
async (req: Request, res: Response<ApiResponse<AdminActionResponse>>) => {
|
||||
const parsed = refreshStaleSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid stale refresh request',
|
||||
details: { errors: parsed.error.issues },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await AdminService.refreshStaleContent(
|
||||
parsed.data.days,
|
||||
parsed.data.limit
|
||||
);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'ADMIN_STALE_REFRESH_ERROR',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Failed to queue stale refresh',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/getinfo/async
|
||||
* Create async job for content scraping
|
||||
|
||||
455
src/services/admin.service.ts
Normal file
455
src/services/admin.service.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import prisma from '../config/database.js';
|
||||
import redis from '../config/redis.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { JobService } from './job.service.js';
|
||||
import { MetricsService } from './metrics.service.js';
|
||||
import { CacheService } from './cache.service.js';
|
||||
import { ContentService } from './content.service.js';
|
||||
import type { AdminActionResponse, AdminOverviewResponse } from '../types/index.js';
|
||||
|
||||
const CACHE_PREFIX = 'netflix:content:';
|
||||
const MAX_CACHE_KEYS_FOR_ANALYSIS = 1000;
|
||||
|
||||
function formatCacheKeyLabel(key: string): string {
|
||||
return key.replace(CACHE_PREFIX, '');
|
||||
}
|
||||
|
||||
function extractTitleIdFromCacheKey(key: string): string | null {
|
||||
const normalized = formatCacheKeyLabel(key);
|
||||
return /^\d+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function extractTitleIdFromUrl(url: string): string | null {
|
||||
return url.match(/\/title\/(\d+)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseRedisInfoValue(info: string, key: string): number | null {
|
||||
const line = info
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.find((item) => item.startsWith(`${key}:`));
|
||||
if (!line) return null;
|
||||
const raw = line.slice(key.length + 1).trim();
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function collectCacheKeys(limit?: number): Promise<{ keys: string[]; sampled: boolean }> {
|
||||
let cursor = '0';
|
||||
const keys: string[] = [];
|
||||
|
||||
do {
|
||||
const [nextCursor, batchKeys] = await redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${CACHE_PREFIX}*`,
|
||||
'COUNT',
|
||||
200
|
||||
);
|
||||
|
||||
cursor = nextCursor;
|
||||
keys.push(...batchKeys);
|
||||
|
||||
if (limit && keys.length >= limit) {
|
||||
return { keys: keys.slice(0, limit), sampled: true };
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return { keys, sampled: false };
|
||||
}
|
||||
|
||||
export class AdminService {
|
||||
static async getOverview(): Promise<AdminOverviewResponse> {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [
|
||||
totalContent,
|
||||
recent24h,
|
||||
recent7d,
|
||||
missingPlot,
|
||||
missingAgeRating,
|
||||
missingBackdrop,
|
||||
groupedTypes,
|
||||
groupedJobs,
|
||||
recentFailedJobs,
|
||||
recentFinishedJobs,
|
||||
topGenreLinks,
|
||||
{ keys: cacheKeys, sampled: cacheSampled },
|
||||
metricsSnapshot,
|
||||
redisMemoryInfo,
|
||||
] = await Promise.all([
|
||||
prisma.content.count(),
|
||||
prisma.content.count({ where: { createdAt: { gte: oneDayAgo } } }),
|
||||
prisma.content.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
prisma.content.count({ where: { plot: null } }),
|
||||
prisma.content.count({ where: { ageRating: null } }),
|
||||
prisma.content.count({ where: { backdropUrl: null } }),
|
||||
prisma.content.groupBy({ by: ['type'], _count: { type: true } }),
|
||||
prisma.scrapeJob.groupBy({ by: ['status'], _count: { status: true } }),
|
||||
prisma.scrapeJob.findMany({
|
||||
where: { status: 'failed' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 8,
|
||||
select: { id: true, url: true, error: true, updatedAt: true },
|
||||
}),
|
||||
prisma.scrapeJob.findMany({
|
||||
where: { status: { in: ['completed', 'failed'] } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 300,
|
||||
select: { createdAt: true, updatedAt: true },
|
||||
}),
|
||||
prisma.contentGenre.groupBy({
|
||||
by: ['genreId'],
|
||||
_count: { genreId: true },
|
||||
orderBy: { _count: { genreId: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
collectCacheKeys(MAX_CACHE_KEYS_FOR_ANALYSIS),
|
||||
MetricsService.getSnapshot(),
|
||||
redis.info('memory').catch(() => ''),
|
||||
]);
|
||||
|
||||
const genreIds = topGenreLinks.map((item) => item.genreId);
|
||||
const genres = genreIds.length
|
||||
? await prisma.genre.findMany({
|
||||
where: { id: { in: genreIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const genreMap = new Map(genres.map((genre) => [genre.id, genre.name]));
|
||||
|
||||
const ttlPipeline = redis.pipeline();
|
||||
const sizePipeline = redis.pipeline();
|
||||
const valuePipeline = redis.pipeline();
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
ttlPipeline.ttl(key);
|
||||
sizePipeline.strlen(key);
|
||||
valuePipeline.get(key);
|
||||
}
|
||||
|
||||
const [ttlResults, sizeResults, valueResults] = await Promise.all([
|
||||
ttlPipeline.exec(),
|
||||
sizePipeline.exec(),
|
||||
valuePipeline.exec(),
|
||||
]);
|
||||
|
||||
const ttlDistribution = {
|
||||
expiredOrNoTtl: 0,
|
||||
lessThan5Min: 0,
|
||||
min5To30: 0,
|
||||
min30Plus: 0,
|
||||
};
|
||||
|
||||
const cacheTitleIds = Array.from(
|
||||
new Set(cacheKeys.map((key) => extractTitleIdFromCacheKey(key)).filter((id): id is string => Boolean(id)))
|
||||
);
|
||||
|
||||
const relatedContent = cacheTitleIds.length
|
||||
? await prisma.content.findMany({
|
||||
where: {
|
||||
OR: cacheTitleIds.map((id) => ({
|
||||
url: { contains: `/title/${id}` },
|
||||
})),
|
||||
},
|
||||
select: {
|
||||
url: true,
|
||||
title: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const titleMap = new Map<string, string>();
|
||||
for (const item of relatedContent) {
|
||||
const id = extractTitleIdFromUrl(item.url);
|
||||
if (id && !titleMap.has(id)) {
|
||||
titleMap.set(id, item.title);
|
||||
}
|
||||
}
|
||||
|
||||
const expiringSoon: {
|
||||
key: string;
|
||||
mediaTitle?: string | null;
|
||||
cachedAt?: number | null;
|
||||
ttlSeconds: number;
|
||||
}[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (let i = 0; i < cacheKeys.length; i += 1) {
|
||||
const ttlValue = Number(ttlResults?.[i]?.[1] ?? -2);
|
||||
const sizeValue = Number(sizeResults?.[i]?.[1] ?? 0);
|
||||
const safeSize = Number.isFinite(sizeValue) ? Math.max(0, sizeValue) : 0;
|
||||
totalBytes += safeSize;
|
||||
|
||||
if (ttlValue <= 0) {
|
||||
ttlDistribution.expiredOrNoTtl += 1;
|
||||
} else if (ttlValue < 300) {
|
||||
ttlDistribution.lessThan5Min += 1;
|
||||
} else if (ttlValue <= 1800) {
|
||||
ttlDistribution.min5To30 += 1;
|
||||
} else {
|
||||
ttlDistribution.min30Plus += 1;
|
||||
}
|
||||
|
||||
if (ttlValue > 0) {
|
||||
const formattedKey = formatCacheKeyLabel(cacheKeys[i] || '');
|
||||
const titleId = extractTitleIdFromCacheKey(cacheKeys[i] || '');
|
||||
const rawValue = valueResults?.[i]?.[1];
|
||||
let cachedAt: number | null = null;
|
||||
if (typeof rawValue === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as { cachedAt?: unknown };
|
||||
cachedAt = typeof parsed.cachedAt === 'number' ? parsed.cachedAt : null;
|
||||
} catch {
|
||||
cachedAt = null;
|
||||
}
|
||||
}
|
||||
expiringSoon.push({
|
||||
key: formattedKey,
|
||||
mediaTitle: titleId ? titleMap.get(titleId) ?? null : null,
|
||||
cachedAt,
|
||||
ttlSeconds: ttlValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expiringSoon.sort((a, b) => {
|
||||
const aCachedAt = a.cachedAt ?? 0;
|
||||
const bCachedAt = b.cachedAt ?? 0;
|
||||
if (aCachedAt !== bCachedAt) return bCachedAt - aCachedAt;
|
||||
return b.ttlSeconds - a.ttlSeconds;
|
||||
});
|
||||
|
||||
const jobCounts = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
for (const row of groupedJobs) {
|
||||
if (row.status in jobCounts) {
|
||||
jobCounts[row.status as keyof typeof jobCounts] = row._count.status;
|
||||
}
|
||||
}
|
||||
|
||||
const contentByType = {
|
||||
movie: 0,
|
||||
tvshow: 0,
|
||||
};
|
||||
|
||||
for (const row of groupedTypes) {
|
||||
if (row.type in contentByType) {
|
||||
contentByType[row.type as keyof typeof contentByType] = row._count.type;
|
||||
}
|
||||
}
|
||||
|
||||
const averageDurationMs =
|
||||
recentFinishedJobs.length === 0
|
||||
? 0
|
||||
: Math.round(
|
||||
recentFinishedJobs.reduce((sum, job) => {
|
||||
const duration = job.updatedAt.getTime() - job.createdAt.getTime();
|
||||
return sum + Math.max(0, duration);
|
||||
}, 0) / recentFinishedJobs.length
|
||||
);
|
||||
|
||||
const totalCacheLookups = metricsSnapshot.cacheHits + metricsSnapshot.cacheMisses;
|
||||
const cacheHitRate = totalCacheLookups
|
||||
? Number((metricsSnapshot.cacheHits / totalCacheLookups).toFixed(4))
|
||||
: 0;
|
||||
const redisUsedBytes = parseRedisInfoValue(redisMemoryInfo, 'used_memory') ?? 0;
|
||||
const redisMaxBytesRaw = parseRedisInfoValue(redisMemoryInfo, 'maxmemory');
|
||||
const redisMaxBytes = redisMaxBytesRaw && redisMaxBytesRaw > 0 ? redisMaxBytesRaw : null;
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
environment: env.NODE_ENV,
|
||||
cache: {
|
||||
configuredTtlSeconds: env.REDIS_TTL_SECONDS,
|
||||
keyCount: cacheKeys.length,
|
||||
analyzedKeyLimit: MAX_CACHE_KEYS_FOR_ANALYSIS,
|
||||
sampled: cacheSampled,
|
||||
totalBytes,
|
||||
redisMemory: {
|
||||
usedBytes: redisUsedBytes,
|
||||
maxBytes: redisMaxBytes,
|
||||
},
|
||||
ttlDistribution,
|
||||
expiringSoon: expiringSoon.slice(0, 10),
|
||||
},
|
||||
content: {
|
||||
total: totalContent,
|
||||
byType: contentByType,
|
||||
addedLast24h: recent24h,
|
||||
addedLast7d: recent7d,
|
||||
metadataGaps: {
|
||||
missingPlot,
|
||||
missingAgeRating,
|
||||
missingBackdrop,
|
||||
},
|
||||
topGenres: topGenreLinks.map((item) => ({
|
||||
name: genreMap.get(item.genreId) ?? 'Unknown',
|
||||
count: item._count.genreId,
|
||||
})),
|
||||
},
|
||||
jobs: {
|
||||
counts: jobCounts,
|
||||
averageDurationMs,
|
||||
failedRecent: recentFailedJobs.map((job) => ({
|
||||
id: job.id,
|
||||
url: job.url,
|
||||
error: job.error ?? 'Unknown error',
|
||||
updatedAt: job.updatedAt.toISOString(),
|
||||
})),
|
||||
},
|
||||
requestMetrics: {
|
||||
cacheHits: metricsSnapshot.cacheHits,
|
||||
cacheMisses: metricsSnapshot.cacheMisses,
|
||||
cacheHitRate,
|
||||
sourceCounts: metricsSnapshot.bySource,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static async clearCache(): Promise<AdminActionResponse> {
|
||||
const { keys } = await collectCacheKeys();
|
||||
if (keys.length === 0) {
|
||||
return {
|
||||
queued: 0,
|
||||
skipped: 0,
|
||||
details: 'No cache keys matched prefix',
|
||||
};
|
||||
}
|
||||
|
||||
await redis.del(...keys);
|
||||
return {
|
||||
queued: keys.length,
|
||||
skipped: 0,
|
||||
details: 'Cache keys deleted',
|
||||
};
|
||||
}
|
||||
|
||||
static async warmupCacheFromDatabase(): Promise<AdminActionResponse> {
|
||||
const allContent = await prisma.content.findMany({
|
||||
include: {
|
||||
genres: {
|
||||
include: {
|
||||
genre: true,
|
||||
},
|
||||
},
|
||||
castMembers: {
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
let queued = 0;
|
||||
for (const item of allContent) {
|
||||
const apiPayload = ContentService.toApiResponse({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
plot: item.plot,
|
||||
backdropUrl: item.backdropUrl,
|
||||
ageRating: item.ageRating,
|
||||
type: item.type as 'movie' | 'tvshow',
|
||||
currentSeason: item.currentSeason,
|
||||
genres: item.genres.map((g) => g.genre.name),
|
||||
cast: item.castMembers.map((c) => c.name),
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
});
|
||||
|
||||
await CacheService.set(item.url, apiPayload);
|
||||
queued += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
queued,
|
||||
skipped: 0,
|
||||
details: 'Database content written to Redis cache',
|
||||
};
|
||||
}
|
||||
|
||||
static async retryFailedJobs(limit: number): Promise<AdminActionResponse> {
|
||||
const failedJobs = await prisma.scrapeJob.findMany({
|
||||
where: { status: 'failed' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limit,
|
||||
select: { url: true },
|
||||
});
|
||||
|
||||
let queued = 0;
|
||||
let skipped = 0;
|
||||
const uniqueUrls = Array.from(new Set(failedJobs.map((job) => job.url)));
|
||||
|
||||
for (const url of uniqueUrls) {
|
||||
const activeJob = await prisma.scrapeJob.findFirst({
|
||||
where: { url, status: { in: ['pending', 'processing'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (activeJob) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const job = await JobService.create(url);
|
||||
JobService.process(job.id).catch(() => {
|
||||
// async retry failures are reflected in job status
|
||||
});
|
||||
queued += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
queued,
|
||||
skipped,
|
||||
details: 'Failed jobs retried',
|
||||
};
|
||||
}
|
||||
|
||||
static async refreshStaleContent(days: number, limit: number): Promise<AdminActionResponse> {
|
||||
const threshold = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
const staleContent = await prisma.content.findMany({
|
||||
where: { updatedAt: { lt: threshold } },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: limit,
|
||||
select: { url: true },
|
||||
});
|
||||
|
||||
let queued = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const item of staleContent) {
|
||||
const activeJob = await prisma.scrapeJob.findFirst({
|
||||
where: { url: item.url, status: { in: ['pending', 'processing'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (activeJob) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const job = await JobService.create(item.url);
|
||||
JobService.process(job.id).catch(() => {
|
||||
// async refresh failures are reflected in job status
|
||||
});
|
||||
queued += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
queued,
|
||||
skipped,
|
||||
details: `Stale content refresh queued for items older than ${days} days`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminService;
|
||||
@@ -1,7 +1,8 @@
|
||||
import redis from '../config/redis.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { emitCacheEvent } from '../config/socket.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import type { GetInfoResponse, CacheEntry, DataSource } from '../types/index.js';
|
||||
import type { GetInfoResponse, CacheEntry } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Cache key prefix for Netflix content
|
||||
@@ -63,6 +64,12 @@ export class CacheService {
|
||||
|
||||
try {
|
||||
await redis.setex(key, ttl, JSON.stringify(entry));
|
||||
emitCacheEvent({
|
||||
action: 'written',
|
||||
key,
|
||||
ttlSeconds: ttl,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('Cache set', { url, ttl });
|
||||
} catch (error) {
|
||||
logger.error('Cache set error', {
|
||||
@@ -80,6 +87,11 @@ export class CacheService {
|
||||
|
||||
try {
|
||||
await redis.del(key);
|
||||
emitCacheEvent({
|
||||
action: 'deleted',
|
||||
key,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('Cache deleted', { url });
|
||||
} catch (error) {
|
||||
logger.error('Cache delete error', {
|
||||
@@ -133,6 +145,11 @@ export class CacheService {
|
||||
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
emitCacheEvent({
|
||||
action: 'cleared',
|
||||
count: keys.length,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
logger.info('Cache cleared', { count: keys.length });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import prisma from '../config/database.js';
|
||||
import { emitContentEvent } from '../config/socket.js';
|
||||
import type { ContentData, ScraperResult, GetInfoResponse } from '../types/index.js';
|
||||
|
||||
/**
|
||||
@@ -105,7 +106,14 @@ export class ContentService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapToContentData(content);
|
||||
const mapped = this.mapToContentData(content);
|
||||
emitContentEvent({
|
||||
action: 'created',
|
||||
url,
|
||||
content: this.toApiResponse(mapped),
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +179,14 @@ export class ContentService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapToContentData(content);
|
||||
const mapped = this.mapToContentData(content);
|
||||
emitContentEvent({
|
||||
action: 'updated',
|
||||
url,
|
||||
content: this.toApiResponse(mapped),
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +196,11 @@ export class ContentService {
|
||||
await prisma.content.delete({
|
||||
where: { url },
|
||||
});
|
||||
emitContentEvent({
|
||||
action: 'deleted',
|
||||
url,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from '../config/database.js';
|
||||
import { CacheService } from './cache.service.js';
|
||||
import { ContentService } from './content.service.js';
|
||||
import { ScraperService } from './scraper.service.js';
|
||||
import { MetricsService } from './metrics.service.js';
|
||||
import {
|
||||
emitJobProgress,
|
||||
emitJobCompleted,
|
||||
@@ -94,9 +95,11 @@ export class JobService {
|
||||
// Step 1: Check cache
|
||||
const cachedData = await CacheService.get(job.url);
|
||||
if (cachedData) {
|
||||
await MetricsService.incrementCacheHit();
|
||||
await this.completeJob(jobId, cachedData, 'cache');
|
||||
return;
|
||||
}
|
||||
await MetricsService.incrementCacheMiss();
|
||||
|
||||
// Update progress
|
||||
await this.update(jobId, { progress: 30, step: 'checking_database' });
|
||||
@@ -169,6 +172,7 @@ export class JobService {
|
||||
});
|
||||
|
||||
emitJobCompleted(jobId, data, source);
|
||||
await MetricsService.incrementSource(source);
|
||||
logger.info('Job completed', { jobId, source });
|
||||
}
|
||||
|
||||
@@ -182,14 +186,18 @@ export class JobService {
|
||||
// Step 1: Check cache
|
||||
const cachedData = await CacheService.get(url);
|
||||
if (cachedData) {
|
||||
await MetricsService.incrementCacheHit();
|
||||
await MetricsService.incrementSource('cache');
|
||||
return { data: cachedData, source: 'cache' };
|
||||
}
|
||||
await MetricsService.incrementCacheMiss();
|
||||
|
||||
// Step 2: Check database
|
||||
const dbContent = await ContentService.findByUrl(url);
|
||||
if (dbContent) {
|
||||
const responseData = ContentService.toApiResponse(dbContent);
|
||||
await CacheService.set(url, responseData);
|
||||
await MetricsService.incrementSource('database');
|
||||
return { data: responseData, source: 'database' };
|
||||
}
|
||||
|
||||
@@ -202,6 +210,7 @@ export class JobService {
|
||||
|
||||
// Step 5: Cache the result
|
||||
await CacheService.set(url, responseData);
|
||||
await MetricsService.incrementSource('netflix');
|
||||
|
||||
return { data: responseData, source: 'netflix' };
|
||||
}
|
||||
|
||||
82
src/services/metrics.service.ts
Normal file
82
src/services/metrics.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import redis from '../config/redis.js';
|
||||
import { emitMetricsEvent } from '../config/socket.js';
|
||||
import type { DataSource } from '../types/index.js';
|
||||
|
||||
const COUNTERS_KEY = 'metrics:counters';
|
||||
const SOURCES_KEY = 'metrics:sources';
|
||||
|
||||
function toInt(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export class MetricsService {
|
||||
private static async emitSnapshot(): Promise<void> {
|
||||
try {
|
||||
const snapshot = await this.getSnapshot();
|
||||
emitMetricsEvent({
|
||||
cacheHits: snapshot.cacheHits,
|
||||
cacheMisses: snapshot.cacheMisses,
|
||||
sourceCounts: snapshot.bySource,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// best-effort metrics emit
|
||||
}
|
||||
}
|
||||
|
||||
static async incrementCacheHit(): Promise<void> {
|
||||
try {
|
||||
await redis.hincrby(COUNTERS_KEY, 'cache_hits', 1);
|
||||
await this.emitSnapshot();
|
||||
} catch {
|
||||
// best-effort metrics
|
||||
}
|
||||
}
|
||||
|
||||
static async incrementCacheMiss(): Promise<void> {
|
||||
try {
|
||||
await redis.hincrby(COUNTERS_KEY, 'cache_misses', 1);
|
||||
await this.emitSnapshot();
|
||||
} catch {
|
||||
// best-effort metrics
|
||||
}
|
||||
}
|
||||
|
||||
static async incrementSource(source: DataSource): Promise<void> {
|
||||
try {
|
||||
await redis.hincrby(SOURCES_KEY, source, 1);
|
||||
await this.emitSnapshot();
|
||||
} catch {
|
||||
// best-effort metrics
|
||||
}
|
||||
}
|
||||
|
||||
static async getSnapshot(): Promise<{
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
bySource: {
|
||||
cache: number;
|
||||
database: number;
|
||||
netflix: number;
|
||||
};
|
||||
}> {
|
||||
const [counters, sources] = await Promise.all([
|
||||
redis.hgetall(COUNTERS_KEY),
|
||||
redis.hgetall(SOURCES_KEY),
|
||||
]);
|
||||
|
||||
return {
|
||||
cacheHits: toInt(counters.cache_hits),
|
||||
cacheMisses: toInt(counters.cache_misses),
|
||||
bySource: {
|
||||
cache: toInt(sources.cache),
|
||||
database: toInt(sources.database),
|
||||
netflix: toInt(sources.netflix),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default MetricsService;
|
||||
@@ -68,6 +68,83 @@ export interface GetInfoResponse {
|
||||
currentSeason: number | null;
|
||||
}
|
||||
|
||||
export interface AdminOverviewResponse {
|
||||
generatedAt: string;
|
||||
environment: 'development' | 'production' | 'test';
|
||||
cache: {
|
||||
configuredTtlSeconds: number;
|
||||
keyCount: number;
|
||||
analyzedKeyLimit: number;
|
||||
sampled: boolean;
|
||||
totalBytes: number;
|
||||
redisMemory: {
|
||||
usedBytes: number;
|
||||
maxBytes: number | null;
|
||||
};
|
||||
ttlDistribution: {
|
||||
expiredOrNoTtl: number;
|
||||
lessThan5Min: number;
|
||||
min5To30: number;
|
||||
min30Plus: number;
|
||||
};
|
||||
expiringSoon: Array<{
|
||||
key: string;
|
||||
mediaTitle?: string | null;
|
||||
cachedAt?: number | null;
|
||||
ttlSeconds: number;
|
||||
}>;
|
||||
};
|
||||
content: {
|
||||
total: number;
|
||||
byType: {
|
||||
movie: number;
|
||||
tvshow: number;
|
||||
};
|
||||
addedLast24h: number;
|
||||
addedLast7d: number;
|
||||
metadataGaps: {
|
||||
missingPlot: number;
|
||||
missingAgeRating: number;
|
||||
missingBackdrop: number;
|
||||
};
|
||||
topGenres: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
jobs: {
|
||||
counts: {
|
||||
pending: number;
|
||||
processing: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
};
|
||||
averageDurationMs: number;
|
||||
failedRecent: Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
error: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
};
|
||||
requestMetrics: {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
cacheHitRate: number;
|
||||
sourceCounts: {
|
||||
cache: number;
|
||||
database: number;
|
||||
netflix: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminActionResponse {
|
||||
queued: number;
|
||||
skipped: number;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Cache Types
|
||||
// ============================================
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user