Files
2026-07-14 00:01:48 +03:00

529 lines
28 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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