Compare commits

...
50 Commits
Author SHA1 Message Date
ayrisdevandClaude Sonnet 5 11e98b300e feat: analiz zincirini atlayıp ham segmenti doğrudan Telegram'a gönderme modu
Yeni "Ham Kayıt Modu" ayarı (send_raw_segments_to_telegram, varsayılan
kapalı): açıksa her ham segment tamamlanır tamamlanmaz sinyal analizi/
STT/9:16 render zincirine hiç girmeden doğrudan Telegram'a gönderiliyor
ve sunucudan siliniyor. Kullanıcı transkript/9:16 istemediğinde ("sen
bana direk videoyu gönder") tüm analiz pipeline'ını atlayıp sadece
kayıt+gönder+sil yapan basit bir mod.

- RawSegmentStatus'a SENT_TO_TELEGRAM eklendi, RawSegment.filePath
  nullable yapıldı (aynı ShortVideo.filePath deseni — gönderim sonrası
  null, dosya diskten silinmiş demek).
- api-daemon/src/telegram.ts'e sendTelegramVideo eklendi (native fetch+
  FormData+Blob ile multipart upload, ~50MB bot-limiti önceden kontrol
  ediliyor) — worker/telegram.py'deki aynı fonksiyonun Node karşılığı.
- streamIngest.ts: segment tamamlanınca, mod açıksa signal-detection
  kuyruğuna hiç eklemeden doğrudan gönderiyor (kuyruğa eklemek, aşağıda
  dosyayı silmenin sinyal analizini bozmasına sebep olurdu).
- Panel: segment listelerinde filePath=null durumunda video/indir yerine
  "Telegram'a gönderildi" notu (segments, channels/[id]).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 19:57:14 +03:00
