Compare commits

...

12 Commits

Author SHA1 Message Date
d50eaf250d feat(ios): align app UX with share extension interactions
- redesign main app screen to dark card-based layout

- add half-star drag/tap rating with haptic feedback

- add in-app comments list and composer interactions
2026-03-05 12:31:27 +03:00
d268bc5696 feat: revamp iOS share extension UX and improve Prime URL parsing
- redesign Share Extension with dark streaming-inspired layout

- add dynamic half-star rating interaction with stronger haptics

- improve dismiss behavior and comments composer UX

- support app.primevideo.com share links via gti parsing
2026-03-03 23:46:01 +03:00
8bd4f24774 feat(UI): ios için güncellemeler içerir. 2026-03-03 22:50:14 +03:00
5c6a829a4d feat(ios): share extension ile Ratebubble iOS istemcisini ekle ve paylaşım akışını düzelt 2026-03-01 18:07:07 +03:00
8c66fa9b82 feat(tmdb): cast alanina gore katı eslesmeli arama destegi ekle 2026-03-01 01:57:57 +03:00
79f90cb287 feat(ui): ana katalog basligini Ratebubble olarak guncelle 2026-03-01 01:57:13 +03:00
ad65453fcf feat(ui): saglayici logosu, kart duzeni ve admin silme onay modali ekle 2026-03-01 01:13:59 +03:00
84131576cf feat(api): Prime Video scraping ve saglayiciya duyarlı metadata destegi ekle 2026-03-01 01:13:41 +03:00
96d8a66a97 chore(gelistirme): frontend servisini docker compose yapisina ekle 2026-03-01 01:12:49 +03:00
48fb0c9487 chore: .env.example güncelleme, brainstorm notu ve build artifact
- .env.example: frontend/.env.example dosyasına yönlendiren yorum eklendi.
- docs/brainstorms/2026-02-28-realtime-db-redis-ui-sync-brainstorm.md:
  realtime DB/Redis/UI sync için mimari kararları, neden event+snapshot
  yaklaşımı seçildiği ve açık soruları belgeleyen brainstorm dökümanı.
- tsconfig.tsbuildinfo: TypeScript incremental build artifact güncellendi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:00:33 +03:00
c0841aab20 feat(frontend): admin dashboard + içerik kataloğu UI, realtime sync
- index.css: IBM Plex Sans + Bricolage Grotesque font'ları import edildi;
  CSS custom property sistemi (--bg-base, --accent-main vb.) tanımlandı;
  body'ye fixed radial gradient + grid overlay arka plan eklendi.

- main.tsx: MantineProvider tema güncellendi — IBM Plex Sans/Bricolage
  Grotesque font ailesi, responsive heading boyutları, özel radius/shadow
  değerleri ayarlandı.

- App.css: Gereksiz yorum temizlendi, stil yönetimi route-level CSS'e taşındı.

- MoviesPage.tsx (büyük güncelleme):
  • Katalog görünümü: film/dizi grid, arama, sıralama, backdrop modal.
  • Admin Dashboard görünümü: cache özeti, content istatistikleri, job
    durum sayaçları, failed job listesi, cache expiry tablosu, metrics
    (hit/miss oranı, kaynak dağılımı).
  • Admin aksiyonlar: cache temizleme, cache ısıtma, başarısız job
    yeniden kuyruklama, eski içerik yenileme.
  • Socket.IO entegrasyonu: content:event dinlenerek katalog anlık
    güncelleniyor; metrics:updated ile dashboard metrikleri canlı akıyor.
  • Reconnect davranışı: bağlantı kopunca her görünüm kendi snapshot'ını
    otomatik yeniliyor.

- movies-page.css (yeni): MoviesPage'e özel kart, backdrop, istatistik
  kutusu ve animasyon stilleri.

- vite.config.ts: /socket.io proxy kuralı eklendi (ws: true) — dev
  sunucusu üzerinden WebSocket bağlantısı backend'e yönlendiriliyor.

- frontend/.env.example (yeni): VITE_API_BASE_URL, VITE_WEB_API_KEY,
  VITE_ADMIN_API_KEY değişken şablonu eklendi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:00:22 +03:00
5c496277f2 feat(backend): realtime event system, admin API ve metrics altyapısı
- socket.ts: ContentRealtimeEvent, CacheRealtimeEvent, MetricsRealtimeEvent
  tipleri eklendi; emitContentEvent / emitCacheEvent / emitMetricsEvent
  fonksiyonları ile tüm istemcilere broadcast desteği getirildi.
  emitJobCompleted imzası GetInfoResponse + DataSource ile güçlendirildi.

- auth.middleware.ts: require() tabanlı env erişimi static import'a
  dönüştürüldü; admin-only endpointler için adminOnlyMiddleware eklendi
  (X-API-Key !== API_KEY_ADMIN → 403).

- cache.service.ts: set / delete / clearAll işlemlerinden sonra
  emitCacheEvent çağrısı eklenerek cache mutasyonları anlık yayınlanıyor.

- content.service.ts: create / update / delete sonrasında emitContentEvent
  çağrısı eklenerek DB yazımları Socket.IO üzerinden duyuruluyor.

- job.service.ts: async ve sync akışa MetricsService entegrasyonu eklendi;
  cache hit/miss ve kaynak (cache/database/netflix) sayaçları her işlemde
  artırılıyor.

- types/index.ts: AdminOverviewResponse ve AdminActionResponse tipleri
  merkezi olarak tanımlandı.

- admin.service.ts (yeni): getOverview, clearCache, warmupCacheFromDatabase,
  retryFailedJobs, refreshStaleContent operasyonları implement edildi.
  Redis pipeline ile TTL/boyut analizi ve DB metrikleri paralel toplanıyor.

- metrics.service.ts (yeni): Redis hash tabanlı cache hit/miss ve kaynak
  sayaçları; her artışta MetricsRealtimeEvent yayınlanıyor.

- api.routes.ts: Admin endpointleri eklendi:
    GET  /api/admin/overview
    POST /api/admin/cache/clear
    POST /api/admin/cache/warmup
    POST /api/admin/jobs/retry-failed
    POST /api/admin/content/refresh-stale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 11:59:59 +03:00
49 changed files with 5740 additions and 313 deletions

View File

@@ -31,6 +31,7 @@ RATE_LIMIT_MAX_REQUESTS=30
API_KEY_WEB=web-frontend-key-change-me-in-production API_KEY_WEB=web-frontend-key-change-me-in-production
API_KEY_MOBILE=mobile-app-key-change-me-in-production API_KEY_MOBILE=mobile-app-key-change-me-in-production
API_KEY_ADMIN=admin-key-super-secret-change-me 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 === # === TMDB API Configuration ===
# Get your API key from https://www.themoviedb.org/settings/api # Get your API key from https://www.themoviedb.org/settings/api

View File

@@ -25,6 +25,7 @@ docker compose -f docker-compose.dev.yml up --build
``` ```
API şu adreste çalışacak: `http://localhost:3000` API şu adreste çalışacak: `http://localhost:3000`
Frontend şu adreste çalışacak: `http://localhost:5173`
## API Kullanımı ## API Kullanımı

View File

@@ -48,6 +48,25 @@ services:
networks: networks:
- netflix-scraper-network - netflix-scraper-network
frontend:
image: node:20-alpine
container_name: netflix-scraper-frontend-dev
restart: unless-stopped
working_dir: /app
ports:
- "5173:5173"
environment:
- VITE_API_PROXY_TARGET=http://app:3000
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 5173"
volumes:
- ./frontend:/app:delegated
- frontend_node_modules_data:/app/node_modules
depends_on:
app:
condition: service_healthy
networks:
- netflix-scraper-network
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: netflix-scraper-postgres-dev container_name: netflix-scraper-postgres-dev
@@ -89,6 +108,7 @@ volumes:
postgres_data_dev: postgres_data_dev:
redis_data_dev: redis_data_dev:
node_modules_data: node_modules_data:
frontend_node_modules_data:
networks: networks:
netflix-scraper-network: netflix-scraper-network:

View File

@@ -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.

View File

@@ -0,0 +1,29 @@
---
date: 2026-03-01
topic: ios-share-extension-v1
---
# iOS Share Extension v1
## What We're Building
Projeye ikinci bir frontend olarak native iOS uygulaması eklenecek. Uygulamanın v1 ana işlevi, Netflix uygulamasından paylaşılan içerik linkini almak ve backend API'ye göndererek metadata sonucunu kullanıcıya göstermek.
Akış: Netflix içerik sayfası -> Paylaş -> bizim iOS app (Share Extension) -> URL alma -> backend `/api/getinfo` isteği -> sonuçları metin olarak gösterme (`title`, `year`, vb.).
## Why This Approach
Share Extension seçimi, iOS paylaşım menüsüne doğal şekilde entegre olur ve kullanıcı davranışıyla birebir örtüşür. Deep link tabanlı alternatiflere göre daha güvenilir URL yakalama sağlar ve v1 için en düşük sürtünmeyle çalışır.
V1 kapsamını sadece “link al, API çağır, sonucu göster” ile sınırlamak, YAGNI prensibine uygundur ve ürünü hızlıca canlı doğrulamaya taşır.
## Key Decisions
- Entegrasyon tipi: Share Extension (zorunlu).
- Kapsam: Sadece Netflix paylaşım linki ile `/api/getinfo` çağrısı.
- Yetkilendirme: `X-API-Key` olarak `API_KEY_MOBILE` kullanılacak.
- Görsellik: UI/UX tasarımı v1 sonrası iterasyona bırakılacak.
## Open Questions
- Share Extension URLyi doğrudan APIye mi gönderecek, yoksa ana appe handoff edip ana app mi çağrı yapacak?
- Başarısız API yanıtlarında kullanıcıya minimum hangi hata metni gösterilecek?
## Next Steps
-> `/workflows:plan`

View File

@@ -0,0 +1,28 @@
---
date: 2026-03-01
topic: tmdb-cast-strict-matching
---
# TMDB Cast Bazlı Katı Eşleşme
## What We're Building
TMDB arama akışına opsiyonel `cast` alanı eklenecek. İstekte `cast` verildiğinde sistem, mevcut `title/year/seasonYear/seasonNumber/type` ile adayları bulduktan sonra ilk 5 adayı cast bilgisi ile doğrulayacak.
Cast doğrulaması katı olacak: verilen cast adı adayın oyuncu listesinde yoksa aday elenecek. İlk 5 adayda hiç eşleşme bulunmazsa boş sonuç dönülecek. `cast` verilmediğinde mevcut davranış korunacak.
## Why This Approach
Kullanıcı beklentisi yanlış eşleşmeleri azaltmak ve “başlık + tek oyuncu adı” ile daha doğru içeriği seçmek. Katı filtreleme, özellikle benzer isimli yapımlarda hatalı ilk sonucu engeller.
Top 5 doğrulama, doğruluk ve API maliyetini dengeler. `cast` alanını opsiyonel tutmak, mevcut istemcilerle geriye dönük uyumluluğu korur.
## Key Decisions
- `cast` alanı opsiyonel: Eski entegrasyonlar bozulmaz.
- Cast eşleşmesi katı: Eşleşme yoksa sonuç dönmez.
- Doğrulama kapsamı Top 5: Aşırı API çağrısından kaçınılır.
- Eşleşme modu esnek normalize: büyük/küçük harf, Türkçe karakter varyasyonları ve boşluk farklılıkları tolere edilir.
## Open Questions
- Cast eşleşmesi yokken yanıt sadece `results: []` mı olmalı, yoksa `reason` gibi açıklayıcı bir alan eklenmeli mi?
## Next Steps
-> `/workflows:plan`

8
frontend/.env.example Normal file
View 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

BIN
frontend/public/prime.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -1,2 +1 @@
/* App styles - Mantine handles most styling */ /* Page-level styles are collocated in route-level css files. */

View File

@@ -1,10 +1,51 @@
body { @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');
margin: 0;
padding: 0; :root {
background-color: #1a1a1b; --bg-base: #090a0e;
min-height: 100vh; --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;
} }

View File

