Initial commit: Animexe Laravel platform

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:01:48 +03:00
co-authored by Claude Opus 4.8
commit a63515cfc6
366 changed files with 74773 additions and 0 deletions
@@ -0,0 +1,528 @@
@extends('admin.layouts.app')
@section('title', 'Anime İçe Aktar')
@section('page-title', 'Anime İçe Aktar')
@push('styles')
<style>
.job-card {
background:var(--bg2); border:1px solid var(--border);
border-radius:var(--radius); padding:16px 20px; margin-bottom:12px;
transition:border-color .15s;
}
.job-card:hover { border-color:var(--border2); }
.test-output {
display:none;
background:var(--bg);
border:1px solid var(--border);
border-radius:var(--radius-sm);
padding:14px;
font-size:12px;
font-family:'JetBrains Mono',monospace;
white-space:pre-wrap;
max-height:200px;
overflow:auto;
margin-top:12px;
}
</style>
@endpush
@section('content')
<div class="page-header">
<div>
<h5 style="font-size:18px;font-weight:700;color:var(--text);margin:0">Anime İçe Aktar</h5>
<div style="font-size:13px;color:var(--text2);margin-top:2px">CDN bağlantısından otomatik anime indirme</div>
</div>
</div>
<div class="row g-4">
{{-- ── Sol: Form ── --}}
<div class="col-lg-5">
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-cloud-download" style="color:var(--accent-lt)"></i>
<h6>Yeni Import Job Oluştur</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ route('admin.import.store') }}">
@csrf
<div class="mb-3">
<label class="form-label">Anime URL <span style="color:var(--danger)">*</span></label>
<input type="url" name="source_url" class="form-control"
placeholder="https://anizium.co/watch/..." value="{{ old('source_url') }}" required>
<div class="form-text">Anizium watch veya anime sayfası URL'si</div>
</div>
<div class="mb-3">
<label class="form-label">Anime Adı <span style="color:var(--text2);font-size:11px">(opsiyonel)</span></label>
<input type="text" name="anime_title" class="form-control"
placeholder="Demon Slayer" value="{{ old('anime_title') }}">
</div>
<div class="mb-3">
<label class="form-label">CDN ID <span style="color:var(--text2);font-size:11px">(opsiyonel otomatik bulunur)</span></label>
<input type="text" name="cdn_id" class="form-control"
style="font-family:monospace" placeholder="85937" value="{{ old('cdn_id') }}">
<div class="form-text">
URL'den al: <code>f.aniziumserver.sbs/<strong>85937</strong>/1/1/...</code>
</div>
</div>
<div class="mb-4">
<label class="form-label">
Sezon / Bölüm Aralıkları
<span style="color:var(--text2);font-size:11px">(opsiyonel otomatik taranır)</span>
</label>
<div id="rangeRows">
@php $oldRanges = old('season_ranges',[['season'=>1,'from'=>1,'to'=>'']]) @endphp
@foreach($oldRanges as $i => $range)
<div class="range-row d-flex gap-2 mb-2 align-items-center">
<div style="flex:0 0 70px">
<input type="number" name="season_ranges[{{ $i }}][season]"
class="form-control form-control-sm text-center"
placeholder="S" min="1" value="{{ $range['season']??1 }}" required>
</div>
<input type="number" name="season_ranges[{{ $i }}][from]"
class="form-control form-control-sm text-center"
placeholder="Baş" min="1" value="{{ $range['from']??'' }}" style="flex:1">
<span style="color:var(--text3);font-size:12px"></span>
<input type="number" name="season_ranges[{{ $i }}][to]"
class="form-control form-control-sm text-center"
placeholder="Bit" min="1" value="{{ $range['to']??'' }}" style="flex:1">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRange(this)">
<i class="bi bi-trash3"></i>
</button>
</div>
@endforeach
</div>
<button type="button" class="btn btn-sm btn-secondary mt-1" onclick="addRange()">
<i class="bi bi-plus-circle"></i> Sezon Ekle
</button>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-download"></i> Import Job Oluştur
</button>
</form>
</div>
</div>
{{-- Test --}}
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-plug" style="color:var(--info)"></i>
<h6>Bağlantı Testi</h6>
</div>
<div class="card-body">
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-sm btn-secondary" onclick="testApi()">
<i class="bi bi-plug"></i> API Test
</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="testCdn()">
<i class="bi bi-play-circle"></i> CDN Test
</button>
</div>
<div class="test-output" id="testResult"></div>
</div>
</div>
{{-- ── Araçlar: Çift Kaynak + Altyazı Fix ── --}}
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-tools" style="color:var(--warning)"></i>
<h6>Çift Kaynak Araçları</h6>
</div>
<div class="card-body" style="font-size:13px">
{{-- İstatistikler --}}
<div id="srcStats" style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:14px">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;text-align:center">
<div style="font-size:11px;color:var(--text2)">İki kaynağı olan bölüm</div>
<div style="font-size:18px;font-weight:700;color:var(--success)" id="st-both"></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;text-align:center">
<div style="font-size:11px;color:var(--text2)">Altyazı hatası</div>
<div style="font-size:18px;font-weight:700;color:var(--danger)" id="st-sub">{{ $stats['subtitle_mismatch'] }}</div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;text-align:center">
<div style="font-size:11px;color:var(--text2)">Sadece Anizium</div>
<div style="font-size:18px;font-weight:700;color:var(--info)" id="st-aniz"></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;text-align:center">
<div style="font-size:11px;color:var(--text2)">Sadece AnimeCix</div>
<div style="font-size:18px;font-weight:700;color:var(--accent)" id="st-acix"></div>
</div>
</div>
<button class="btn btn-sm btn-secondary mb-3 w-100" onclick="loadStats()">
<i class="bi bi-arrow-clockwise"></i> İstatistikleri Yenile
</button>
<hr style="border-color:var(--border);margin:10px 0">
{{-- Altyazı Fix --}}
<div class="mb-3">
<div style="font-weight:600;color:var(--text);margin-bottom:6px">
<i class="bi bi-closed-caption" style="color:var(--danger)"></i>
Altyazı Uyuşmazlık Düzelt
</div>
<div style="color:var(--text2);font-size:12px;margin-bottom:8px">
Anizium'dan yanlış bölümün altyazısının kaydedildiği satırları siler.
Bot sonraki çalışmada doğru altyazıları yeniden indirir.
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-warning flex-fill" onclick="fixSubs(true)">
<i class="bi bi-eye"></i> Önce Gör
</button>
<button class="btn btn-sm btn-danger flex-fill" onclick="fixSubs(false)">
<i class="bi bi-trash3"></i> Hataları Sil
</button>
</div>
<div id="fixSubResult" style="display:none;margin-top:8px;font-size:12px;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-family:monospace;white-space:pre-wrap;max-height:150px;overflow:auto"></div>
</div>
<hr style="border-color:var(--border);margin:10px 0">
{{-- Re-queue --}}
<div class="mb-2">
<div style="font-weight:600;color:var(--text);margin-bottom:6px">
<i class="bi bi-arrow-repeat" style="color:var(--info)"></i>
Yeniden Kuyruğa Al
</div>
<div style="color:var(--text2);font-size:12px;margin-bottom:8px">
Done job'ları tekrar pending yaparak botların her iki kaynaktan
<code>video_sources</code> eklemesini sağlar.
</div>
<div class="d-flex gap-2 mb-2">
<div style="flex:1">
<label style="font-size:11px;color:var(--text2)">Adet</label>
<input type="number" id="requeueLimit" class="form-control form-control-sm" value="50" min="1" max="500">
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-info flex-fill" onclick="requeueAnizium()">
<i class="bi bi-cloud-arrow-down"></i> Anizium'u Yenile
</button>
<button class="btn btn-sm btn-outline-primary flex-fill" onclick="requeueAnimecix()">
<i class="bi bi-film"></i> AnimeCix'i Yenile
</button>
</div>
<div id="requeueResult" style="display:none;margin-top:8px;font-size:12px;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px"></div>
</div>
</div>
</div>
{{-- Yardım --}}
<div class="card">
<div class="card-header">
<i class="bi bi-question-circle" style="color:var(--text2)"></i>
<h6>Nasıl Kullanılır?</h6>
</div>
<div class="card-body" style="font-size:13px">
<div style="font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px">Çift Kaynak Akışı</div>
<ol style="color:var(--text2);padding-left:18px;line-height:2;margin-bottom:10px">
<li>Local: <code>python reimport_all.py</code> çalıştır</li>
<li>Local: <code>python anizium_scraper/bot2_upload.py --daemon</code></li>
<li>Local: <code>python animecix_scraper/daemon.py</code></li>
<li>Her bölüm otomatik <strong>"4K"</strong> + <strong>çevirmen</strong> seçeneğiyle gelir</li>
</ol>
<div style="font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px">CDN ID Nasıl Bulunur?</div>
<ol style="color:var(--text2);padding-left:18px;line-height:2;margin-bottom:0">
<li>Anizium'da herhangi bir bölümü oynat</li>
<li>F12 Network <code>master.m3u8</code> ara</li>
<li>URL'den sayıyı al: <code style="color:var(--warning)">85937</code></li>
</ol>
</div>
</div>
</div>
{{-- ── Sağ: Job Listesi ── --}}
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<i class="bi bi-list-task" style="color:var(--info)"></i>
<h6>Import Geçmişi</h6>
<span style="font-size:12px;color:var(--text2)">{{ $jobs->total() }} job</span>
<button class="btn btn-sm btn-outline-danger ms-auto" onclick="openBulkDelete()" title="Toplu Sil">
<i class="bi bi-trash3"></i> Toplu Sil
</button>
</div>
{{-- Toplu silme paneli --}}
<div id="bulkDeletePanel" style="display:none;padding:12px 16px;background:rgba(220,53,69,.06);border-bottom:1px solid rgba(220,53,69,.2)">
<div style="font-size:12px;font-weight:700;color:var(--danger);margin-bottom:10px">
<i class="bi bi-exclamation-triangle"></i> Hangi joblar silinsin?
</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px">
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer">
<input type="checkbox" id="del-failed" checked> <span style="color:var(--danger)">Hatalı</span> <span id="cnt-failed" class="badge bg-danger ms-1"></span>
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer">
<input type="checkbox" id="del-pending"> <span style="color:var(--text2)">Bekleyen</span> <span id="cnt-pending" class="badge bg-secondary ms-1"></span>
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer">
<input type="checkbox" id="del-stuck"> <span style="color:var(--warning)">Takılı Kalan</span> <span id="cnt-stuck" class="badge bg-warning text-dark ms-1"></span>
<span style="font-size:11px;color:var(--text2)">(2+ saat işlemde)</span>
</label>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-sm btn-danger" onclick="runBulkDelete()">
<i class="bi bi-trash3"></i> Seçilenleri Sil
</button>
<button class="btn btn-sm btn-secondary" onclick="document.getElementById('bulkDeletePanel').style.display='none'">
İptal
</button>
<span id="bulkDeleteMsg" style="font-size:12px;margin-left:4px"></span>
</div>
</div>
<div class="card-body">
@forelse($jobs as $job)
<div class="job-card">
<div class="d-flex align-items-start justify-content-between gap-3 mb-2">
<div style="flex:1;min-width:0">
<div style="font-size:13.5px;font-weight:700;color:var(--text)" class="text-truncate">
{{ $job->anime_title ?: 'Anime CDN #'.$job->cdn_id }}
</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px">
CDN: <code>{{ $job->cdn_id }}</code>
@if($job->season_ranges)
&nbsp;·&nbsp;
@foreach($job->season_ranges as $r)
S{{ $r['season'] }} E{{ $r['from'] }}-{{ $r['to'] }}{{ !$loop->last ? ', ' : '' }}
@endforeach
@endif
</div>
</div>
<div class="d-flex gap-2 align-items-center flex-shrink-0">
<span class="badge bg-{{ $job->status_color }}">{{ $job->status_label }}</span>
<a href="{{ route('admin.import.show',$job) }}" class="btn btn-sm btn-secondary">
<i class="bi bi-eye"></i>
</a>
<form method="POST" action="{{ route('admin.import.destroy',$job) }}"
onsubmit="return confirm('Job silinsin mi?')">
@csrf @method('DELETE')
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i></button>
</form>
</div>
</div>
@if($job->total_episodes > 0)
<div class="mb-2">
<div class="d-flex justify-content-between mb-1" style="font-size:12px;color:var(--text2)">
<span>{{ $job->done_episodes }}/{{ $job->total_episodes }} bölüm</span>
<span>%{{ $job->progress_percent }}</span>
</div>
<div class="progress" style="height:5px">
<div class="progress-bar" style="background:{{ $job->status_color==='success'?'var(--success)':($job->status_color==='danger'?'var(--danger)':'var(--accent)') }};width:{{ $job->progress_percent }}%"></div>
</div>
</div>
@endif
@if($job->current_step)
<div style="font-size:12px;color:var(--text2)">{{ $job->current_step }}</div>
@endif
<div style="font-size:11.5px;color:var(--text3);margin-top:4px">{{ $job->created_at->format('d.m.Y H:i') }}</div>
</div>
@empty
<div class="empty-state">
<i class="bi bi-cloud-download"></i>
<h6>Henüz import job yok</h6>
<p>Sol panelden yeni bir import başlatın.</p>
</div>
@endforelse
<div class="mt-3">
{{ $jobs->links('admin.partials.pagination') }}
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
function showTestResult(text, ok=true) {
const el = document.getElementById('testResult');
el.style.display='block';
el.style.color = ok ? 'var(--success)' : 'var(--danger)';
el.textContent = text;
}
async function testApi() {
showTestResult('Test ediliyor...', true);
try {
const r = await fetch('{{ url("/api/import/test") }}', {
headers:{'X-Import-Key':'{{ config("app.import_api_key") }}','Accept':'application/json'}
});
const d = await r.json();
if (d.ok) showTestResult(`✓ API bağlantısı başarılı\n DB: ${d.db}\n Toplam job: ${d.total}\n Bekleyen: ${d.pending}`, true);
else showTestResult('✗ API hatası: '+JSON.stringify(d), false);
} catch(e) { showTestResult('✗ Bağlantı hatası: '+e.message, false); }
}
async function testCdn() {
const cdnId = document.querySelector('[name="cdn_id"]').value.trim()||'85937';
const url = `https://f.aniziumserver.sbs/${cdnId}/1/1/1080p-original/master.m3u8`;
showTestResult(`CDN test ediliyor...\nURL: ${url}`, true);
try {
await fetch(url, {method:'HEAD',mode:'no-cors'});
showTestResult(`✓ CDN erişilebilir (CDN ID: ${cdnId})\n${url}`, true);
} catch(e) { showTestResult(`✗ CDN erişilemedi: ${e.message}`, false); }
}
let rangeIndex = {{ count(old('season_ranges',[[]])) }};
function addRange() {
const row = document.createElement('div');
row.className='range-row d-flex gap-2 mb-2 align-items-center';
row.innerHTML=`
<div style="flex:0 0 70px"><input type="number" name="season_ranges[${rangeIndex}][season]" class="form-control form-control-sm text-center" placeholder="S" min="1" value="1" required></div>
<input type="number" name="season_ranges[${rangeIndex}][from]" class="form-control form-control-sm text-center" placeholder="Baş" min="1" style="flex:1">
<span style="color:var(--text3);font-size:12px"></span>
<input type="number" name="season_ranges[${rangeIndex}][to]" class="form-control form-control-sm text-center" placeholder="Bit" min="1" style="flex:1">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRange(this)"><i class="bi bi-trash3"></i></button>`;
document.getElementById('rangeRows').appendChild(row);
rangeIndex++;
}
function removeRange(btn) {
if (document.querySelectorAll('.range-row').length<=1) return;
btn.closest('.range-row').remove();
}
// ── Araçlar ──────────────────────────────────────────────────────────────────
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
async function loadStats() {
try {
const r = await fetch('{{ route("admin.import.sourceStats") }}', {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
});
const d = await r.json();
document.getElementById('st-both').textContent = d.episodes_with_both ?? '—';
document.getElementById('st-aniz').textContent = d.episodes_only_anizium ?? '—';
document.getElementById('st-acix').textContent = d.episodes_only_animecix ?? '—';
document.getElementById('st-sub').textContent = d.subtitle_mismatch ?? '—';
} catch(e) { console.warn('stats error', e); }
}
loadStats(); // sayfa açılışında bir kez çek
async function fixSubs(dryRun) {
const el = document.getElementById('fixSubResult');
el.style.display = 'block';
el.textContent = 'İşleniyor...';
try {
const r = await fetch('{{ route("admin.import.fixSubtitles") }}', {
method:'POST',
headers:{'Accept':'application/json','Content-Type':'application/json','X-CSRF-TOKEN':CSRF},
body: JSON.stringify({dry_run: dryRun}),
});
const d = await r.json();
if (dryRun) {
el.textContent = `Kontrol edildi: ${d.checked}\nUyuşmazlık: ${d.mismatch}\n\n`
+ (d.details.map(x => `S${x.season}E${x.episode} ${x.lang}: name=${x.name} → beklenen=${x.expected}`).join('\n') || 'Hata bulunamadı.');
} else {
el.textContent = `Kontrol: ${d.checked} • Uyuşmazlık: ${d.mismatch} • Silindi: ${d.deleted}`;
loadStats();
}
} catch(e) { el.textContent = 'Hata: ' + e.message; }
}
async function requeueAnizium() {
const limit = document.getElementById('requeueLimit').value || 50;
const el = document.getElementById('requeueResult');
el.style.display = 'block';
el.textContent = 'İşleniyor...';
try {
const r = await fetch('{{ route("admin.import.requeueAnizium") }}', {
method:'POST',
headers:{'Accept':'application/json','Content-Type':'application/json','X-CSRF-TOKEN':CSRF},
body: JSON.stringify({limit: parseInt(limit)}),
});
const d = await r.json();
el.innerHTML = `<span style="color:var(--success)">✓ Anizium: ${d.requeued} job yeniden kuyruğa alındı (${d.checked} kontrol edildi)</span>`;
} catch(e) { el.textContent = 'Hata: ' + e.message; }
}
async function requeueAnimecix() {
const limit = document.getElementById('requeueLimit').value || 50;
const el = document.getElementById('requeueResult');
el.style.display = 'block';
el.textContent = 'İşleniyor...';
try {
const r = await fetch('{{ route("admin.import.requeueAnimecix") }}', {
method:'POST',
headers:{'Accept':'application/json','Content-Type':'application/json','X-CSRF-TOKEN':CSRF},
body: JSON.stringify({limit: parseInt(limit)}),
});
const d = await r.json();
el.innerHTML = `<span style="color:var(--success)">✓ AnimeCix: ${d.requeued} job yeniden kuyruğa alındı (${d.checked} kontrol edildi)</span>`;
} catch(e) { el.textContent = 'Hata: ' + e.message; }
}
// ── Toplu Silme ──────────────────────────────────────────────────────────────
const BULK_COUNTS_URL = '{{ route("admin.import.bulkCounts") }}';
const BULK_DELETE_URL = '{{ route("admin.import.destroyByStatus") }}';
async function openBulkDelete() {
const panel = document.getElementById('bulkDeletePanel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
if (panel.style.display === 'block') loadBulkCounts();
}
async function loadBulkCounts() {
try {
const r = await fetch(BULK_COUNTS_URL, {headers:{'Accept':'application/json'}});
const d = await r.json();
document.getElementById('cnt-failed').textContent = d.failed ?? 0;
document.getElementById('cnt-pending').textContent = d.pending ?? 0;
document.getElementById('cnt-stuck').textContent = d.stuck ?? 0;
} catch(e) {}
}
async function runBulkDelete() {
const statuses = [];
if (document.getElementById('del-failed').checked) statuses.push('failed');
if (document.getElementById('del-pending').checked) statuses.push('pending');
if (document.getElementById('del-stuck').checked) statuses.push('fetching','downloading','uploading');
if (!statuses.length) { alert('En az bir kategori seç.'); return; }
const names = [];
if (document.getElementById('del-failed').checked) names.push('hatalı');
if (document.getElementById('del-pending').checked) names.push('bekleyen');
if (document.getElementById('del-stuck').checked) names.push('takılı kalan');
if (!confirm(`${names.join(', ')} joblar silinsin mi? Bu işlem geri alınamaz.`)) return;
const msgEl = document.getElementById('bulkDeleteMsg');
msgEl.style.color = 'var(--text2)';
msgEl.textContent = 'Siliniyor...';
try {
const r = await fetch(BULK_DELETE_URL, {
method: 'DELETE',
headers: {'Accept':'application/json','Content-Type':'application/json','X-CSRF-TOKEN':CSRF},
body: JSON.stringify({ statuses, stuck_hours: 2 }),
});
const d = await r.json();
if (d.ok) {
msgEl.style.color = 'var(--success)';
msgEl.textContent = `✓ ${d.deleted} job silindi.`;
setTimeout(() => location.reload(), 1200);
} else {
msgEl.style.color = 'var(--danger)';
msgEl.textContent = d.message || 'Hata oluştu.';
}
} catch(e) {
msgEl.style.color = 'var(--danger)';
msgEl.textContent = 'Sunucu hatası: ' + e.message;
}
}
</script>
@endpush