ayrisdevandClaude Sonnet 5 9aed4e891e perf: segment süresini 5 dakikadan 3 dakikaya düşür (Telegram video limiti)
Global SEGMENT_TIME_SEC varsayılanı 300->180 (docker-compose, .env.example,
env.ts, signal_detection.py fallback'i). 5 dakikalık segmentlerden çıkan
adaylar ~56MB'a kadar çıkabiliyordu — Telegram bot'ların sendVideo ile
yükleyebileceği ~50MB sınırını aşıp gönderim başarısız oluyordu (bkz.
delete_after_telegram_send). 3 dakika bu sınırın güvenli tarafında kalıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:48:45 +03:00
ayrisdevandClaude Sonnet 5 ccd922ba37 feat: birden fazla YouTube hesabı arasında cookie'yi dağıt (Faz 6)
Proxy IP'lerini havuza dağıttığımız aynı sorun cookie/hesap seviyesinde de
vardı: tek bir Google hesabı 7-8 kanalı taramaya çalışınca YouTube hesabın
kendisini şüpheli bulup çoğu/tüm kanalda "Sign in to confirm you're not a
bot" veriyordu — proxy IP'den bağımsız bir sinyal, IP dağıtımı bunu
çözemez.

Yeni CookieProfile tablosu: her kanal, DB id'sinden aynı deterministik hash
ile (proxyUrlForChannel'daki gibi) havuzdaki hesaplardan birine sabitlenir.
Her profil kendi ayrı geçici dosyasına yazılıyor (/tmp/yt-cookies-<id>.txt)
— tek paylaşılan dosya, farklı profil kullanan kanallar eşzamanlı
çalışınca birbirinin cookie'sini ezerdi. Hiç profil eklenmemişse eski tekli
ytdlp_cookies ayarına geri düşüyor (geriye dönük uyumlu, hemen kırılmaz).

Ayarlar sayfasında yeni "Cookie Hesapları" kartı: hesap ekle/sil/yenile,
her biri için ayrı bayatlık rozeti. Eski tekli cookie kartı "tekli/eski"
etiketiyle fallback olarak duruyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:20:36 +03:00
ayrisdevandClaude Sonnet 5 eb2a9d28ed feat: 9:16 render'ı isteğe bağlı yap + Telegram'a video gönderip sunucudan sil (Faz 5)
- auto_render_enabled (varsayılan açık): kapatılınca transkript sonrası
  otomatik render tetiklenmez, aday TRANSCRIBED'de bekler — panelin zaten
  var olan "9:16 Render Et" butonuyla elle tetiklenebilir.
- delete_after_telegram_send (varsayılan KAPALI — geri alınamaz bir
  davranış, bilinçli açılmalı): açıksa render biten video Telegram'a
  gerçek dosya olarak gönderilir (yeni send_telegram_video, sendVideo
  multipart upload, ~50MB bot-upload limiti önceden kontrol ediliyor);
  gönderim başarılıysa dosya diskten silinip ShortVideo.file_path NULL'a
  çekiliyor (DB kaydı/transkript kalıcı kalıyor), başarısızsa dosya
  sunucuda kalıp düz metin bildirimi gidiyor. Kanal sayısı arttıkça VPS
  disk doluluğunu önlemek için.
- Panel: READY + file_path=null durumunda video/indir yerine "Telegram'a
  gönderildi, sunucuda saklanmıyor" notu (channels/[id], segments).

Şema değişikliği yok — ShortVideo.filePath zaten nullable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:04:57 +03:00
ayrisdevandClaude Sonnet 5 63443f3392 fix: capture'da android player_client'ı kapat (videoplayback 403 kaynağı olabilir)
@liberotv'de segment indirirken sürekli "403 Forbidden / Failed to open
segment" hatası vardı — kendi ayrılmış proxy IP'sinde bile. yt-dlp'nin
GitHub issue'larında android client'ının videoplayback isteklerinde 403'e
sebep olduğu birden fazla kez raporlanmış. Tespit adımı (--simulate, format
çözümlemesi yok) risksiz olduğu için web,mweb,android'i koruyor; asıl
capture artık sadece web,mweb kullanıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 00:25:58 +03:00
ayrisdevandClaude Sonnet 5 57ca4b5878 perf: her kanalı Oxylabs ISP havuzundaki ayrı bir statik IP'ye sabitle
Dashboard'dan doğrulandı: isp.oxylabs.io:8001-8010 her biri farklı, statik
(rotasyon yapmayan) bir IP'ye karşılık geliyor. Ama PROXY_URL'in portu hep
sabitti — 7-8 kanalın tüm poll+capture trafiği tek bir IP'den (8001)
geçiyordu. Artık her kanal, DB id'sinden deterministik bir hash ile
havuzdaki 10 porttan birine sabitleniyor (aynı kanal hep aynı IP'yi
kullanıyor, farklı kanallar havuza yayılıyor) — hem YouTube'un tek IP
üzerindeki yük algısını azaltıyor hem de bant genişliğini paylaştırıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 00:11:05 +03:00
ayrisdevandClaude Sonnet 5 59888a6513 fix: bildirim spam'i - kanal başına 10dk soğuma süresi
Çöküp yeniden başlayan bir kanal (bkz. streamIngest'teki crash-loop notu)
dakikalar içinde art arda "başladı" + "hata" + "düzeldi" mesajı atıyordu —
birkaç kanal aynı anda flap edince gerçek bir spam'e dönüştü (kullanıcı
raporu: "sürekli bildirim geliyor"). Üç bildirim türü de artık kanal
başına ortak 10dk'lık bir soğuma süresini paylaşıyor — bir kanal ilk
olayını bildirir, sonra durum tekrar tekrar sıçrasa bile bir süre sessiz
kalır.

Not: bu sadece bildirim gürültüsünü kesiyor, alttaki crash-loop'un kendisi
(muhtemelen kaynak/proxy kapasitesi) hâlâ duruyor — o konu ayrı, kullanıcı
ile "önce test edelim" üzerinde anlaşıldı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 00:00:32 +03:00
ayrisdevandClaude Sonnet 5 597b1186ab perf: zaten kayıtta olan kanalları tekrar poll etme + kontroller arasına gecikme ekle
pollOnce her döngüde AKTİF kanalların hepsini (zaten kayıtta olanlar dahil)
yt-dlp'nin bot-check riskli /live sorgusuna sokuyordu — gereksiz. Birden
fazla kanal aynı anda canlıyken bu, aynı paylaşılan cookie+proxy üzerinden
gereksiz istek hacmi katlıyordu (muhtemelen 4-5 kanal eklenince birkaç
kanalın aynı anda tespit hatası vermeye başlamasının sebeplerinden biri).
Artık zaten açık bir StreamSession'ı olan kanal tamamen atlanıyor, kalan
kanallar arasına da 2sn gecikme eklendi (art arda patlama yerine seyrek).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 23:20:58 +03:00
ayrisdevandClaude Sonnet 5 b5c761afbf fix: frontend production'da next dev yerine build+start çalışsın
Dockerfile'ın CMD'si next dev kullanıyordu — Coolify'de canlı sistemde
dev-mode overlay/bildirimleri (Next.js sürüm uyarısı, hata detayları) ve
optimize edilmemiş, JIT-compile edilen bir build çalışıyordu. Artık image
build sırasında next build çalışıyor, CMD sadece next start.

DATABASE_URL build adımında gerçek bir DB'ye ihtiyaç duymuyor (tüm
sayfalar force-dynamic, build sırasında sorgu atılmıyor) ama Prisma
client import'ta connection string formatını doğruluyor — yer tutucu bir
değer verildi, Coolify'nin gerçek DATABASE_URL'i runtime'da onun üzerine
yazıyor zaten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:59:53 +03:00
ayrisdevandClaude Sonnet 5 dea207996e fix: kanal detay sayfasında iç içe girinti (segment/aday klip) netleştirildi
3 seviyeli border-left girinti (oturum > segment > aday klip) yerine
gerçek kutu (soft-card) blokları — her segment kendi arka planıyla
ayrışıyor, adaylar onun içinde daha da net bir alt kutuda. Segment
başlığı artık ham dosya yolu değil ("15sn segment · 02.09.2026 20:14"
gibi) insan-okur bir etiket — dosya yolu küçük/soluk referans olarak
altta kaldı. "Canlı Durum" kartındaki karışık bölümler de (hata/canlı
önizleme/log/segment ayarı/manuel başlat) Separator ile ayrıştırıldı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:58:06 +03:00
ayrisdevandClaude Sonnet 5 58ef40edf8 feat: segment süresi varsayılanını 15 dk -> 5 dk düşür + dashboard'u kart-grid'e çevir
Global SEGMENT_TIME_SEC varsayılanı 900->300 (docker-compose, .env.example,
env.ts, signal_detection.py fallback'i) — kanal bazlı override zaten
panelden ayarlanabiliyordu, bu sadece varsayılanı değiştiriyor.

Dashboard'daki kanal listesi tablo yerine kart-grid: her kanal kendi
kartında, durum rozetleri üstte belirgin, aksiyonlar altta — önceki
tablo düzeni (özellikle aksiyon sütunundaki sıkışık butonlar) okunması
zor bulunuyordu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:55:12 +03:00
ayrisdevandClaude Sonnet 5 eca80959fb fix: aday klibi olmayan segmentlerde neden hiçbir şey olmadığı belirsizdi
UI yenilemesinde segments/page.tsx'teki "aday klip bulunamadı" boş-durum
mesajı kanal detay sayfasına taşınmamıştı — sinyal analizi bir segmentte
ilginç an bulamayınca panelde sessizce hiçbir şey görünmüyordu, bozukmuş
gibi duruyordu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:06:08 +03:00
ayrisdevandClaude Sonnet 5 a53f0a5e04 feat: canlı-tespit hatası artık Telegram'a düşüyor + zaman damgası ekle
Poll hatası şu ana kadar sadece panele girip bakınca görülüyordu — proaktif
haberdar olma yolu yoktu. Şimdi bir kanal hata vermeye BAŞLADIĞINDA (her
60sn'de tekrar değil, sadece durum değişince) ve düzeldiğinde Telegram
bildirimi gidiyor. Ayarlar'dan kapatılabilir (notify_poll_error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 20:53:56 +03:00
ayrisdevandClaude Sonnet 5 ce9e01bbeb fix: panelde saatler 3 saat geri gösteriliyordu (sunucu UTC, Türkiye UTC+3)
.toLocaleString("tr-TR") tarih formatını Türkçe yapıyor ama saat değerini
hâlâ sunucunun sistem saat dilimiyle (Coolify/Hetzner container'ı UTC)
hesaplıyordu. Tüm tarih gösterimleri artık ortak formatTr() helper'ı
üzerinden timeZone: "Europe/Istanbul" ile sabitleniyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:27:46 +03:00
ayrisdevandClaude Sonnet 5 3e01116729 feat: kanal başına segment süresini panelden ayarlama
Channel.segmentTimeSec (nullable, null = global SEGMENT_TIME_SEC env
var'ı kullan) eklendi. Kanal detay sayfasından dakika cinsinden
girilebiliyor, StreamIngestJob'a taşınıp streamIngest.ts'te ffmpeg'in
-segment_time argümanına yansıyor. Sadece o kanal için bundan sonra
başlayacak yeni kayıt oturumlarını etkiler — halihazırda çalışan bir
ffmpeg process'inin segment süresi zaten sabitlenmiş durumda.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:14:09 +03:00
ayrisdevandClaude Sonnet 5 ee79dce340 fix: Ayarlar sayfasındaki switch'ler kapatılıp kaydedilince kalıcı olmuyordu
Radix Switch'in kendi gizli-input form entegrasyonu bu ortamda Server
Action'ın FormData'sına güvenilir şekilde ulaşmıyordu — switch'i "off"
yapıp Kaydet'e basınca eski değer kalıcı oluyordu (STT hep otomatik
çalışmaya devam ediyordu). Artık her switch kendi değerini doğrudan
kontrol eden bir hidden input'la (SettingSwitch) submit ediliyor,
Radix'in iç mekanizmasına bağımlı değil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:10:24 +03:00
ayrisdevandClaude Sonnet 5 cbb16697ca fix: poll hatasına zaman damgası ekle + STT'yi panelden kapatılabilir yap
- lastPollErrors artık {message, at} tutuyor — panelde hangi hatanın ne
  zaman oluştuğu görünmüyordu, eski/güncel ayrımı yapılamıyordu.
- "This live event will begin in N minutes" gibi zararsız mesajlar da
  hata rozetine düşüyordu, "is not currently live" ile aynı gruba alındı.
- Yeni stt_enabled ayarı (Ayarlar sayfası): kapatılınca stt_scoring.py
  OpenAI Whisper çağrısı yapmadan adayı PENDING_STT'de bırakıp çıkıyor —
  konuşmasız/ambiyans yayınlarda (bkz. NASA testi) boşa API maliyeti
  önleniyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:52:15 +03:00
ayrisdevandClaude Sonnet 5 7d504d1938 feat: panel UI'ını Tailwind v4 + shadcn/ui ile yeniden tasarla (Faz 4)
Elle yazılmış tek-dosya CSS (sabit koyu tema, ham table/button, window.confirm
ile silme onayı) yerine tutarlı bir bileşen sistemi: Card/Table/Badge/Button/
Input/Switch/AlertDialog primitifleri, Hanken Grotesk + JetBrains Mono
tipografi çifti, "broadcast/monitör" temalı özel bir renk paleti (camgöbeği
accent, ok/warn/err/live durum rengi sözlüğü). Tüm sayfalar (dashboard, kanal
detay, segmentler, ayarlar, istatistik, kullanıcılar, login) aynı oturumda
taşındı — silme onayları artık gerçek AlertDialog, native confirm() değil.

Veri akışı/server action'lar değişmedi, tamamen görsel katman.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:33:24 +03:00
ayrisdevandClaude Sonnet 5 d045b24c85 fix: silme fonksiyonlarında ShortVideo FK ihlali (kanal/segment/candidate sil hata veriyordu)
Faz 2'de ShortVideo eklenirken deleteRawSegment/deleteCandidateSegment/
deleteChannel güncellenmemiş — candidate_id FK'sı yüzünden bir short'u
olan candidate silinmeye çalışılınca Postgres reddediyordu. Şimdi her
üç yol da candidate'leri silmeden önce ilişkili ShortVideo satırlarını
(+ render edilmiş dosyalarını) temizliyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:03:54 +03:00
ayrisdevandClaude Sonnet 5 43ccbd9ec7 feat: interaktif panel kontrolleri (Faz 3)
Yayın durdurma, kanal duraklat/devam (tekli+toplu), şimdi kontrol et,
manuel URL ile zorla kayıt başlatma, canlı player+log önizleme, oturum
süre sayacı, başarısız/takılı render için tekrar dene ve iptal, poll
aralığı + ham segment TTL'yi panelden canlı değiştirme, cookie bayatlık
uyarısı, Telegram bildirim aç/kapa, ve kullanım/maliyet özeti (/stats).

api-daemon'ın yeni state-değiştiren endpoint'leri (stop/force-start/
render/cancel) INTERNAL_API_TOKEN ile korunuyor — bu daemon'ın portu
Coolify'de public'e açık olduğu için korumasız bırakmak güvenlik açığı
olurdu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 13:38:47 +03:00
ayrisdev 2fc3254d64 fix: buyuk/populer kanallarda bot-check duvari icin player_client fallback zinciri ekle (web,mweb,android) 2026-09-01 18:06:52 +03:00
ayrisdev 93fd7a95df fix: canlı-tespit komutuna da --js-runtimes node ekle (n-challenge bazı kanallarda metadata isteğinde de tetikleniyor) 2026-09-01 17:36:58 +03:00
ayrisdevandClaude Sonnet 5 f00fc73b0b feat: 9:16 crop + dinamik altyazı render motoru (Faz 2, Modül 4)
Her aday klip transkribe olduktan hemen sonra otomatik olarak
video-render kuyruğuna giriyor. Yeni Python worker (video_render.py):

- Klibi ffmpeg ile keser
- OpenCV + MediaPipe Face Detector (Tasks API — yeni mediapipe
  sürümlerinde eski solutions API'si kalktı, .tflite model dosyası
  Docker build'de indiriliyor) ile örneklenmiş karelerde yüz merkezi
  tespit eder, yumuşatır (moving average); hiç yüz bulunamazsa sabit
  merkez crop'a düşer, hata saymaz
- Kırpılmış kareleri OpenCV'den ffmpeg'e pipe'layıp tek geçişte orijinal
  sesi map'ler ve transcript_json'dan ürettiği .ass altyazıyı yakar
- Sonucu /shared-media/shorts/<candidate_id>.mp4'e yazar

Yeni ShortVideo modeli (PENDING/RENDERING/READY/FAILED). Panelde
(segments + kanal detay sayfaları) render durumu + READY olunca video
önizleme/indirme. VIDEO_RENDER_CONCURRENCY ayrı ve düşük (varsayılan 2)
— bu iş diğerlerinden çok daha ağır.

Kapsam: sadece tekil konuşmacı şablonu (çoklu konuşmacı/split-screen
sonraki bir iterasyona bırakıldı — aktif-konuşmacı tespiti gerektiriyor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:26:22 +03:00
ayrisdevandClaude Sonnet 5 5fef7460cf frontend: kullanıcı yönetimi sayfası
/users: kullanıcı listesi, ekleme formu, silme (kendi hesabını veya
son kalan tek kullanıcıyı silmeye izin verilmiyor). Nav'a link eklendi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 12:28:19 +03:00
ayrisdevandClaude Sonnet 5 783125b8f2 feat: işlenmiş ham kayıtları 24 saat sonra otomatik sil
PRD'nin auto-purge lifecycle'ı: RawSegment analiz tamamlanmış
(PROCESSED/DISCARDED) ve hiçbir candidate'i PENDING_STT'de değilse
(yani hâlâ transkribe edilmeyi bekleyen yoksa) 24 saatten (yapılandırı-
labilir: RAW_SEGMENT_TTL_HOURS) eskiyse DB kaydı + dosyası siliniyor.
Saatlik kontrol ediliyor, api-daemon başlarken de bir kere çalışıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 12:27:36 +03:00
ayrisdevandClaude Sonnet 5 775633bfd0 fix: oturum çerezini x-forwarded-proto'ya göre secure işaretle
NODE_ENV=production'da cookie hep secure:true oluyordu, ama panel
şu an SSL'siz sslip.io preview domain'inde (http://) çalışıyor —
tarayıcı Secure cookie'yi HTTP origin'de hiç saklamıyor. Giriş
"başarılı" gibi görünüp anında /login'e geri dönülüyordu. Artık
gerçek istek protokolüne (Traefik'in x-forwarded-proto header'ı)
bakılıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 12:10:11 +03:00
ayrisdevandClaude Sonnet 5 fb8485d638 feat: panele auth sistemi ekle (kullanıcı/şifre + oturum + middleware)
Panel şu ana kadar herkese açıktı — URL'yi bilen herkes kanal
ekleyip/silebilir, cookie güncelleyebilir, kayıt indirebilirdi.

- users tablosu (bcrypt şifre hash'i)
- /login: hiç kullanıcı yoksa "ilk admin hesabı oluştur" formu, varsa
  normal giriş formu
- jose ile imzalanmış, httpOnly çerezde tutulan 30 günlük oturum
- middleware.ts: /login hariç tüm rotaları korur (video stream route'u
  dahil — aynı origin istekleri çerezi otomatik taşır)
- layout: oturum yoksa nav hiç gösterilmiyor, varsa kullanıcı adı +
  çıkış butonu ekleniyor

SESSION_SECRET production'da zorunlu (docker-compose derleme zamanında
kontrol ediyor); Coolify'a rastgele bir değer eklendi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:26:42 +03:00
ayrisdevandClaude Sonnet 5 ef1dc1b7ac frontend: kanal silme özelliği
Kanal detay sayfasına "Kanalı Sil" eklendi — kanal + tüm session/
segment/candidate geçmişini (dosyalar dahil) siler, ana sayfaya
yönlendirir. Yanlışlıkla/test amaçlı eklenen kanalları temizlemek için.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 21:44:26 +03:00
ayrisdevandClaude Sonnet 5 2b8035ba68 fix: "kanal canlı değil" durumunu hata olarak gösterme
yt-dlp bunu da non-zero exit code ile bildiriyor, bu yüzden panelde
her kontrolde (kanal gerçekten sadece canlı değilken bile) kırmızı
"hata" rozeti görünüyordu — yanıltıcıydı. "is not currently live"
mesajı artık gerçek bir hata olarak işlenmiyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 21:32:25 +03:00
ayrisdevandClaude Sonnet 5 f139d6002f fix: canlı-tespitini tekrar yt-dlp'ye taşı, temiz ISP proxy ekle
Regex tabanlı HTML çıkarımı IP'den bağımsız olarak tutarsız çıktı:
YouTube bazen player verisini (videoDetails) sunucu tarafında hiç
göndermiyor. yt-dlp'nin kendi extractor'ı engellenmediği sürece her
zaman doğru çözüyor, o yüzden kontrol tekrar yt-dlp'ye taşındı — bu
sefer üç katmanı birlikte kullanarak: cookie (oturum), pot-provider
(PO-token) ve PROXY_URL (temiz residential/ISP IP — Coolify
sunucusunun kendi IP'si bir günlük yoğun testten sonra işaretlendi).
Oxylabs ISP Proxies ile doğrulandı (gerçek ISP: CenturyLink), datacenter
proxy'lerin aksine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 21:17:00 +03:00
ayrisdevandClaude Sonnet 5 f18692dd82 debug: playabilityStatus'u teşhis bilgisine ekle
videoDetails yok ama sayfa consent duvarına da takılmıyor (büyük,
tam HTML dönüyor) — YouTube oynatıcı verisini bilerek vermiyor gibi
görünüyor. playabilityStatus.status/.reason genelde tam olarak
nedenini açıklıyor (LOGIN_REQUIRED, UNPLAYABLE, ERROR vb.).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:09:04 +03:00
ayrisdevandClaude Sonnet 5 5447a8d269 debug: canlı-tespit için detaylı teşhis bilgisi ekle
Cookie header'ı eklemek beklenen sonucu vermedi — @ertemsener kanalı
gerçekten canlıydı ama sistem hâlâ "değil" diyordu. Kör tahminle
devam etmek yerine, her kontrolde (sonuç ne olursa olsun) final URL,
HTML uzunluğu, cookie gönderilip gönderilmediği, videoDetails bulunup
bulunmadığı ve consent formu varlığı GET /status'ta lastPollDebug
alanında görünür kılındı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:01:58 +03:00
ayrisdevandClaude Sonnet 5 32c09de896 fix: canlı-tespit fetch'ine cookie ekle — AB consent duvarını aş
Coolify sunucusu AB'de; oturumsuz istek YouTube'un interaktif consent
sayfasına düşüyor (o sayfada videoDetails yok), bu da gerçekten canlı
bir kanalı "değil" gibi gösteriyordu (doğrulandı: @ertemsener canlıydı,
sistem "değil" dedi). yt-dlp için zaten sakladığımız oturum açık
cookie'leri aynı fetch'e Cookie header'ı olarak eklendi — giriş yapılmış
hesaplar consent duvarını hiç görmüyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:50:55 +03:00
ayrisdevandClaude Sonnet 5 0547018cc3 fix: canlı-tespitini yt-dlp'siz, doğru kapsamlı fetch'e taşı
Proxy'ye gerek kalmadan bir çözüm: bugün boyunca düz HTTP fetch hiç
engellenmedi, sadece yt-dlp çağrıları bot-check/PO-token duvarına
takıldı. Önceki naive regex sorunuysa "videoId" alanının sayfada
onlarca kez (kanal video listesi, öneriler) geçmesiydi. Çözüm:
YouTube'un videoDetails objesi — sayfa gerçekten canlıysa tek ve
biricik olarak videoId/isLive/channelId'yi birlikte içeriyor (canlı
değilse obje hiç yok, doğrulandı). channelId çapraz kontrolüyle
birlikte hem doğru video hem yt-dlp'ye hiç dokunmadan (dolayısıyla
bot-check'e hiç maruz kalmadan) tespit yapılıyor. yt-dlp + cookie +
pot-provider artık sadece gerçek capture adımında kullanılıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:40:56 +03:00
ayrisdevandClaude Sonnet 5 5e211096c5 feat: YouTube cookie'lerini panelden yönetilebilir hale getir
Cookie'ler saatler içinde eskiyordu, her seferinde manuel Coolify env
güncellemesi + redeploy gerekiyordu. Artık DB'de (app_settings tablosu)
tutuluyor; /settings sayfasından yeni cookies.txt yapıştırılıp
kaydedilebiliyor, bir sonraki yt-dlp çağrısında redeploy gerekmeden
devreye giriyor. YTDLP_COOKIES_B64 env var mekanizması kaldırıldı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:13:10 +03:00
ayrisdevandClaude Sonnet 5 d562c05882 fix: yeniden başlatma sonrası açık kalan session'ları temizle
Bir redeploy, devam eden capture'ı temizlik kodu hiç çalışmadan
öldürüyordu — StreamSession.endedAt sonsuza dek null kalıyordu. Bu
sadece panelde yanlış "kayıtta" göstermekle kalmıyor, startSession()
açık bir session gördüğünde o kanal için yeni session başlatmayı
tamamen atlıyor — yani o kanal bir daha asla kayda alınamıyordu.
api-daemon başlarken artık tüm açık session'ları kapatıyor (taze bir
process başlangıcı = gerçekte devam eden capture yok demektir).
BullMQ lockDuration da 24 saatten 10 dakikaya indirildi — worker
canlıyken kilit otomatik yenileniyor, düşük değer sadece ölü bir
worker'ın job'ının ne kadar hızlı kurtarılacağını etkiliyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:54:47 +03:00
ayrisdevandClaude Sonnet 5 d05aed72ff fix: canlı-tespitini yt-dlp'ye geri taşı — HTML regex yanlış videoId yakalıyordu
Kullanıcı panelindeki kayıtların kendi yayınıyla alakasız olduğunu
bildirdi. Sebep: channel /live sayfası onlarca alakasız videoId
içeriyor (kanal video listesi, öneriler); düz regex sayfadaki İLK
videoId'yi alıyordu, bu da rastgele başka bir videoydu — isLive:true
kontrolü de aynı bağlamda değildi. yt-dlp'nin extractor'ı kanalın asıl
o anki canlı videosunu doğru çözüyor; -f (format) verilmediği için
PO-token duvarını da tetiklemiyor (format çözümleme sadece gerçek
capture adımında, -f ile, gerekiyor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:46:59 +03:00
ayrisdevandClaude Sonnet 5 8a92b9b019 feat: panelden video izleme/indirme/silme + eşzamanlı yayın kapasitesi düzeltmesi
- streamIngest worker concurrency 3'ten sabit değildi, artık
  MAX_CONCURRENT_CAPTURES (varsayılan 10) ile ayarlanabiliyor. Eskiden
  3'ten fazla kanal aynı anda canlıya geçerse fazlası, ilk 3'ten biri
  bitene kadar (saatlerce) hiç kayda alınmıyordu.
- Python worker'larda (signal-detection, stt-scoring) bullmq varsayılan
  concurrency'si 1'di — WORKER_CONCURRENCY (varsayılan 5) ile
  paralelleştirildi, birden fazla kanal aynı anda segment/transkript
  üretirse sıraya takılmasın diye.
- Frontend artık shared-media volume'üne bağlı: /api/segments/[id]/video
  route'u Range destekli video stream/indirme sağlıyor. Segment ve
  kanal detay sayfalarına <video> oynatıcı, indirme linki ve onaylı
  silme (RawSegment + CandidateSegment, dosya dahil) eklendi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:33:33 +03:00
ayrisdevandClaude Sonnet 5 fd21bbfb14 fix: yt-dlp'ye Node.js JS runtime + EJS solver ekle (n-challenge)
Gerçek capture denemesinde debug endpoint'i asıl nedeni gösterdi:
"n challenge solving failed" — YouTube'un imza çözme JS challenge'ı
için yt-dlp'nin bir JS runtime'ı yoktu, bu da formatların eksik
kalıp "page needs to be reloaded" ile sonuçlanmasına yol açıyordu.
node:22-slim zaten Node 22 içeriyor (minimum gereksinim); yt-dlp'yi
"yt-dlp[default]" (EJS solver dahil) olarak kurup capture çağrısına
--js-runtimes node eklendi. Lokal olarak doğrulandı: aynı video artık
formatları hatasız çözüyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:55:43 +03:00
ayrisdevandClaude Sonnet 5 08569ce4ae debug: yt-dlp/ffmpeg capture stderr'ını GET /debug/capture/:sessionId ile görünür kıl
İlk gerçek capture denemesi 2 saniyede sessionı bitirdi (0 segment) —
Coolify /logs endpoint'i api-daemon'un stdout'unu güvenilir şekilde
izole edemediği için sebebi göremedik. Artık her session'ın son
yt-dlp/ffmpeg stderr satırları bellekte tutulup HTTP'den okunabiliyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:45:36 +03:00
ayrisdevandClaude Sonnet 5 19fe430549 frontend: kanal detay sayfası (canlı durum + poll hatası + yayın geçmişi)
/channels/[id] rotası eklendi: dashboard'daki kanal adı artık oraya
link veriyor. Sayfa api-daemon'un /status'undan canlı durum ve son
poll hatasını (lastPollError, DB'de değil sadece bellekte tutuluyordu)
çekiyor, Prisma'dan da o kanala ait tüm StreamSession/RawSegment/
CandidateSegment geçmişini transkript önizlemesiyle listeliyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:34:15 +03:00
ayrisdevandClaude Sonnet 5 b62d6a7e5f fix: canlı-tespitini yt-dlp'den düz HTTP fetch'e taşı
PO-token duvarı polling'de hiç gerekli değildi — sadece stream byte'ı
indirirken (capture) lazım. yt-dlp --simulate bile format çözümlemeye
çalıştığı için PO-token'a takılıyordu. Artık /channel/<id>/live
sayfasını düz fetch ile çekip ytInitialData içindeki isLive/videoId'yi
regex ile okuyoruz — bot-check/cookie/PO-token'a hiç maruz kalmıyor.
yt-dlp + cookie + pot-provider sadece gerçek capture adımında kalıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:31:29 +03:00
ayrisdevandClaude Sonnet 5 fc7d1109aa fix: bgutil-ytdlp-pot-provider ile PO-token duvarını aş
Cookie bot-check'i çözdü ama "The page needs to be reloaded" hatası
kalıcıydı — YouTube artık proof-of-origin token da istiyor. Coolify
stack'ine brainicism/bgutil-ytdlp-pot-provider sidecar servisi
(sc_pot_provider, port 4416) eklendi, api-daemon imajına Python
plugin'i kuruldu, her yt-dlp çağrısına
--extractor-args youtubepot-bgutilhttp:base_url=... eklendi.
ytdlpCookieArgs -> ytdlpAntiBotArgs olarak birleştirildi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:12:39 +03:00
ayrisdevandClaude Sonnet 5 e5a6f67788 fix: android player_client'ı kaldır, sadece cookie + web client kullan
Cookie tek başına bot-check'i çözüyordu ama android client ile
birlikte kullanılınca "No video formats found" hatası sürekli
tekrarladı — android client + cookie kombinasyonu tutarsız bir
manifest döndürüyor gibi. android client'sız, sadece cookie +
varsayılan web client + bestvideo+bestaudio/best format seçiciyle
aynı video id lokal olarak sorunsuz çözüldü.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:02:43 +03:00
ayrisdevandClaude Sonnet 5 ab7b1551cc fix: yt-dlp format seçicisini bestvideo+bestaudio/best'e çevir
Cookie bot-check'i çözdü ama yeni bir hata çıktı: "No video formats
found" / "Requested format is not available". AyrisTech'in canlı
yayını dikey (1080x1920) ve HLS formatları video-only/audio-only
ayrı geliyor — tek parça "best" format hiç yok. Hem live-check hem
capture çağrısını "bestvideo+bestaudio/best" seçicisine çevirdik;
lokal olarak doğrulandı (yt-dlp video id'yi doğru döndürüyor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 16:24:12 +03:00
ayrisdevandClaude Sonnet 5 09b958ec00 fix: yt-dlp'ye YouTube cookie desteği ekle (bot-check bypass)
android player_client YouTube'un "Sign in to confirm you're not a bot"
duvarını aşmadı — Coolify sunucusunun IP'si datacenter olarak
işaretlenmiş. Kimlik doğrulanmış bir tarayıcı oturumunun cookies.txt'i
YTDLP_COOKIES_B64 env değişkeni (Coolify'da ayarlandı, repo'da yok)
üzerinden container'a taşınıp /tmp'e yazılıyor ve her yt-dlp
çağrısına --cookies olarak veriliyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 16:14:46 +03:00
ayrisdevandClaude Sonnet 5 81d71bd761 fix: yt-dlp android player_client ile bot-tespitini azalt
AyrisTech kanalı gerçekten canlıydı ama Coolify sunucusunun IP'si
YouTube'un "Sign in to confirm you're not a bot" duvarına takıldı
(bu ortamdan aynı komut sorunsuz çalıştı — IP itibarına bağlı).
Hem live-check hem capture çağrısına --extractor-args
"youtube:player_client=android" eklendi; bu client genelde aynı
PO-token doğrulamasına tabi değil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 16:02:03 +03:00
ayrisdevandClaude Sonnet 5 ad419f38b0 debug: yt-dlp live-check hatalarını /status'ta görünür kıl
yt-dlp hem "kanal canlı değil" hem de gerçek bir hata (ağ engeli,
bot tespiti, extractor kırılması) durumunda non-zero exit code
veriyor; ikisi ayırt edilemiyordu. Son hata mesajı artık kanal
başına saklanıp GET /status içinde lastPollError alanında dönüyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:54:51 +03:00
ayrisdevandClaude Sonnet 5 bae115f497 chore: tsbuildinfo build artifact'ını gitignore'a ekle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:17:40 +03:00
ayrisdevandClaude Sonnet 5 deefa75926 frontend: kanal ekleme formu
Dashboard'a name/youtubeHandle/channelId alanlarıyla bir server action
formu eklendi. Polling ve /status için channel_id gerçek bir YouTube
UC... kimliği olmalı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:17:26 +03:00
73 changed files with 5915 additions and 277 deletions
+43 -1
View File
@@ -12,10 +12,52 @@ TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= TELEGRAM_CHAT_ID=
POLL_INTERVAL_MS=60000 POLL_INTERVAL_MS=60000
SEGMENT_TIME_SEC=900 SEGMENT_TIME_SEC=180
API_DAEMON_PORT=4001 API_DAEMON_PORT=4001
# Signs the panel's login session cookies (frontend). Generate with:
# openssl rand -base64 32
# Required in production (docker-compose fails to start without it) — a
# fixed insecure default is only used for local dev when unset.
SESSION_SECRET=
# Set to a live YouTube URL to bypass the 60s polling loop and start # Set to a live YouTube URL to bypass the 60s polling loop and start
# capturing immediately — useful for testing the pipeline without waiting # capturing immediately — useful for testing the pipeline without waiting
# for a real scheduled stream. # for a real scheduled stream.
FORCE_LIVE_URL= FORCE_LIVE_URL=
# YouTube cookies (Netscape cookies.txt content) are configured from the
# panel's /settings page, not here — they rotate every few hours and are
# stored in the app_settings table so they can be updated without a
# redeploy. Use a throwaway/secondary Google account, not your main one —
# it's a live session credential.
# URL of a bgutil-ytdlp-pot-provider HTTP server (see docker-compose.yml
# sc_pot_provider). Needed alongside cookies to avoid YouTube's "The page
# needs to be reloaded" proof-of-origin token check. In docker-compose this
# is auto-set to http://sc_pot_provider:4416; leave blank for local dev
# unless you're running the provider yourself.
YTDLP_POT_PROVIDER_URL=
# Residential/ISP proxy (http://user:pass@host:port) for yt-dlp calls.
# Datacenter server IPs get progressively more flagged the more they're
# used for yt-dlp requests — a clean residential/ISP IP avoids that. Use an
# ISP proxy product specifically (e.g. Oxylabs "ISP Proxies"), not a plain
# datacenter proxy — the latter has the same problem as the server itself.
PROXY_URL=
# Processed raw recordings (raw_segments) older than this get auto-deleted
# (DB row + file) — PRD's auto-purge lifecycle. Only segments already fully
# analyzed (no candidate still waiting on STT) are eligible.
RAW_SEGMENT_TTL_HOURS=24
# Video render (yüz takibi + 9:16 crop + altyazı) her segment/stt-scoring
# işinden çok daha ağır — düşük tutulur ki aynı anda birden fazla render
# worker container'ını boğmasın.
VIDEO_RENDER_CONCURRENCY=2
# api-daemon'ın durdur/zorla-başlat/render-tetikle gibi durum-değiştiren
# endpoint'lerini korur (bu daemon'ın portu Coolify'de public'e açık).
# frontend ve api-daemon aynı değeri kullanmalı. Generate: openssl rand -hex 24
# Boş bırakılırsa (sadece local dev için) bu endpoint'ler korumasız kalır.
INTERNAL_API_TOKEN=
+1
View File
@@ -12,3 +12,4 @@ __pycache__/
venv/ venv/
shared-media/ shared-media/
.DS_Store .DS_Store
*.tsbuildinfo
+34 -13
View File
@@ -1,11 +1,14 @@
# StreamClipper AI — Faz 1 # StreamClipper AI — Faz 1 + Faz 2 (render)
`prd.md` içindeki tam kapsamlı otonom pipeline'ın ilk fazı: ingestion (yt-dlp `prd.md` içindeki tam kapsamlı otonom pipeline'ın Faz 1+2'si: ingestion
headless capture + 15dk segmentleme), sinyal analizi (ses peak + chat (yt-dlp headless capture + 15dk segmentleme), sinyal analizi (ses peak +
velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/Redis/ chat velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/
Postgres altyapısı ve durum/transkript gösteren bir Next.js panel. LLM Redis/Postgres altyapısı, auth korumalı bir Next.js panel — ve Faz 2 olarak
virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu fazda transkribe edilen her aday klibi otomatik yüz-takipli 9:16'ya crop'layıp
**yok** — bkz. `prd.md` Modül 3 (skorlama kısmı), 4 ve 5. dinamik altyazı basan bir render motoru (`apps/worker/worker/video_render.py`).
LLM virality skorlama, çoklu-konuşmacı/split-screen şablonu ve
multi-platform dağıtım hâlâ **yok** — bkz. `prd.md` Modül 3 (skorlama
kısmı) ve Modül 5.
## Klasör Yapısı ## Klasör Yapısı
@@ -72,6 +75,18 @@ docker compose up --build
Panel: http://localhost:3000 · API daemon health: http://localhost:4001/health Panel: http://localhost:3000 · API daemon health: http://localhost:4001/health
## Panel Girişi
Panelin tüm sayfaları giriş gerektirir (middleware ile korunuyor). İlk açılışta
henüz hiç kullanıcı yoksa `/login` otomatik olarak "İlk Yönetici Hesabını
Oluştur" formunu gösterir — kullanıcı adı/şifre girip ilk admin hesabını
oradan oluşturursun. `/users` sayfasından ek kullanıcı ekleyip silebilirsin
(kendi hesabını veya son kalan tek kullanıcıyı silmek engellenir).
`SESSION_SECRET` production'da zorunlu (`openssl rand -base64 32` ile
üretilebilir) — local dev'de boş bırakılırsa güvensiz bir varsayılan
kullanılır, sadece test için.
## Test / Doğrulama Akışı ## Test / Doğrulama Akışı
1. `FORCE_LIVE_URL=<gerçek veya kısa test yayını URL'si>` ile `apps/api-daemon`'ı 1. `FORCE_LIVE_URL=<gerçek veya kısa test yayını URL'si>` ile `apps/api-daemon`'ı
@@ -82,15 +97,21 @@ Panel: http://localhost:3000 · API daemon health: http://localhost:4001/health
kuyruğundan alıp ses peak + (varsa) chat velocity analiziyle kuyruğundan alıp ses peak + (varsa) chat velocity analiziyle
`candidate_segments` üretir. `candidate_segments` üretir.
4. Her aday, `stt-scoring` kuyruğunda OpenAI Whisper API ile transkribe edilir; 4. Her aday, `stt-scoring` kuyruğunda OpenAI Whisper API ile transkribe edilir;
sonuç `candidate_segments.transcript_json` alanına yazılır ve Telegram'a sonuç `candidate_segments.transcript_json` alanına yazılır, Telegram'a
bildirim gider. bildirim gider ve `video-render` kuyruğuna otomatik iş eklenir.
5. Panelde (`/` ve `/segments`) kanal durumu ve üretilen transkriptleri 5. `video-render` (Python, `video_render.py`) yüz tespiti + yumuşatılmış
kontrol et. dinamik crop ile 9:16'ya çevirir, transkriptten `.ass` altyazı üretip
yakar, `/shared-media/shorts/<candidate_id>.mp4`'e yazar;
`short_videos.status` `READY`/`FAILED` olur, Telegram'a bildirim gider.
6. Panelde (`/`, `/segments`, kanal detay sayfaları) kanal durumu, ham
segment/transkript ve render edilen 9:16 kısa videoları izle/indir.
## Bilinen Sınırlamalar (bu faz kapsamı dışı) ## Bilinen Sınırlamalar (bu faz kapsamı dışı)
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok. - LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok
- 9:16 crop/altyazı render motoru yok. transkribe edilen her aday otomatik render edilir, eşik/filtre yok.
- Çoklu konuşmacı / split-screen şablonu yok — sadece tekil konuşmacı
(yüz-merkezli dinamik crop).
- YouTube/Instagram/TikTok'a otomatik yayınlama yok (bu API'ler ayrı onay - YouTube/Instagram/TikTok'a otomatik yayınlama yok (bu API'ler ayrı onay
süreçleri gerektiriyor — `prd.md` tartışmasına bkz.). süreçleri gerektiriyor — `prd.md` tartışmasına bkz.).
- Aday pencere çıkarımı her zaman tek bir 15dk'lık segment dosyasıyla sınırlı; - Aday pencere çıkarımı her zaman tek bir 15dk'lık segment dosyasıyla sınırlı;
+1 -1
View File
@@ -2,7 +2,7 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg python3 python3-pip ca-certificates \ ffmpeg python3 python3-pip ca-certificates \
&& pip3 install --break-system-packages -U yt-dlp \ && pip3 install --break-system-packages -U "yt-dlp[default]" bgutil-ytdlp-pot-provider \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN corepack enable RUN corepack enable
+103 -17
View File
@@ -1,14 +1,48 @@
import { spawn } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { mkdir, readFile } from "node:fs/promises"; import { mkdir, readFile, unlink } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Worker, type Job } from "bullmq"; import { Worker, type Job } from "bullmq";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { env } from "../env"; import { env } from "../env";
import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues"; import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues";
import { redisConnection } from "../redis"; import { redisConnection } from "../redis";
import { ytdlpAntiBotArgs } from "../ytdlpCookies";
import { sendTelegramVideo } from "../telegram";
const SEGMENT_LIST_POLL_MS = 5_000; const SEGMENT_LIST_POLL_MS = 5_000;
/**
* Coolify's /logs endpoint can't reliably isolate one docker-compose
* service's stdout, so the last capture attempt's yt-dlp/ffmpeg stderr tail
* is kept here and surfaced via GET /status for debugging.
*/
export const lastCaptureDebug = new Map<string, string[]>();
/**
* Live yt-dlp processes, keyed by sessionId — lets a panel "stop" action
* reach a specific in-progress capture. Only yt-dlp is tracked/killed:
* ffmpeg is downstream of it in the pipe, so ending yt-dlp closes its stdin
* with EOF and ffmpeg finishes the same way it does on a natural stream end
* (flushes the last partial segment, exits, existing finalize code runs
* unchanged).
*/
const activeCaptures = new Map<string, ChildProcess>();
/** Returns false if the session has no tracked live process (already ended, or this daemon didn't start it — e.g. after a restart). */
export function stopCapture(sessionId: string): boolean {
const ytdlp = activeCaptures.get(sessionId);
if (!ytdlp) return false;
ytdlp.kill("SIGTERM");
return true;
}
function appendCaptureDebug(sessionId: string, line: string) {
const lines = lastCaptureDebug.get(sessionId) ?? [];
lines.push(line);
if (lines.length > 40) lines.shift();
lastCaptureDebug.set(sessionId, lines);
}
interface SegmentListEntry { interface SegmentListEntry {
fileName: string; fileName: string;
startSec: number; startSec: number;
@@ -26,13 +60,20 @@ function parseSegmentListLines(raw: string): SegmentListEntry[] {
}); });
} }
async function sendRawSegmentsToTelegramEnabled(): Promise<boolean> {
const setting = await prisma.appSetting.findUnique({ where: { key: "send_raw_segments_to_telegram" } });
return setting?.value === "true";
}
async function processCompletedSegments( async function processCompletedSegments(
entries: SegmentListEntry[], entries: SegmentListEntry[],
fromIndex: number, fromIndex: number,
outDir: string, outDir: string,
sessionId: string, sessionId: string,
channelName: string,
): Promise<number> { ): Promise<number> {
let processed = fromIndex; let processed = fromIndex;
const sendRawToTelegram = await sendRawSegmentsToTelegramEnabled();
for (let i = fromIndex; i < entries.length; i++) { for (let i = fromIndex; i < entries.length; i++) {
const entry = entries[i]; const entry = entries[i];
@@ -49,13 +90,29 @@ async function processCompletedSegments(
}, },
}); });
if (sendRawToTelegram) {
// Kullanıcı transkript/9:16 zincirini hiç istemiyor — ham segmenti
// analiz kuyruğuna hiç sokmadan doğrudan gönderiyoruz. Kuyruğa
// eklemiyoruz çünkü aşağıda dosyayı silmek, o segmenti daha sonra
// okumaya çalışacak sinyal analizini bozardı.
const caption = `🎬 *${channelName}* — ${duration}sn ham kayıt`;
const sent = await sendTelegramVideo(filePath, caption);
if (sent) {
await unlink(filePath).catch(() => {});
await prisma.rawSegment.update({ where: { id: segment.id }, data: { status: "SENT_TO_TELEGRAM", filePath: null } });
console.log(`[stream-ingest] segment ${entry.fileName} sent to Telegram and removed from disk`);
} else {
console.warn(`[stream-ingest] segment ${entry.fileName} failed to send to Telegram, kept on disk`);
}
} else {
await signalDetectionQueue.add("analyze-segment", { await signalDetectionQueue.add("analyze-segment", {
rawSegmentId: segment.id, rawSegmentId: segment.id,
filePath, filePath,
sessionId, sessionId,
}); });
console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`); console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`);
}
processed = i + 1; processed = i + 1;
} }
@@ -63,7 +120,9 @@ async function processCompletedSegments(
} }
async function runCapture(job: Job<StreamIngestJob>): Promise<void> { async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
const { sessionId, youtubeUrl } = job.data; const { sessionId, youtubeUrl, segmentTimeSec, channelDbId } = job.data;
const channel = await prisma.channel.findUnique({ where: { id: channelDbId } });
const channelName = channel?.name ?? "Bilinmeyen kanal";
const outDir = path.join(env.sharedMediaRoot, "raw", sessionId); const outDir = path.join(env.sharedMediaRoot, "raw", sessionId);
await mkdir(outDir, { recursive: true }); await mkdir(outDir, { recursive: true });
@@ -71,9 +130,16 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
console.log(`[stream-ingest] starting capture for session ${sessionId}: ${youtubeUrl}`); console.log(`[stream-ingest] starting capture for session ${sessionId}: ${youtubeUrl}`);
const ytdlp = spawn("yt-dlp", ["-f", "best", "-o", "-", youtubeUrl], { const ytdlp = spawn(
stdio: ["ignore", "pipe", "pipe"], "yt-dlp",
}); [
"--js-runtimes", "node",
...(await ytdlpAntiBotArgs(channelDbId, { includeAndroidClient: false })),
"-f", "bestvideo+bestaudio/best", "-o", "-", youtubeUrl,
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
activeCaptures.set(sessionId, ytdlp);
const ffmpeg = spawn( const ffmpeg = spawn(
"ffmpeg", "ffmpeg",
@@ -81,7 +147,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
"-i", "pipe:0", "-i", "pipe:0",
"-c", "copy", "-c", "copy",
"-f", "segment", "-f", "segment",
"-segment_time", String(env.segmentTimeSec), "-segment_time", String(segmentTimeSec ?? env.segmentTimeSec),
"-reset_timestamps", "1", "-reset_timestamps", "1",
"-segment_list", segmentListPath, "-segment_list", segmentListPath,
"-segment_list_type", "csv", "-segment_list_type", "csv",
@@ -91,9 +157,16 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
); );
ytdlp.stdout.pipe(ffmpeg.stdin); ytdlp.stdout.pipe(ffmpeg.stdin);
ytdlp.stderr.on("data", (chunk) => console.error(`[yt-dlp:${sessionId}]`, chunk.toString().trim())); ytdlp.on("error", (err) => appendCaptureDebug(sessionId, `[yt-dlp spawn error] ${err.message}`));
ffmpeg.stderr.on("data", () => { ytdlp.stderr.on("data", (chunk) => {
/* ffmpeg logs are extremely verbose; only surface on error below */ const text = chunk.toString().trim();
console.error(`[yt-dlp:${sessionId}]`, text);
appendCaptureDebug(sessionId, `[yt-dlp] ${text}`);
});
ffmpeg.on("error", (err) => appendCaptureDebug(sessionId, `[ffmpeg spawn error] ${err.message}`));
ffmpeg.stderr.on("data", (chunk) => {
/* ffmpeg logs are extremely verbose; keep only the tail for debugging */
appendCaptureDebug(sessionId, `[ffmpeg] ${chunk.toString().trim().split("\n").pop()}`);
}); });
let lastProcessedIndex = 0; let lastProcessedIndex = 0;
@@ -103,7 +176,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
if (!raw) return; if (!raw) return;
const entries = parseSegmentListLines(raw); const entries = parseSegmentListLines(raw);
if (entries.length > lastProcessedIndex) { if (entries.length > lastProcessedIndex) {
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId); lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
await prisma.streamSession.update({ await prisma.streamSession.update({
where: { id: sessionId }, where: { id: sessionId },
data: { totalSegments: lastProcessedIndex }, data: { totalSegments: lastProcessedIndex },
@@ -125,12 +198,13 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
}); });
clearInterval(pollTimer); clearInterval(pollTimer);
activeCaptures.delete(sessionId);
// final sweep in case segments closed between the last poll and process exit // final sweep in case segments closed between the last poll and process exit
const raw = await readFile(segmentListPath, "utf8").catch(() => ""); const raw = await readFile(segmentListPath, "utf8").catch(() => "");
const entries = parseSegmentListLines(raw); const entries = parseSegmentListLines(raw);
if (entries.length > lastProcessedIndex) { if (entries.length > lastProcessedIndex) {
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId); lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
} }
await prisma.streamSession.update({ await prisma.streamSession.update({
@@ -144,9 +218,21 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
export function startStreamIngestWorker(): Worker<StreamIngestJob> { export function startStreamIngestWorker(): Worker<StreamIngestJob> {
return new Worker<StreamIngestJob>(QUEUE_NAMES.STREAM_INGEST, runCapture, { return new Worker<StreamIngestJob>(QUEUE_NAMES.STREAM_INGEST, runCapture, {
connection: redisConnection, connection: redisConnection,
concurrency: 3, // Each capture job runs for the entire duration of a live stream
// Captures run for the lifetime of the stream (can be hours) — extend the // (potentially hours) before its BullMQ job resolves. If concurrency is
// default lock well beyond BullMQ's stalled-job assumptions for an MVP. // lower than the number of channels going live at once, the extra
lockDuration: 24 * 60 * 60 * 1000, // channels queue behind already-running captures and may not start
// recording until one of those finishes — i.e. effectively never, for
// streams still live. This must cover the realistic simultaneous-channel
// count; the real ceiling is the server's CPU/bandwidth for running that
// many yt-dlp+ffmpeg pairs at once, not this number.
concurrency: env.maxConcurrentCaptures,
// BullMQ renews this lock automatically while a worker is alive, so a
// low value doesn't cap how long a capture can run — it only controls
// how fast a *dead* worker's job is detected as stalled and recovered.
// (An earlier 24h value meant a killed process's job wouldn't be
// recovered for up to 24h; closeOrphanedSessions() in index.ts is what
// actually unblocks that channel's StreamSession in the meantime.)
lockDuration: 10 * 60 * 1000,
}); });
} }
+49
View File
@@ -0,0 +1,49 @@
import { unlink } from "node:fs/promises";
import { prisma } from "@streamclipper/db";
import { env } from "./env";
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
const TTL_SETTING_KEY = "raw_segment_ttl_hours";
async function getTtlHours(): Promise<number> {
const setting = await prisma.appSetting.findUnique({ where: { key: TTL_SETTING_KEY } });
const parsed = setting ? Number(setting.value) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : env.rawSegmentTtlHours;
}
/**
* PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after
* capture. Only segments that are done being analyzed (PROCESSED/DISCARDED)
* and have no candidate still waiting on STT are eligible — deleting a raw
* file mid-analysis or mid-transcription would break the pipeline for it.
*/
async function cleanupOnce(): Promise<void> {
const ttlHours = await getTtlHours();
const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000);
const staleSegments = await prisma.rawSegment.findMany({
where: {
createdAt: { lt: cutoff },
status: { in: ["PROCESSED", "DISCARDED"] },
candidates: { none: { status: "PENDING_STT" } },
},
});
for (const segment of staleSegments) {
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segment.id } });
await prisma.rawSegment.delete({ where: { id: segment.id } });
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
}
if (staleSegments.length > 0) {
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${ttlHours}h`);
}
}
export function startCleanupLoop(): NodeJS.Timeout {
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, default TTL ${env.rawSegmentTtlHours}h`);
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
return setInterval(() => {
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
}, CHECK_INTERVAL_MS);
}
+6 -1
View File
@@ -4,7 +4,12 @@ export const env = {
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379", redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media", sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000), pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000),
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 900), segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 180),
port: Number(process.env.API_DAEMON_PORT ?? 4001), port: Number(process.env.API_DAEMON_PORT ?? 4001),
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "", forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
proxyUrl: process.env.PROXY_URL ?? "",
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24),
internalApiToken: process.env.INTERNAL_API_TOKEN ?? "",
}; };
+23
View File
@@ -4,6 +4,7 @@ import { startPollingLoop } from "./youtubePolling";
import { startStreamIngestWorker } from "./capture/streamIngest"; import { startStreamIngestWorker } from "./capture/streamIngest";
import { streamIngestQueue } from "./queues"; import { streamIngestQueue } from "./queues";
import { startServer } from "./server"; import { startServer } from "./server";
import { startCleanupLoop } from "./cleanup";
async function forceLiveBypass(youtubeUrl: string) { async function forceLiveBypass(youtubeUrl: string) {
console.log(`[index] FORCE_LIVE_URL set — bypassing polling and capturing directly: ${youtubeUrl}`); console.log(`[index] FORCE_LIVE_URL set — bypassing polling and capturing directly: ${youtubeUrl}`);
@@ -30,9 +31,31 @@ async function forceLiveBypass(youtubeUrl: string) {
}); });
} }
/**
* A container restart (e.g. a redeploy) kills any in-progress capture
* without running its cleanup code, leaving that StreamSession's endedAt
* stuck at null forever. Besides showing "still recording" in the panel,
* startSession() treats any open session as a reason to skip starting a new
* one for that channel — so an orphaned session permanently blocks that
* channel from ever being recorded again until this runs. Since a fresh
* process start means no capture can actually still be in progress, every
* open session at boot is stale by definition and gets closed out.
*/
async function closeOrphanedSessions() {
const { count } = await prisma.streamSession.updateMany({
where: { endedAt: null },
data: { endedAt: new Date() },
});
if (count > 0) {
console.log(`[index] closed ${count} orphaned session(s) left open by a previous process`);
}
}
async function main() { async function main() {
await closeOrphanedSessions();
startServer(); startServer();
startStreamIngestWorker(); startStreamIngestWorker();
startCleanupLoop();
if (env.forceLiveUrl) { if (env.forceLiveUrl) {
await forceLiveBypass(env.forceLiveUrl); await forceLiveBypass(env.forceLiveUrl);
+12
View File
@@ -19,6 +19,8 @@ export interface StreamIngestJob {
sessionId: string; sessionId: string;
channelDbId: string; channelDbId: string;
youtubeUrl: string; youtubeUrl: string;
/** Kanala özel segment süresi (sn) — verilmezse env.segmentTimeSec kullanılır. */
segmentTimeSec?: number;
} }
export interface SignalDetectionJob { export interface SignalDetectionJob {
@@ -34,3 +36,13 @@ export const streamIngestQueue = new Queue<StreamIngestJob>(QUEUE_NAMES.STREAM_I
export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, { export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, {
connection: redisConnection, connection: redisConnection,
}); });
export interface VideoRenderJob {
candidateSegmentId: string;
}
// Consumed by the Python worker (apps/worker/worker/video_render.py) — job
// name/payload shape must match what stt_scoring.py already enqueues.
export const videoRenderQueue = new Queue<VideoRenderJob>(QUEUE_NAMES.VIDEO_RENDER, {
connection: redisConnection,
});
+73
View File
@@ -1,9 +1,13 @@
import express from "express"; import express from "express";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { env } from "./env"; import { env } from "./env";
import { lastPollErrors, checkChannel, startCaptureSession } from "./youtubePolling";
import { lastCaptureDebug, stopCapture } from "./capture/streamIngest";
import { videoRenderQueue } from "./queues";
export function startServer() { export function startServer() {
const app = express(); const app = express();
app.use(express.json());
app.get("/health", (_req, res) => res.json({ ok: true })); app.get("/health", (_req, res) => res.json({ ok: true }));
@@ -26,10 +30,79 @@ export function startServer() {
lastCheckedAt: c.lastCheckedAt, lastCheckedAt: c.lastCheckedAt,
recording: c.sessions.length > 0, recording: c.sessions.length > 0,
activeSessionId: c.sessions[0]?.id ?? null, activeSessionId: c.sessions[0]?.id ?? null,
lastPollError: lastPollErrors.get(c.channelId) ?? null,
})), })),
}); });
}); });
app.get("/debug/capture/:sessionId", (req, res) => {
res.json({ lines: lastCaptureDebug.get(req.params.sessionId) ?? [] });
});
/**
* These endpoints change state (stop a capture, start one against an
* arbitrary URL, re-run a render) — unlike /health and /status they must
* not be reachable by anyone who finds this daemon's public sslip.io URL.
* If INTERNAL_API_TOKEN isn't set (local dev), they stay open — matches
* SESSION_SECRET's existing "insecure default, fine for local" pattern.
*/
function requireInternalToken(req: express.Request, res: express.Response, next: express.NextFunction) {
if (!env.internalApiToken) return next();
const header = req.header("authorization") ?? "";
if (header === `Bearer ${env.internalApiToken}`) return next();
res.status(401).json({ error: "unauthorized" });
}
app.post("/sessions/:sessionId/stop", requireInternalToken, (req, res) => {
const stopped = stopCapture(req.params.sessionId);
res.json({ stopped });
});
app.post("/channels/:channelId/check-now", requireInternalToken, async (req, res) => {
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
if (!channel) return res.status(404).json({ error: "channel not found" });
const result = await checkChannel(channel);
res.json(result);
});
app.post("/channels/:channelId/force-start", requireInternalToken, async (req, res) => {
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
if (!channel) return res.status(404).json({ error: "channel not found" });
const youtubeUrl = String(req.body?.url ?? "").trim();
if (!youtubeUrl) return res.status(400).json({ error: "url is required" });
const result = await startCaptureSession(channel, youtubeUrl);
res.json(result);
});
app.post("/shorts/:candidateId/render", requireInternalToken, async (req, res) => {
const candidateId = req.params.candidateId;
const candidate = await prisma.candidateSegment.findUnique({ where: { id: candidateId } });
if (!candidate) return res.status(404).json({ error: "candidate not found" });
await prisma.shortVideo.upsert({
where: { candidateId },
create: { candidateId, status: "PENDING" },
update: { status: "PENDING", errorMessage: null },
});
await videoRenderQueue.add("render-short", { candidateSegmentId: candidateId });
res.json({ queued: true });
});
app.post("/shorts/:candidateId/cancel", requireInternalToken, async (req, res) => {
const candidateId = req.params.candidateId;
const short = await prisma.shortVideo.findUnique({ where: { candidateId } });
if (!short) return res.status(404).json({ error: "short not found" });
// Doesn't kill the in-flight Python render process — just clears the
// stuck DB state so the panel can offer a retry instead of showing
// "RENDERING" forever if a worker died mid-job.
await prisma.shortVideo.update({
where: { candidateId },
data: { status: "FAILED", errorMessage: "Manuel olarak iptal edildi." },
});
res.json({ cancelled: true });
});
app.listen(env.port, () => { app.listen(env.port, () => {
console.log(`[server] api-daemon listening on :${env.port}`); console.log(`[server] api-daemon listening on :${env.port}`);
}); });
+43
View File
@@ -1,6 +1,12 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? ""; const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? "";
const CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? ""; const CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
// Telegram's bot-upload limit (no local Bot API server).
const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
export async function sendTelegramMessage(text: string): Promise<void> { export async function sendTelegramMessage(text: string): Promise<void> {
if (!BOT_TOKEN || !CHAT_ID) { if (!BOT_TOKEN || !CHAT_ID) {
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping notification:", text); console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping notification:", text);
@@ -18,3 +24,40 @@ export async function sendTelegramMessage(text: string): Promise<void> {
console.error("[telegram] failed to send message:", res.status, await res.text()); console.error("[telegram] failed to send message:", res.status, await res.text());
} }
} }
/** Uploads a video file to Telegram. Returns false (never throws) on any failure — the caller decides what that means for the local file. */
export async function sendTelegramVideo(filePath: string, caption: string): Promise<boolean> {
if (!BOT_TOKEN || !CHAT_ID) {
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping video:", caption);
return false;
}
const { size } = await stat(filePath);
if (size > MAX_VIDEO_BYTES) {
console.warn(`[telegram] video ${filePath} is ${size} bytes, over Telegram's bot-upload limit — skipping`);
return false;
}
try {
const buffer = await readFile(filePath);
const form = new FormData();
form.set("chat_id", CHAT_ID);
form.set("caption", caption);
form.set("parse_mode", "Markdown");
form.set("video", new Blob([buffer], { type: "video/mp4" }), path.basename(filePath));
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendVideo`, {
method: "POST",
body: form,
});
if (!res.ok) {
console.error("[telegram] failed to send video:", res.status, await res.text());
return false;
}
return true;
} catch (err) {
console.error("[telegram] failed to upload video:", err);
return false;
}
}
+178 -26
View File
@@ -4,38 +4,132 @@ import { prisma } from "@streamclipper/db";
import { env } from "./env"; import { env } from "./env";
import { streamIngestQueue } from "./queues"; import { streamIngestQueue } from "./queues";
import { sendTelegramMessage } from "./telegram"; import { sendTelegramMessage } from "./telegram";
import { ytdlpAntiBotArgs } from "./ytdlpCookies";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
/** /**
* Live detection goes through yt-dlp itself instead of the YouTube Data API. * Live detection failures are kept per channel and surfaced via GET /status
* `search.list?eventType=live` costs 100 quota units per call against a * for debugging. `at` lets the panel show how stale an error is — a message
* 10,000/day free quota — polling one channel every 60s alone would need * on its own doesn't say whether it's from the last poll or from hours ago
* ~144,000 units/day, well over quota. yt-dlp's `/live` redirect check is * before the channel started passing again.
* free and needs no API key.
*/ */
async function findLiveVideoId(channelId: string): Promise<string | null> { export interface PollError {
message: string;
at: string;
}
export const lastPollErrors = new Map<string, PollError>();
/** Non-error outcomes yt-dlp still reports as a non-zero exit — not worth surfacing as a scary error badge in the panel. */
const BENIGN_POLL_MESSAGE = /is not currently live|will begin in/;
/**
* A few approaches were tried before landing here:
* - A naive regex over the channel /live page HTML grabbed the FIRST
* "videoId" anywhere on the page — one of dozens of unrelated ids
* (channel video list, recommendations), not the live broadcast.
* - Scoping to the page's `videoDetails` object fixed that, but turned out
* unreliable for a different reason: YouTube doesn't always embed the
* player response server-side (confirmed on a clean, unflagged IP) — the
* field is sometimes just absent regardless of bot-check status.
* - yt-dlp's own extractor doesn't have that inconsistency (it resolves the
* live video correctly every time it isn't blocked), so the check is
* back on yt-dlp — this time with every anti-bot layer combined:
* authenticated cookies, the bgutil PO-token provider, and a clean
* residential/ISP proxy (the Coolify server's own IP got flagged from a
* day of heavy testing). No `-f` format selector here — resolving
* downloadable formats is a separate, heavier check only needed later,
* in the actual capture. `--js-runtimes node` (the n-challenge/EJS
* solver) is included too — some channels return "The page needs to be
* reloaded" on the plain metadata fetch too, not just format resolution.
*/
/**
* A channel whose capture keeps crashing and restarting (see streamIngest's
* crash-loop notes) fires "started" + "poll failing" + "poll recovered" over
* and over within minutes — observed directly once several channels were
* live at once. One shared per-channel cooldown across all three message
* types means a flapping channel says its piece once, then goes quiet for a
* while instead of narrating every blip.
*/
const NOTIFY_COOLDOWN_MS = 10 * 60 * 1000;
const lastNotifyAt = new Map<string, number>();
async function notifyIfEnabled(key: string, channelId: string, text: string): Promise<void> {
const last = lastNotifyAt.get(channelId) ?? 0;
if (Date.now() - last < NOTIFY_COOLDOWN_MS) return;
const setting = await prisma.appSetting.findUnique({ where: { key } });
if (setting?.value === "false") return;
lastNotifyAt.set(channelId, Date.now());
await sendTelegramMessage(text);
}
async function findLiveVideoId(channelId: string, channelName: string, channelDbId: string): Promise<string | null> {
const liveUrl = `https://www.youtube.com/channel/${channelId}/live`; const liveUrl = `https://www.youtube.com/channel/${channelId}/live`;
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
"yt-dlp", "yt-dlp",
["--simulate", "--no-warnings", "--print", "%(id)s", liveUrl], [
"--simulate",
"--no-warnings",
"--js-runtimes",
"node",
...(await ytdlpAntiBotArgs(channelDbId)),
"--print",
"%(id)s",
liveUrl,
],
{ timeout: 20_000 }, { timeout: 20_000 },
); );
// Only worth a "recovered" message if it was actually failing before —
// this runs on every successful poll, most of which were never broken.
if (lastPollErrors.has(channelId)) {
lastPollErrors.delete(channelId);
await notifyIfEnabled("notify_poll_error", channelDbId, `✅ *${channelName}* canlı-tespiti tekrar çalışıyor.`);
}
const videoId = stdout.trim().split("\n")[0]; const videoId = stdout.trim().split("\n")[0];
return videoId || null; return videoId || null;
} catch { } catch (err) {
// yt-dlp exits non-zero when the channel isn't currently live const message = err instanceof Error ? err.message : String(err);
// yt-dlp reports "not live" / "starts in N minutes" as a non-zero exit
// too — normal, expected outcomes, not failures worth a red badge.
if (!BENIGN_POLL_MESSAGE.test(message)) {
// Alert only on the transition into failing — not every 60s poll
// while it stays broken, or this would spam constantly.
const wasAlreadyFailing = lastPollErrors.has(channelId);
lastPollErrors.set(channelId, { message: message.slice(0, 500), at: new Date().toISOString() });
if (!wasAlreadyFailing) {
await notifyIfEnabled(
"notify_poll_error",
channelDbId,
`⚠️ *${channelName}* canlı-tespiti başarısız oluyor:\n\`${message.slice(0, 300)}\``,
);
}
} else {
lastPollErrors.delete(channelId);
}
return null; return null;
} }
} }
async function startSession(channel: { id: string; name: string }, liveVideoId: string) { /**
* Shared by the automatic poll loop and the panel's manual "force-start"
* action — both just need a channel + a URL to begin capturing. A no-op if
* that channel already has an open session (covers both callers: the poll
* loop re-checking a channel it's already recording, and someone hitting
* force-start on a channel that's already live).
*/
export async function startCaptureSession(
channel: { id: string; name: string; segmentTimeSec?: number | null },
youtubeUrl: string,
liveVideoId: string | null = null,
): Promise<{ started: boolean }> {
const existing = await prisma.streamSession.findFirst({ const existing = await prisma.streamSession.findFirst({
where: { channelId: channel.id, endedAt: null }, where: { channelId: channel.id, endedAt: null },
}); });
if (existing) return; if (existing) return { started: false };
const session = await prisma.streamSession.create({ const session = await prisma.streamSession.create({
data: { channelId: channel.id, liveVideoId }, data: { channelId: channel.id, liveVideoId },
@@ -44,37 +138,95 @@ async function startSession(channel: { id: string; name: string }, liveVideoId:
await streamIngestQueue.add("start-capture", { await streamIngestQueue.add("start-capture", {
sessionId: session.id, sessionId: session.id,
channelDbId: channel.id, channelDbId: channel.id,
youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`, youtubeUrl,
segmentTimeSec: channel.segmentTimeSec ?? undefined,
}); });
await sendTelegramMessage(`🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`); await notifyIfEnabled("notify_stream_start", channel.id, `🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`);
console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`); console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`);
return { started: true };
} }
export async function pollOnce(): Promise<void> { /** Runs the live-check for a single channel and applies its result — the unit both `pollOnce` and the panel's manual "şimdi kontrol et" action reuse. */
const channels = await prisma.channel.findMany({ where: { isActive: true } }); export async function checkChannel(channel: {
id: string;
for (const channel of channels) { name: string;
try { channelId: string;
const liveVideoId = await findLiveVideoId(channel.channelId); segmentTimeSec?: number | null;
}): Promise<{
liveVideoId: string | null;
error: PollError | null;
}> {
const liveVideoId = await findLiveVideoId(channel.channelId, channel.name, channel.id);
await prisma.channel.update({ await prisma.channel.update({
where: { id: channel.id }, where: { id: channel.id },
data: { lastCheckedAt: new Date() }, data: { lastCheckedAt: new Date() },
}); });
if (liveVideoId) { if (liveVideoId) {
await startSession(channel, liveVideoId); await startCaptureSession(channel, `https://www.youtube.com/watch?v=${liveVideoId}`, liveVideoId);
} }
return { liveVideoId, error: lastPollErrors.get(channel.channelId) ?? null };
}
export async function pollOnce(): Promise<void> {
const channels = await prisma.channel.findMany({ where: { isActive: true } });
const openSessions = await prisma.streamSession.findMany({
where: { endedAt: null, channelId: { in: channels.map((c) => c.id) } },
select: { channelId: true },
});
const recordingChannelIds = new Set(openSessions.map((s) => s.channelId));
for (const channel of channels) {
// Already capturing this channel — we already know it's live, so skip
// the bot-check-prone /live lookup entirely. With several channels
// sharing one cookie session + one proxy IP, re-checking channels that
// don't need it multiplies request volume through that single shared
// identity for no reason — a likely contributor to multiple channels
// failing detection at once once a few are live simultaneously.
if (recordingChannelIds.has(channel.id)) continue;
try {
await checkChannel(channel);
} catch (err) { } catch (err) {
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err); console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
} }
// Stagger checks instead of firing them back-to-back — a tight burst of
// near-simultaneous requests through the same cookie/IP looks more
// automated than the same requests spread out.
await new Promise((resolve) => setTimeout(resolve, 2000));
} }
} }
export function startPollingLoop(): NodeJS.Timeout { const POLL_INTERVAL_SETTING_KEY = "poll_interval_ms";
console.log(`[youtube-polling] polling every ${env.pollIntervalMs}ms`);
pollOnce().catch((err) => console.error("[youtube-polling] initial poll failed:", err)); async function getPollIntervalMs(): Promise<number> {
return setInterval(() => { const setting = await prisma.appSetting.findUnique({ where: { key: POLL_INTERVAL_SETTING_KEY } });
pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err)); const parsed = setting ? Number(setting.value) : NaN;
}, env.pollIntervalMs); return Number.isFinite(parsed) && parsed >= 5_000 ? parsed : env.pollIntervalMs;
}
/**
* Self-rescheduling instead of setInterval so a panel-side change to the
* poll_interval_ms AppSetting (read fresh on every iteration, same pattern
* as ytdlpAntiBotArgs()'s cookies) takes effect on the very next cycle
* without a redeploy.
*/
export function startPollingLoop(): void {
let stopped = false;
async function tick() {
if (stopped) return;
await pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
const intervalMs = await getPollIntervalMs().catch(() => env.pollIntervalMs);
if (!stopped) setTimeout(tick, intervalMs);
}
getPollIntervalMs()
.then((ms) => console.log(`[youtube-polling] polling every ${ms}ms`))
.catch(() => {});
tick();
} }
+121
View File
@@ -0,0 +1,121 @@
import { writeFileSync } from "node:fs";
import { prisma } from "@streamclipper/db";
import { env } from "./env";
const LEGACY_COOKIES_PATH = "/tmp/yt-cookies.txt";
const LEGACY_COOKIES_SETTING_KEY = "ytdlp_cookies";
// Oxylabs' static ISP pool: one dedicated (non-rotating) IP per port,
// confirmed against the dashboard's Proxy list (isp.oxylabs.io:8001..8010 ->
// 10 distinct IPs). PROXY_URL's own port is the base; funneling every
// channel through that single port meant 7-8 simultaneous channels' worth
// of YouTube-facing traffic all looked like it came from one IP.
const PROXY_PORT_POOL_SIZE = 10;
function simpleHash(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
}
return hash;
}
/** Deterministically pins a channel to one of the pool's ports — same channel always gets the same IP (so it isn't mid-session IP-mismatched against a live URL signed for a different one), different channels spread across the pool. */
function proxyUrlForChannel(baseProxyUrl: string, channelKey: string): string {
const match = baseProxyUrl.match(/^(.*:)(\d+)$/);
if (!match) return baseProxyUrl;
const [, prefix, basePortStr] = match;
const basePort = Number(basePortStr);
const offset = simpleHash(channelKey) % PROXY_PORT_POOL_SIZE;
return `${prefix}${basePort + offset}`;
}
/**
* Picks which cookie profile a channel uses (same deterministic-hash
* pinning as proxyUrlForChannel — same channel always gets the same
* account, different channels spread across the pool) and writes it to a
* profile-specific temp file. Falls back to the old single global
* ytdlp_cookies AppSetting if no profiles have been added yet, so nothing
* breaks before the panel's Settings page is used to add accounts.
*
* A single shared cookie file was a real bottleneck once several channels
* were active at once: it isn't just proxy IP reputation that YouTube can
* flag, it's the *account* — one Google session making requests that look
* like it's scanning many unrelated channels trips its own abuse signal,
* independent of which IP each request came from. Splitting across
* separate throwaway accounts dilutes that per-account signal the same way
* the proxy pool dilutes per-IP signal.
*
* Profile-specific file paths (not one shared /tmp/yt-cookies.txt) matter
* once multiple channels can be using *different* profiles concurrently —
* a shared path would let one channel's capture process read a file another
* channel's poll check just overwrote with a different account's cookies.
*/
async function cookiesFilePathForChannel(channelKey: string): Promise<string | null> {
const profiles = await prisma.cookieProfile.findMany({ orderBy: { createdAt: "asc" } });
if (profiles.length === 0) {
const legacy = await prisma.appSetting.findUnique({ where: { key: LEGACY_COOKIES_SETTING_KEY } });
if (!legacy?.value) return null;
writeFileSync(LEGACY_COOKIES_PATH, legacy.value, { mode: 0o600 });
return LEGACY_COOKIES_PATH;
}
const profile = profiles[simpleHash(channelKey) % profiles.length];
const path = `/tmp/yt-cookies-${profile.id}.txt`;
writeFileSync(path, profile.value, { mode: 0o600 });
return path;
}
/**
* YouTube's anti-bot layer against yt-dlp has three parts, all needed
* together: a "Sign in to confirm you're not a bot" wall (avoided with an
* authenticated session's cookies), a "The page needs to be reloaded"
* proof-of-origin token check (avoided by querying the bgutil-ytdlp-pot-
* provider sidecar — see docker-compose.yml sc_pot_provider), and — once the
* Coolify server's own IP had a day of heavy testing behind it — outright
* blocking tied to that IP's reputation specifically (avoided by routing
* through a clean residential/ISP proxy, PROXY_URL).
*
* Cookies rotate/expire over hours, so they're stored in the DB (updated
* from the panel's Settings page — see apps/frontend/app/settings) rather
* than baked in at container start from an env var. Every call re-reads the
* current value and rewrites the temp file, so a panel update takes effect
* on the very next yt-dlp invocation without a redeploy.
*/
export async function ytdlpAntiBotArgs(
channelKey: string,
{ includeAndroidClient = true }: { includeAndroidClient?: boolean } = {},
): Promise<string[]> {
const args: string[] = [];
const cookiesPath = await cookiesFilePathForChannel(channelKey);
if (cookiesPath) {
args.push("--cookies", cookiesPath);
}
if (env.ytdlpPotProviderUrl) {
args.push("--extractor-args", `youtubepot-bgutilhttp:base_url=${env.ytdlpPotProviderUrl}`);
}
// Büyük/popüler kanallarda (ör. NASA, çok izleyicili yayınlar) sade "web"
// client'ı cookie+PO-token kombinasyonuna rağmen "Sign in to confirm
// you're not a bot" ile tıkanabiliyor — küçük kanallarda aynı kombinasyon
// sorunsuz çalışıyor. mweb/android'i cookie-uyumlu fallback olarak
// ekleyip yt-dlp'nin ilk başarılı client'ı kullanmasını sağlıyoruz.
//
// Ama "android" client'ı yt-dlp'nin kendi GitHub issue'larında birden
// fazla kez videoplayback/segment isteklerinde 403 Forbidden'a sebep
// olarak raporlanmış — sade metadata/tespit isteğinde (--simulate, format
// çözümlemesi yok) risk taşımıyor ama gerçek segment indirimi sırasında
// (asıl capture) bu 403'lerin kaynağı olabilir. O yüzden capture çağrısı
// android'i devre dışı bırakıyor, sadece tespit çağrısı kullanıyor.
const clients = includeAndroidClient ? "web,mweb,android" : "web,mweb";
args.push("--extractor-args", `youtube:player_client=${clients}`);
if (env.proxyUrl) {
args.push("--proxy", proxyUrlForChannel(env.proxyUrl, channelKey));
}
return args;
}
+9 -1
View File
@@ -7,4 +7,12 @@ COPY . .
RUN pnpm install RUN pnpm install
RUN pnpm --filter @streamclipper/db exec prisma generate RUN pnpm --filter @streamclipper/db exec prisma generate
CMD ["pnpm", "--filter", "@streamclipper/frontend", "dev"] # DATABASE_URL isn't needed for real at build time (all pages are
# force-dynamic — nothing queries the DB during `next build`), but
# Prisma's client validates the connection string format at import, so a
# well-formed placeholder keeps the build step from failing. Coolify's
# actual DATABASE_URL is supplied as a runtime env var and overrides this.
ENV DATABASE_URL="postgresql://build:build@localhost:5432/build"
RUN pnpm --filter @streamclipper/frontend build
CMD ["pnpm", "--filter", "@streamclipper/frontend", "start"]
+253
View File
@@ -0,0 +1,253 @@
"use server";
import { unlink } from "node:fs/promises";
import { prisma } from "@streamclipper/db";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { postToApiDaemon } from "../lib/apiDaemon";
export async function addChannel(formData: FormData) {
const name = String(formData.get("name") ?? "").trim();
const youtubeHandle = String(formData.get("youtubeHandle") ?? "").trim();
const channelId = String(formData.get("channelId") ?? "").trim();
if (!name || !youtubeHandle || !channelId) {
throw new Error("Kanal adı, handle ve channel_id alanları zorunlu.");
}
await prisma.channel.create({
data: { name, youtubeHandle, channelId, isActive: true },
});
revalidatePath("/");
}
/** ShortVideo has a required FK to CandidateSegment (candidate_id) — every deletion path that removes candidates must clear their shorts (row + rendered file) first, or Postgres rejects the candidate delete. */
async function deleteShortsForCandidates(candidateIds: string[]): Promise<void> {
if (candidateIds.length === 0) return;
const shorts = await prisma.shortVideo.findMany({ where: { candidateId: { in: candidateIds } } });
await prisma.shortVideo.deleteMany({ where: { candidateId: { in: candidateIds } } });
await Promise.all(shorts.filter((s) => s.filePath).map((s) => unlink(s.filePath!).catch(() => {})));
}
export async function deleteRawSegment(segmentId: string) {
const segment = await prisma.rawSegment.findUnique({
where: { id: segmentId },
include: { candidates: true },
});
if (!segment) return;
await deleteShortsForCandidates(segment.candidates.map((c) => c.id));
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segmentId } });
await prisma.rawSegment.delete({ where: { id: segmentId } });
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
revalidatePath("/segments");
revalidatePath("/", "layout");
}
export async function deleteCandidateSegment(candidateId: string) {
await deleteShortsForCandidates([candidateId]);
await prisma.candidateSegment.delete({ where: { id: candidateId } });
revalidatePath("/segments");
revalidatePath("/", "layout");
}
export async function deleteChannel(channelId: string) {
const sessions = await prisma.streamSession.findMany({
where: { channelId },
include: { segments: { include: { candidates: true } } },
});
const rawSegmentIds = sessions.flatMap((s) => s.segments.map((seg) => seg.id));
const candidateIds = sessions.flatMap((s) => s.segments.flatMap((seg) => seg.candidates.map((c) => c.id)));
const filePaths = sessions.flatMap((s) => s.segments.map((seg) => seg.filePath)).filter((p): p is string => Boolean(p));
await deleteShortsForCandidates(candidateIds);
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: { in: rawSegmentIds } } });
await prisma.rawSegment.deleteMany({ where: { sessionId: { in: sessions.map((s) => s.id) } } });
await prisma.streamSession.deleteMany({ where: { channelId } });
await prisma.channel.delete({ where: { id: channelId } });
await Promise.all(filePaths.map((p) => unlink(p).catch(() => {})));
revalidatePath("/", "layout");
redirect("/");
}
export async function toggleChannelActive(channelId: string, nextActive: boolean) {
await prisma.channel.update({ where: { id: channelId }, data: { isActive: nextActive } });
revalidatePath("/", "layout");
}
export async function updateChannelSegmentTime(channelId: string, formData: FormData) {
const raw = String(formData.get("segmentTimeMin") ?? "").trim();
const minutes = raw === "" ? null : Number(raw);
const seconds = minutes !== null && Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes * 60) : null;
await prisma.channel.update({ where: { id: channelId }, data: { segmentTimeSec: seconds } });
revalidatePath(`/channels/${channelId}`);
}
export async function toggleAllChannels(nextActive: boolean) {
await prisma.channel.updateMany({ data: { isActive: nextActive } });
revalidatePath("/", "layout");
}
export async function stopSession(sessionId: string) {
await postToApiDaemon(`/sessions/${sessionId}/stop`);
revalidatePath("/", "layout");
}
export async function checkChannelNow(channelId: string) {
await postToApiDaemon(`/channels/${channelId}/check-now`);
revalidatePath("/", "layout");
}
export async function forceStartCapture(channelId: string, formData: FormData) {
const url = String(formData.get("url") ?? "").trim();
if (!url) throw new Error("YouTube URL zorunlu.");
await postToApiDaemon(`/channels/${channelId}/force-start`, { url });
revalidatePath("/", "layout");
}
export async function retryRender(candidateId: string) {
await postToApiDaemon(`/shorts/${candidateId}/render`);
revalidatePath("/segments");
revalidatePath("/", "layout");
}
export async function cancelRender(candidateId: string) {
await postToApiDaemon(`/shorts/${candidateId}/cancel`);
revalidatePath("/segments");
revalidatePath("/", "layout");
}
export async function updateSystemSettings(formData: FormData) {
const pollSeconds = Number(formData.get("pollIntervalSec"));
const ttlHours = Number(formData.get("ttlHours"));
if (Number.isFinite(pollSeconds) && pollSeconds >= 5) {
await prisma.appSetting.upsert({
where: { key: "poll_interval_ms" },
create: { key: "poll_interval_ms", value: String(pollSeconds * 1000) },
update: { value: String(pollSeconds * 1000) },
});
}
if (Number.isFinite(ttlHours) && ttlHours > 0) {
await prisma.appSetting.upsert({
where: { key: "raw_segment_ttl_hours" },
create: { key: "raw_segment_ttl_hours", value: String(ttlHours) },
update: { value: String(ttlHours) },
});
}
revalidatePath("/settings");
}
export async function updateSttEnabled(formData: FormData) {
const enabled = formData.get("sttEnabled") === "true";
await prisma.appSetting.upsert({
where: { key: "stt_enabled" },
create: { key: "stt_enabled", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateAutoRenderEnabled(formData: FormData) {
const enabled = formData.get("autoRenderEnabled") === "true";
await prisma.appSetting.upsert({
where: { key: "auto_render_enabled" },
create: { key: "auto_render_enabled", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateDeleteAfterTelegramSend(formData: FormData) {
const enabled = formData.get("deleteAfterTelegramSend") === "true";
await prisma.appSetting.upsert({
where: { key: "delete_after_telegram_send" },
create: { key: "delete_after_telegram_send", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateSendRawSegmentsToTelegram(formData: FormData) {
const enabled = formData.get("sendRawSegmentsToTelegram") === "true";
await prisma.appSetting.upsert({
where: { key: "send_raw_segments_to_telegram" },
create: { key: "send_raw_segments_to_telegram", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateNotificationPrefs(formData: FormData) {
const streamStart = formData.get("notifyStreamStart") === "true";
const renderDone = formData.get("notifyRenderDone") === "true";
const pollError = formData.get("notifyPollError") === "true";
await prisma.appSetting.upsert({
where: { key: "notify_poll_error" },
create: { key: "notify_poll_error", value: String(pollError) },
update: { value: String(pollError) },
});
await prisma.appSetting.upsert({
where: { key: "notify_stream_start" },
create: { key: "notify_stream_start", value: String(streamStart) },
update: { value: String(streamStart) },
});
await prisma.appSetting.upsert({
where: { key: "notify_render_done" },
create: { key: "notify_render_done", value: String(renderDone) },
update: { value: String(renderDone) },
});
revalidatePath("/settings");
}
export async function updateYtdlpCookies(formData: FormData) {
const value = String(formData.get("cookies") ?? "").trim();
if (!value) {
throw new Error("Cookie içeriği boş olamaz.");
}
await prisma.appSetting.upsert({
where: { key: "ytdlp_cookies" },
create: { key: "ytdlp_cookies", value },
update: { value },
});
revalidatePath("/settings");
}
export async function addCookieProfile(formData: FormData) {
const label = String(formData.get("label") ?? "").trim();
const value = String(formData.get("value") ?? "").trim();
if (!label || !value) {
throw new Error("Hesap adı ve cookie içeriği zorunlu.");
}
await prisma.cookieProfile.create({ data: { label, value } });
revalidatePath("/settings");
}
export async function updateCookieProfile(profileId: string, formData: FormData) {
const value = String(formData.get("value") ?? "").trim();
if (!value) {
throw new Error("Cookie içeriği boş olamaz.");
}
await prisma.cookieProfile.update({ where: { id: profileId }, data: { value } });
revalidatePath("/settings");
}
export async function deleteCookieProfile(profileId: string) {
await prisma.cookieProfile.delete({ where: { id: profileId } });
revalidatePath("/settings");
}
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { API_DAEMON_URL } from "../../../../../lib/apiDaemon";
/** Client components can't reach api-daemon's internal Docker hostname directly — this proxies the (auth-free, read-only) debug endpoint through the frontend's own origin. */
export async function GET(_req: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
const res = await fetch(`${API_DAEMON_URL}/debug/capture/${sessionId}`, { cache: "no-store" });
if (!res.ok) return NextResponse.json({ lines: [] }, { status: res.status });
const data = await res.json();
return NextResponse.json(data);
}
@@ -0,0 +1,12 @@
import type { NextRequest } from "next/server";
import { prisma } from "@streamclipper/db";
import { streamVideoFile } from "../../../../../lib/videoStream";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const segment = await prisma.rawSegment.findUnique({ where: { id } });
if (!segment?.filePath) return new Response("Not found", { status: 404 });
return streamVideoFile(req, segment.filePath, `${id}.mp4`);
}
@@ -0,0 +1,12 @@
import type { NextRequest } from "next/server";
import { prisma } from "@streamclipper/db";
import { streamVideoFile } from "../../../../../lib/videoStream";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const short = await prisma.shortVideo.findUnique({ where: { id } });
if (!short?.filePath) return new Response("Not found", { status: 404 });
return streamVideoFile(req, short.filePath, `${id}.mp4`);
}
+348
View File
@@ -0,0 +1,348 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@streamclipper/db";
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
import { formatTr } from "../../../lib/formatDate";
import {
deleteRawSegment,
deleteCandidateSegment,
deleteChannel,
stopSession,
forceStartCapture,
retryRender,
cancelRender,
updateChannelSegmentTime,
} from "../../actions";
import { ConfirmButton } from "../../components/ConfirmButton";
import { LiveLogViewer } from "../../components/LiveLogViewer";
import { SessionTimer } from "../../components/SessionTimer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge, type badgeVariants } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import type { VariantProps } from "class-variance-authority";
export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
SENT_TO_TELEGRAM: "ok",
PENDING_STT: "warn",
TRANSCRIBED: "ok",
RENDERING: "warn",
READY: "ok",
FAILED: "err",
};
function transcriptPreview(transcriptJson: unknown): string | null {
if (!transcriptJson || typeof transcriptJson !== "object") return null;
const text = (transcriptJson as { text?: string }).text;
return text ? text.slice(0, 240) : null;
}
export default async function ChannelDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [channel, statuses] = await Promise.all([
prisma.channel.findUnique({
where: { id },
include: {
sessions: {
orderBy: { startedAt: "desc" },
include: {
segments: {
orderBy: { createdAt: "desc" },
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
},
},
},
},
}),
fetchChannelStatuses(),
]);
if (!channel) notFound();
const status = statuses.get(channel.id);
const activeSession = channel.sessions.find((s) => s.endedAt === null);
return (
<div className="flex flex-col gap-6">
<div>
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Kanal Durumu
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{channel.name}</h1>
<p className="font-mono-num text-xs text-muted-foreground">
{channel.youtubeHandle} · {channel.channelId}
</p>
</div>
<form action={deleteChannel.bind(null, channel.id)}>
<ConfirmButton
confirmText="Bu kanalı ve tüm kayıt geçmişini silmek istediğine emin misin? Geri alınamaz."
label="Kanalı Sil"
/>
</form>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">Canlı Durum</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={status?.recording ? "live" : "muted"}>
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
{status?.recording && activeSession && (
<>
{" · "}
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
</>
)}
</Badge>
{status?.recording && activeSession && (
<form action={stopSession.bind(null, activeSession.id)}>
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
</form>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="font-mono-num text-xs text-muted-foreground">
Aktif: {channel.isActive ? "evet" : "hayır"} · Son kontrol:{" "}
{channel.lastCheckedAt ? formatTr(channel.lastCheckedAt) : "—"}
</p>
{status === undefined && (
<p className="text-sm text-muted-foreground">
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</p>
)}
{status?.lastPollError && (
<div className="flex flex-col gap-1.5 rounded-lg border border-err/30 bg-err/5 p-3">
<div className="flex items-center gap-2">
<Badge variant="err" className="w-fit">
son poll hatası
</Badge>
<span className="font-mono-num text-xs text-muted-foreground">
{formatTr(status.lastPollError.at)}
</span>
</div>
<pre className="font-mono-num whitespace-pre-wrap text-[0.7rem] text-muted-foreground">
{status.lastPollError.message}
</pre>
</div>
)}
{status?.recording && (
<div className="flex flex-col gap-3">
{activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme"
className="aspect-video w-full max-w-md rounded-md border border-border"
allow="autoplay; encrypted-media"
/>
)}
{activeSession && <LiveLogViewer sessionId={activeSession.id} />}
</div>
)}
<Separator />
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<form
action={updateChannelSegmentTime.bind(null, channel.id)}
className="flex flex-wrap items-end gap-2"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="segmentTimeMin" className="text-xs text-muted-foreground">
Bu kanal için segment süresi (dakika)
</Label>
<Input
id="segmentTimeMin"
name="segmentTimeMin"
type="number"
min={1}
max={60}
step={0.5}
placeholder="varsayılan"
defaultValue={channel.segmentTimeSec ? channel.segmentTimeSec / 60 : ""}
className="w-32"
/>
</div>
<Button type="submit" variant="outline" size="sm">
Kaydet
</Button>
</form>
<details className="text-sm">
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Manuel URL ile kayıt başlat
</summary>
<form action={forceStartCapture.bind(null, channel.id)} className="mt-2 flex gap-2">
<Input name="url" placeholder="https://www.youtube.com/watch?v=..." required className="w-64" />
<Button type="submit">Başlat</Button>
</form>
</details>
</div>
</CardContent>
</Card>
<div>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Yayın Geçmişi</h2>
{channel.sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu kanal için henüz bir kayıt oturumu yok.</p>
) : (
<div className="flex flex-col gap-4">
{channel.sessions.map((session) => (
<Card key={session.id}>
<CardHeader className="flex-row items-center justify-between">
<span className="font-mono-num text-xs text-muted-foreground">
{formatTr(session.startedAt)}
{session.endedAt ? ` ${formatTr(session.endedAt)}` : " (devam ediyor)"}
</span>
<Badge variant="muted">{session.totalSegments} segment</Badge>
</CardHeader>
{session.segments.length === 0 ? (
<CardContent>
<p className="text-sm text-muted-foreground">Henüz segment yok.</p>
</CardContent>
) : (
<CardContent className="flex flex-col gap-4">
{session.segments.map((segment) => (
<div key={segment.id} className="rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
{segment.duration}sn segment · {formatTr(segment.startedAt)}
</span>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</div>
{segment.filePath ? (
<>
<span className="font-mono-num text-[0.7rem] text-muted-foreground">{segment.filePath}</span>
<video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div className="mt-2 flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
</>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Telegram&apos;a gönderildi, sunucuda saklanmıyor.
</p>
)}
{segment.status === "SENT_TO_TELEGRAM" ? (
<p className="mt-3 text-xs text-muted-foreground">
Analiz edilmeden (transkript/9:16 atlanarak) doğrudan gönderildi.
</p>
) : (
segment.candidates.length === 0 &&
segment.status !== "PENDING" && (
<p className="mt-3 text-xs text-muted-foreground">
Bu segmentte aday klip bulunamadı (sinyal analizi ilginç bir an tespit etmedi).
</p>
)
)}
{segment.candidates.length > 0 && (
<div className="mt-3 flex flex-col gap-2">
{segment.candidates.map((c) => (
<div key={c.id} className="rounded-md border border-border bg-background/60 p-3">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
</span>
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)}
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && c.short.filePath && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<Button asChild variant="outline" size="sm">
<a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</Button>
</>
)}
{c.short.status === "READY" && !c.short.filePath && (
<p className="text-xs text-muted-foreground">
Telegram&apos;a gönderildi, sunucuda saklanmıyor.
</p>
)}
{c.short.status === "FAILED" && (
<>
{c.short.errorMessage && (
<p className="font-mono-num text-xs text-muted-foreground">{c.short.errorMessage}</p>
)}
<form action={retryRender.bind(null, c.id)}>
<Button type="submit" size="sm">
Tekrar Dene
</Button>
</form>
</>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)}>
<ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et"
variant="outline"
/>
</form>
)}
</div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} className="mt-2">
<Button type="submit" size="sm">
9:16 Render Et
</Button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form>
</div>
))}
</div>
)}
</div>
))}
</CardContent>
)}
</Card>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useRef } from "react";
import { Button, type buttonVariants } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { VariantProps } from "class-variance-authority";
/** Submits the form this button lives in — used inside the (portaled) AlertDialogAction, which isn't a DOM descendant of that form, so `type="submit"` alone wouldn't reach it. */
export function ConfirmButton({
confirmText,
label,
triggerLabel,
variant = "destructive",
}: {
confirmText: string;
label: string;
triggerLabel?: string;
variant?: VariantProps<typeof buttonVariants>["variant"];
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button ref={triggerRef} type="button" variant={variant} size="sm">
{triggerLabel ?? label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emin misin?</AlertDialogTitle>
<AlertDialogDescription>{confirmText}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Vazgeç</AlertDialogCancel>
<AlertDialogAction
variant={variant}
onClick={() => triggerRef.current?.closest("form")?.requestSubmit()}
>
{label}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,42 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function LiveLogViewer({ sessionId }: { sessionId: string }) {
const [lines, setLines] = useState<string[]>([]);
const boxRef = useRef<HTMLPreElement>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const res = await fetch(`/api/debug/capture/${sessionId}`, { cache: "no-store" });
const data = (await res.json()) as { lines: string[] };
if (!cancelled) setLines(data.lines);
} catch {
// transient fetch failures are fine to just skip — next tick retries
}
}
poll();
const timer = setInterval(poll, 4000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [sessionId]);
useEffect(() => {
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
}, [lines]);
return (
<pre
ref={boxRef}
className="font-mono-num mt-3 max-h-56 overflow-y-auto rounded-md border border-border bg-background/60 p-3 text-[0.7rem] leading-relaxed whitespace-pre-wrap text-muted-foreground"
>
{lines.length > 0 ? lines.join("\n") : "Log bekleniyor…"}
</pre>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function NavLink({ href, children }: { href: string; children: ReactNode }) {
const pathname = usePathname();
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
href={href}
className={cn(
"text-sm font-medium transition-colors hover:text-foreground",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{children}
</Link>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect, useState } from "react";
function formatElapsed(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function SessionTimer({ startedAt }: { startedAt: string }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
return <span className="font-mono-num">{formatElapsed(now - new Date(startedAt).getTime())}</span>;
}
@@ -0,0 +1,22 @@
"use client";
import { useState } from "react";
import { Switch } from "@/components/ui/switch";
/**
* Radix Switch's own hidden-input form integration wasn't reliably reaching
* the Server Action's FormData in this app (toggling off + saving silently
* kept the old value) — this owns the submitted value directly instead of
* depending on that mechanism.
*/
export function SettingSwitch({ name, defaultChecked }: { name: string; defaultChecked: boolean }) {
const [checked, setChecked] = useState(defaultChecked);
return (
<>
<input type="hidden" name={name} value={checked ? "true" : "false"} />
<Switch checked={checked} onCheckedChange={setChecked} />
</>
);
}
+81 -104
View File
@@ -1,117 +1,94 @@
@import "tailwindcss";
@import "tw-animate-css";
/*
* Panel koyu temayla sabit tasarlandı (broadcast/monitör kimliği) — açık
* tema desteği bilinçli olarak yok, tek bir palet var.
*/
:root { :root {
color-scheme: light dark; --radius: 0.625rem;
--bg: #0b0d12;
--panel: #141822; --background: #0a0e15;
--border: #262c3a; --foreground: #e8ecf2;
--text: #e6e9ef;
--muted: #8b93a7; --card: #121722;
--accent: #4f8cff; --card-foreground: #e8ecf2;
--popover: #121722;
--popover-foreground: #e8ecf2;
--border: #232b3a;
--input: #1b2230;
--muted: #171d29;
--muted-foreground: #8b93a7;
--primary: #22d3c8;
--primary-foreground: #072421;
--secondary: #1b2230;
--secondary-foreground: #c7cfde;
--accent: #1a2130;
--accent-foreground: #e8ecf2;
--destructive: #ef5b5b;
--ring: #22d3c8;
/* Ürünün durum sözlüğü — badge/rozet renkleri, primary'den bağımsız */
--ok: #37c976; --ok: #37c976;
--warn: #f2b84b; --warn: #f2b84b;
--err: #ef5b5b; --err: #ef5b5b;
--live: #ff5c5c;
} }
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-ring: var(--ring);
--color-ok: var(--ok);
--color-warn: var(--warn);
--color-err: var(--err);
--color-live: var(--live);
--font-sans: var(--font-hanken-grotesk);
--font-mono: var(--font-jetbrains-mono);
}
@layer base {
* { * {
box-sizing: border-box; @apply border-border outline-ring/50;
} }
body { body {
margin: 0; @apply bg-background text-foreground;
background: var(--bg); font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
color: var(--text); }
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.nav { @layer utilities {
display: flex; .font-mono-num {
gap: 1.5rem; font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 1rem 2rem; font-variant-numeric: tabular-nums;
border-bottom: 1px solid var(--border);
} }
.nav a {
color: var(--muted);
text-decoration: none;
font-weight: 600;
}
.nav a.active {
color: var(--text);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
th {
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.03em;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.ok { background: rgba(55,201,118,0.15); color: var(--ok); }
.badge.muted { background: rgba(139,147,167,0.15); color: var(--muted); }
.badge.warn { background: rgba(242,184,75,0.15); color: var(--warn); }
.badge.err { background: rgba(239,91,91,0.15); color: var(--err); }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
color: var(--muted);
}
.transcript-preview {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text);
line-height: 1.4;
}
.empty {
color: var(--muted);
font-size: 0.9rem;
} }
+41 -7
View File
@@ -1,20 +1,54 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import { Hanken_Grotesk, JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { getSession } from "../lib/auth";
import { logout } from "./login/actions";
import { NavLink } from "./components/NavLink";
import { Button } from "@/components/ui/button";
const hankenGrotesk = Hanken_Grotesk({
subsets: ["latin"],
variable: "--font-hanken-grotesk",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "StreamClipper AI — Panel", title: "StreamClipper AI — Panel",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
return ( return (
<html lang="tr"> <html lang="tr" className={`${hankenGrotesk.variable} ${jetbrainsMono.variable}`}>
<body> <body>
<nav className="nav"> {session && (
<Link href="/">Kanal Durumu</Link> <nav className="flex items-center gap-6 border-b border-border px-6 py-4">
<Link href="/segments">Segment & Aday Kütüphanesi</Link> <span className="mr-2 text-sm font-semibold tracking-tight text-foreground">
StreamClipper<span className="text-primary">.</span>
</span>
<NavLink href="/">Kanal Durumu</NavLink>
<NavLink href="/segments">Segment & Aday Kütüphanesi</NavLink>
<NavLink href="/stats">İstatistik</NavLink>
<NavLink href="/settings">Ayarlar</NavLink>
<NavLink href="/users">Kullanıcılar</NavLink>
<span className="ml-auto flex items-center gap-4">
<span className="font-mono-num text-xs text-muted-foreground">{session.username}</span>
<form action={logout}>
<Button type="submit" variant="ghost" size="sm" className="text-muted-foreground">
Çıkış Yap
</Button>
</form>
</span>
</nav> </nav>
<main>{children}</main> )}
<main className="mx-auto max-w-4xl px-6 py-10">{children}</main>
</body> </body>
</html> </html>
); );
+67
View File
@@ -0,0 +1,67 @@
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { prisma } from "@streamclipper/db";
import { createSessionToken, SESSION_COOKIE_NAME } from "../../lib/auth";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
async function setSessionCookie(userId: string, username: string) {
const token = await createSessionToken({ sub: userId, username });
const jar = await cookies();
// NODE_ENV alone isn't a reliable signal for this — the panel is often
// served over plain HTTP (e.g. Coolify's auto-generated sslip.io preview
// domain has no TLS). A `Secure` cookie set on an HTTP origin is silently
// dropped by the browser, which made every login look successful for an
// instant and then immediately bounce back to /login. Check the actual
// request scheme instead (Traefik/Coolify sets x-forwarded-proto).
const proto = (await headers()).get("x-forwarded-proto");
jar.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: proto === "https",
sameSite: "lax",
path: "/",
maxAge: SESSION_MAX_AGE_SEC,
});
}
export async function bootstrapAdmin(formData: FormData) {
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!username || password.length < 8) {
throw new Error("Kullanıcı adı gerekli, şifre en az 8 karakter olmalı.");
}
const existing = await prisma.user.count();
if (existing > 0) {
redirect("/login");
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({ data: { username, passwordHash } });
await setSessionCookie(user.id, user.username);
redirect("/");
}
export async function login(formData: FormData) {
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
const user = await prisma.user.findUnique({ where: { username } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
redirect("/login?error=1");
}
await setSessionCookie(user.id, user.username);
redirect("/");
}
export async function logout() {
const jar = await cookies();
jar.delete(SESSION_COOKIE_NAME);
redirect("/login");
}
+34
View File
@@ -0,0 +1,34 @@
import { prisma } from "@streamclipper/db";
import { bootstrapAdmin, login } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
export const dynamic = "force-dynamic";
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const userCount = await prisma.user.count();
const isBootstrap = userCount === 0;
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-xl">{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</CardTitle>
{isBootstrap && (
<CardDescription>Henüz kullanıcı yok panele erişecek ilk admin hesabını burada oluştur.</CardDescription>
)}
</CardHeader>
<CardContent>
{!isBootstrap && error && <p className="mb-3 text-sm text-destructive">Kullanıcı adı veya şifre hatalı.</p>}
<form action={isBootstrap ? bootstrapAdmin : login} className="flex flex-col gap-3">
<Input name="username" placeholder="Kullanıcı adı" required />
<Input name="password" type="password" placeholder="Şifre" required minLength={isBootstrap ? 8 : undefined} />
<Button type="submit">{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+100 -34
View File
@@ -1,9 +1,19 @@
import Link from "next/link";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions";
import { fetchChannelStatuses } from "../lib/apiDaemon";
import { formatTr } from "../lib/formatDate";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function DashboardPage() { export default async function DashboardPage() {
const channels = await prisma.channel.findMany({ const [channels, statuses] = await Promise.all([
prisma.channel.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { include: {
sessions: { sessions: {
@@ -12,50 +22,106 @@ export default async function DashboardPage() {
take: 1, take: 1,
}, },
}, },
}); }),
fetchChannelStatuses(),
]);
return ( return (
<> <div className="flex flex-col gap-6">
<h1>Kanal Durumu</h1> <h1 className="text-2xl font-semibold tracking-tight">Kanal Durumu</h1>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Yeni Kanal Ekle</CardTitle>
</CardHeader>
<CardContent>
<form action={addChannel} className="flex flex-wrap gap-2">
<Input name="name" placeholder="Kanal adı" required className="flex-1 min-w-[140px]" />
<Input name="youtubeHandle" placeholder="@handle" required className="flex-1 min-w-[120px]" />
<Input name="channelId" placeholder="channel_id (UC...)" required className="flex-1 min-w-[180px]" />
<Button type="submit">Kanal Ekle</Button>
</form>
</CardContent>
</Card>
{channels.length === 0 ? ( {channels.length === 0 ? (
<p className="empty"> <p className="text-sm text-muted-foreground">Henüz kanal eklenmedi.</p>
Henüz kanal eklenmedi. `channels` tablosuna bir kayıt ekleyin (Prisma Studio veya seed script).
</p>
) : ( ) : (
<table> <div className="flex flex-col gap-3">
<thead> <div className="flex items-center justify-between">
<tr> <span className="text-sm font-medium text-muted-foreground">{channels.length} kanal</span>
<th>Kanal</th> <div className="flex gap-2">
<th>Durum</th> <form action={toggleAllChannels.bind(null, true)}>
<th>Kayıt</th> <Button type="submit" variant="outline" size="sm">
<th>Son Kontrol</th> Tümünü Devam Ettir
</tr> </Button>
</thead> </form>
<tbody> <form action={toggleAllChannels.bind(null, false)}>
<Button type="submit" variant="outline" size="sm">
Tümünü Duraklat
</Button>
</form>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c) => { {channels.map((c) => {
const recording = c.sessions.length > 0; const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return ( return (
<tr key={c.id}> <Card
<td>{c.name} <span className="mono">{c.youtubeHandle}</span></td> key={c.id}
<td> className={cn("gap-4 py-5", recording && "border-live/40")}
<span className={`badge ${c.isActive ? "ok" : "muted"}`}> >
<CardHeader className="gap-1 px-5">
<div className="flex items-start justify-between gap-2">
<Link href={`/channels/${c.id}`} className="font-semibold leading-tight hover:text-primary hover:underline">
{c.name}
</Link>
<Badge variant={c.isActive ? "ok" : "muted"} className="shrink-0">
{c.isActive ? "Aktif" : "Pasif"} {c.isActive ? "Aktif" : "Pasif"}
</Badge>
</div>
<span className="font-mono-num text-xs text-muted-foreground">{c.youtubeHandle}</span>
</CardHeader>
<CardContent className="flex flex-col gap-2 px-5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={recording ? "live" : "muted"}>
{recording ? "🔴 Kayıtta" : "Kayıtta değil"}
</Badge>
{status?.lastPollError && (
<Badge
variant="err"
title={`${formatTr(status.lastPollError.at)}${status.lastPollError.message}`}
>
hata
</Badge>
)}
</div>
<span className="font-mono-num text-xs text-muted-foreground">
Son kontrol: {c.lastCheckedAt ? formatTr(c.lastCheckedAt) : "—"}
</span> </span>
</td> </CardContent>
<td>
<span className={`badge ${recording ? "warn" : "muted"}`}> <CardFooter className="gap-2 px-5">
{recording ? "🔴 Kayıtta" : "—"} <form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
</span> <Button type="submit" variant="outline" size="sm">
</td> {c.isActive ? "Duraklat" : "Devam Ettir"}
<td className="mono"> </Button>
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"} </form>
</td> <form action={checkChannelNow.bind(null, c.id)}>
</tr> <Button type="submit" variant="ghost" size="sm">
Şimdi Kontrol Et
</Button>
</form>
</CardFooter>
</Card>
); );
})} })}
</tbody> </div>
</table> </div>
)} )}
</> </div>
); );
} }
+122 -30
View File
@@ -1,13 +1,23 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
import { formatTr } from "../../lib/formatDate";
import { ConfirmButton } from "../components/ConfirmButton";
import { Button } from "@/components/ui/button";
import { Badge, type badgeVariants } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import type { VariantProps } from "class-variance-authority";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
PENDING: "muted", PENDING: "muted",
PROCESSED: "ok", PROCESSED: "ok",
DISCARDED: "err", DISCARDED: "err",
SENT_TO_TELEGRAM: "ok",
PENDING_STT: "warn", PENDING_STT: "warn",
TRANSCRIBED: "ok", TRANSCRIBED: "ok",
RENDERING: "warn",
READY: "ok",
FAILED: "err", FAILED: "err",
}; };
@@ -21,49 +31,131 @@ export default async function SegmentsPage() {
const segments = await prisma.rawSegment.findMany({ const segments = await prisma.rawSegment.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 50, take: 50,
include: { candidates: { orderBy: { createdAt: "desc" } } }, include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
}); });
return ( return (
<> <div className="flex flex-col gap-6">
<h1>Segment & Aday Klip Kütüphanesi</h1> <h1 className="text-2xl font-semibold tracking-tight">Segment & Aday Klip Kütüphanesi</h1>
{segments.length === 0 ? (
<p className="empty">Henüz işlenmiş segment yok.</p>
) : (
segments.map((segment) => (
<div className="card" key={segment.id}>
<div className="card-head">
<span className="mono">{segment.filePath}</span>
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
</div>
<div className="mono">
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
</div>
{segment.candidates.length === 0 ? ( {segments.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}> <p className="text-sm text-muted-foreground">Henüz işlenmiş segment yok.</p>
Bu segmentte aday klip bulunamadı. ) : (
<div className="flex flex-col gap-4">
{segments.map((segment) => (
<Card key={segment.id}>
<CardHeader className="flex-row items-center justify-between">
<div>
<span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<p className="font-mono-num text-xs text-muted-foreground">
{segment.duration}sn · {formatTr(segment.startedAt)}
</p> </p>
</div>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{segment.filePath ? (
<>
<video controls preload="metadata" className="w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
</>
) : (
<p className="text-xs text-muted-foreground"> Telegram&apos;a gönderildi, sunucuda saklanmıyor.</p>
)}
{segment.status === "SENT_TO_TELEGRAM" ? (
<p className="text-sm text-muted-foreground">
Bu segment analiz edilmeden (transkript/9:16 atlanarak) doğrudan Telegram&apos;a gönderildi.
</p>
) : segment.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu segmentte aday klip bulunamadı.</p>
) : ( ) : (
segment.candidates.map((c) => ( segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}> <div key={c.id} className="border-l-2 border-border pl-4">
<div className="card-head"> <div className="flex items-center justify-between gap-2">
<span className="mono"> <span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s {c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`} {c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
{c.chatVelocityScore != null && " · chat spike"} {c.chatVelocityScore != null && " · chat spike"}
</span> </span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span> <Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div> </div>
{transcriptPreview(c.transcriptJson) && ( {transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p> <p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)}
</div>
))
)}
</div>
))
)} )}
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && c.short.filePath && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<Button asChild variant="outline" size="sm">
<a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</Button>
</> </>
)}
{c.short.status === "READY" && !c.short.filePath && (
<p className="text-xs text-muted-foreground">
Telegram&apos;a gönderildi, sunucuda saklanmıyor.
</p>
)}
{c.short.status === "FAILED" && (
<>
{c.short.errorMessage && (
<p className="font-mono-num text-xs text-muted-foreground">{c.short.errorMessage}</p>
)}
<form action={retryRender.bind(null, c.id)}>
<Button type="submit" size="sm">
Tekrar Dene
</Button>
</form>
</>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)}>
<ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et"
variant="outline"
/>
</form>
)}
</div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} className="mt-2">
<Button type="submit" size="sm">
9:16 Render Et
</Button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form>
</div>
))
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
); );
} }
+337
View File
@@ -0,0 +1,337 @@
import { prisma } from "@streamclipper/db";
import {
updateYtdlpCookies,
updateSystemSettings,
updateNotificationPrefs,
updateSttEnabled,
updateAutoRenderEnabled,
updateDeleteAfterTelegramSend,
updateSendRawSegmentsToTelegram,
addCookieProfile,
updateCookieProfile,
deleteCookieProfile,
} from "../actions";
import { formatTr } from "../../lib/formatDate";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { SettingSwitch } from "../components/SettingSwitch";
import { ConfirmButton } from "../components/ConfirmButton";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export const dynamic = "force-dynamic";
const STALE_COOKIE_HOURS = 4;
export default async function SettingsPage() {
const [
setting,
pollSetting,
ttlSetting,
notifyStart,
notifyRender,
notifyError,
sttSetting,
autoRenderSetting,
deleteAfterSendSetting,
sendRawSetting,
cookieProfiles,
] = await Promise.all([
prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }),
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
prisma.appSetting.findUnique({ where: { key: "notify_poll_error" } }),
prisma.appSetting.findUnique({ where: { key: "stt_enabled" } }),
prisma.appSetting.findUnique({ where: { key: "auto_render_enabled" } }),
prisma.appSetting.findUnique({ where: { key: "delete_after_telegram_send" } }),
prisma.appSetting.findUnique({ where: { key: "send_raw_segments_to_telegram" } }),
prisma.cookieProfile.findMany({ orderBy: { createdAt: "asc" } }),
]);
const cookieAgeHours = setting ? (Date.now() - setting.updatedAt.getTime()) / 3_600_000 : null;
const cookieStale = cookieAgeHours !== null && cookieAgeHours > STALE_COOKIE_HOURS;
const pollIntervalSec = pollSetting ? Math.round(Number(pollSetting.value) / 1000) : 60;
const ttlHours = ttlSetting ? Number(ttlSetting.value) : 24;
const notifyStreamStart = notifyStart?.value !== "false";
const notifyRenderDone = notifyRender?.value !== "false";
const notifyPollError = notifyError?.value !== "false";
const sttEnabled = sttSetting?.value !== "false";
const autoRenderEnabled = autoRenderSetting?.value !== "false";
const deleteAfterTelegramSend = deleteAfterSendSetting?.value === "true";
const sendRawSegmentsToTelegram = sendRawSetting?.value === "true";
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Cookie Hesapları</CardTitle>
<CardDescription>
Her kanal, aşağıdaki hesaplardan birine sabit olarak atanır (aynı kanal hep aynı hesabı kullanır, kanallar
havuza dağılır). Tek hesap çok kanalı taramaya çalışınca YouTube hesabın kendisini şüpheli bulup
&quot;Sign in to confirm you&apos;re not a bot&quot; hatası verebiliyor birkaç ayrı, önemsiz Google
hesabı ekleyerek bu yükü dağıt. Her hesap ayrı bir tarayıcıda giriş yapılıp cookies.txt olarak dışa
aktarılmalı.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{cookieProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground">
Henüz hesap eklenmedi aşağıdan en az bir tane ekle. Eklenene kadar aşağıdaki tekli/eski cookie ayarı
kullanılmaya devam eder.
</p>
) : (
<div className="flex flex-col gap-3">
{cookieProfiles.map((profile) => {
const ageHours = (Date.now() - profile.updatedAt.getTime()) / 3_600_000;
const stale = ageHours > STALE_COOKIE_HOURS;
return (
<div key={profile.id} className="rounded-lg border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{profile.label}</span>
<div className="flex items-center gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{formatTr(profile.updatedAt)}
</span>
<form action={deleteCookieProfile.bind(null, profile.id)}>
<ConfirmButton confirmText={`${profile.label} hesabını silmek istediğine emin misin?`} label="Sil" />
</form>
</div>
</div>
{stale && (
<Badge variant="warn" className="mt-2 w-fit">
{Math.round(ageHours)} saattir güncellenmedi
</Badge>
)}
<details className="mt-2 text-sm">
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Cookie&apos;yi yenile
</summary>
<form action={updateCookieProfile.bind(null, profile.id)} className="mt-2 flex flex-col gap-2">
<Textarea
name="value"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={6}
className="font-mono-num text-xs"
/>
<Button type="submit" size="sm" className="self-start">
Kaydet
</Button>
</form>
</details>
</div>
);
})}
</div>
)}
<Separator />
<form action={addCookieProfile} className="flex flex-col gap-2">
<Label htmlFor="newProfileLabel" className="text-xs text-muted-foreground">
Yeni hesap ekle
</Label>
<Input id="newProfileLabel" name="label" placeholder="Hesap adı (ör. Hesap 2)" required />
<Textarea
name="value"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={6}
className="font-mono-num text-xs"
/>
<Button type="submit" className="self-start">
Hesap Ekle
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">
YouTube Cookie&apos;leri (tekli / eski)
</CardTitle>
<span className="font-mono-num text-xs text-muted-foreground">
{setting ? `son güncelleme: ${formatTr(setting.updatedAt)}` : "hiç ayarlanmadı"}
</span>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{cookieStale && cookieProfiles.length === 0 && (
<Badge variant="warn" className="w-fit">
{Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check hatası
görülebilir
</Badge>
)}
<CardDescription>
{cookieProfiles.length > 0
? "Yukarıda en az bir hesap eklendiği için bu ayar artık kullanılmıyor — sadece geriye dönük referans için duruyor."
: "Yukarıda hiç hesap eklenmediği sürece tüm kanallar bu tek cookie'yi paylaşır."}
</CardDescription>
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
<Textarea
name="cookies"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={8}
className="font-mono-num text-xs"
/>
<Button type="submit" variant="outline" className="self-start">
Kaydet
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Sistem Parametreleri</CardTitle>
<CardDescription>Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.</CardDescription>
</CardHeader>
<CardContent>
<form action={updateSystemSettings} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="pollIntervalSec">Poll aralığı (saniye)</Label>
<Input id="pollIntervalSec" name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} className="max-w-40" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ttlHours">Ham segment TTL (saat)</Label>
<Input id="ttlHours" name="ttlHours" type="number" min={1} defaultValue={ttlHours} className="max-w-40" />
</div>
<p className="max-w-prose text-xs text-muted-foreground">
Eşzamanlı capture limiti ve render eşzamanlılığı BullMQ worker&apos;ları başlatılırken sabitleniyor
bunları değiştirmek için Coolify&apos;deki <code className="font-mono-num">MAX_CONCURRENT_CAPTURES</code>{" "}
/ <code className="font-mono-num">VIDEO_RENDER_CONCURRENCY</code> env var&apos;larını güncelleyip
redeploy etmek gerekiyor.
</p>
<Button type="submit" className="self-start">
Kaydet
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">STT (Transkripsiyon)</CardTitle>
<CardDescription>
Kapatırsan yeni aday klipler transkribe edilmez (PENDING_STT&apos;de bekler) OpenAI Whisper çağrısı
yapılmaz, render de tetiklenmez. Boş/konuşmasız yayınlarda (ör. sadece ambiyans müzik) gereksiz API
maliyetini önlemek için kapatabilirsin.
</CardDescription>
</CardHeader>
<form action={updateSttEnabled}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Otomatik transkripsiyon aktif
<SettingSwitch name="sttEnabled" defaultChecked={sttEnabled} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">9:16 Render</CardTitle>
<CardDescription>
Kapatırsan transkribe edilen adaylar için otomatik render tetiklenmez, klip
&quot;TRANSCRIBED&quot; durumunda bekler kanal/segment sayfalarındaki &quot;9:16 Render Et&quot;
butonuyla istediğini elle render edebilirsin.
</CardDescription>
</CardHeader>
<form action={updateAutoRenderEnabled}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Otomatik 9:16 render aktif
<SettingSwitch name="autoRenderEnabled" defaultChecked={autoRenderEnabled} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Ham Kayıt Modu</CardTitle>
<CardDescription>
Açarsan her ham segment (analiz/transkript/9:16 zincirine hiç girmeden) tamamlanır tamamlanmaz doğrudan
Telegram&apos;a gönderilip sunucudan silinir transkript ve 9:16 render tamamen atlanır. Bunu açtıysan
STT ve 9:16 Render ayarlarının kapalı olması mantıklı (aksi halde ikisi de boşa kalır, çünkü bu mod
segmenti analiz kuyruğuna hiç sokmuyor). ~50MB&apos;ı geçen segmentler gönderilemez, sunucuda kalır
gerekirse yukarıdaki &quot;Sistem Parametreleri&quot;nden segment süresini kısalt.
</CardDescription>
</CardHeader>
<form action={updateSendRawSegmentsToTelegram}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Ham segmenti doğrudan Telegram&apos;a gönder (analiz atlanır)
<SettingSwitch name="sendRawSegmentsToTelegram" defaultChecked={sendRawSegmentsToTelegram} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Depolama (9:16 klipler)</CardTitle>
<CardDescription>
Yukarıdaki &quot;Ham Kayıt Modu&quot;ndan farklı bu, STT+9:16 render zincirinden geçmiş klipler için.
Açarsan her render biten video Telegram&apos;a gönderilip <strong>sunucudan kalıcı olarak
silinir</strong> sadece Telegram&apos;daki kopyada kalır (geri alınamaz). Gönderim başarısız olursa
dosya sunucuda kalır. Kanal sayısı arttıkça disk doluluğunu önlemek için.
</CardDescription>
</CardHeader>
<form action={updateDeleteAfterTelegramSend}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Telegram&apos;a gönderip sunucudan sil
<SettingSwitch name="deleteAfterTelegramSend" defaultChecked={deleteAfterTelegramSend} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
</CardHeader>
<form action={updateNotificationPrefs}>
<CardContent className="flex flex-col gap-4">
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Canlı-tespit hata verince/düzelince Telegram bildirimi
<SettingSwitch name="notifyPollError" defaultChecked={notifyPollError} />
</Label>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Yayın başladığında Telegram bildirimi
<SettingSwitch name="notifyStreamStart" defaultChecked={notifyStreamStart} />
</Label>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
9:16 render tamamlandığında Telegram bildirimi
<SettingSwitch name="notifyRenderDone" defaultChecked={notifyRenderDone} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { prisma } from "@streamclipper/db";
import { Card } from "@/components/ui/card";
export const dynamic = "force-dynamic";
const STT_COST_PER_MINUTE = 0.006; // OpenAI Whisper API, doğrulanmalı — bkz. openai.com/pricing
const GB_PER_HOUR_ESTIMATE = 1.3; // 2026-09-02 oturumunda ölçülen ~2.8 Mbps ortalama bitrate'ten
function fmt(n: number, digits = 1): string {
return n.toLocaleString("tr-TR", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<Card className="gap-1 px-5 py-4">
<span className="font-mono-num text-xs tracking-wide text-muted-foreground uppercase">{label}</span>
<span className="font-mono-num text-2xl font-semibold">{value}</span>
</Card>
);
}
export default async function StatsPage() {
const [transcribed, durationSum, readyShorts, failedShorts, totalCandidates] = await Promise.all([
prisma.candidateSegment.findMany({
where: { status: "TRANSCRIBED" },
select: { startSec: true, endSec: true },
}),
prisma.rawSegment.aggregate({ _sum: { duration: true } }),
prisma.shortVideo.count({ where: { status: "READY" } }),
prisma.shortVideo.count({ where: { status: "FAILED" } }),
prisma.candidateSegment.count(),
]);
const sttSeconds = transcribed.reduce((sum, c) => sum + Math.max(0, c.endSec - c.startSec), 0);
const sttMinutes = sttSeconds / 60;
const sttCost = sttMinutes * STT_COST_PER_MINUTE;
const captureSeconds = durationSum._sum.duration ?? 0;
const captureHours = captureSeconds / 3600;
const estimatedGb = captureHours * GB_PER_HOUR_ESTIMATE;
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kullanım & Maliyet</h1>
<p className="mt-1 text-sm text-muted-foreground">
Şimdiye kadarki toplam kullanım tüm zamanlar. STT maliyeti ve disk kullanımı tahmini, gerçek faturayla
küçük farklar olabilir.
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Toplam Kayıt Süresi" value={`${fmt(captureHours)} sa`} />
<StatCard label="Toplam STT Süresi" value={`${fmt(sttMinutes)} dk`} />
<StatCard label="Tahmini Whisper Maliyeti" value={`$${fmt(sttCost, 2)}`} />
<StatCard label="Tahmini Disk Kullanımı" value={`${fmt(estimatedGb)} GB`} />
<StatCard label="Üretilen 9:16 Klip" value={String(readyShorts)} />
<StatCard label="Başarısız Render" value={String(failedShorts)} />
<StatCard label="Toplam Aday Klip" value={String(totalCandidates)} />
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use server";
import bcrypt from "bcryptjs";
import { prisma } from "@streamclipper/db";
import { revalidatePath } from "next/cache";
import { getSession } from "../../lib/auth";
export async function addUser(formData: FormData) {
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!username || password.length < 8) {
throw new Error("Kullanıcı adı gerekli, şifre en az 8 karakter olmalı.");
}
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.create({ data: { username, passwordHash } });
revalidatePath("/users");
}
export async function deleteUser(userId: string) {
const session = await getSession();
if (session?.sub === userId) {
throw new Error("Kendi hesabını silemezsin.");
}
const totalUsers = await prisma.user.count();
if (totalUsers <= 1) {
throw new Error("Son kalan kullanıcı silinemez.");
}
await prisma.user.delete({ where: { id: userId } });
revalidatePath("/users");
}
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from "@streamclipper/db";
import { getSession } from "../../lib/auth";
import { addUser, deleteUser } from "./actions";
import { formatTr } from "../../lib/formatDate";
import { ConfirmButton } from "../components/ConfirmButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export const dynamic = "force-dynamic";
export default async function UsersPage() {
const [users, session] = await Promise.all([prisma.user.findMany({ orderBy: { createdAt: "asc" } }), getSession()]);
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight">Kullanıcılar</h1>
<Card>
<CardContent>
<form action={addUser} className="flex flex-wrap gap-2">
<Input name="username" placeholder="Kullanıcı adı" required className="flex-1 min-w-[140px]" />
<Input
name="password"
type="password"
placeholder="Şifre (en az 8 karakter)"
required
minLength={8}
className="flex-1 min-w-[160px]"
/>
<Button type="submit">Kullanıcı Ekle</Button>
</form>
</CardContent>
</Card>
<Card>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kullanıcı Adı</TableHead>
<TableHead>Oluşturulma</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell>
{u.username}
{u.id === session?.sub && (
<Badge variant="muted" className="ml-2">
sen
</Badge>
)}
</TableCell>
<TableCell className="font-mono-num text-xs text-muted-foreground">
{formatTr(u.createdAt)}
</TableCell>
<TableCell className="text-right">
{u.id !== session?.sub && users.length > 1 && (
<form action={deleteUser.bind(null, u.id)}>
<ConfirmButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} label="Sil" />
</form>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,142 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
};
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
warn: "bg-card text-warn *:data-[slot=alert-description]:text-warn/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-title" className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)} {...props} />;
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn("col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
// Ürünün kendi durum sözlüğü (kayıt/render/segment durumları) —
// primary accent'ten bağımsız, sabit anlamları var.
ok: "bg-ok/15 text-ok",
warn: "bg-warn/15 text-warn",
err: "bg-err/15 text-err",
muted: "bg-muted text-muted-foreground",
live: "bg-live/15 text-live",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return <Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />;
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+35
View File
@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+76
View File
@@ -0,0 +1,76 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+46
View File
@@ -0,0 +1,46 @@
export interface ChannelStatus {
id: string;
name: string;
isActive: boolean;
lastCheckedAt: string | null;
recording: boolean;
activeSessionId: string | null;
lastPollError: { message: string; at: string } | null;
}
export const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
/** POSTs to a state-changing api-daemon endpoint with the shared internal auth header (see INTERNAL_API_TOKEN in .env.example). */
export async function postToApiDaemon<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_DAEMON_URL}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(process.env.INTERNAL_API_TOKEN ? { authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`api-daemon ${path} failed (${res.status}): ${text.slice(0, 300)}`);
}
return res.json() as Promise<T>;
}
/**
* lastPollError only exists in api-daemon's in-memory map (not persisted to
* the DB), so the panel fetches it live over HTTP rather than via Prisma.
* Failures here (api-daemon down, network hiccup) shouldn't break the page —
* they just mean live-status fields fall back to unknown.
*/
export async function fetchChannelStatuses(): Promise<Map<string, ChannelStatus>> {
try {
const res = await fetch(`${API_DAEMON_URL}/status`, { cache: "no-store" });
if (!res.ok) return new Map();
const data = (await res.json()) as { channels: ChannelStatus[] };
return new Map(data.channels.map((c) => [c.id, c]));
} catch {
return new Map();
}
}
+40
View File
@@ -0,0 +1,40 @@
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
export const SESSION_COOKIE_NAME = "session";
const secret = new TextEncoder().encode(
process.env.SESSION_SECRET ?? "dev-insecure-secret-change-me-in-production",
);
export interface SessionPayload {
sub: string;
username: string;
}
export async function createSessionToken(payload: SessionPayload): Promise<string> {
return new SignJWT({ username: payload.username })
.setProtectedHeader({ alg: "HS256" })
.setSubject(payload.sub)
.setIssuedAt()
.setExpirationTime("30d")
.sign(secret);
}
export async function verifySessionToken(token: string): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, secret);
if (typeof payload.sub !== "string" || typeof payload.username !== "string") return null;
return { sub: payload.sub, username: payload.username };
} catch {
return null;
}
}
/** Server Component helper — reads and verifies the session cookie. */
export async function getSession(): Promise<SessionPayload | null> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
return verifySessionToken(token);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Server (Coolify/Hetzner) runs in UTC, but the panel is used from Turkey
* (UTC+3, no DST since 2016) — `.toLocaleString("tr-TR")` alone formats
* with tr-TR conventions but still uses the *server's* timezone for the
* actual time value, which was showing everything 3h behind. Always pin
* the timezone explicitly instead of relying on the runtime's default.
*/
export function formatTr(date: Date | string): string {
return new Date(date).toLocaleString("tr-TR", { timeZone: "Europe/Istanbul" });
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+42
View File
@@ -0,0 +1,42 @@
import { createReadStream, statSync } from "node:fs";
import { Readable } from "node:stream";
import type { NextRequest } from "next/server";
export function streamVideoFile(req: NextRequest, filePath: string, downloadName: string): Response {
let size: number;
try {
size = statSync(filePath).size;
} catch {
return new Response("File not found on disk", { status: 404 });
}
const headers: Record<string, string> = {
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
};
if (req.nextUrl.searchParams.get("download") === "1") {
headers["Content-Disposition"] = `attachment; filename="${downloadName}"`;
}
const range = req.headers.get("range");
const match = range ? /bytes=(\d+)-(\d*)/.exec(range) : null;
if (match) {
const start = Number(match[1]);
const end = match[2] ? Number(match[2]) : size - 1;
const stream = createReadStream(filePath, { start, end });
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Length": String(end - start + 1),
},
});
}
const stream = createReadStream(filePath);
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: { ...headers, "Content-Length": String(size) },
});
}
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, verifySessionToken } from "./lib/auth";
export async function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith("/login")) {
return NextResponse.next();
}
const token = req.cookies.get(SESSION_COOKIE_NAME)?.value;
const session = token ? await verifySessionToken(token) : null;
if (!session) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+12 -1
View File
@@ -9,11 +9,22 @@
}, },
"dependencies": { "dependencies": {
"@streamclipper/db": "workspace:*", "@streamclipper/db": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "^5.9.6",
"lucide-react": "^1.38.0",
"next": "^15.1.0", "next": "^15.1.0",
"radix-ui": "^1.6.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+7 -1
View File
@@ -1,7 +1,7 @@
FROM python:3.12-slim FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg libsndfile1 \ ffmpeg libsndfile1 libgl1 libglib2.0-0 curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -9,5 +9,11 @@ COPY apps/worker/requirements.txt ./apps/worker/requirements.txt
RUN pip install --no-cache-dir -r apps/worker/requirements.txt RUN pip install --no-cache-dir -r apps/worker/requirements.txt
COPY apps/worker ./apps/worker COPY apps/worker ./apps/worker
# MediaPipe's Tasks API needs the face detector model asset on disk — not
# bundled with the pip package, downloaded once at build time instead of on
# every worker startup.
RUN mkdir -p /app/models && curl -L -o /app/models/blaze_face_short_range.tflite \
https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite
WORKDIR /app/apps/worker WORKDIR /app/apps/worker
CMD ["python", "-m", "worker.main"] CMD ["python", "-m", "worker.main"]
+2
View File
@@ -6,3 +6,5 @@ openai>=1.50.0
httpx>=0.27.0 httpx>=0.27.0
chat-downloader>=0.2.7 chat-downloader>=0.2.7
python-dotenv>=1.0.0 python-dotenv>=1.0.0
opencv-python-headless>=4.10.0
mediapipe>=0.10.14
+74
View File
@@ -21,6 +21,13 @@ async def get_pool() -> asyncpg.Pool:
return _pool return _pool
async def get_setting(key: str, default: str | None = None) -> str | None:
"""Same app_settings table the Node side reads for cookies/poll-interval — settings authored from the panel are shared across both languages."""
pool = await get_pool()
row = await pool.fetchrow("SELECT value FROM app_settings WHERE key = $1", key)
return row["value"] if row else default
async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None: async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None:
pool = await get_pool() pool = await get_pool()
return await pool.fetchrow( return await pool.fetchrow(
@@ -93,3 +100,70 @@ async def update_candidate_transcript(candidate_id: str, transcript: dict, statu
async def get_active_sessions() -> list[asyncpg.Record]: async def get_active_sessions() -> list[asyncpg.Record]:
pool = await get_pool() pool = await get_pool()
return await pool.fetch("SELECT id, live_video_id FROM stream_sessions WHERE ended_at IS NULL") return await pool.fetch("SELECT id, live_video_id FROM stream_sessions WHERE ended_at IS NULL")
async def get_candidate_for_render(candidate_id: str) -> asyncpg.Record | None:
pool = await get_pool()
return await pool.fetchrow(
"""
SELECT cs.id, cs.start_sec, cs.end_sec, cs.transcript_json, rs.file_path, c.name AS channel_name
FROM candidate_segments cs
JOIN raw_segments rs ON rs.id = cs.raw_segment_id
JOIN stream_sessions ss ON ss.id = rs.session_id
JOIN channels c ON c.id = ss.channel_id
WHERE cs.id = $1
""",
candidate_id,
)
async def upsert_short_video(candidate_id: str, status: str) -> str:
pool = await get_pool()
existing = await pool.fetchrow(
"SELECT id FROM short_videos WHERE candidate_id = $1", candidate_id
)
if existing:
await pool.execute(
"UPDATE short_videos SET status = $2, error_message = NULL, updated_at = now() WHERE id = $1",
existing["id"],
status,
)
return existing["id"]
short_id = str(uuid.uuid4())
await pool.execute(
"""
INSERT INTO short_videos (id, candidate_id, status, created_at, updated_at)
VALUES ($1, $2, $3, now(), now())
""",
short_id,
candidate_id,
status,
)
return short_id
async def update_short_video(
short_id: str, status: str, file_path: str | None = None, error_message: str | None = None
) -> None:
pool = await get_pool()
await pool.execute(
"""
UPDATE short_videos
SET status = $2, file_path = COALESCE($3, file_path), error_message = $4, updated_at = now()
WHERE id = $1
""",
short_id,
status,
file_path,
error_message,
)
async def clear_short_video_file(short_id: str) -> None:
"""update_short_video's COALESCE keeps the old file_path when passed None — this explicitly nulls it out once the rendered file has been deleted from disk (e.g. after a successful Telegram delivery)."""
pool = await get_pool()
await pool.execute(
"UPDATE short_videos SET file_path = NULL, updated_at = now() WHERE id = $1",
short_id,
)
+4 -1
View File
@@ -4,6 +4,7 @@ from . import db
from .chat_capture import start_chat_capture from .chat_capture import start_chat_capture
from .signal_detection import start_signal_detection_worker from .signal_detection import start_signal_detection_worker
from .stt_scoring import start_stt_scoring_worker from .stt_scoring import start_stt_scoring_worker
from .video_render import start_video_render_worker
SESSION_WATCH_INTERVAL_SEC = 10 SESSION_WATCH_INTERVAL_SEC = 10
@@ -22,13 +23,15 @@ async def watch_sessions_for_chat_capture():
async def main(): async def main():
signal_worker = start_signal_detection_worker() signal_worker = start_signal_detection_worker()
stt_worker = start_stt_scoring_worker() stt_worker = start_stt_scoring_worker()
print("[main] worker started: signal-detection + stt-scoring queues") render_worker = start_video_render_worker()
print("[main] worker started: signal-detection + stt-scoring + video-render queues")
try: try:
await watch_sessions_for_chat_capture() await watch_sessions_for_chat_capture()
finally: finally:
await signal_worker.close() await signal_worker.close()
await stt_worker.close() await stt_worker.close()
await render_worker.close()
if __name__ == "__main__": if __name__ == "__main__":
+10
View File
@@ -16,3 +16,13 @@ QUEUE_NAMES = {
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379") REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
SHARED_MEDIA_ROOT = os.environ.get("SHARED_MEDIA_ROOT", "./shared-media") SHARED_MEDIA_ROOT = os.environ.get("SHARED_MEDIA_ROOT", "./shared-media")
# bullmq-python defaults Worker concurrency to 1, so several channels
# producing segments/candidates around the same time would otherwise process
# strictly one at a time and build up a backlog.
WORKER_CONCURRENCY = int(os.environ.get("WORKER_CONCURRENCY", "5"))
# Video rendering (face detection across sampled frames + two ffmpeg encodes)
# is far heavier per job than signal-detection/stt-scoring — kept separate
# and lower by default so several renders at once don't starve the container.
VIDEO_RENDER_CONCURRENCY = int(os.environ.get("VIDEO_RENDER_CONCURRENCY", "2"))
+7 -3
View File
@@ -11,13 +11,13 @@ import soundfile as sf
from bullmq import Queue, Worker from bullmq import Queue, Worker
from . import db from . import db
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT, WORKER_CONCURRENCY
WINDOW_SEC = 1.0 WINDOW_SEC = 1.0
PEAK_STD_MULTIPLIER = 1.5 PEAK_STD_MULTIPLIER = 1.5
CANDIDATE_PAD_SEC = 45 CANDIDATE_PAD_SEC = 45
CHAT_SPIKE_MULTIPLIER = 3 CHAT_SPIKE_MULTIPLIER = 3
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "900")) SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "180"))
stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL}) stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL})
@@ -161,4 +161,8 @@ async def process_signal_detection(job, job_token=None):
def start_signal_detection_worker() -> Worker: def start_signal_detection_worker() -> Worker:
return Worker(QUEUE_NAMES["SIGNAL_DETECTION"], process_signal_detection, {"connection": REDIS_URL}) return Worker(
QUEUE_NAMES["SIGNAL_DETECTION"],
process_signal_detection,
{"connection": REDIS_URL, "concurrency": WORKER_CONCURRENCY},
)
+17 -3
View File
@@ -3,14 +3,15 @@ import os
import subprocess import subprocess
import tempfile import tempfile
from bullmq import Worker from bullmq import Queue, Worker
from openai import OpenAI from openai import OpenAI
from . import db from . import db
from .queues import QUEUE_NAMES, REDIS_URL from .queues import QUEUE_NAMES, REDIS_URL, WORKER_CONCURRENCY
from .telegram import send_telegram_message from .telegram import send_telegram_message
_openai_client: OpenAI | None = None _openai_client: OpenAI | None = None
video_render_queue = Queue(QUEUE_NAMES["VIDEO_RENDER"], {"connection": REDIS_URL})
def _client() -> OpenAI: def _client() -> OpenAI:
@@ -38,6 +39,10 @@ async def process_stt_scoring(job, job_token=None):
print(f"[stt-scoring] candidate {candidate_id} not found, skipping") print(f"[stt-scoring] candidate {candidate_id} not found, skipping")
return return
if await db.get_setting("stt_enabled", "true") == "false":
print(f"[stt-scoring] STT panelden kapatılmış, {candidate_id} atlanıyor (PENDING_STT'de kalır)")
return
with tempfile.NamedTemporaryFile(suffix=".mp3") as clip: with tempfile.NamedTemporaryFile(suffix=".mp3") as clip:
subprocess.run( subprocess.run(
[ [
@@ -67,8 +72,17 @@ async def process_stt_scoring(job, job_token=None):
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_" f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
) )
if await db.get_setting("auto_render_enabled", "true") != "false":
await video_render_queue.add("render-short", {"candidateSegmentId": candidate_id})
else:
print(f"[stt-scoring] auto-render kapalı, {candidate_id} render kuyruğuna eklenmedi (panelden manuel tetiklenebilir)")
print(f"[stt-scoring] candidate {candidate_id} transcribed") print(f"[stt-scoring] candidate {candidate_id} transcribed")
def start_stt_scoring_worker() -> Worker: def start_stt_scoring_worker() -> Worker:
return Worker(QUEUE_NAMES["STT_SCORING"], process_stt_scoring, {"connection": REDIS_URL}) return Worker(
QUEUE_NAMES["STT_SCORING"],
process_stt_scoring,
{"connection": REDIS_URL, "concurrency": WORKER_CONCURRENCY},
)
+37
View File
@@ -5,6 +5,11 @@ import httpx
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
# Telegram's bot-upload limit (no local Bot API server) — checked before
# attempting an upload so an oversized file fails fast instead of burning
# time/bandwidth on a request Telegram will reject anyway.
MAX_VIDEO_BYTES = 50 * 1024 * 1024
async def send_telegram_message(text: str) -> None: async def send_telegram_message(text: str) -> None:
if not BOT_TOKEN or not CHAT_ID: if not BOT_TOKEN or not CHAT_ID:
@@ -18,3 +23,35 @@ async def send_telegram_message(text: str) -> None:
) )
if resp.status_code >= 400: if resp.status_code >= 400:
print(f"[telegram] failed to send message: {resp.status_code} {resp.text}") print(f"[telegram] failed to send message: {resp.status_code} {resp.text}")
async def send_telegram_video(video_path: str, caption: str) -> bool:
"""Uploads the actual rendered clip to Telegram. Returns False (without
raising) on any failure — the caller decides what that means for the
local file (e.g. keep it if delivery didn't succeed)."""
if not BOT_TOKEN or not CHAT_ID:
print(f"[telegram] not configured, skipping video: {caption}")
return False
size = os.path.getsize(video_path)
if size > MAX_VIDEO_BYTES:
print(f"[telegram] video {video_path} is {size} bytes, over Telegram's bot-upload limit — skipping")
return False
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendVideo"
try:
async with httpx.AsyncClient(timeout=120.0) as client:
with open(video_path, "rb") as f:
resp = await client.post(
url,
data={"chat_id": CHAT_ID, "caption": caption, "parse_mode": "Markdown"},
files={"video": (os.path.basename(video_path), f, "video/mp4")},
)
except httpx.HTTPError as exc:
print(f"[telegram] failed to upload video: {exc}")
return False
if resp.status_code >= 400:
print(f"[telegram] failed to send video: {resp.status_code} {resp.text}")
return False
return True
+276
View File
@@ -0,0 +1,276 @@
"""
9:16 crop (yüz takipli) + dinamik altyazı render motoru.
İki geçişli işleme: (1) OpenCV ile örneklenmiş karelerde MediaPipe yüz
tespiti yapıp bir merkez-yol haritası çıkarır, yumuşatır; (2) OpenCV ile
kareleri tekrar okuyup o an için hesaplanan crop penceresini uygulayıp
ffmpeg'e pipe'lar — aynı ffmpeg çağrısı orijinal klibin sesini de
map'leyip .ass altyazısını yakar. Hiç yüz bulunamazsa sabit merkez crop'a
düşer (hata değil, sadece daha az "akıllı" bir sonuç).
"""
import asyncio
import json
import os
import subprocess
import tempfile
from pathlib import Path
import cv2
import numpy as np
from bullmq import Worker
from mediapipe.tasks.python import BaseOptions
from mediapipe.tasks.python.vision import FaceDetector, FaceDetectorOptions
import mediapipe as mp
from . import db
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT, VIDEO_RENDER_CONCURRENCY
from .telegram import send_telegram_message, send_telegram_video
FACE_MODEL_PATH = os.environ.get(
"FACE_DETECTOR_MODEL_PATH", "/app/models/blaze_face_short_range.tflite"
)
SAMPLE_INTERVAL_SEC = 0.5
OUTPUT_WIDTH = 1080
OUTPUT_HEIGHT = 1920
_detector: FaceDetector | None = None
def _get_detector() -> FaceDetector:
global _detector
if _detector is None:
options = FaceDetectorOptions(base_options=BaseOptions(model_asset_path=FACE_MODEL_PATH))
_detector = FaceDetector.create_from_options(options)
return _detector
def _detect_face_centers(video_path: str) -> tuple[list[tuple[float, float]], int, int, float]:
"""Örneklenmiş karelerde en büyük yüzün merkezini (x_frac, y_frac) döner."""
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
frame_interval = max(1, int(fps * SAMPLE_INTERVAL_SEC))
detector = _get_detector()
points: list[tuple[float, float]] = [] # (time_sec, x_frac)
frame_idx = 0
while True:
ok, frame = cap.read()
if not ok:
break
if frame_idx % frame_interval == 0:
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
result = detector.detect(mp_image)
if result.detections:
best = max(
result.detections,
key=lambda d: d.bounding_box.width * d.bounding_box.height,
)
bbox = best.bounding_box
cx = (bbox.origin_x + bbox.width / 2) / width
points.append((frame_idx / fps, cx))
frame_idx += 1
cap.release()
return points, width, height, fps
def _smooth_x_path(points: list[tuple[float, float]], total_frames: int, fps: float) -> np.ndarray | None:
"""Kare başına yumuşatılmış crop merkezi (x_frac). Hiç yüz yoksa None (sabit merkez crop kullanılır)."""
if not points or total_frames <= 0:
return None
times = np.array([p[0] for p in points])
xs = np.array([p[1] for p in points])
frame_times = np.arange(total_frames) / fps
interp_x = np.interp(frame_times, times, xs)
window = min(max(1, int(fps)), len(interp_x))
kernel = np.ones(window) / window
return np.convolve(interp_x, kernel, mode="same")
def _seconds_to_ass_time(t: float) -> str:
t = max(0.0, t)
h = int(t // 3600)
m = int((t % 3600) // 60)
s = t % 60
return f"{h:d}:{m:02d}:{s:05.2f}"
def _build_ass_subtitles(transcript: dict, path: str, words_per_caption: int = 3) -> None:
words = transcript.get("words") or []
header = (
"[Script Info]\n"
"ScriptType: v4.00+\n"
f"PlayResX: {OUTPUT_WIDTH}\n"
f"PlayResY: {OUTPUT_HEIGHT}\n\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, "
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV\n"
"Style: Default,Arial,64,&H00FFFFFF,&H00000000,&H80000000,1,1,3,2,2,60,60,300\n\n"
"[Events]\n"
"Format: Layer, Start, End, Style, Text\n"
)
lines = [header]
for i in range(0, len(words), words_per_caption):
chunk = words[i : i + words_per_caption]
if not chunk:
continue
start = chunk[0].get("start", 0.0)
end = chunk[-1].get("end", start)
text = " ".join(str(w.get("word", "")).strip() for w in chunk).strip()
if not text:
continue
lines.append(f"Dialogue: 0,{_seconds_to_ass_time(start)},{_seconds_to_ass_time(end)},Default,{text}\n")
with open(path, "w", encoding="utf-8") as f:
f.writelines(lines)
def _render_final(
clip_path: str,
smoothed_x: np.ndarray | None,
width: int,
height: int,
fps: float,
ass_path: str,
out_path: str,
) -> None:
crop_w = min(width, max(2, int(height * OUTPUT_WIDTH / OUTPUT_HEIGHT) // 2 * 2))
ffmpeg = subprocess.Popen(
[
"ffmpeg", "-y",
"-f", "rawvideo", "-pix_fmt", "bgr24",
"-s", f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}",
"-r", str(fps),
"-i", "-",
"-i", clip_path,
"-vf", f"ass={ass_path}",
"-map", "0:v:0", "-map", "1:a:0?",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
"-c:a", "aac",
"-shortest",
out_path,
],
stdin=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
cap = cv2.VideoCapture(clip_path)
frame_idx = 0
try:
while True:
ok, frame = cap.read()
if not ok:
break
cx_frac = 0.5
if smoothed_x is not None and frame_idx < len(smoothed_x):
cx_frac = float(smoothed_x[frame_idx])
cx_px = int(cx_frac * width)
x0 = max(0, min(width - crop_w, cx_px - crop_w // 2))
cropped = frame[0:height, x0 : x0 + crop_w]
resized = cv2.resize(cropped, (OUTPUT_WIDTH, OUTPUT_HEIGHT))
assert ffmpeg.stdin is not None
ffmpeg.stdin.write(resized.tobytes())
frame_idx += 1
finally:
cap.release()
if ffmpeg.stdin:
ffmpeg.stdin.close()
ffmpeg.wait()
def _render_sync(file_path: str, start_sec: int, end_sec: int, transcript: dict, out_path: str) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
clip_path = os.path.join(tmp_dir, "clip.mp4")
subprocess.run(
[
"ffmpeg", "-y",
"-ss", str(start_sec),
"-to", str(end_sec),
"-i", file_path,
"-c", "copy",
clip_path,
],
check=True,
capture_output=True,
)
points, width, height, fps = _detect_face_centers(clip_path)
total_frames = int(cv2.VideoCapture(clip_path).get(cv2.CAP_PROP_FRAME_COUNT))
smoothed = _smooth_x_path(points, total_frames, fps)
ass_path = os.path.join(tmp_dir, "subs.ass")
_build_ass_subtitles(transcript, ass_path)
_render_final(clip_path, smoothed, width, height, fps, ass_path, out_path)
async def process_video_render(job, job_token=None):
candidate_id = job.data["candidateSegmentId"]
candidate = await db.get_candidate_for_render(candidate_id)
if candidate is None:
print(f"[video-render] candidate {candidate_id} not found, skipping")
return
short_id = await db.upsert_short_video(candidate_id, status="RENDERING")
out_dir = Path(SHARED_MEDIA_ROOT) / "shorts"
out_dir.mkdir(parents=True, exist_ok=True)
out_path = str(out_dir / f"{candidate_id}.mp4")
try:
transcript = candidate["transcript_json"] or {}
if isinstance(transcript, str):
transcript = json.loads(transcript)
await asyncio.to_thread(
_render_sync,
candidate["file_path"],
candidate["start_sec"],
candidate["end_sec"],
transcript,
out_path,
)
await db.update_short_video(short_id, status="READY", file_path=out_path)
caption = f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!"
if await db.get_setting("delete_after_telegram_send", "false") == "true":
# Video delivery *is* the notification here — it doesn't also
# gate on notify_render_done. Only delete the local file once
# Telegram actually has it; a failed upload leaves it in place
# with a plain-text heads-up instead of silently losing it.
if await send_telegram_video(out_path, caption):
os.remove(out_path)
await db.clear_short_video_file(short_id)
print(f"[video-render] short {short_id} sent to Telegram and removed from disk")
else:
await send_telegram_message(f"{caption}\n(video Telegram'a gönderilemedi, sunucuda kaldı)")
print(f"[video-render] short {short_id} ready: {out_path}")
else:
if await db.get_setting("notify_render_done", "true") != "false":
await send_telegram_message(caption)
print(f"[video-render] short {short_id} ready: {out_path}")
except Exception as exc:
print(f"[video-render] failed for candidate {candidate_id}: {exc}")
await db.update_short_video(short_id, status="FAILED", error_message=str(exc)[:500])
def start_video_render_worker() -> Worker:
return Worker(
QUEUE_NAMES["VIDEO_RENDER"],
process_video_render,
{"connection": REDIS_URL, "concurrency": VIDEO_RENDER_CONCURRENCY},
)
+21 -2
View File
@@ -19,6 +19,10 @@ services:
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
sc_pot_provider:
image: brainicism/bgutil-ytdlp-pot-provider
restart: unless-stopped
sc_api_daemon: sc_api_daemon:
build: build:
context: . context: .
@@ -29,11 +33,16 @@ services:
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
SHARED_MEDIA_ROOT: /shared-media SHARED_MEDIA_ROOT: /shared-media
POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-60000} POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-60000}
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900} SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
API_DAEMON_PORT: ${API_DAEMON_PORT:-4001} API_DAEMON_PORT: ${API_DAEMON_PORT:-4001}
FORCE_LIVE_URL: ${FORCE_LIVE_URL:-} FORCE_LIVE_URL: ${FORCE_LIVE_URL:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
YTDLP_POT_PROVIDER_URL: http://sc_pot_provider:4416
MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10}
PROXY_URL: ${PROXY_URL:-}
RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
volumes: volumes:
- shared-media:/shared-media - shared-media:/shared-media
ports: ports:
@@ -41,6 +50,7 @@ services:
depends_on: depends_on:
- sc_redis - sc_redis
- sc_postgres - sc_postgres
- sc_pot_provider
sc_heavy_worker: sc_heavy_worker:
build: build:
@@ -51,10 +61,12 @@ services:
REDIS_URL: redis://sc_redis:6379 REDIS_URL: redis://sc_redis:6379
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
SHARED_MEDIA_ROOT: /shared-media SHARED_MEDIA_ROOT: /shared-media
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900} SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
VIDEO_RENDER_CONCURRENCY: ${VIDEO_RENDER_CONCURRENCY:-2}
volumes: volumes:
- shared-media:/shared-media - shared-media:/shared-media
depends_on: depends_on:
@@ -68,10 +80,17 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
API_DAEMON_URL: http://sc_api_daemon:4001
SHARED_MEDIA_ROOT: /shared-media
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
volumes:
- shared-media:/shared-media
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
- sc_postgres - sc_postgres
- sc_api_daemon
volumes: volumes:
redis-data: redis-data:
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "app_settings" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "app_settings_pkey" PRIMARY KEY ("key")
);
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
@@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "ShortVideoStatus" AS ENUM ('PENDING', 'RENDERING', 'READY', 'FAILED');
-- CreateTable
CREATE TABLE "short_videos" (
"id" TEXT NOT NULL,
"candidate_id" TEXT NOT NULL,
"file_path" TEXT,
"status" "ShortVideoStatus" NOT NULL DEFAULT 'PENDING',
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "short_videos_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "short_videos_candidate_id_key" ON "short_videos"("candidate_id");
-- AddForeignKey
ALTER TABLE "short_videos" ADD CONSTRAINT "short_videos_candidate_id_fkey" FOREIGN KEY ("candidate_id") REFERENCES "candidate_segments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "channels" ADD COLUMN "segment_time_sec" INTEGER;
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "cookie_profiles" (
"id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"value" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cookie_profiles_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "RawSegmentStatus" ADD VALUE 'SENT_TO_TELEGRAM';
-- AlterTable
ALTER TABLE "raw_segments" ALTER COLUMN "file_path" DROP NOT NULL;
+61 -1
View File
@@ -11,6 +11,10 @@ enum RawSegmentStatus {
PENDING PENDING
PROCESSED PROCESSED
DISCARDED DISCARDED
// Analiz zincirine (sinyal analizi/STT/9:16) hiç girmeden ham haliyle
// doğrudan Telegram'a gönderildi — file_path bu durumda null (dosya
// gönderim sonrası diskten silindi).
SENT_TO_TELEGRAM
} }
enum CandidateSegmentStatus { enum CandidateSegmentStatus {
@@ -19,6 +23,13 @@ enum CandidateSegmentStatus {
FAILED FAILED
} }
enum ShortVideoStatus {
PENDING
RENDERING
READY
FAILED
}
model Channel { model Channel {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -27,6 +38,8 @@ model Channel {
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
lastCheckedAt DateTime? @map("last_checked_at") lastCheckedAt DateTime? @map("last_checked_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
// null = global varsayılanı (SEGMENT_TIME_SEC env var) kullan
segmentTimeSec Int? @map("segment_time_sec")
sessions StreamSession[] sessions StreamSession[]
@@ -51,7 +64,7 @@ model RawSegment {
id String @id @default(cuid()) id String @id @default(cuid())
sessionId String @map("session_id") sessionId String @map("session_id")
session StreamSession @relation(fields: [sessionId], references: [id]) session StreamSession @relation(fields: [sessionId], references: [id])
filePath String @map("file_path") filePath String? @map("file_path")
duration Int duration Int
startedAt DateTime @map("started_at") startedAt DateTime @map("started_at")
status RawSegmentStatus @default(PENDING) status RawSegmentStatus @default(PENDING)
@@ -74,5 +87,52 @@ model CandidateSegment {
status CandidateSegmentStatus @default(PENDING_STT) status CandidateSegmentStatus @default(PENDING_STT)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
short ShortVideo?
@@map("candidate_segments") @@map("candidate_segments")
} }
model ShortVideo {
id String @id @default(cuid())
candidateId String @unique @map("candidate_id")
candidate CandidateSegment @relation(fields: [candidateId], references: [id])
filePath String? @map("file_path")
status ShortVideoStatus @default(PENDING)
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("short_videos")
}
// Birden fazla YouTube hesabının cookie'si — her kanal (channel.id'den
// deterministik bir hash ile) bu havuzdaki bir profile sabitlenir, tek bir
// hesabın çok fazla kanalı taramasından kaynaklanan hesap-seviyesi
// bot-tespitini havuza yayarak azaltır (bkz. proxyUrlForChannel ile aynı
// mantık, ytdlpCookies.ts).
model CookieProfile {
id String @id @default(cuid())
label String
value String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("cookie_profiles")
}
model AppSetting {
key String @id
value String
updatedAt DateTime @updatedAt @map("updated_at")
@@map("app_settings")
}
model User {
id String @id @default(cuid())
username String @unique
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
@@map("users")
}
+2104 -2
View File
File diff suppressed because it is too large Load Diff