@@ -3,11 +3,28 @@ import { createRoot } from 'react-dom/client'
import { MantineProvider, createTheme } from '@mantine/core' import { MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css' import '@mantine/core/styles.css'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App'
const theme = createTheme({ const theme = createTheme({
primaryColor: 'red', 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( createRoot(document.getElementById('root')!).render(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,462 @@
.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;
display: flex;
flex-direction: column;
height: 100%;
min-height: 420px;
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;
height: 190px;
flex: 0 0 190px;
}
.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-content-stack {
padding-bottom: 38px;
}
.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;
display: flex;
flex-direction: column;
flex: 1;
min-height: 168px;
z-index: 2;
}
.card-meta-row {
min-height: 26px;
align-items: center;
}
.card-plot {
min-height: 4.8em;
line-height: 1.6;
}
.card-genres {
margin-top: auto;
min-height: 24px;
padding-right: 36px;
overflow: hidden;
}
.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;
}
}

View File

@@ -1,16 +1,24 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
const apiTarget = process.env.VITE_API_PROXY_TARGET || 'http://localhost:3000'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: apiTarget,
changeOrigin: true, changeOrigin: true,
}, },
'/socket.io': {
target: apiTarget,
changeOrigin: true,
ws: true,
},
}, },
}, },
css: { css: {

39
ios/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Ratebubble iOS (v1)
Bu klasör, Ratebubble için iOS ana uygulama + Share Extension iskeletini içerir.
## Hedef
- Netflix uygulamasından paylaşılan URL'yi almak
- Ana app'e handoff etmek
- Backend `/api/getinfo` endpointine gönderip sonucu göstermek
## Gereksinimler
- Xcode 15+
- iOS 16+
- (Opsiyonel) XcodeGen
## Proje Oluşturma (XcodeGen)
```bash
cd ios
xcodegen generate
open Ratebubble.xcodeproj
```
Eğer `xcodegen` kurulu değilse:
```bash
brew install xcodegen
```
## Yapılandırma
`Ratebubble/Resources/Config.xcconfig` dosyasında:
- `API_BASE_URL`
- `MOBILE_API_KEY`
- `APP_GROUP_ID`
- `APP_URL_SCHEME`
değerlerini ortamına göre güncelle.
## Not
Share Extension, URL'yi App Group `UserDefaults` içine yazar ve custom URL scheme ile ana app'i açar.

View File

@@ -0,0 +1,454 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 63;
objects = {
/* Begin PBXBuildFile section */
0315885AA91662FE48BBC594 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E2C9211EC8E30B83ABBCFB /* ContentView.swift */; };
0C602ACFD0DC100ECCFA84A5 /* SharedPayloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */; };
17157E8CF084270023A56C06 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA40D68C79904DEE4125D874 /* Models.swift */; };
186EA66540EFA4CEA949617D /* RatebubbleShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7539736C403588176FB7D80C /* RatebubbleShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3993F1B14739F8177B844EE0 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */; };
3E1D36E3AE2CDBD9C402E921 /* RatebubbleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */; };
77D601458525635F705EA471 /* SharedPayloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */; };
886AF155C91E366210524E45 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37749607968C1A751317B7BD /* MainViewModel.swift */; };
A547A283AD74B06B25FDB424 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C1C571628107632B4A2F90 /* ShareViewController.swift */; };
B31B1B7EC5BAE4FB9011C94C /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */; };
F670AE07545EBB4ABE2889F7 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA40D68C79904DEE4125D874 /* Models.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
2FFA444A9840D8FEE9B59CB3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 7944D79A93FF32A326D78AB8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D66EABC12ABB12431BD3554C;
remoteInfo = RatebubbleShare;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
87D7EB893873C1953A4B8483 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
186EA66540EFA4CEA949617D /* RatebubbleShare.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
19F8E500E3A70D57454E49A6 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
37749607968C1A751317B7BD /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = "<group>"; };
72C1C571628107632B4A2F90 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
7539736C403588176FB7D80C /* RatebubbleShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RatebubbleShare.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7FEF3C0785A60EA1B560627C /* Ratebubble.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ratebubble.app; sourceTree = BUILT_PRODUCTS_DIR; };
96E2C9211EC8E30B83ABBCFB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
AA40D68C79904DEE4125D874 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedPayloadStore.swift; sourceTree = "<group>"; };
D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatebubbleApp.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0EF0C634DF5D5383BE2D7771 /* Shared */ = {
isa = PBXGroup;
children = (
D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */,
AA40D68C79904DEE4125D874 /* Models.swift */,
CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */,
);
name = Shared;
path = Ratebubble/Shared;
sourceTree = "<group>";
};
108289137F406D4F63BFC3AE /* ShareExtension */ = {
isa = PBXGroup;
children = (
72C1C571628107632B4A2F90 /* ShareViewController.swift */,
);
name = ShareExtension;
path = Ratebubble/ShareExtension;
sourceTree = "<group>";
};
1C0E0078471B0855017A64DD = {
isa = PBXGroup;
children = (
EF496B275DF9294D162CFA6E /* App */,
BFD8EEAEAAE9601A16238D2D /* Resources */,
0EF0C634DF5D5383BE2D7771 /* Shared */,
108289137F406D4F63BFC3AE /* ShareExtension */,
E032C44ACA0299B26854540A /* Products */,
);
sourceTree = "<group>";
};
BFD8EEAEAAE9601A16238D2D /* Resources */ = {
isa = PBXGroup;
children = (
19F8E500E3A70D57454E49A6 /* Config.xcconfig */,
);
name = Resources;
path = Ratebubble/Resources;
sourceTree = "<group>";
};
E032C44ACA0299B26854540A /* Products */ = {
isa = PBXGroup;
children = (
7FEF3C0785A60EA1B560627C /* Ratebubble.app */,
7539736C403588176FB7D80C /* RatebubbleShare.appex */,
);
name = Products;
sourceTree = "<group>";
};
EF496B275DF9294D162CFA6E /* App */ = {
isa = PBXGroup;
children = (
96E2C9211EC8E30B83ABBCFB /* ContentView.swift */,
37749607968C1A751317B7BD /* MainViewModel.swift */,
FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */,
);
name = App;
path = Ratebubble/App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1E5B7142527B8E64D5BB475A /* Ratebubble */ = {
isa = PBXNativeTarget;
buildConfigurationList = DE6F9D82ED742462C54ED81A /* Build configuration list for PBXNativeTarget "Ratebubble" */;
buildPhases = (
21317B3B7EEF00F8D3448F1A /* Sources */,
87D7EB893873C1953A4B8483 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
B360B60BC3BB55F1DEFD5F02 /* PBXTargetDependency */,
);
name = Ratebubble;
packageProductDependencies = (
);
productName = Ratebubble;
productReference = 7FEF3C0785A60EA1B560627C /* Ratebubble.app */;
productType = "com.apple.product-type.application";
};
D66EABC12ABB12431BD3554C /* RatebubbleShare */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3BCC0C2B27341BF3ADDCB3B9 /* Build configuration list for PBXNativeTarget "RatebubbleShare" */;
buildPhases = (
B64AC19652E4EA372111002C /* Sources */,
);
buildRules = (
);
dependencies = (
);
name = RatebubbleShare;
packageProductDependencies = (
);
productName = RatebubbleShare;
productReference = 7539736C403588176FB7D80C /* RatebubbleShare.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
7944D79A93FF32A326D78AB8 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
};
buildConfigurationList = BA6BB0478ED529686B10D5F3 /* Build configuration list for PBXProject "Ratebubble" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 1C0E0078471B0855017A64DD;
minimizedProjectReferenceProxies = 1;
projectDirPath = "";
projectRoot = "";
targets = (
1E5B7142527B8E64D5BB475A /* Ratebubble */,
D66EABC12ABB12431BD3554C /* RatebubbleShare */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
21317B3B7EEF00F8D3448F1A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B31B1B7EC5BAE4FB9011C94C /* APIClient.swift in Sources */,
0315885AA91662FE48BBC594 /* ContentView.swift in Sources */,
886AF155C91E366210524E45 /* MainViewModel.swift in Sources */,
17157E8CF084270023A56C06 /* Models.swift in Sources */,
3E1D36E3AE2CDBD9C402E921 /* RatebubbleApp.swift in Sources */,
77D601458525635F705EA471 /* SharedPayloadStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B64AC19652E4EA372111002C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3993F1B14739F8177B844EE0 /* APIClient.swift in Sources */,
F670AE07545EBB4ABE2889F7 /* Models.swift in Sources */,
A547A283AD74B06B25FDB424 /* ShareViewController.swift in Sources */,
0C602ACFD0DC100ECCFA84A5 /* SharedPayloadStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B360B60BC3BB55F1DEFD5F02 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D66EABC12ABB12431BD3554C /* RatebubbleShare */;
targetProxy = 2FFA444A9840D8FEE9B59CB3 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
03B172804432CFDEF0B15A28 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/RatebubbleShare.entitlements;
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/RatebubbleShare-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble.share;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
2C4CEF1731FBB13FC87F825C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
};
name = Release;
};
3AE70298876BFAD681CF451B /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/Ratebubble.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/Ratebubble-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
3B575860868E0B1AF5B7D410 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.9;
};
name = Debug;
};
5C1878476CD3B8F15D53C417 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/Ratebubble.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/Ratebubble-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
A005F6D39148A83B59423F17 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/RatebubbleShare.entitlements;
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/RatebubbleShare-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble.share;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3BCC0C2B27341BF3ADDCB3B9 /* Build configuration list for PBXNativeTarget "RatebubbleShare" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A005F6D39148A83B59423F17 /* Debug */,
03B172804432CFDEF0B15A28 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
BA6BB0478ED529686B10D5F3 /* Build configuration list for PBXProject "Ratebubble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3B575860868E0B1AF5B7D410 /* Debug */,
2C4CEF1731FBB13FC87F825C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
DE6F9D82ED742462C54ED81A /* Build configuration list for PBXNativeTarget "Ratebubble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3AE70298876BFAD681CF451B /* Debug */,
5C1878476CD3B8F15D53C417 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 7944D79A93FF32A326D78AB8 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D66EABC12ABB12431BD3554C"
BuildableName = "RatebubbleShare.appex"
BlueprintName = "RatebubbleShare"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,418 @@
import SwiftUI
import UIKit
struct ContentView: View {
@StateObject private var viewModel = MainViewModel()
@Environment(\.scenePhase) private var scenePhase
@State private var selectedRating: Double = 0
@State private var commentDraft = ""
@State private var comments: [CommentItem] = []
@State private var interactionKey = ""
private let hoverHaptic = UIImpactFeedbackGenerator(style: .light)
private let submitHaptic = UIImpactFeedbackGenerator(style: .medium)
var body: some View {
NavigationStack {
ZStack {
LinearGradient(
colors: [
Color(red: 0.05, green: 0.05, blue: 0.07),
Color(red: 0.02, green: 0.02, blue: 0.03)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
urlComposerCard
if viewModel.isLoading {
loadingCard
}
if let error = viewModel.errorMessage {
errorCard(error)
}
if let result = viewModel.result {
resultCard(result)
ratingCard
commentsCard
} else if !viewModel.isLoading {
emptyCard
}
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
}
}
.toolbar {
ToolbarItem(placement: .principal) {
Text("Ratebubble")
.font(.headline)
.foregroundStyle(.white)
}
}
}
.preferredColorScheme(.dark)
.onAppear {
hoverHaptic.prepare()
submitHaptic.prepare()
viewModel.consumeSharedURLIfAny()
}
.onOpenURL { _ in viewModel.consumeSharedURLIfAny() }
.onChange(of: scenePhase) { phase in
if phase == .active { viewModel.consumeSharedURLIfAny() }
}
}
private var urlComposerCard: some View {
card {
VStack(alignment: .leading, spacing: 12) {
Text("Paylaşılan Link")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
HStack(spacing: 10) {
Image(systemName: "link")
.foregroundStyle(.white.opacity(0.65))
TextField("https://www.netflix.com/title/...", text: $viewModel.sharedURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.URL)
.foregroundStyle(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 11)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
Button {
Task { await viewModel.fetch() }
} label: {
HStack {
Spacer()
Text(viewModel.isLoading ? "Analiz Ediliyor..." : "İçeriği Analiz Et")
.font(.subheadline.weight(.semibold))
Spacer()
}
.padding(.vertical, 11)
.background(
LinearGradient(
colors: [Color.red.opacity(0.95), Color.orange.opacity(0.9)],
startPoint: .leading,
endPoint: .trailing
),
in: RoundedRectangle(cornerRadius: 12)
)
}
.disabled(viewModel.isLoading)
.foregroundStyle(.white)
.opacity(viewModel.isLoading ? 0.7 : 1)
}
}
}
private var loadingCard: some View {
card {
HStack(spacing: 12) {
ProgressView()
Text("İçerik çözülüyor ve metadata hazırlanıyor...")
.font(.footnote)
.foregroundStyle(.white.opacity(0.8))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func errorCard(_ message: String) -> some View {
card {
VStack(alignment: .leading, spacing: 8) {
Text("Bir sorun oluştu")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.red.opacity(0.95))
Text(message)
.font(.footnote)
.foregroundStyle(.white.opacity(0.82))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func resultCard(_ result: GetInfoResponse) -> some View {
let key = contentKey(result)
return card {
VStack(alignment: .leading, spacing: 10) {
providerBadge(result.provider)
Text(result.title)
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.lineLimit(3)
Text(metaLine(result))
.font(.footnote)
.foregroundStyle(.white.opacity(0.65))
if !result.genres.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(result.genres, id: \.self) { genre in
Text(genre)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.09), in: Capsule())
}
}
}
}
if let plot = result.plot, !plot.isEmpty {
Text(plot)
.font(.footnote)
.foregroundStyle(.white.opacity(0.84))
.lineLimit(6)
}
}
.onAppear { prepareInteractionState(for: result, key: key) }
.onChange(of: key) { _ in prepareInteractionState(for: result, key: key) }
}
}
private var ratingCard: some View {
card {
VStack(alignment: .leading, spacing: 10) {
Text("Puanla")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text("Yarım yıldız da seçebilirsin. Sürükleyerek hızlı puan ver.")
.font(.caption)
.foregroundStyle(.white.opacity(0.6))
StarRatingBar(
rating: $selectedRating,
onChanged: { changed in
guard changed else { return }
hoverHaptic.impactOccurred(intensity: 0.8)
hoverHaptic.prepare()
}
)
.frame(height: 46)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var commentsCard: some View {
card {
VStack(alignment: .leading, spacing: 12) {
Text("Yorumlar")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
if comments.isEmpty {
Text("Henüz yorum yok. İlk yorumu sen yaz.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.58))
} else {
ForEach(comments) { item in
CommentBubble(item: item)
}
}
VStack(alignment: .trailing, spacing: 8) {
ZStack(alignment: .topLeading) {
TextEditor(text: $commentDraft)
.scrollContentBackground(.hidden)
.foregroundStyle(.white)
.frame(minHeight: 88, maxHeight: 120)
.padding(8)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
if commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Yorumunu yaz...")
.font(.footnote)
.foregroundStyle(.white.opacity(0.38))
.padding(.horizontal, 18)
.padding(.vertical, 18)
.allowsHitTesting(false)
}
}
Button {
submitComment()
} label: {
Text("Gönder")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 16)
.padding(.vertical, 9)
.background(Color.red.opacity(0.9), in: Capsule())
}
.disabled(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.opacity(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.45 : 1)
.foregroundStyle(.white)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var emptyCard: some View {
card {
VStack(alignment: .leading, spacing: 8) {
Text("Hazır")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text("Paylaşımdan gelen bir içerik varsa otomatik doldurulur. İstersen yukarıdan URL girip analiz başlat.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.66))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func providerBadge(_ provider: String) -> some View {
Text(provider.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(
provider == "netflix" ? Color.red.opacity(0.92) :
(provider == "primevideo" ? Color.cyan.opacity(0.82) : Color.white.opacity(0.2)),
in: Capsule()
)
}
private func metaLine(_ result: GetInfoResponse) -> String {
var parts: [String] = []
if let year = result.year { parts.append(String(year)) }
parts.append(result.type == "movie" ? "Film" : "Dizi")
if let currentSeason = result.currentSeason { parts.append("Sezon \(currentSeason)") }
return parts.joined(separator: "")
}
private func contentKey(_ result: GetInfoResponse) -> String {
"\(result.provider)|\(result.title)|\(result.year.map(String.init) ?? "-")"
}
private func prepareInteractionState(for result: GetInfoResponse, key: String) {
guard interactionKey != key else { return }
interactionKey = key
selectedRating = 0
commentDraft = ""
comments = [
CommentItem(user: "deniz", body: "Sinematografi çok temiz, finali de iyi bağlamışlar.", time: "2 saat önce"),
CommentItem(user: "melis", body: "\(result.title) için tempo yer yer düşse de genel deneyim çok keyifli.", time: "Dün")
]
}
private func submitComment() {
let trimmed = commentDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
comments.insert(CommentItem(user: "sen", body: trimmed, time: "Şimdi"), at: 0)
commentDraft = ""
submitHaptic.impactOccurred(intensity: 0.5)
submitHaptic.prepare()
}
private func card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
content()
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
)
}
}
private struct CommentItem: Identifiable {
let id = UUID()
let user: String
let body: String
let time: String
}
private struct CommentBubble: View {
let item: CommentItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("@\(item.user)")
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
Text(item.body)
.font(.footnote)
.foregroundStyle(.white.opacity(0.82))
Text(item.time)
.font(.caption2)
.foregroundStyle(.white.opacity(0.5))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
}
}
private struct StarRatingBar: View {
@Binding var rating: Double
let onChanged: (Bool) -> Void
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { idx in
Image(systemName: iconName(for: idx))
.font(.system(size: 30, weight: .medium))
.foregroundStyle(iconColor(for: idx))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let changed = updateRating(from: value.location.x, width: geo.size.width)
onChanged(changed)
}
)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Puan")
.accessibilityValue("\(String(format: "%.1f", rating)) yıldız")
}
private func iconName(for index: Int) -> String {
let value = Double(index)
if rating >= value { return "star.fill" }
if rating >= value - 0.5 { return "star.leadinghalf.filled" }
return "star"
}
private func iconColor(for index: Int) -> Color {
let value = Double(index)
return rating >= value - 0.5
? Color(red: 0.96, green: 0.74, blue: 0.20)
: Color.white.opacity(0.20)
}
@discardableResult
private func updateRating(from x: CGFloat, width: CGFloat) -> Bool {
guard width > 0 else { return false }
let clampedX = min(max(x, 0), width - 0.001)
let raw = (clampedX / width) * 5.0
let stepped = max(0.5, min(5.0, (raw * 2).rounded(.up) / 2))
guard abs(stepped - rating) > 0.001 else { return false }
rating = stepped
return true
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
@MainActor
final class MainViewModel: ObservableObject {
@Published var sharedURL: String = ""
@Published var isLoading: Bool = false
@Published var result: GetInfoResponse?
@Published var errorMessage: String?
func consumeSharedURLIfAny() {
guard let incoming = SharedPayloadStore.consumeIncomingURL(), !incoming.isEmpty else {
return
}
sharedURL = incoming
Task { await fetch() }
}
func fetch() async {
guard !sharedURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = "Paylaşılan URL boş olamaz."
return
}
isLoading = true
errorMessage = nil
result = nil
do {
result = try await APIClient.shared.getInfo(url: sharedURL)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct RatebubbleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,5 @@
SLASH = /
API_BASE_URL = http:$(SLASH)$(SLASH)192.168.1.124:3000
MOBILE_API_KEY = mobile-app-key-change-me-in-production
APP_GROUP_ID = group.net.wisecolt.ratebubble
APP_URL_SCHEME = ratebubble

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(APP_URL_SCHEME)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
<key>MOBILE_API_KEY</key>
<string>$(MOBILE_API_KEY)</string>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>Ratebubble Share</string>
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
<key>MOBILE_API_KEY</key>
<string>$(MOBILE_API_KEY)</string>
<key>APP_GROUP_ID</key>
<string>$(APP_GROUP_ID)</string>
<key>APP_URL_SCHEME</key>
<string>$(APP_URL_SCHEME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>TRUEPREDICATE</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,838 @@
import UIKit
import UniformTypeIdentifiers
final class ShareViewController: UIViewController, UITextViewDelegate {
private enum ViewState {
case loading
case success(GetInfoResponse)
case error(String)
}
private struct CommentItem {
let user: String
let body: String
let time: String
}
private var selectedRating = 0.0
private var starButtons: [UIButton] = []
private var comments: [CommentItem] = []
private let headerView = UIView()
private let headerLabel = UILabel()
private let closeButton = UIButton(type: .system)
private let overlayView = UIView()
private let spinner = UIActivityIndicatorView(style: .large)
private let overlayLabel = UILabel()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let backdropContainer = UIView()
private let backdropImageView = UIImageView()
private let gradientLayer = CAGradientLayer()
private let providerBadge = UILabel()
private let heroTitleLabel = UILabel()
private let heroMetaLabel = UILabel()
private let genreScroll = UIScrollView()
private let genreStack = UIStackView()
private let plotLabel = UILabel()
private let castLabel = UILabel()
private let commentsListStack = UIStackView()
private let commentTextView = UITextView()
private let commentPlaceholderLabel = UILabel()
private let submitCommentButton = UIButton(type: .system)
private let starsRow = UIStackView()
private let hoverHaptic = UIImpactFeedbackGenerator(style: .light)
private var dismissPanStartTransform: CGAffineTransform = .identity
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .dark
// Prevent system pull-down dismiss that only closes this extension UI.
// We'll handle downward dismiss ourselves and always call completeRequest.
isModalInPresentation = true
view.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 1)
setupHeader()
setupScrollView()
setupOverlay()
setupFeedback()
Task { await handleIncomingShare() }
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
gradientLayer.frame = backdropContainer.bounds
}
private func setupFeedback() {
hoverHaptic.prepare()
}
private func setupHeader() {
headerView.backgroundColor = UIColor(red: 0.06, green: 0.06, blue: 0.08, alpha: 0.96)
headerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(headerView)
headerLabel.text = "Ratebubble"
headerLabel.font = .systemFont(ofSize: 17, weight: .semibold)
headerLabel.textColor = .white
headerLabel.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(headerLabel)
closeButton.setTitle("Kapat", for: .normal)
closeButton.setTitleColor(.systemGray2, for: .normal)
closeButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
closeButton.accessibilityLabel = "Ekranı kapat"
closeButton.accessibilityHint = "Paylaşım ekranını kapatır"
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
headerView.addSubview(closeButton)
let dismissPan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan(_:)))
dismissPan.maximumNumberOfTouches = 1
headerView.addGestureRecognizer(dismissPan)
let separator = UIView()
separator.backgroundColor = UIColor.white.withAlphaComponent(0.08)
separator.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(separator)
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 46),
headerLabel.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
headerLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
closeButton.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16),
closeButton.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
separator.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
separator.heightAnchor.constraint(equalToConstant: 0.5)
])
}
private func setupScrollView() {
scrollView.alwaysBounceVertical = true
scrollView.keyboardDismissMode = .interactive
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.isHidden = true
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
setupBackdrop()
setupContentStack()
setupMetadataSection()
setupRatingSection()
setupCommentsSection()
}
private func setupBackdrop() {
backdropContainer.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.clipsToBounds = true
scrollView.addSubview(backdropContainer)
backdropImageView.translatesAutoresizingMaskIntoConstraints = false
backdropImageView.contentMode = .scaleAspectFill
backdropImageView.backgroundColor = UIColor(red: 0.10, green: 0.10, blue: 0.12, alpha: 1)
backdropImageView.alpha = 0
backdropContainer.addSubview(backdropImageView)
gradientLayer.colors = [
UIColor.clear.cgColor,
UIColor.black.withAlphaComponent(0.40).cgColor,
UIColor.black.withAlphaComponent(0.92).cgColor
]
gradientLayer.locations = [0.25, 0.55, 1.0]
backdropContainer.layer.addSublayer(gradientLayer)
providerBadge.font = .systemFont(ofSize: 10, weight: .bold)
providerBadge.textColor = .white
providerBadge.layer.cornerRadius = 10
providerBadge.layer.masksToBounds = true
providerBadge.textAlignment = .center
providerBadge.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.addSubview(providerBadge)
heroTitleLabel.font = .systemFont(ofSize: 28, weight: .heavy)
heroTitleLabel.textColor = .white
heroTitleLabel.numberOfLines = 2
heroTitleLabel.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.addSubview(heroTitleLabel)
heroMetaLabel.font = .systemFont(ofSize: 14, weight: .medium)
heroMetaLabel.textColor = UIColor.white.withAlphaComponent(0.78)
heroMetaLabel.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.addSubview(heroMetaLabel)
NSLayoutConstraint.activate([
backdropContainer.topAnchor.constraint(equalTo: scrollView.topAnchor),
backdropContainer.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
backdropContainer.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
backdropContainer.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
backdropContainer.heightAnchor.constraint(equalToConstant: 248),
backdropImageView.topAnchor.constraint(equalTo: backdropContainer.topAnchor),
backdropImageView.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor),
backdropImageView.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor),
backdropImageView.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor),
providerBadge.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
providerBadge.bottomAnchor.constraint(equalTo: heroTitleLabel.topAnchor, constant: -10),
providerBadge.heightAnchor.constraint(equalToConstant: 20),
providerBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 78),
heroTitleLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
heroTitleLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
heroTitleLabel.bottomAnchor.constraint(equalTo: heroMetaLabel.topAnchor, constant: -5),
heroMetaLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
heroMetaLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
heroMetaLabel.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: -16)
])
}
private func setupContentStack() {
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentStack)
NSLayoutConstraint.activate([
contentStack.topAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: 14),
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 14),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -14),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -28),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -30)
])
}
private func setupMetadataSection() {
let card = makeSectionCard()
let stack = makeCardStack()
genreScroll.showsHorizontalScrollIndicator = false
genreScroll.translatesAutoresizingMaskIntoConstraints = false
genreScroll.heightAnchor.constraint(equalToConstant: 32).isActive = true
genreStack.axis = .horizontal
genreStack.spacing = 8
genreStack.translatesAutoresizingMaskIntoConstraints = false
genreScroll.addSubview(genreStack)
NSLayoutConstraint.activate([
genreStack.topAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.topAnchor),
genreStack.leadingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.leadingAnchor),
genreStack.trailingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.trailingAnchor),
genreStack.bottomAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.bottomAnchor),
genreStack.heightAnchor.constraint(equalTo: genreScroll.frameLayoutGuide.heightAnchor)
])
plotLabel.font = .systemFont(ofSize: 14)
plotLabel.textColor = UIColor.white.withAlphaComponent(0.84)
plotLabel.numberOfLines = 6
castLabel.font = .systemFont(ofSize: 13, weight: .regular)
castLabel.textColor = UIColor.white.withAlphaComponent(0.66)
castLabel.numberOfLines = 2
stack.addArrangedSubview(genreScroll)
stack.addArrangedSubview(plotLabel)
stack.addArrangedSubview(castLabel)
card.addSubview(stack)
pinCardStack(stack, in: card)
contentStack.addArrangedSubview(card)
}
private func setupRatingSection() {
let card = makeSectionCard()
let stack = makeCardStack()
let title = UILabel()
title.text = "Puanla"
title.font = .systemFont(ofSize: 17, weight: .semibold)
title.textColor = .white
let subtitle = UILabel()
subtitle.text = "Puanını istediğin zaman değiştirebilirsin."
subtitle.font = .systemFont(ofSize: 12, weight: .regular)
subtitle.textColor = UIColor.white.withAlphaComponent(0.55)
starsRow.axis = .horizontal
starsRow.alignment = .center
starsRow.distribution = .fillEqually
starsRow.spacing = 4
starsRow.translatesAutoresizingMaskIntoConstraints = false
starsRow.isUserInteractionEnabled = true
starsRow.heightAnchor.constraint(equalToConstant: 48).isActive = true
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleStarPan(_:)))
panGesture.maximumNumberOfTouches = 1
starsRow.addGestureRecognizer(panGesture)
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
for i in 1...5 {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "star", withConfiguration: symbolConfig), for: .normal)
button.tintColor = UIColor.white.withAlphaComponent(0.18)
button.tag = i
button.addTarget(self, action: #selector(starTapped(_:forEvent:)), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: 46).isActive = true
button.accessibilityLabel = "\(i) yıldız"
button.accessibilityHint = "Puanı ayarlar"
starButtons.append(button)
starsRow.addArrangedSubview(button)
}
stack.addArrangedSubview(title)
stack.addArrangedSubview(subtitle)
stack.addArrangedSubview(starsRow)
card.addSubview(stack)
pinCardStack(stack, in: card)
contentStack.addArrangedSubview(card)
}
private func setupCommentsSection() {
let card = makeSectionCard()
let stack = makeCardStack()
let title = UILabel()
title.text = "Yorumlar"
title.font = .systemFont(ofSize: 17, weight: .semibold)
title.textColor = .white
commentsListStack.axis = .vertical
commentsListStack.spacing = 10
commentsListStack.translatesAutoresizingMaskIntoConstraints = false
let composerContainer = UIView()
composerContainer.translatesAutoresizingMaskIntoConstraints = false
composerContainer.backgroundColor = UIColor.white.withAlphaComponent(0.06)
composerContainer.layer.cornerRadius = 14
composerContainer.layer.borderWidth = 1
composerContainer.layer.borderColor = UIColor.white.withAlphaComponent(0.08).cgColor
commentTextView.backgroundColor = .clear
commentTextView.textColor = .white
commentTextView.font = .systemFont(ofSize: 14)
commentTextView.tintColor = .systemRed
commentTextView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
commentTextView.translatesAutoresizingMaskIntoConstraints = false
commentTextView.delegate = self
commentTextView.isScrollEnabled = true
commentTextView.heightAnchor.constraint(equalToConstant: 96).isActive = true
commentPlaceholderLabel.text = "Yorumunu yaz..."
commentPlaceholderLabel.textColor = UIColor.white.withAlphaComponent(0.38)
commentPlaceholderLabel.font = .systemFont(ofSize: 14)
commentPlaceholderLabel.translatesAutoresizingMaskIntoConstraints = false
submitCommentButton.setTitle("Gönder", for: .normal)
submitCommentButton.setTitleColor(.white, for: .normal)
submitCommentButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
submitCommentButton.layer.cornerRadius = 10
submitCommentButton.contentEdgeInsets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
submitCommentButton.translatesAutoresizingMaskIntoConstraints = false
submitCommentButton.addTarget(self, action: #selector(submitCommentTapped), for: .touchUpInside)
submitCommentButton.isEnabled = false
submitCommentButton.alpha = 0.5
submitCommentButton.accessibilityLabel = "Yorumu gönder"
composerContainer.addSubview(commentTextView)
composerContainer.addSubview(commentPlaceholderLabel)
composerContainer.addSubview(submitCommentButton)
NSLayoutConstraint.activate([
commentTextView.topAnchor.constraint(equalTo: composerContainer.topAnchor),
commentTextView.leadingAnchor.constraint(equalTo: composerContainer.leadingAnchor),
commentTextView.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor),
commentPlaceholderLabel.leadingAnchor.constraint(equalTo: commentTextView.leadingAnchor, constant: 14),
commentPlaceholderLabel.topAnchor.constraint(equalTo: commentTextView.topAnchor, constant: 12),
submitCommentButton.topAnchor.constraint(equalTo: commentTextView.bottomAnchor, constant: 8),
submitCommentButton.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor, constant: -10),
submitCommentButton.bottomAnchor.constraint(equalTo: composerContainer.bottomAnchor, constant: -10)
])
stack.addArrangedSubview(title)
stack.addArrangedSubview(commentsListStack)
stack.addArrangedSubview(composerContainer)
card.addSubview(stack)
pinCardStack(stack, in: card)
contentStack.addArrangedSubview(card)
}
private func pinCardStack(_ stack: UIStackView, in card: UIView) {
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
])
}
private func makeSectionCard() -> UIView {
let card = UIView()
card.backgroundColor = UIColor.white.withAlphaComponent(0.05)
card.layer.cornerRadius = 16
card.layer.borderWidth = 1
card.layer.borderColor = UIColor.white.withAlphaComponent(0.07).cgColor
return card
}
private func makeCardStack() -> UIStackView {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}
@MainActor
private func apply(_ state: ViewState) {
switch state {
case .loading:
overlayView.isHidden = false
scrollView.isHidden = true
spinner.startAnimating()
overlayLabel.text = "İçerik analiz ediliyor..."
case .success(let info):
if info.provider == "netflix" {
providerBadge.text = " NETFLIX "
providerBadge.backgroundColor = UIColor(red: 0.90, green: 0.11, blue: 0.15, alpha: 0.95)
} else if info.provider == "primevideo" {
providerBadge.text = " PRIME VIDEO "
providerBadge.backgroundColor = UIColor(red: 0.05, green: 0.62, blue: 0.90, alpha: 0.95)
} else {
providerBadge.text = " \(info.provider.uppercased()) "
providerBadge.backgroundColor = UIColor.white.withAlphaComponent(0.20)
}
heroTitleLabel.text = info.title
var parts: [String] = []
if let year = info.year { parts.append("\(year)") }
parts.append(info.type == "movie" ? "Film" : "Dizi")
if let season = info.currentSeason { parts.append("Sezon \(season)") }
heroMetaLabel.text = parts.joined(separator: "")
genreStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if info.genres.isEmpty {
genreScroll.isHidden = true
} else {
genreScroll.isHidden = false
info.genres.forEach { genreStack.addArrangedSubview(makeChip($0)) }
}
if let plot = info.plot, !plot.isEmpty {
plotLabel.text = plot
plotLabel.isHidden = false
} else {
plotLabel.text = nil
plotLabel.isHidden = true
}
if info.cast.isEmpty {
castLabel.text = nil
castLabel.isHidden = true
} else {
castLabel.text = "Oyuncular: \(info.cast.prefix(7).joined(separator: ", "))"
castLabel.isHidden = false
}
renderComments()
if let urlString = info.backdrop, let imageURL = URL(string: urlString) {
Task {
guard let (data, _) = try? await URLSession.shared.data(from: imageURL),
let image = UIImage(data: data) else { return }
await MainActor.run {
self.backdropImageView.image = image
UIView.animate(withDuration: 0.35) {
self.backdropImageView.alpha = 1
}
}
}
} else {
backdropImageView.image = nil
backdropImageView.alpha = 0
}
scrollView.isHidden = false
spinner.stopAnimating()
UIView.animate(withDuration: 0.20, animations: {
self.overlayView.alpha = 0
}, completion: { _ in
self.overlayView.isHidden = true
self.overlayView.alpha = 1
})
case .error(let message):
overlayView.isHidden = false
scrollView.isHidden = true
spinner.stopAnimating()
overlayLabel.text = message
}
}
private func makeChip(_ text: String) -> UIView {
var cfg = UIButton.Configuration.filled()
cfg.title = text
cfg.baseForegroundColor = .white
cfg.baseBackgroundColor = UIColor.white.withAlphaComponent(0.10)
cfg.cornerStyle = .capsule
cfg.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)
cfg.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in
var attrs = attrs
attrs.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
return attrs
}
let button = UIButton(configuration: cfg)
button.isUserInteractionEnabled = false
return button
}
@objc private func starTapped(_ sender: UIButton, forEvent event: UIEvent?) {
let touchLocation = event?.allTouches?.first?.location(in: sender) ?? CGPoint(x: sender.bounds.midX, y: sender.bounds.midY)
let isLeftHalf = touchLocation.x < sender.bounds.midX
let rating = Double(sender.tag - 1) + (isLeftHalf ? 0.5 : 1.0)
updateRating(to: rating, animatedFrom: sender, withHaptic: true)
}
@objc private func handleStarPan(_ gesture: UIPanGestureRecognizer) {
let point = gesture.location(in: starsRow)
guard starsRow.bounds.width > 0 else { return }
switch gesture.state {
case .began:
hoverHaptic.prepare()
fallthrough
case .changed:
let clampedX = min(max(point.x, 0), starsRow.bounds.width - 0.001)
let ratio = clampedX / starsRow.bounds.width
let starCount = Double(max(starButtons.count, 1))
let rawValue = ratio * starCount
let halfStepped = max(0.5, min(starCount, (rawValue * 2).rounded(.up) / 2))
let value = halfStepped
updateRating(to: value, animatedFrom: nil, withHaptic: true)
default:
break
}
}
private func updateRating(to newValue: Double, animatedFrom sourceButton: UIButton?, withHaptic: Bool) {
let maxRating = Double(starButtons.count)
let clamped = min(max(newValue, 0.5), maxRating)
guard abs(clamped - selectedRating) > 0.001 else { return }
selectedRating = clamped
refreshStars(animatedFrom: sourceButton)
if withHaptic {
hoverHaptic.impactOccurred(intensity: 1.0)
hoverHaptic.prepare()
}
}
private func refreshStars(animatedFrom sourceButton: UIButton? = nil) {
let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
for button in starButtons {
let buttonValue = Double(button.tag)
let imageName: String
if selectedRating >= buttonValue {
imageName = "star.fill"
} else if selectedRating >= (buttonValue - 0.5) {
imageName = "star.leadinghalf.filled"
} else {
imageName = "star"
}
button.setImage(UIImage(systemName: imageName, withConfiguration: config), for: .normal)
button.tintColor = imageName == "star" ? UIColor.white.withAlphaComponent(0.18) : UIColor(red: 0.96, green: 0.74, blue: 0.20, alpha: 1.0)
let isActive = (selectedRating >= (buttonValue - 0.5))
if isActive && sourceButton === button {
UIView.animate(withDuration: 0.10, animations: {
button.transform = CGAffineTransform(scaleX: 1.18, y: 1.18)
}, completion: { _ in
UIView.animate(withDuration: 0.10) {
button.transform = .identity
}
})
}
}
}
private func setupOverlay() {
overlayView.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 0.96)
overlayView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayView)
spinner.color = .white
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.hidesWhenStopped = true
overlayView.addSubview(spinner)
overlayLabel.font = .systemFont(ofSize: 15, weight: .medium)
overlayLabel.textColor = UIColor.white.withAlphaComponent(0.82)
overlayLabel.textAlignment = .center
overlayLabel.numberOfLines = 0
overlayLabel.translatesAutoresizingMaskIntoConstraints = false
overlayView.addSubview(overlayLabel)
NSLayoutConstraint.activate([
overlayView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
spinner.centerXAnchor.constraint(equalTo: overlayView.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: overlayView.centerYAnchor, constant: -18),
overlayLabel.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 12),
overlayLabel.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor, constant: 24),
overlayLabel.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor, constant: -24)
])
spinner.startAnimating()
overlayLabel.text = "İçerik analiz ediliyor..."
}
private func renderComments() {
commentsListStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
guard !comments.isEmpty else {
let emptyState = UILabel()
emptyState.text = "Henüz yorum yok. İlk yorumu sen yaz."
emptyState.textColor = UIColor.white.withAlphaComponent(0.55)
emptyState.font = .systemFont(ofSize: 13, weight: .medium)
emptyState.numberOfLines = 0
commentsListStack.addArrangedSubview(emptyState)
return
}
for item in comments {
commentsListStack.addArrangedSubview(makeCommentBubble(item))
}
}
private func makeCommentBubble(_ item: CommentItem) -> UIView {
let bubble = UIView()
bubble.backgroundColor = UIColor.white.withAlphaComponent(0.07)
bubble.layer.cornerRadius = 12
bubble.layer.borderWidth = 1
bubble.layer.borderColor = UIColor.white.withAlphaComponent(0.06).cgColor
let userLabel = UILabel()
userLabel.font = .systemFont(ofSize: 12, weight: .semibold)
userLabel.textColor = .white
userLabel.text = "@\(item.user)"
userLabel.translatesAutoresizingMaskIntoConstraints = false
let bodyLabel = UILabel()
bodyLabel.font = .systemFont(ofSize: 13, weight: .regular)
bodyLabel.textColor = UIColor.white.withAlphaComponent(0.83)
bodyLabel.numberOfLines = 0
bodyLabel.text = item.body
bodyLabel.translatesAutoresizingMaskIntoConstraints = false
let timeLabel = UILabel()
timeLabel.font = .systemFont(ofSize: 11, weight: .regular)
timeLabel.textColor = UIColor.white.withAlphaComponent(0.50)
timeLabel.text = item.time
timeLabel.translatesAutoresizingMaskIntoConstraints = false
bubble.addSubview(userLabel)
bubble.addSubview(bodyLabel)
bubble.addSubview(timeLabel)
NSLayoutConstraint.activate([
userLabel.topAnchor.constraint(equalTo: bubble.topAnchor, constant: 10),
userLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
userLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
bodyLabel.topAnchor.constraint(equalTo: userLabel.bottomAnchor, constant: 6),
bodyLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
bodyLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
timeLabel.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: 8),
timeLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
timeLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
timeLabel.bottomAnchor.constraint(equalTo: bubble.bottomAnchor, constant: -10)
])
return bubble
}
@objc private func submitCommentTapped() {
let text = commentTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
comments.insert(CommentItem(user: "sen", body: text, time: "Şimdi"), at: 0)
commentTextView.text = ""
textViewDidChange(commentTextView)
renderComments()
hoverHaptic.impactOccurred(intensity: 0.35)
hoverHaptic.prepare()
submitCommentButton.setTitle("Gönderildi", for: .normal)
submitCommentButton.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.9)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
self.submitCommentButton.setTitle("Gönder", for: .normal)
self.submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
}
}
func textViewDidChange(_ textView: UITextView) {
commentPlaceholderLabel.isHidden = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasText = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
submitCommentButton.alpha = hasText ? 1.0 : 0.5
submitCommentButton.isEnabled = hasText
}
@MainActor
private func handleIncomingShare() async {
apply(.loading)
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
let providers = items.flatMap { $0.attachments ?? [] }
guard !providers.isEmpty else {
apply(.error("Paylaşılan içerik okunamadı."))
return
}
for provider in providers {
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
let normalized = normalizeURL(extracted)
SharedPayloadStore.saveIncomingURL(normalized.absoluteString)
do {
let info = try await APIClient.shared.getInfo(url: normalized.absoluteString)
apply(.success(info))
} catch {
apply(.error("Bilgiler alınamadı.\n\(error.localizedDescription)"))
}
return
}
}
apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
}
@objc private func closeTapped() {
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
@objc private func handleDismissPan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
let velocity = gesture.velocity(in: view)
switch gesture.state {
case .began:
dismissPanStartTransform = headerView.transform
case .changed:
let downY = max(0, translation.y)
let progress = min(downY / 140.0, 1.0)
headerView.transform = dismissPanStartTransform.translatedBy(x: 0, y: downY * 0.3)
headerView.alpha = 1.0 - (progress * 0.25)
case .ended, .cancelled, .failed:
let shouldDismiss = translation.y > 90 || velocity.y > 900
if shouldDismiss {
closeTapped()
return
}
UIView.animate(withDuration: 0.18) {
self.headerView.transform = .identity
self.headerView.alpha = 1.0
}
default:
break
}
}
private func extractURL(from provider: NSItemProvider) async -> URL? {
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
return await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
if let url = item as? URL {
continuation.resume(returning: url)
return
}
if let raw = item as? String {
continuation.resume(returning: Self.firstURL(in: raw))
return
}
continuation.resume(returning: nil)
}
}
}
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
return await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
if let raw = item as? String, let url = Self.firstURL(in: raw) {
continuation.resume(returning: url)
return
}
continuation.resume(returning: nil)
}
}
}
return nil
}
private func normalizeURL(_ url: URL) -> URL {
let host = url.host?.lowercased() ?? ""
guard host == "app.primevideo.com" else { return url }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let gti = components?.queryItems?.first(where: { $0.name == "gti" }) {
components?.queryItems = [gti]
} else {
components?.queryItems = nil
}
return components?.url ?? url
}
private func isSupportedStreamingURL(_ url: URL) -> Bool {
let host = url.host?.lowercased() ?? ""
let netflixHosts = ["www.netflix.com", "netflix.com", "www.netflix.com.tr", "netflix.com.tr"]
let primeHosts = ["www.primevideo.com", "primevideo.com", "www.amazon.com", "amazon.com", "app.primevideo.com"]
guard netflixHosts.contains(host) || primeHosts.contains(host) else { return false }
let path = url.path.lowercased()
if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") {
return true
}
return !path.isEmpty && path != "/"
}
private static func firstURL(in raw: String) -> URL? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: text), url.scheme?.isEmpty == false {
return url
}
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return nil
}
return detector.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text))?.url
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
enum APIClientError: LocalizedError {
case invalidBaseURL
case invalidResponse
case server(String)
var errorDescription: String? {
switch self {
case .invalidBaseURL:
return "API_BASE_URL geçersiz."
case .invalidResponse:
return "Sunucudan geçerli yanıt alınamadı."
case .server(let message):
return message
}
}
}
final class APIClient {
static let shared = APIClient()
private init() {}
private var baseURL: URL? {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String else {
return nil
}
return URL(string: raw)
}
private var mobileAPIKey: String {
Bundle.main.object(forInfoDictionaryKey: "MOBILE_API_KEY") as? String
?? "mobile-dev-key-change-me"
}
func getInfo(url: String) async throws -> GetInfoResponse {
guard let baseURL else { throw APIClientError.invalidBaseURL }
var request = URLRequest(url: baseURL.appending(path: "/api/getinfo"))
request.httpMethod = "POST"
request.timeoutInterval = 20
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(mobileAPIKey, forHTTPHeaderField: "X-API-Key")
request.httpBody = try JSONEncoder().encode(GetInfoRequest(url: url))
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIClientError.invalidResponse
}
let decoder = JSONDecoder()
let envelope = try decoder.decode(APIEnvelope<GetInfoResponse>.self, from: data)
if (200..<300).contains(http.statusCode), envelope.success, let payload = envelope.data {
return payload
}
if let errorMessage = envelope.error?.message {
throw APIClientError.server(errorMessage)
}
throw APIClientError.server("İstek başarısız oldu (\(http.statusCode)).")
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
struct GetInfoRequest: Encodable {
let url: String
}
struct APIErrorPayload: Decodable, Error {
let code: String
let message: String
}
struct APIEnvelope<T: Decodable>: Decodable {
let success: Bool
let data: T?
let error: APIErrorPayload?
}
struct GetInfoResponse: Decodable {
let provider: String
let title: String
let year: Int?
let plot: String?
let ageRating: String?
let type: String
let genres: [String]
let cast: [String]
let backdrop: String?
let currentSeason: Int?
}

View File

@@ -0,0 +1,31 @@
import Foundation
enum SharedConfig {
static var appGroupID: String {
Bundle.main.object(forInfoDictionaryKey: "APP_GROUP_ID") as? String
?? "group.net.wisecolt.ratebubble"
}
static var appURLScheme: String {
Bundle.main.object(forInfoDictionaryKey: "APP_URL_SCHEME") as? String
?? "ratebubble"
}
}
enum SharedKeys {
static let incomingURL = "incoming_shared_url"
}
enum SharedPayloadStore {
static func saveIncomingURL(_ url: String) {
guard let defaults = UserDefaults(suiteName: SharedConfig.appGroupID) else { return }
defaults.set(url, forKey: SharedKeys.incomingURL)
defaults.synchronize()
}
static func consumeIncomingURL() -> String? {
guard let defaults = UserDefaults(suiteName: SharedConfig.appGroupID) else { return nil }
defer { defaults.removeObject(forKey: SharedKeys.incomingURL) }
return defaults.string(forKey: SharedKeys.incomingURL)
}
}

58
ios/project.yml Normal file
View File

@@ -0,0 +1,58 @@
name: Ratebubble
options:
deploymentTarget:
iOS: 16.0
configs:
Debug: debug
Release: release
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.wisecolt.ratebubble
SWIFT_VERSION: 5.9
targets:
Ratebubble:
type: application
platform: iOS
deploymentTarget: "16.0"
sources:
- path: Ratebubble/App
- path: Ratebubble/Shared
configFiles:
Debug: Ratebubble/Resources/Config.xcconfig
Release: Ratebubble/Resources/Config.xcconfig
info:
path: Ratebubble/Resources/Ratebubble-Info.plist
properties:
UILaunchScreen: {}
CFBundleURLTypes:
- CFBundleTypeRole: Editor
CFBundleURLSchemes:
- $(APP_URL_SCHEME)
entitlements:
path: Ratebubble/Resources/Ratebubble.entitlements
properties:
com.apple.security.application-groups:
- $(APP_GROUP_ID)
dependencies:
- target: RatebubbleShare
RatebubbleShare:
type: app-extension
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.wisecolt.ratebubble.share
platform: iOS
deploymentTarget: "16.0"
sources:
- path: Ratebubble/ShareExtension
- path: Ratebubble/Shared
configFiles:
Debug: Ratebubble/Resources/Config.xcconfig
Release: Ratebubble/Resources/Config.xcconfig
info:
path: Ratebubble/Resources/RatebubbleShare-Info.plist
entitlements:
path: Ratebubble/Resources/RatebubbleShare.entitlements
properties:
com.apple.security.application-groups:
- $(APP_GROUP_ID)

13
ios/scripts/bootstrap.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v xcodegen >/dev/null 2>&1; then
echo "xcodegen bulunamadı. Kurulum: brew install xcodegen"
exit 1
fi
xcodegen generate
echo "Tamamlandı: ios/Ratebubble.xcodeproj oluşturuldu."

View File

@@ -1,6 +1,7 @@
import { Server as HttpServer } from 'http'; import { Server as HttpServer } from 'http';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import type { DataSource, GetInfoResponse } from '../types/index.js';
/** /**
* Socket.IO Server singleton * Socket.IO Server singleton
@@ -11,6 +12,32 @@ export interface SocketData {
subscribedJobs: Set<string>; 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;
scraper: number;
};
occurredAt: string;
}
/** /**
* Initialize Socket.IO server * Initialize Socket.IO server
*/ */
@@ -86,8 +113,8 @@ export function emitJobProgress(
*/ */
export function emitJobCompleted( export function emitJobCompleted(
jobId: string, jobId: string,
data: unknown, data: GetInfoResponse,
source: string source: DataSource
): void { ): void {
if (io) { if (io) {
io.to(`job:${jobId}`).emit('job:completed', { 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 * Emit job error event
*/ */

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express'; 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 logger from '../utils/logger.js';
import type { ApiResponse } from '../types/index.js'; import type { ApiResponse } from '../types/index.js';
@@ -61,8 +61,6 @@ export function authMiddleware(
* Optional: Identify which client made the request * Optional: Identify which client made the request
*/ */
export function identifyClient(apiKey: string): string { 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_WEB) return 'web';
if (apiKey === env.API_KEY_MOBILE) return 'mobile'; if (apiKey === env.API_KEY_MOBILE) return 'mobile';
if (apiKey === env.API_KEY_ADMIN) return 'admin'; if (apiKey === env.API_KEY_ADMIN) return 'admin';
@@ -70,4 +68,46 @@ export function identifyClient(apiKey: string): string {
return 'unknown'; 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; export default authMiddleware;

View File

@@ -1,27 +1,19 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import type { ApiResponse, GetInfoRequest } from '../types/index.js'; import type { ApiResponse, GetInfoRequest } from '../types/index.js';
import { isSupportedContentUrl } from '../utils/contentUrl.js';
/** /**
* Validation schema for /api/getinfo endpoint * Validation schema for /api/getinfo endpoint
*/ */
const getInfoSchema = z.object({ const getInfoSchema = z.object({
url: z.string().url('Invalid URL format').refine((url) => { url: z
// Validate Netflix URL .string()
try { .url('Invalid URL format')
const parsedUrl = new URL(url); .refine(
const validHosts = [ (url) => isSupportedContentUrl(url),
'www.netflix.com', 'URL must be Netflix /title/... or PrimeVideo /detail/...'
'netflix.com', ),
'www.netflix.com.tr',
'netflix.com.tr',
];
const hasTitlePath = /\/title\/\d+/.test(url);
return validHosts.includes(parsedUrl.hostname) && hasTitlePath;
} catch {
return false;
}
}, 'URL must be a valid Netflix title URL (e.g., https://www.netflix.com/tr/title/81616256)'),
}); });
/** /**

View File

@@ -1,17 +1,31 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { z } from 'zod'; 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 { scrapeRateLimiter } from '../middleware/rateLimit.middleware.js';
import { validateGetInfo } from '../middleware/validation.middleware.js'; import { validateGetInfo } from '../middleware/validation.middleware.js';
import { JobService } from '../services/job.service.js'; import { JobService } from '../services/job.service.js';
import { ContentService } from '../services/content.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 router = Router();
const listContentSchema = z.object({ const listContentSchema = z.object({
type: z.enum(['movie', 'tvshow']).optional(), type: z.enum(['movie', 'tvshow']).optional(),
limit: z.coerce.number().int().min(1).max(100).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 * POST /api/getinfo
@@ -118,6 +132,184 @@ 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/admin/content/purge
* Delete all content rows from DB (with related entities).
*/
router.post(
'/admin/content/purge',
adminOnlyMiddleware,
async (_req: Request, res: Response<ApiResponse<AdminActionResponse>>) => {
try {
const result = await AdminService.purgeAllContent();
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({
success: false,
error: {
code: 'ADMIN_CONTENT_PURGE_ERROR',
message:
error instanceof Error ? error.message : 'Failed to purge content',
},
});
}
}
);
/** /**
* POST /api/getinfo/async * POST /api/getinfo/async
* Create async job for content scraping * Create async job for content scraping

View File

@@ -19,6 +19,7 @@ const tmdbSearchSchema = z.object({
type: z.enum(['movie', 'tv', 'multi']).optional(), type: z.enum(['movie', 'tv', 'multi']).optional(),
seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
seasonNumber: z.coerce.number().int().min(1).max(100).optional(), seasonNumber: z.coerce.number().int().min(1).max(100).optional(),
cast: z.string().trim().min(1).max(120).optional(),
}); });
/** /**
@@ -59,7 +60,7 @@ router.post(
return; return;
} }
const { query, year, type, seasonYear, seasonNumber } = result.data; const { query, year, type, seasonYear, seasonNumber, cast } = result.data;
try { try {
const searchResult = await TmdbService.search({ const searchResult = await TmdbService.search({
@@ -68,6 +69,7 @@ router.post(
type: type || 'multi', type: type || 'multi',
seasonYear, seasonYear,
seasonNumber, seasonNumber,
cast,
}); });
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
@@ -106,6 +108,7 @@ router.post(
const movieSearchSchema = z.object({ const movieSearchSchema = z.object({
query: z.string().trim().min(1).max(200), query: z.string().trim().min(1).max(200),
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
cast: z.string().trim().min(1).max(120).optional(),
}); });
const result = movieSearchSchema.safeParse(req.body); const result = movieSearchSchema.safeParse(req.body);
@@ -128,10 +131,10 @@ router.post(
return; return;
} }
const { query, year } = result.data; const { query, year, cast } = result.data;
try { try {
const searchResult = await TmdbService.searchMovies(query, year); const searchResult = await TmdbService.searchMovies(query, year, cast);
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
success: true, success: true,
@@ -171,6 +174,7 @@ router.post(
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
seasonNumber: z.coerce.number().int().min(1).max(100).optional(), seasonNumber: z.coerce.number().int().min(1).max(100).optional(),
cast: z.string().trim().min(1).max(120).optional(),
}); });
const result = tvSearchSchema.safeParse(req.body); const result = tvSearchSchema.safeParse(req.body);
@@ -193,10 +197,10 @@ router.post(
return; return;
} }
const { query, year, seasonYear, seasonNumber } = result.data; const { query, year, seasonYear, seasonNumber, cast } = result.data;
try { try {
const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear); const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear, cast);
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
success: true, success: true,

View File

@@ -0,0 +1,494 @@
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';
import { parseSupportedContentUrl } from '../utils/contentUrl.js';
const CACHE_PREFIX = 'content:';
const MAX_CACHE_KEYS_FOR_ANALYSIS = 1000;
function formatCacheKeyLabel(key: string): string {
return key.replace(CACHE_PREFIX, '');
}
function extractProviderIdFromCacheKey(key: string): { provider: string; id: string } | null {
const normalized = formatCacheKeyLabel(key);
const match = normalized.match(/^(netflix|primevideo):([A-Za-z0-9]+)$/);
if (!match) return null;
const provider = match[1];
const id = match[2];
if (!provider || !id) return null;
return { provider, id };
}
function extractProviderIdFromUrl(url: string): { provider: string; id: string } | null {
const parsed = parseSupportedContentUrl(url);
if (!parsed) return null;
return { provider: parsed.provider, id: parsed.id };
}
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 cacheProviderIds = Array.from(
new Set(
cacheKeys
.map((key) => extractProviderIdFromCacheKey(key))
.filter((item): item is { provider: string; id: string } => Boolean(item))
.map((item) => `${item.provider}:${item.id}`)
)
);
const relatedContent = cacheProviderIds.length
? await prisma.content.findMany({
where: {
OR: cacheProviderIds.map((providerId) => {
const [provider, id] = providerId.split(':');
if (provider === 'primevideo') {
return { url: { contains: `/detail/${id}` } };
}
return { url: { contains: `/title/${id}` } };
}),
},
select: {
url: true,
title: true,
},
})
: [];
const titleMap = new Map<string, string>();
for (const item of relatedContent) {
const parsed = extractProviderIdFromUrl(item.url);
if (parsed) {
const key = `${parsed.provider}:${parsed.id}`;
if (!titleMap.has(key)) {
titleMap.set(key, 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 providerId = extractProviderIdFromCacheKey(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: providerId
? titleMap.get(`${providerId.provider}:${providerId.id}`) ?? 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`,
};
}
static async purgeAllContent(): Promise<AdminActionResponse> {
const totalContent = await prisma.content.count();
await prisma.$transaction([
prisma.content.deleteMany({}),
prisma.genre.deleteMany({}),
]);
await CacheService.clearAll();
return {
queued: totalContent,
skipped: 0,
details: 'Tum icerik verileri veritabanindan silindi',
};
}
}
export default AdminService;

View File

@@ -1,20 +1,37 @@
import redis from '../config/redis.js'; import redis from '../config/redis.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { emitCacheEvent } from '../config/socket.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import type { GetInfoResponse, CacheEntry, DataSource } from '../types/index.js'; import type { GetInfoResponse, CacheEntry } from '../types/index.js';
import { parseSupportedContentUrl } from '../utils/contentUrl.js';
/** /**
* Cache key prefix for Netflix content * Cache key prefix for scraped content
*/ */
const CACHE_PREFIX = 'netflix:content:'; const CACHE_PREFIX = 'content:';
/** /**
* Generate cache key from URL * Generate cache key from URL
*/ */
function getCacheKey(url: string): string { function getCacheKey(url: string): string {
// Use URL hash or title ID as key const parsed = parseSupportedContentUrl(url);
const titleId = url.match(/\/title\/(\d+)/)?.[1] || url;
return `${CACHE_PREFIX}${titleId}`; if (parsed) {
return `${CACHE_PREFIX}${parsed.provider}:${parsed.id}`;
}
return `${CACHE_PREFIX}url:${encodeURIComponent(url)}`;
}
function normalizeCachedResponse(url: string, data: GetInfoResponse): GetInfoResponse {
if (data.provider === 'netflix' || data.provider === 'primevideo') {
return data;
}
return {
...data,
provider: parseSupportedContentUrl(url)?.provider ?? 'netflix',
};
} }
/** /**
@@ -38,7 +55,7 @@ export class CacheService {
logger.debug('Cache hit', { url }); logger.debug('Cache hit', { url });
const entry: CacheEntry<GetInfoResponse> = JSON.parse(cached); const entry: CacheEntry<GetInfoResponse> = JSON.parse(cached);
return entry.data; return normalizeCachedResponse(url, entry.data);
} catch (error) { } catch (error) {
logger.error('Cache get error', { logger.error('Cache get error', {
url, url,
@@ -56,13 +73,19 @@ export class CacheService {
const ttl = env.REDIS_TTL_SECONDS; const ttl = env.REDIS_TTL_SECONDS;
const entry: CacheEntry<GetInfoResponse> = { const entry: CacheEntry<GetInfoResponse> = {
data, data: normalizeCachedResponse(url, data),
cachedAt: Date.now(), cachedAt: Date.now(),
ttl, ttl,
}; };
try { try {
await redis.setex(key, ttl, JSON.stringify(entry)); await redis.setex(key, ttl, JSON.stringify(entry));
emitCacheEvent({
action: 'written',
key,
ttlSeconds: ttl,
occurredAt: new Date().toISOString(),
});
logger.debug('Cache set', { url, ttl }); logger.debug('Cache set', { url, ttl });
} catch (error) { } catch (error) {
logger.error('Cache set error', { logger.error('Cache set error', {
@@ -80,6 +103,11 @@ export class CacheService {
try { try {
await redis.del(key); await redis.del(key);
emitCacheEvent({
action: 'deleted',
key,
occurredAt: new Date().toISOString(),
});
logger.debug('Cache deleted', { url }); logger.debug('Cache deleted', { url });
} catch (error) { } catch (error) {
logger.error('Cache delete error', { logger.error('Cache delete error', {
@@ -125,7 +153,7 @@ export class CacheService {
} }
/** /**
* Clear all Netflix content cache * Clear all scraped content cache
*/ */
static async clearAll(): Promise<void> { static async clearAll(): Promise<void> {
try { try {
@@ -133,6 +161,11 @@ export class CacheService {
if (keys.length > 0) { if (keys.length > 0) {
await redis.del(...keys); await redis.del(...keys);
emitCacheEvent({
action: 'cleared',
count: keys.length,
occurredAt: new Date().toISOString(),
});
logger.info('Cache cleared', { count: keys.length }); logger.info('Cache cleared', { count: keys.length });
} }
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,7 @@
import prisma from '../config/database.js'; import prisma from '../config/database.js';
import { emitContentEvent } from '../config/socket.js';
import type { ContentData, ScraperResult, GetInfoResponse } from '../types/index.js'; import type { ContentData, ScraperResult, GetInfoResponse } from '../types/index.js';
import { parseSupportedContentUrl } from '../utils/contentUrl.js';
/** /**
* Content Service for database operations * Content Service for database operations
@@ -105,7 +107,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 +180,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 +197,11 @@ export class ContentService {
await prisma.content.delete({ await prisma.content.delete({
where: { url }, where: { url },
}); });
emitContentEvent({
action: 'deleted',
url,
occurredAt: new Date().toISOString(),
});
} }
/** /**
@@ -222,7 +243,9 @@ export class ContentService {
* Convert ContentData to API response format * Convert ContentData to API response format
*/ */
static toApiResponse(data: ContentData): GetInfoResponse { static toApiResponse(data: ContentData): GetInfoResponse {
const provider = parseSupportedContentUrl(data.url)?.provider ?? 'netflix';
return { return {
provider,
title: data.title, title: data.title,
year: data.year, year: data.year,
plot: data.plot, plot: data.plot,

View File

@@ -1,8 +1,10 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import type { Prisma } from '@prisma/client';
import prisma from '../config/database.js'; import prisma from '../config/database.js';
import { CacheService } from './cache.service.js'; import { CacheService } from './cache.service.js';
import { ContentService } from './content.service.js'; import { ContentService } from './content.service.js';
import { ScraperService } from './scraper.service.js'; import { ScraperService } from './scraper.service.js';
import { MetricsService } from './metrics.service.js';
import { import {
emitJobProgress, emitJobProgress,
emitJobCompleted, emitJobCompleted,
@@ -59,7 +61,7 @@ export class JobService {
status?: JobStatus; status?: JobStatus;
progress?: number; progress?: number;
step?: string; step?: string;
result?: unknown; result?: Prisma.InputJsonValue;
error?: string; error?: string;
} }
): Promise<ScrapeJob> { ): Promise<ScrapeJob> {
@@ -72,7 +74,7 @@ export class JobService {
} }
/** /**
* Process a scrape job (hybrid: cache -> db -> netflix) * Process a scrape job (hybrid: cache -> db -> scraper)
*/ */
static async process(jobId: string): Promise<void> { static async process(jobId: string): Promise<void> {
const job = await this.getById(jobId); const job = await this.getById(jobId);
@@ -94,9 +96,11 @@ export class JobService {
// Step 1: Check cache // Step 1: Check cache
const cachedData = await CacheService.get(job.url); const cachedData = await CacheService.get(job.url);
if (cachedData) { if (cachedData) {
await MetricsService.incrementCacheHit();
await this.completeJob(jobId, cachedData, 'cache'); await this.completeJob(jobId, cachedData, 'cache');
return; return;
} }
await MetricsService.incrementCacheMiss();
// Update progress // Update progress
await this.update(jobId, { progress: 30, step: 'checking_database' }); await this.update(jobId, { progress: 30, step: 'checking_database' });
@@ -114,11 +118,14 @@ export class JobService {
return; return;
} }
// Update progress const provider = ScraperService.detectProvider(job.url);
await this.update(jobId, { progress: 50, step: 'scraping_netflix' }); const providerLabel = provider === 'primevideo' ? 'Prime Video' : 'Netflix';
emitJobProgress(jobId, 50, 'processing', 'Scraping Netflix');
// Step 3: Scrape from Netflix // Update progress
await this.update(jobId, { progress: 50, step: `scraping_${provider ?? 'source'}` });
emitJobProgress(jobId, 50, 'processing', `Scraping ${providerLabel}`);
// Step 3: Scrape from source URL
const scraperResult = await ScraperService.scrape(job.url); const scraperResult = await ScraperService.scrape(job.url);
// Update progress // Update progress
@@ -133,7 +140,7 @@ export class JobService {
await CacheService.set(job.url, responseData); await CacheService.set(job.url, responseData);
// Complete the job // Complete the job
await this.completeJob(jobId, responseData, 'netflix'); await this.completeJob(jobId, responseData, 'scraper');
} catch (error) { } catch (error) {
const apiError: ApiError = { const apiError: ApiError = {
code: 'SCRAPE_ERROR', code: 'SCRAPE_ERROR',
@@ -165,10 +172,11 @@ export class JobService {
status: 'completed', status: 'completed',
progress: 100, progress: 100,
step: 'completed', step: 'completed',
result: data, result: data as unknown as Prisma.InputJsonValue,
}); });
emitJobCompleted(jobId, data, source); emitJobCompleted(jobId, data, source);
await MetricsService.incrementSource(source);
logger.info('Job completed', { jobId, source }); logger.info('Job completed', { jobId, source });
} }
@@ -182,18 +190,22 @@ export class JobService {
// Step 1: Check cache // Step 1: Check cache
const cachedData = await CacheService.get(url); const cachedData = await CacheService.get(url);
if (cachedData) { if (cachedData) {
await MetricsService.incrementCacheHit();
await MetricsService.incrementSource('cache');
return { data: cachedData, source: 'cache' }; return { data: cachedData, source: 'cache' };
} }
await MetricsService.incrementCacheMiss();
// Step 2: Check database // Step 2: Check database
const dbContent = await ContentService.findByUrl(url); const dbContent = await ContentService.findByUrl(url);
if (dbContent) { if (dbContent) {
const responseData = ContentService.toApiResponse(dbContent); const responseData = ContentService.toApiResponse(dbContent);
await CacheService.set(url, responseData); await CacheService.set(url, responseData);
await MetricsService.incrementSource('database');
return { data: responseData, source: 'database' }; return { data: responseData, source: 'database' };
} }
// Step 3: Scrape from Netflix // Step 3: Scrape from source URL
const scraperResult = await ScraperService.scrape(url); const scraperResult = await ScraperService.scrape(url);
// Step 4: Save to database // Step 4: Save to database
@@ -202,8 +214,9 @@ export class JobService {
// Step 5: Cache the result // Step 5: Cache the result
await CacheService.set(url, responseData); await CacheService.set(url, responseData);
await MetricsService.incrementSource('scraper');
return { data: responseData, source: 'netflix' }; return { data: responseData, source: 'scraper' };
} }
/** /**

View 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;
scraper: 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),
scraper: toInt(sources.scraper),
},
};
}
}
export default MetricsService;

View File

@@ -1,6 +1,10 @@
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import type { ScraperResult, ContentType } from '../types/index.js'; import type { ScraperResult, ContentType } from '../types/index.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import {
parseSupportedContentUrl,
type SupportedProvider,
} from '../utils/contentUrl.js';
/** /**
* Age rating patterns to detect and exclude from genres * Age rating patterns to detect and exclude from genres
@@ -14,43 +18,55 @@ const AGE_RATING_PATTERN = /^[\u2066-\u2069\u202A-\u202E\u200E-\u200F]*(\d+\+|PG
* Matches patterns like "3 Sezon", "2 Seasons", "1. Sezon", etc. * Matches patterns like "3 Sezon", "2 Seasons", "1. Sezon", etc.
*/ */
const SEASON_PATTERN = /(\d+)\.?\s*(sezon|season|sezonlar|seasons)/i; const SEASON_PATTERN = /(\d+)\.?\s*(sezon|season|sezonlar|seasons)/i;
const EPISODE_PATTERN = /(\d+)\.?\s*(bölüm|bolum|bölümler|bolumler|episode|episodes)/i;
const EPISODE_TOKEN_PATTERN = /\b(bölüm|bolum|bölümler|bolumler|episode|episodes)\b/i;
/** /**
* Netflix HTML Scraper Service * Scraper Service (Netflix + Prime Video)
* Uses Cheerio for parsing HTML content * Uses Cheerio for parsing HTML content
*/ */
export class ScraperService { export class ScraperService {
/**
* Detect content provider from URL
*/
static detectProvider(url: string): SupportedProvider | null {
return parseSupportedContentUrl(url)?.provider ?? null;
}
/**
* Validate if URL is a supported content URL
*/
static isSupportedUrl(url: string): boolean {
return Boolean(parseSupportedContentUrl(url));
}
/** /**
* Validate if URL is a valid Netflix URL * Validate if URL is a valid Netflix URL
*/ */
static isValidNetflixUrl(url: string): boolean { static isValidNetflixUrl(url: string): boolean {
try { return parseSupportedContentUrl(url)?.provider === 'netflix';
const parsedUrl = new URL(url); }
const validHosts = [
'www.netflix.com', /**
'netflix.com', * Validate if URL is a valid Prime Video URL
'www.netflix.com.tr', */
'netflix.com.tr', static isValidPrimeVideoUrl(url: string): boolean {
]; return parseSupportedContentUrl(url)?.provider === 'primevideo';
return validHosts.includes(parsedUrl.hostname);
} catch {
return false;
}
} }
/** /**
* Extract Netflix title ID from URL * Extract Netflix title ID from URL
*/ */
static extractTitleId(url: string): string | null { static extractTitleId(url: string): string | null {
const match = url.match(/\/title\/(\d+)/); const parsed = parseSupportedContentUrl(url);
return match ? match[1] : null; return parsed?.provider === 'netflix' ? parsed.id : null;
} }
/** /**
* Fetch HTML content from Netflix URL * Fetch HTML content from URL
*/ */
private static async fetchHtml(url: string): Promise<string> { private static async fetchHtml(url: string, provider: SupportedProvider): Promise<string> {
logger.info('Fetching Netflix page', { url }); logger.info('Fetching content page', { provider, url });
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
@@ -63,7 +79,7 @@ export class ScraperService {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch Netflix page: ${response.status}`); throw new Error(`Failed to fetch ${provider} page: ${response.status}`);
} }
return response.text(); return response.text();
@@ -73,22 +89,46 @@ export class ScraperService {
* Parse HTML and extract content data * Parse HTML and extract content data
*/ */
static async scrape(url: string): Promise<ScraperResult> { static async scrape(url: string): Promise<ScraperResult> {
if (!this.isValidNetflixUrl(url)) { const parsed = parseSupportedContentUrl(url);
throw new Error('Invalid Netflix URL');
if (!parsed) {
throw new Error(
'Invalid content URL. Use Netflix /title/... or PrimeVideo /detail/...'
);
} }
const html = await this.fetchHtml(url); const html = await this.fetchHtml(url, parsed.provider);
const $ = cheerio.load(html); const $ = cheerio.load(html);
const title = this.extractTitle($); const result =
const year = this.extractYear($); parsed.provider === 'netflix'
const plot = this.extractPlot($); ? this.scrapeNetflix($)
const ageRating = this.extractAgeRating($); : this.scrapePrimeVideo($, parsed.id);
const { genres, type, currentSeason } = this.extractGenresTypeAndSeason($);
const cast = this.extractCast($);
const backdropUrl = this.extractBackdrop($);
const result: ScraperResult = { logger.info('Scraping completed', {
provider: parsed.provider,
url,
title: result.title,
year: result.year,
ageRating: result.ageRating,
type: result.type,
genresCount: result.genres.length,
castCount: result.cast.length,
});
return result;
}
private static scrapeNetflix($: cheerio.CheerioAPI): ScraperResult {
const title = this.extractNetflixTitle($);
const year = this.extractNetflixYear($);
const plot = this.extractNetflixPlot($);
const ageRating = this.extractNetflixAgeRating($);
const { genres, type, currentSeason } = this.extractNetflixGenresTypeAndSeason($);
const cast = this.extractNetflixCast($);
const backdropUrl = this.extractNetflixBackdrop($);
return {
title, title,
year, year,
plot, plot,
@@ -99,24 +139,71 @@ export class ScraperService {
backdropUrl, backdropUrl,
currentSeason, currentSeason,
}; };
}
logger.info('Scraping completed', { private static scrapePrimeVideo($: cheerio.CheerioAPI, detailId: string): ScraperResult {
url, const title = this.extractPrimeTitle($, detailId);
const year = this.extractPrimeYear($);
const { type, currentSeason } = this.extractPrimeTypeAndSeason($);
const plot = this.extractPrimePlot($);
const cast = this.extractPrimeCast($);
const genres = this.extractPrimeGenres($);
const backdropUrl = this.extractPrimeBackdrop($);
const ageRating = this.extractPrimeAgeRating($);
return {
title, title,
year, year,
plot,
ageRating, ageRating,
type, type,
genresCount: genres.length, genres,
castCount: cast.length, cast,
}); backdropUrl,
currentSeason,
};
}
return result; private static parseYear(text: string): number | null {
const yearMatch = text.match(/(19|20)\d{2}/);
if (!yearMatch) return null;
const year = Number.parseInt(yearMatch[0], 10);
if (Number.isNaN(year)) return null;
if (year < 1900 || year > new Date().getFullYear() + 5) return null;
return year;
}
private static cleanText(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
private static normalizePrimeTitleCandidate(text: string): string {
return this.cleanText(text)
.replace(/^[İIiı]zle:\s*/i, '')
.replace(/^canl[ıi]\s+izleyin:\s*/i, '')
.replace(/^watch\s+now:\s*/i, '')
.replace(/^prime\s+video:\s*/i, '')
.replace(/\s*(sezon|season)\s+\d+(?=\s*[-–—]\s*prime\s+video$)/i, '')
.replace(/\s*[-–—]\s*prime\s+video$/i, '')
.replace(/\s*\|\s*prime\s*video$/i, '')
.replace(/\s+(sezon|season)\s+\d+\s*$/i, '')
.trim();
}
private static uniqueTextList(items: string[]): string[] {
const unique = new Set<string>();
for (const item of items) {
const normalized = this.cleanText(item);
if (normalized) unique.add(normalized);
}
return Array.from(unique);
} }
/** /**
* Extract title from HTML * Netflix extractors
*/ */
private static extractTitle($: cheerio.CheerioAPI): string { private static extractNetflixTitle($: cheerio.CheerioAPI): string {
let title = $('h2.default-ltr-iqcdef-cache-tnklrp').first().text().trim(); let title = $('h2.default-ltr-iqcdef-cache-tnklrp').first().text().trim();
if (!title) { if (!title) {
@@ -131,24 +218,12 @@ export class ScraperService {
return title || 'Unknown Title'; return title || 'Unknown Title';
} }
/** private static extractNetflixYear($: cheerio.CheerioAPI): number | null {
* Extract year from HTML (first li element)
*/
private static extractYear($: cheerio.CheerioAPI): number | null {
const yearText = $('li.default-ltr-iqcdef-cache-6prs41').first().text().trim(); const yearText = $('li.default-ltr-iqcdef-cache-6prs41').first().text().trim();
const year = parseInt(yearText, 10); return this.parseYear(yearText);
if (!isNaN(year) && year >= 1900 && year <= new Date().getFullYear() + 5) {
return year;
}
return null;
} }
/** private static extractNetflixPlot($: cheerio.CheerioAPI): string | null {
* Extract plot/description from HTML
*/
private static extractPlot($: cheerio.CheerioAPI): string | null {
const plot = $('span.default-ltr-iqcdef-cache-6ukeej').first().text().trim(); const plot = $('span.default-ltr-iqcdef-cache-6ukeej').first().text().trim();
if (!plot) { if (!plot) {
@@ -159,91 +234,70 @@ export class ScraperService {
return plot || null; return plot || null;
} }
/** private static extractNetflixAgeRating($: cheerio.CheerioAPI): string | null {
* Extract age rating from HTML (e.g., "18+", "16+") const items = $('li.default-ltr-iqcdef-cache-6prs41').toArray();
* Searches all li elements (except first which is year) for (let i = 1; i < items.length; i += 1) {
*/ const element = items[i];
private static extractAgeRating($: cheerio.CheerioAPI): string | null { if (!element) continue;
let ageRating: string | null = null;
const foundTexts: string[] = [];
$('li.default-ltr-iqcdef-cache-6prs41').each((index, element) => {
if (index === 0) return; // Skip year
const text = $(element).text().trim(); const text = $(element).text().trim();
foundTexts.push(text); const cleanText = text
.replace(/[\u2066-\u2069\u202A-\u202E\u200E-\u200F]/g, '')
// Clean Unicode characters first .trim();
const cleanText = text.replace(/[\u2066-\u2069\u202A-\u202E\u200E-\u200F]/g, '').trim();
if (cleanText && AGE_RATING_PATTERN.test(cleanText)) { if (cleanText && AGE_RATING_PATTERN.test(cleanText)) {
ageRating = cleanText; return cleanText;
return false; // Break loop
} }
});
// Debug logging
if (!ageRating && foundTexts.length > 0) {
logger.debug('Age rating not found in elements', {
foundTexts,
pattern: AGE_RATING_PATTERN.source,
});
} }
return ageRating; return null;
} }
/** private static extractNetflixGenresTypeAndSeason(
* Extract genres from HTML (skip year, age rating, and season info) $: cheerio.CheerioAPI
* Also detects content type (movie/tvshow) based on season presence ): { genres: string[]; type: ContentType; currentSeason: number | null } {
* Extracts current season number from season text
*/
private static extractGenresTypeAndSeason($: cheerio.CheerioAPI): { genres: string[]; type: ContentType; currentSeason: number | null } {
const genres: string[] = []; const genres: string[] = [];
let type: ContentType = 'movie'; let type: ContentType = 'movie';
let currentSeason: number | null = null; let currentSeason: number | null = null;
const foundTexts: string[] = [];
$('li.default-ltr-iqcdef-cache-6prs41').each((index, element) => { $('li.default-ltr-iqcdef-cache-6prs41').each((index, element) => {
if (index === 0) return; // Skip year if (index === 0) return;
const text = $(element).text().trim(); const text = $(element).text().trim();
const cleanText = text.replace(/[\u2066\u2069\u202A\u202B\u202C\u202D\u202E\u200E\u200F]/g, '').trim(); const cleanText = text
foundTexts.push(cleanText); .replace(/[\u2066\u2069\u202A\u202B\u202C\u202D\u202E\u200E\u200F]/g, '')
.trim();
// Check for season pattern - indicates TV show
const seasonMatch = cleanText.match(SEASON_PATTERN); const seasonMatch = cleanText.match(SEASON_PATTERN);
if (cleanText && seasonMatch) { if (cleanText && seasonMatch) {
type = 'tvshow'; type = 'tvshow';
// Extract season number from the text const seasonValue = seasonMatch[1];
const seasonNum = parseInt(seasonMatch[1], 10); const seasonNum = seasonValue ? Number.parseInt(seasonValue, 10) : Number.NaN;
if (!isNaN(seasonNum)) { if (Number.isFinite(seasonNum)) {
currentSeason = seasonNum; currentSeason = seasonNum;
} }
return; // Skip adding to genres return;
}
const episodeMatch = cleanText.match(EPISODE_PATTERN);
const hasEpisodeToken = EPISODE_TOKEN_PATTERN.test(cleanText);
if (cleanText && (episodeMatch || hasEpisodeToken)) {
type = 'tvshow';
if (currentSeason == null) {
currentSeason = 1;
}
return;
} }
// Skip age rating - only add actual genres
if (cleanText && !AGE_RATING_PATTERN.test(cleanText)) { if (cleanText && !AGE_RATING_PATTERN.test(cleanText)) {
genres.push(cleanText); genres.push(cleanText);
} }
}); });
// Debug logging
logger.debug('extractGenresTypeAndSeason completed', {
foundTexts,
genres,
type,
currentSeason,
});
return { genres, type, currentSeason }; return { genres, type, currentSeason };
} }
/** private static extractNetflixCast($: cheerio.CheerioAPI): string[] {
* Extract cast members from HTML
*/
private static extractCast($: cheerio.CheerioAPI): string[] {
const castText = $('span.default-ltr-iqcdef-cache-m0886o').first().text().trim(); const castText = $('span.default-ltr-iqcdef-cache-m0886o').first().text().trim();
if (!castText) { if (!castText) {
@@ -256,10 +310,7 @@ export class ScraperService {
.filter((name) => name.length > 0); .filter((name) => name.length > 0);
} }
/** private static extractNetflixBackdrop($: cheerio.CheerioAPI): string | null {
* Extract backdrop image URL from HTML
*/
private static extractBackdrop($: cheerio.CheerioAPI): string | null {
const backdropDiv = $('div.default-ltr-iqcdef-cache-1wezh7a').first(); const backdropDiv = $('div.default-ltr-iqcdef-cache-1wezh7a').first();
const img = backdropDiv.find('img').first(); const img = backdropDiv.find('img').first();
@@ -279,6 +330,176 @@ export class ScraperService {
return null; return null;
} }
/**
* Prime Video extractors
*/
private static extractPrimeTitle($: cheerio.CheerioAPI, detailId: string): string {
const primaryTitle = this.normalizePrimeTitleCandidate(
$('h1[data-automation-id="title"]').first().text() || ''
);
const detailLinkSelector = `a[href*="/detail/${detailId}"]`;
const imageLinkAriaTitle = this.normalizePrimeTitleCandidate(
$(`a[data-testid="image-link"][aria-label][href*="/detail/${detailId}"]`).first().attr('aria-label') ||
$(`${detailLinkSelector}[aria-label]`).first().attr('aria-label') ||
''
);
const imageLinkTextTitle = this.normalizePrimeTitleCandidate(
$(`a[data-testid="image-link"][href*="/detail/${detailId}"]`).first().text() ||
$(detailLinkSelector).first().text() ||
''
);
const metaOgTitle = this.normalizePrimeTitleCandidate(
$('meta[property="og:title"]').attr('content') || ''
);
const metaNameTitle = this.normalizePrimeTitleCandidate(
$('meta[name="title"]').attr('content') || ''
);
const pageTitle = this.normalizePrimeTitleCandidate(
$('title').first().text() || ''
);
const canonicalHref = $('link[rel="canonical"]').attr('href') || '';
let canonicalTitle = '';
if (canonicalHref) {
try {
const canonicalUrl = new URL(canonicalHref, 'https://www.primevideo.com');
const canonicalMatch = canonicalUrl.pathname.match(/\/detail\/([^/]+)\/([A-Za-z0-9]+)/i);
if (canonicalMatch && canonicalMatch[2] === detailId) {
canonicalTitle = this.normalizePrimeTitleCandidate(
decodeURIComponent(canonicalMatch[1] || '')
);
}
} catch {
// best effort
}
}
const title =
primaryTitle ||
imageLinkAriaTitle ||
imageLinkTextTitle ||
metaOgTitle ||
metaNameTitle ||
pageTitle ||
canonicalTitle;
return title || 'Unknown Title';
}
private static extractPrimeYear($: cheerio.CheerioAPI): number | null {
const releaseBadge = $('span[data-automation-id="release-year-badge"]').first();
return (
this.parseYear(this.cleanText(releaseBadge.text())) ||
this.parseYear(this.cleanText(releaseBadge.attr('aria-label') || ''))
);
}
private static extractPrimeTypeAndSeason(
$: cheerio.CheerioAPI
): { type: ContentType; currentSeason: number | null } {
const seasonNodeText = this.cleanText(
$('div.dv-node-dp-seasons, [data-testid="dp-season-selector"]').text()
);
const hasSeasonMarker = /\b(sezon|season)\b/i.test(seasonNodeText);
const seasonLabel =
$('input#av-droplist-av-atf-season-selector').attr('aria-label') ||
$('label[for="av-droplist-av-atf-season-selector"] ._36qUej').first().text() ||
'';
const seasonMatch = this.cleanText(seasonLabel).match(
/(?:sezon|season)\s*(\d+)|(\d+)\.?\s*(?:sezon|season)/i
);
const currentSeasonRaw = seasonMatch ? seasonMatch[1] || seasonMatch[2] : null;
const currentSeason = currentSeasonRaw
? Number.parseInt(currentSeasonRaw, 10)
: null;
return {
type: hasSeasonMarker ? 'tvshow' : 'movie',
currentSeason: Number.isNaN(currentSeason as number) ? null : currentSeason,
};
}
private static extractPrimeCast($: cheerio.CheerioAPI): string[] {
const cast = $('dd.skJCpF a._1NNx6V')
.map((_, el) => $(el).text())
.get();
return this.uniqueTextList(cast);
}
private static extractPrimeGenres($: cheerio.CheerioAPI): string[] {
const genres = $(
'div[data-testid="dv-node-dp-genres"] [data-testid="genre-texts"], div[data-testid="dv-node-dp-genres"] [data-testid="mood-texts"]'
)
.map((_, el) => $(el).text())
.get();
return this.uniqueTextList(genres);
}
private static extractPrimePlot($: cheerio.CheerioAPI): string | null {
const plot = this.cleanText(
$('span.fbl-expandable-text span._1H6ABQ').first().text() ||
$('meta[property="og:description"]').attr('content') ||
''
);
return plot || null;
}
private static extractPrimeAgeRating($: cheerio.CheerioAPI): string | null {
const ageRating = this.cleanText(
$('span[data-automation-id="age-rating-badge"]').first().text() ||
$('[data-testid="age-rating-badge"]').first().text() ||
''
);
return ageRating || null;
}
private static extractPrimeBackdrop($: cheerio.CheerioAPI): string | null {
const webpSrcSet =
$('div.Kc5eKF picture source[type="image/webp"]').first().attr('srcset') ||
$('picture source[type="image/webp"]').first().attr('srcset') ||
'';
if (webpSrcSet) {
const sources = webpSrcSet
.split(',')
.map((item) => item.trim())
.map((item) => {
const match = item.match(/^(\S+)\s+(\d+)w$/);
if (!match) return null;
const url = match[1];
const widthRaw = match[2];
if (!url || !widthRaw) return null;
return {
url,
width: Number.parseInt(widthRaw, 10),
};
})
.filter((item): item is { url: string; width: number } => Boolean(item));
if (sources.length > 0) {
const exact1080 = sources.find((item) => item.width === 1080);
if (exact1080) return exact1080.url;
const nextLargest = sources
.filter((item) => item.width > 1080)
.sort((a, b) => a.width - b.width)[0];
if (nextLargest) return nextLargest.url;
const largest = sources.sort((a, b) => b.width - a.width)[0];
if (largest) return largest.url;
}
}
const fallback = $('img[data-testid="base-image"]').first().attr('src');
return fallback || null;
}
} }
export default ScraperService; export default ScraperService;

View File

@@ -52,11 +52,85 @@ const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
* TMDB Image Base URL * TMDB Image Base URL
*/ */
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original'; const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original';
const CAST_FILTER_CANDIDATE_LIMIT = 5;
/** /**
* TMDB Service for movie/TV show search * TMDB Service for movie/TV show search
*/ */
export class TmdbService { export class TmdbService {
private static normalizeCastName(name: string): string {
return name
.normalize('NFKC')
.trim()
.replace(/[-‐‑‒–—―'`.]/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.toLocaleLowerCase('tr')
.replace(/[ıİ]/g, 'i')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
private static isCastNameMatch(candidate: string, requested: string): boolean {
const normalizedCandidate = this.normalizeCastName(candidate);
const normalizedRequested = this.normalizeCastName(requested);
if (!normalizedCandidate || !normalizedRequested) return false;
if (normalizedCandidate === normalizedRequested) return true;
// Secondary strictness: allow spacing variants like "eun jin" vs "eunjin"
const compactCandidate = normalizedCandidate.replace(/\s+/g, '');
const compactRequested = normalizedRequested.replace(/\s+/g, '');
return compactCandidate === compactRequested;
}
private static async getCreditsCastNames(
mediaType: 'movie' | 'tv',
tmdbId: number
): Promise<string[]> {
const url = `${TMDB_BASE_URL}/${mediaType}/${tmdbId}/credits`;
try {
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
return [];
}
const data = await response.json() as { cast?: Array<{ name?: string | null }> };
const castNames = Array.isArray(data.cast) ? data.cast : [];
return castNames
.map((item) => (typeof item?.name === 'string' ? item.name : ''))
.filter((name) => name.length > 0);
} catch {
return [];
}
}
private static async filterResultsByCast(
results: TmdbSearchResult[],
castName: string
): Promise<TmdbSearchResult[]> {
const normalizedRequested = this.normalizeCastName(castName);
if (!normalizedRequested) return [];
const candidates = results.slice(0, CAST_FILTER_CANDIDATE_LIMIT);
const matched: TmdbSearchResult[] = [];
for (const candidate of candidates) {
const castNames = await this.getCreditsCastNames(candidate.type, candidate.id);
const hasMatch = castNames.some((name) => this.isCastNameMatch(name, castName));
if (hasMatch) {
matched.push(candidate);
}
}
return matched;
}
/** /**
* Get common headers for TMDB API requests * Get common headers for TMDB API requests
*/ */
@@ -265,7 +339,11 @@ export class TmdbService {
/** /**
* Search for movies * Search for movies
*/ */
static async searchMovies(query: string, year?: number): Promise<TmdbSearchResponse> { static async searchMovies(
query: string,
year?: number,
cast?: string
): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
language: 'tr-TR', language: 'tr-TR',
@@ -292,15 +370,19 @@ export class TmdbService {
const data: TmdbRawResponse = await response.json(); const data: TmdbRawResponse = await response.json();
const results = data.results const normalizedResults = data.results
.map((r) => this.normalizeMovie(r as TmdbRawMovie)) .map((r) => this.normalizeMovie(r as TmdbRawMovie))
.filter((r): r is TmdbSearchResult => r !== null); .filter((r): r is TmdbSearchResult => r !== null);
const results = cast
? await this.filterResultsByCast(normalizedResults, cast)
: normalizedResults;
return { return {
page: data.page, page: data.page,
results, results,
totalPages: data.total_pages, totalPages: data.total_pages,
totalResults: data.total_results, totalResults: results.length,
}; };
} }
@@ -315,7 +397,8 @@ export class TmdbService {
query: string, query: string,
year?: number, year?: number,
seasonNumber?: number, seasonNumber?: number,
seasonYear?: number seasonYear?: number,
cast?: string
): Promise<TmdbSearchResponse> { ): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
@@ -354,6 +437,10 @@ export class TmdbService {
results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear); results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear);
} }
if (cast) {
results = await this.filterResultsByCast(results, cast);
}
return { return {
page: data.page, page: data.page,
results, results,
@@ -365,7 +452,11 @@ export class TmdbService {
/** /**
* Multi search (movies, TV shows, and people) * Multi search (movies, TV shows, and people)
*/ */
static async searchMulti(query: string, year?: number): Promise<TmdbSearchResponse> { static async searchMulti(
query: string,
year?: number,
cast?: string
): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
language: 'tr-TR', language: 'tr-TR',
@@ -393,16 +484,20 @@ export class TmdbService {
const data: TmdbRawResponse = await response.json(); const data: TmdbRawResponse = await response.json();
// Filter out person results and normalize // Filter out person results and normalize
const results = data.results const normalizedResults = data.results
.filter((r) => r.media_type !== 'person') .filter((r) => r.media_type !== 'person')
.map((r) => this.normalizeResult(r)) .map((r) => this.normalizeResult(r))
.filter((r): r is TmdbSearchResult => r !== null); .filter((r): r is TmdbSearchResult => r !== null);
const results = cast
? await this.filterResultsByCast(normalizedResults, cast)
: normalizedResults;
return { return {
page: data.page, page: data.page,
results, results,
totalPages: data.total_pages, totalPages: data.total_pages,
totalResults: data.total_results, totalResults: results.length,
}; };
} }
@@ -411,17 +506,17 @@ export class TmdbService {
* @param request Search request with query, year, type, and optional season parameters * @param request Search request with query, year, type, and optional season parameters
*/ */
static async search(request: TmdbSearchRequest): Promise<TmdbSearchResponse> { static async search(request: TmdbSearchRequest): Promise<TmdbSearchResponse> {
const { query, year, type = 'multi', seasonYear, seasonNumber } = request; const { query, year, type = 'multi', seasonYear, seasonNumber, cast } = request;
switch (type) { switch (type) {
case 'movie': case 'movie':
return this.searchMovies(query, year); return this.searchMovies(query, year, cast);
case 'tv': case 'tv':
// For TV shows, use season parameters if provided // For TV shows, use season parameters if provided
return this.searchTv(query, year, seasonNumber, seasonYear); return this.searchTv(query, year, seasonNumber, seasonYear, cast);
case 'multi': case 'multi':
default: default:
return this.searchMulti(query, year); return this.searchMulti(query, year, cast);
} }
} }
} }

View File

@@ -57,6 +57,7 @@ export interface GetInfoRequest {
} }
export interface GetInfoResponse { export interface GetInfoResponse {
provider: 'netflix' | 'primevideo';
title: string; title: string;
year: number | null; year: number | null;
plot: string | null; plot: string | null;
@@ -68,6 +69,83 @@ export interface GetInfoResponse {
currentSeason: number | null; 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;
scraper: number;
};
};
}
export interface AdminActionResponse {
queued: number;
skipped: number;
details?: string;
}
// ============================================ // ============================================
// Cache Types // Cache Types
// ============================================ // ============================================
@@ -78,7 +156,7 @@ export interface CacheEntry<T> {
ttl: number; ttl: number;
} }
export type DataSource = 'cache' | 'database' | 'netflix'; export type DataSource = 'cache' | 'database' | 'scraper';
// ============================================ // ============================================
// Socket Event Types // Socket Event Types
@@ -141,6 +219,7 @@ export interface TmdbSearchRequest {
type?: 'movie' | 'tv' | 'multi'; type?: 'movie' | 'tv' | 'multi';
seasonYear?: number; seasonYear?: number;
seasonNumber?: number; seasonNumber?: number;
cast?: string;
} }
export interface TmdbSearchResult { export interface TmdbSearchResult {

59
src/utils/contentUrl.ts Normal file
View File

@@ -0,0 +1,59 @@
export type SupportedProvider = 'netflix' | 'primevideo';
const NETFLIX_HOSTS = new Set([
'www.netflix.com',
'netflix.com',
'www.netflix.com.tr',
'netflix.com.tr',
]);
const PRIME_HOSTS = new Set([
'www.primevideo.com',
'primevideo.com',
'app.primevideo.com', // iOS uygulama paylaşım linkleri
]);
export interface ParsedContentUrl {
provider: SupportedProvider;
id: string;
}
export function parseSupportedContentUrl(rawUrl: string): ParsedContentUrl | null {
try {
const parsedUrl = new URL(rawUrl);
const hostname = parsedUrl.hostname.toLowerCase();
if (NETFLIX_HOSTS.has(hostname)) {
const titleIdMatch = parsedUrl.pathname.match(/\/title\/(\d+)/);
if (!titleIdMatch) return null;
const id = titleIdMatch[1];
if (!id) return null;
return { provider: 'netflix', id };
}
if (PRIME_HOSTS.has(hostname)) {
// Standart web URL: /detail/TITLE/ID veya /-/tr/detail/ID
// GTI formatı nokta ve tire içerebilir: amzn1.dv.gti.UUID
const detailIdMatch = parsedUrl.pathname.match(/\/detail\/([A-Za-z0-9._-]+)/);
if (detailIdMatch?.[1]) {
return { provider: 'primevideo', id: detailIdMatch[1] };
}
// iOS uygulama paylaşım linki: /detail?gti=amzn1.dv.gti.UUID
if (parsedUrl.pathname === '/detail') {
const gti = parsedUrl.searchParams.get('gti');
if (gti) return { provider: 'primevideo', id: gti };
}
return null;
}
return null;
} catch {
return null;
}
}
export function isSupportedContentUrl(rawUrl: string): boolean {
return Boolean(parseSupportedContentUrl(rawUrl));
}

File diff suppressed because one or more lines are too long