mirror of
https://github.com/HyPnoTiiK/stream-fusion.git
synced 2026-08-11 17:50:58 +00:00
- Implemented a new admin page for managing TMDB mismatches, including listing, creating, and deleting mismatches. - Added functionality to search for unmatched torrents with filters for language and type. - Enhanced UI with improved filtering options and group toggling for better user experience. - Introduced toast notifications for user actions such as creating mismatches and assigning TMDB IDs. - Updated backend routes to handle mismatch creation, deletion, and assignment of TMDB IDs to selected torrents.
365 lines
15 KiB
HTML
365 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Recherche par TMDB ID — StreamFusion Admin{% endblock %}
|
|
{% block page_title %}Recherche par TMDB ID{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
.lang-badge {
|
|
font-size: 0.72rem;
|
|
background: rgba(13,110,253,0.12);
|
|
color: #6ea8fe;
|
|
border: 1px solid rgba(13,110,253,0.25);
|
|
border-radius: 4px;
|
|
padding: 1px 6px;
|
|
}
|
|
code.hash-code {
|
|
font-size: 0.78rem;
|
|
color: var(--bs-secondary-color);
|
|
letter-spacing: 0.01em;
|
|
}
|
|
.raw-title-cell { font-size: 0.875rem; max-width: 340px; word-break: break-word; line-height: 1.35; }
|
|
.mismatch-badge {
|
|
font-size: 0.7rem;
|
|
background: rgba(255,193,7,0.15);
|
|
color: #ffc107;
|
|
border: 1px solid rgba(255,193,7,0.3);
|
|
border-radius: 4px;
|
|
padding: 1px 6px;
|
|
white-space: nowrap;
|
|
}
|
|
.group-header {
|
|
background: rgba(255,255,255,0.04);
|
|
font-size: 0.78rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--bs-secondary-color);
|
|
user-select: none;
|
|
cursor: pointer;
|
|
}
|
|
.group-header:hover { background: rgba(255,255,255,0.07); }
|
|
[data-bs-theme="light"] .group-header { background: rgba(0,0,0,0.04); }
|
|
[data-bs-theme="light"] .group-header:hover { background: rgba(0,0,0,0.07); }
|
|
.group-toggle i { font-size: 0.7rem; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<!-- Search bar -->
|
|
<form method="get" action="{{ url_for('search_tmdb_page')(request) }}" class="d-flex gap-2 mb-4" style="max-width:400px;">
|
|
<input type="number" name="q" value="{{ query }}"
|
|
class="form-control"
|
|
placeholder="TMDB ID (ex : 272565)"
|
|
min="1" autocomplete="off" autofocus>
|
|
<button type="submit" class="btn btn-primary flex-shrink-0">
|
|
<i class="bi bi-search me-1"></i>Rechercher
|
|
</button>
|
|
</form>
|
|
|
|
{% if error %}
|
|
<div class="alert alert-danger">{{ error }}</div>
|
|
{% endif %}
|
|
|
|
{% if query and not error %}
|
|
{% if not results %}
|
|
<div class="text-center text-secondary py-5">
|
|
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
|
Aucun résultat pour TMDB ID <code class="text-primary">{{ query }}</code>
|
|
</div>
|
|
{% else %}
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span class="text-secondary" style="font-size:0.85rem;">
|
|
{{ results|length }} entrée(s) pour TMDB ID <code class="text-primary">{{ query }}</code>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Filter bar -->
|
|
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
|
|
<input type="text" id="searchInput" class="form-control form-control-sm"
|
|
style="width:200px;font-size:0.83rem;" placeholder="Titre, hash…">
|
|
<select id="filterLang" class="form-select form-select-sm" style="width:auto;min-width:130px;font-size:0.83rem;">
|
|
<option value="">Toutes les langues</option>
|
|
</select>
|
|
<select id="filterType" class="form-select form-select-sm" style="width:auto;min-width:110px;font-size:0.83rem;">
|
|
<option value="">Tous les types</option>
|
|
<option value="movie">Film</option>
|
|
<option value="series">Série</option>
|
|
</select>
|
|
<button id="clearFilters" class="btn btn-outline-secondary btn-sm">
|
|
<i class="bi bi-x-lg me-1"></i>Effacer
|
|
</button>
|
|
<span class="text-secondary ms-2" style="font-size:0.8rem;" id="filteredCount"></span>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle" id="resultsTable">
|
|
<thead>
|
|
<tr style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--bs-secondary-color);">
|
|
<th>Titre brut</th>
|
|
<th>Taille</th>
|
|
<th>Hash</th>
|
|
<th style="text-align:right;">Seeders</th>
|
|
<th>Langues</th>
|
|
<th>Créé le</th>
|
|
<th>Mis à jour</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tableBody">
|
|
{% for indexer, group in results | groupby('indexer') %}
|
|
<tr class="group-header" data-group-header="{{ indexer }}">
|
|
<td colspan="8" class="py-2 px-3">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<button class="btn btn-link btn-sm p-0 group-toggle text-secondary"
|
|
data-group="{{ indexer }}" tabindex="-1">
|
|
<i class="bi bi-chevron-down"></i>
|
|
</button>
|
|
<i class="bi bi-server"></i>
|
|
{{ indexer }}
|
|
<span class="ms-1" style="font-size:0.72rem;opacity:0.6;">({{ group | list | length }})</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% for r in group %}
|
|
<tr data-group="{{ indexer }}"
|
|
data-search="{{ r.raw_title }} {{ r.info_hash }}"
|
|
data-type="{{ r.type or '' }}"
|
|
data-langs="{{ (r.languages or []) | join(',') }}"
|
|
id="row-{{ r.info_hash }}">
|
|
<td class="raw-title-cell">
|
|
{% if r.type == 'series' %}
|
|
<span class="badge me-1" style="font-size:0.65rem;background:rgba(13,202,240,0.15);color:#0dcaf0;border:1px solid rgba(13,202,240,0.3);">S</span>
|
|
{% elif r.type == 'movie' %}
|
|
<span class="badge me-1" style="font-size:0.65rem;background:rgba(13,110,253,0.15);color:#6ea8fe;border:1px solid rgba(13,110,253,0.3);">M</span>
|
|
{% endif %}
|
|
{{ r.raw_title }}
|
|
</td>
|
|
<td style="white-space:nowrap;font-size:0.83rem;">{{ r.size | fmt_size }}</td>
|
|
<td>
|
|
<div class="d-flex align-items-center gap-1">
|
|
<code class="hash-code">{{ r.info_hash[:10] }}…</code>
|
|
<button class="btn btn-link btn-sm p-0 copy-btn text-secondary" title="Copier le hash complet"
|
|
data-value="{{ r.info_hash }}">
|
|
<i class="bi bi-clipboard" style="font-size:0.8rem;"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td style="text-align:right;font-size:0.83rem;font-variant-numeric:tabular-nums;">{{ r.seeders }}</td>
|
|
<td>
|
|
<div class="d-flex flex-wrap gap-1">
|
|
{% for lang in (r.languages or []) %}
|
|
<span class="lang-badge">{{ lang }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
</td>
|
|
<td style="white-space:nowrap;">
|
|
<span style="font-size:0.8rem;">{{ r.created_at | fmt_date }}</span>
|
|
</td>
|
|
<td style="white-space:nowrap;">
|
|
<div style="font-size:0.8rem;">{{ r.updated_at | fmt_relative }}</div>
|
|
<div style="font-size:0.72rem;color:var(--bs-secondary-color);">{{ r.updated_at | fmt_date }}</div>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex gap-1">
|
|
<a href="{{ url_for('search_hash_page')(request) }}?q={{ r.info_hash }}"
|
|
class="btn btn-sm btn-outline-info" title="Voir le détail complet par hash">
|
|
<i class="bi bi-hash"></i>
|
|
</a>
|
|
<button class="btn btn-sm btn-outline-warning mismatch-btn"
|
|
data-hash="{{ r.info_hash }}"
|
|
data-tmdb="{{ query }}"
|
|
data-title="{{ r.raw_title | replace('"', '"') }}"
|
|
data-indexer="{{ r.indexer }}"
|
|
title="Signaler comme mismatch TMDB">
|
|
<i class="bi bi-exclamation-triangle"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
<div id="emptyMsg" class="text-center text-secondary py-3 d-none" style="font-size:0.85rem;">
|
|
<i class="bi bi-search me-1"></i>Aucun résultat ne correspond aux filtres.
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{% endblock %}
|
|
|
|
<!-- Toast -->
|
|
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
|
|
<div id="toast" class="toast align-items-center border-0" role="alert">
|
|
<div class="d-flex">
|
|
<div class="toast-body" id="toastMsg"></div>
|
|
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
const CSRF = "{{ csrf_token_gen() }}";
|
|
|
|
function showToast(msg, isError = false) {
|
|
const toast = document.getElementById('toast');
|
|
toast.className = `toast align-items-center border-0 text-bg-${isError ? 'danger' : 'success'}`;
|
|
document.getElementById('toastMsg').textContent = msg;
|
|
new bootstrap.Toast(toast, { delay: 3500 }).show();
|
|
}
|
|
|
|
// ── State ──
|
|
const collapsedGroups = new Set();
|
|
|
|
// ── Build language filter ──
|
|
function buildFilters() {
|
|
const langs = new Set();
|
|
document.querySelectorAll('#tableBody tr[data-group]:not(.group-header)').forEach(row => {
|
|
(row.dataset.langs || '').split(',').filter(Boolean).forEach(l => langs.add(l));
|
|
});
|
|
const langSel = document.getElementById('filterLang');
|
|
if (langSel) {
|
|
[...langs].sort().forEach(lang => {
|
|
const opt = document.createElement('option');
|
|
opt.value = lang; opt.textContent = lang;
|
|
langSel.appendChild(opt);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Apply all filters ──
|
|
function applyFilters() {
|
|
const text = (document.getElementById('searchInput')?.value || '').toLowerCase();
|
|
const filterLang = document.getElementById('filterLang')?.value || '';
|
|
const filterType = document.getElementById('filterType')?.value || '';
|
|
|
|
document.querySelectorAll('#tableBody tr[data-group]:not(.group-header)').forEach(row => {
|
|
const passesText = !text || (row.dataset.search || '').toLowerCase().includes(text);
|
|
const passesLang = !filterLang || (row.dataset.langs || '').split(',').includes(filterLang);
|
|
const passesType = !filterType || row.dataset.type === filterType;
|
|
row.dataset.filteredOut = (passesText && passesLang && passesType) ? 'false' : 'true';
|
|
});
|
|
|
|
updateVisibility();
|
|
}
|
|
|
|
function updateVisibility() {
|
|
const total = document.querySelectorAll('#tableBody tr[data-group]:not(.group-header)').length;
|
|
let totalVisible = 0;
|
|
|
|
document.querySelectorAll('.group-header').forEach(header => {
|
|
const group = header.dataset.groupHeader;
|
|
const rows = document.querySelectorAll(`#tableBody tr[data-group="${CSS.escape(group)}"]:not(.group-header)`);
|
|
const isCollapsed = collapsedGroups.has(group);
|
|
let anyPassesFilter = false;
|
|
|
|
rows.forEach(row => {
|
|
const filtered = row.dataset.filteredOut === 'true';
|
|
if (!filtered) anyPassesFilter = true;
|
|
row.style.display = (filtered || isCollapsed) ? 'none' : '';
|
|
if (!filtered && !isCollapsed) totalVisible++;
|
|
});
|
|
|
|
header.style.display = anyPassesFilter ? '' : 'none';
|
|
|
|
const icon = header.querySelector('.group-toggle i');
|
|
if (icon) icon.className = isCollapsed ? 'bi bi-chevron-right' : 'bi bi-chevron-down';
|
|
});
|
|
|
|
const filteredCount = document.getElementById('filteredCount');
|
|
if (filteredCount) {
|
|
filteredCount.textContent = totalVisible < total ? `${totalVisible} / ${total} affichés` : '';
|
|
}
|
|
|
|
document.getElementById('emptyMsg')?.classList.toggle('d-none', totalVisible > 0);
|
|
}
|
|
|
|
// ── Group toggle ──
|
|
function toggleGroup(group) {
|
|
collapsedGroups.has(group) ? collapsedGroups.delete(group) : collapsedGroups.add(group);
|
|
updateVisibility();
|
|
}
|
|
|
|
document.querySelectorAll('.group-header').forEach(header => {
|
|
header.addEventListener('click', function (e) {
|
|
if (e.target.closest('button') && !e.target.closest('.group-toggle')) return;
|
|
toggleGroup(this.dataset.groupHeader);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.group-toggle').forEach(btn => {
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
toggleGroup(this.dataset.group);
|
|
});
|
|
});
|
|
|
|
// ── Filters ──
|
|
document.getElementById('searchInput')?.addEventListener('input', applyFilters);
|
|
document.getElementById('filterLang')?.addEventListener('change', applyFilters);
|
|
document.getElementById('filterType')?.addEventListener('change', applyFilters);
|
|
document.getElementById('clearFilters')?.addEventListener('click', () => {
|
|
const si = document.getElementById('searchInput');
|
|
if (si) si.value = '';
|
|
const fl = document.getElementById('filterLang');
|
|
if (fl) fl.value = '';
|
|
const ft = document.getElementById('filterType');
|
|
if (ft) ft.value = '';
|
|
applyFilters();
|
|
});
|
|
|
|
// ── Copy buttons ──
|
|
document.querySelectorAll('.copy-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
navigator.clipboard.writeText(btn.dataset.value).then(() => {
|
|
const icon = btn.querySelector('i');
|
|
icon.className = 'bi bi-clipboard-check text-success';
|
|
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 1500);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Mismatch buttons ──
|
|
document.querySelectorAll('.mismatch-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const title = btn.dataset.title.substring(0, 60);
|
|
if (!confirm(`Signaler comme mismatch ?\n\n"${title}"\n\nLe tmdb_id sera remis à NULL dans la base de données.`)) return;
|
|
|
|
const fd = new FormData();
|
|
fd.append('csrf_token', CSRF);
|
|
fd.append('info_hash', btn.dataset.hash);
|
|
fd.append('tmdb_id', btn.dataset.tmdb);
|
|
fd.append('raw_title', btn.dataset.title);
|
|
fd.append('indexer', btn.dataset.indexer);
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
const resp = await fetch('{{ url_for("create_mismatch")(request) }}', { method: 'POST', body: fd });
|
|
const data = await resp.json();
|
|
if (resp.ok) {
|
|
btn.replaceWith(Object.assign(document.createElement('span'), {
|
|
className: 'mismatch-badge',
|
|
textContent: '⚠ Mismatch'
|
|
}));
|
|
showToast('Mismatch signalé — tmdb_id remis à NULL.');
|
|
} else if (resp.status === 409) {
|
|
showToast('Déjà signalé comme mismatch.', true);
|
|
btn.disabled = false;
|
|
} else {
|
|
showToast(data.error || 'Erreur lors du signalement.', true);
|
|
btn.disabled = false;
|
|
}
|
|
} catch {
|
|
showToast('Erreur réseau.', true);
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Init ──
|
|
buildFilters();
|
|
applyFilters();
|
|
</script>
|
|
{% endblock %}
|