Compare commits
12 Commits
97fb289fe7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d50eaf250d | |||
| d268bc5696 | |||
| 8bd4f24774 | |||
| 5c6a829a4d | |||
| 8c66fa9b82 | |||
| 79f90cb287 | |||
| ad65453fcf | |||
| 84131576cf | |||
| 96d8a66a97 | |||
| 48fb0c9487 | |||
| c0841aab20 | |||
| 5c496277f2 |
@@ -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
|
||||||
|
|||||||
@@ -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ı
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 URL’yi doğrudan API’ye mi gönderecek, yoksa ana app’e 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`
|
||||||
@@ -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
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Frontend (Vite) environment variables
|
||||||
|
# Copy to frontend/.env.local for local development
|
||||||
|
|
||||||
|
# Must match backend API_KEY_WEB value
|
||||||
|
VITE_WEB_API_KEY=web-frontend-key-change-me-in-production
|
||||||
|
|
||||||
|
# Must match backend API_KEY_ADMIN value (for admin dashboard endpoints)
|
||||||
|
VITE_ADMIN_API_KEY=admin-key-super-secret-change-me
|
||||||
BIN
frontend/public/prime.png
Normal file
BIN
frontend/public/prime.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
@@ -1,2 +1 @@
|
|||||||
/* App styles - Mantine handles most styling */
|
/* Page-level styles are collocated in route-level css files. */
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
462
frontend/src/pages/movies-page.css
Normal file
462
frontend/src/pages/movies-page.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
39
ios/README.md
Normal 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.
|
||||||
454
ios/Ratebubble.xcodeproj/project.pbxproj
Normal file
454
ios/Ratebubble.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/Ratebubble.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Ratebubble.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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>
|
||||||
418
ios/Ratebubble/App/ContentView.swift
Normal file
418
ios/Ratebubble/App/ContentView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
36
ios/Ratebubble/App/MainViewModel.swift
Normal file
36
ios/Ratebubble/App/MainViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/Ratebubble/App/RatebubbleApp.swift
Normal file
10
ios/Ratebubble/App/RatebubbleApp.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct RatebubbleApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
ios/Ratebubble/Resources/Config.xcconfig
Normal file
5
ios/Ratebubble/Resources/Config.xcconfig
Normal 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
|
||||||
39
ios/Ratebubble/Resources/Ratebubble-Info.plist
Normal file
39
ios/Ratebubble/Resources/Ratebubble-Info.plist
Normal 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>
|
||||||
10
ios/Ratebubble/Resources/Ratebubble.entitlements
Normal file
10
ios/Ratebubble/Resources/Ratebubble.entitlements
Normal 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>
|
||||||
44
ios/Ratebubble/Resources/RatebubbleShare-Info.plist
Normal file
44
ios/Ratebubble/Resources/RatebubbleShare-Info.plist
Normal 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>
|
||||||
10
ios/Ratebubble/Resources/RatebubbleShare.entitlements
Normal file
10
ios/Ratebubble/Resources/RatebubbleShare.entitlements
Normal 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>
|
||||||
838
ios/Ratebubble/ShareExtension/ShareViewController.swift
Normal file
838
ios/Ratebubble/ShareExtension/ShareViewController.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
65
ios/Ratebubble/Shared/APIClient.swift
Normal file
65
ios/Ratebubble/Shared/APIClient.swift
Normal 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)).")
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ios/Ratebubble/Shared/Models.swift
Normal file
29
ios/Ratebubble/Shared/Models.swift
Normal 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?
|
||||||
|
}
|
||||||
31
ios/Ratebubble/Shared/SharedPayloadStore.swift
Normal file
31
ios/Ratebubble/Shared/SharedPayloadStore.swift
Normal 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
58
ios/project.yml
Normal 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
13
ios/scripts/bootstrap.sh
Executable 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."
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
494
src/services/admin.service.ts
Normal file
494
src/services/admin.service.ts
Normal 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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
82
src/services/metrics.service.ts
Normal file
82
src/services/metrics.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import redis from '../config/redis.js';
|
||||||
|
import { emitMetricsEvent } from '../config/socket.js';
|
||||||
|
import type { DataSource } from '../types/index.js';
|
||||||
|
|
||||||
|
const COUNTERS_KEY = 'metrics:counters';
|
||||||
|
const SOURCES_KEY = 'metrics:sources';
|
||||||
|
|
||||||
|
function toInt(value: string | null | undefined): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetricsService {
|
||||||
|
private static async emitSnapshot(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const snapshot = await this.getSnapshot();
|
||||||
|
emitMetricsEvent({
|
||||||
|
cacheHits: snapshot.cacheHits,
|
||||||
|
cacheMisses: snapshot.cacheMisses,
|
||||||
|
sourceCounts: snapshot.bySource,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort metrics emit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async incrementCacheHit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await redis.hincrby(COUNTERS_KEY, 'cache_hits', 1);
|
||||||
|
await this.emitSnapshot();
|
||||||
|
} catch {
|
||||||
|
// best-effort metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async incrementCacheMiss(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await redis.hincrby(COUNTERS_KEY, 'cache_misses', 1);
|
||||||
|
await this.emitSnapshot();
|
||||||
|
} catch {
|
||||||
|
// best-effort metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async incrementSource(source: DataSource): Promise<void> {
|
||||||
|
try {
|
||||||
|
await redis.hincrby(SOURCES_KEY, source, 1);
|
||||||
|
await this.emitSnapshot();
|
||||||
|
} catch {
|
||||||
|
// best-effort metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getSnapshot(): Promise<{
|
||||||
|
cacheHits: number;
|
||||||
|
cacheMisses: number;
|
||||||
|
bySource: {
|
||||||
|
cache: number;
|
||||||
|
database: number;
|
||||||
|
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;
|
||||||
@@ -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',
|
|
||||||
'www.netflix.com.tr',
|
|
||||||
'netflix.com.tr',
|
|
||||||
];
|
|
||||||
return validHosts.includes(parsedUrl.hostname);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if URL is a valid Prime Video URL
|
||||||
|
*/
|
||||||
|
static isValidPrimeVideoUrl(url: string): boolean {
|
||||||
|
return parseSupportedContentUrl(url)?.provider === 'primevideo';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
59
src/utils/contentUrl.ts
Normal 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
Reference in New Issue
Block a user