mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
512 lines
25 KiB
Twig
512 lines
25 KiB
Twig
{% extends 'admin/layout.twig' %}
|
|
|
|
{% block title %}Manage Actors - Admin Panel - MediaLib{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h1 class="h3 mb-0">Manage Actors</h1>
|
|
<p class="text-muted mb-0">View and manage actors, find and merge duplicates</p>
|
|
</div>
|
|
<div class="btn-group">
|
|
<a href="{{ path_for('admin.actors') }}" class="btn btn-outline-primary {% if not filters.duplicates %}active{% endif %}">
|
|
<i class="bi bi-people me-2"></i>All Actors
|
|
</a>
|
|
<a href="{{ path_for('admin.actors', {}, {'duplicates': 1}) }}" class="btn btn-outline-warning {% if filters.duplicates %}active{% endif %}">
|
|
<i class="bi bi-exclamation-triangle me-2"></i>Duplicates
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
{% if flash.success %}
|
|
<div class="alert alert-success">
|
|
{{ flash.success }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if filters.duplicates %}
|
|
<!-- Duplicate Actors View -->
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h5 class="mb-0">Duplicate Actor Groups</h5>
|
|
<small class="text-muted">Actors with similar names that may need to be merged</small>
|
|
</div>
|
|
<button type="button" class="btn btn-success btn-sm" onclick="autoMergeAll()">
|
|
<i class="bi bi-magic me-1"></i>Auto-Merge All
|
|
</button>
|
|
</div>
|
|
|
|
<div class="row">
|
|
{% for group in actors %}
|
|
<div class="col-md-6 mb-4">
|
|
<div class="card border-warning">
|
|
<div class="card-header bg-warning-subtle">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h6 class="mb-0">{{ group.normalized_name|title }}</h6>
|
|
<span class="badge bg-warning">{{ group.duplicate_count }} duplicates</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
{% for actor in group.actors %}
|
|
<div class="col-12">
|
|
<div class="d-flex align-items-center p-2 border rounded">
|
|
<div class="form-check me-3">
|
|
<input class="form-check-input duplicate-checkbox"
|
|
type="checkbox"
|
|
value="{{ actor.id }}"
|
|
id="actor_{{ actor.id }}"
|
|
data-group="{{ group.normalized_name }}">
|
|
</div>
|
|
{% if actor.thumbnail_path %}
|
|
<img src="{{ actor.thumbnail_path }}"
|
|
alt="{{ actor.name }}"
|
|
class="rounded me-3"
|
|
style="width: 40px; height: 40px; object-fit: cover;">
|
|
{% else %}
|
|
<div class="bg-light rounded d-flex align-items-center justify-content-center me-3"
|
|
style="width: 40px; height: 40px;">
|
|
<i class="bi bi-person text-muted"></i>
|
|
</div>
|
|
{% endif %}
|
|
<div class="flex-grow-1">
|
|
<div class="fw-bold">{{ actor.name }}</div>
|
|
<small class="text-muted">
|
|
{{ actor.stats.movie_count }} movies,
|
|
{{ actor.stats.tv_show_count }} TV shows,
|
|
{{ actor.stats.adult_video_count }} adult videos
|
|
<span class="text-primary">({{ actor.stats.total_media_count }} total)</span>
|
|
</small>
|
|
</div>
|
|
<div class="text-end">
|
|
<small class="text-muted d-block">ID: {{ actor.id }}</small>
|
|
{% if actor.thumbnail_path %}
|
|
<i class="bi bi-image text-success" title="Has thumbnail"></i>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div class="mt-3">
|
|
<button type="button"
|
|
class="btn btn-primary btn-sm me-2"
|
|
onclick="mergeSelected('{{ group.normalized_name }}')">
|
|
<i class="bi bi-arrow-merge me-1"></i>Merge Selected
|
|
</button>
|
|
<button type="button"
|
|
class="btn btn-success btn-sm"
|
|
onclick="autoMergeGroup('{{ group.normalized_name }}')">
|
|
<i class="bi bi-robot me-1"></i>Auto-Merge Group
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="col-12">
|
|
<div class="text-center py-5">
|
|
<i class="bi bi-check-circle text-success" style="font-size: 3rem;"></i>
|
|
<h5 class="mt-3">No Duplicate Actors Found</h5>
|
|
<p class="text-muted">All actors in your database appear to be unique.</p>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Pagination for duplicates -->
|
|
{% if pagination.total > 1 %}
|
|
<nav class="mt-4">
|
|
<ul class="pagination justify-content-center">
|
|
{% if pagination.current > 1 %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'duplicates': 1,
|
|
'page': pagination.current - 1,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}"
|
|
aria-label="Previous">
|
|
<span aria-hidden="true">«</span>
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
|
|
{% set start = max(1, pagination.current - 2) %}
|
|
{% set end = min(pagination.total, pagination.current + 2) %}
|
|
|
|
{% if start > 1 %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'duplicates': 1,
|
|
'page': 1,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}">1</a>
|
|
</li>
|
|
{% if start > 2 %}
|
|
<li class="page-item disabled">
|
|
<span class="page-link">...</span>
|
|
</li>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{% for i in start..end %}
|
|
<li class="page-item {% if i == pagination.current %}active{% endif %}">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'duplicates': 1,
|
|
'page': i,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}">
|
|
{{ i }}
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
|
|
{% if end < pagination.total %}
|
|
{% if end < pagination.total - 1 %}
|
|
<li class="page-item disabled">
|
|
<span class="page-link">...</span>
|
|
</li>
|
|
{% endif %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'duplicates': 1,
|
|
'page': pagination.total,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}">
|
|
{{ pagination.total }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
|
|
{% if pagination.current < pagination.total %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'duplicates': 1,
|
|
'page': pagination.current + 1,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}"
|
|
aria-label="Next">
|
|
<span aria-hidden="true">»</span>
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</nav>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<!-- All Actors View -->
|
|
<form method="get" class="mb-4">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<div class="input-group">
|
|
<input type="text"
|
|
class="form-control"
|
|
name="search"
|
|
placeholder="Search actors..."
|
|
value="{{ filters.search }}">
|
|
<button class="btn btn-primary" type="submit">
|
|
<i class="bi bi-search"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<select name="sort" class="form-select">
|
|
<option value="name_asc" {{ filters.sort == 'name_asc' ? 'selected' : '' }}>Name (A-Z)</option>
|
|
<option value="name_desc" {{ filters.sort == 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
|
|
<option value="media_desc" {{ filters.sort == 'media_desc' ? 'selected' : '' }}>Most Media</option>
|
|
<option value="media_asc" {{ filters.sort == 'media_asc' ? 'selected' : '' }}>Least Media</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="btn-group w-100">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-funnel me-1"></i> Filter
|
|
</button>
|
|
<a href="{{ path_for('admin.actors.index') }}" class="btn btn-outline-secondary">
|
|
<i class="bi bi-x-lg"></i>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Results Summary -->
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="text-muted">
|
|
Showing {{ pagination.from }} to {{ pagination.to }} of {{ pagination.total_items }} actors
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width: 60px;">Photo</th>
|
|
<th>Name</th>
|
|
<th>Movies</th>
|
|
<th>TV Shows</th>
|
|
<th>Adult Videos</th>
|
|
<th>Total Media</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for actor in actors %}
|
|
<tr>
|
|
<td>
|
|
{% if actor.thumbnail_path %}
|
|
<img src="{{ actor.thumbnail_path }}" alt="{{ actor.name }}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 50%;">
|
|
{% else %}
|
|
<div class="bg-light d-flex align-items-center justify-content-center rounded-circle" style="width: 50px; height: 50px;">
|
|
<i class="bi bi-person text-muted"></i>
|
|
</div>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ actor.name }}</td>
|
|
<td>{{ actor.movies|length }}</td>
|
|
<td>{{ actor.tvShows|length }}</td>
|
|
<td>{{ actor.adultVideos|length }}</td>
|
|
<td>
|
|
<span class="badge bg-primary">{{ actor.movies|length + actor.tvShows|length + actor.adultVideos|length }}</span>
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
<a href="{{ path_for('actors.show', {id: actor.id}) }}" class="btn btn-outline-info" target="_blank">
|
|
<i class="bi bi-eye"></i>
|
|
</a>
|
|
<a href="{{ path_for('actors.edit', {id: actor.id}) }}" class="btn btn-outline-primary">
|
|
<i class="bi bi-pencil"></i>
|
|
</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="7" class="text-center py-4">
|
|
<div class="text-muted">No actors found.</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination for all actors -->
|
|
{% if pagination.total > 1 %}
|
|
<nav class="mt-4">
|
|
<ul class="pagination justify-content-center">
|
|
{% if pagination.current > 1 %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'page': pagination.current - 1,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}"
|
|
aria-label="Previous">
|
|
<span aria-hidden="true">«</span>
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
|
|
{% set start = max(1, pagination.current - 2) %}
|
|
{% set end = min(pagination.total, pagination.current + 2) %}
|
|
|
|
{% if start > 1 %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'page': 1,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}">1</a>
|
|
</li>
|
|
{% if start > 2 %}
|
|
<li class="page-item disabled">
|
|
<span class="page-link">...</span>
|
|
</li>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{% for i in start..end %}
|
|
<li class="page-item {% if i == pagination.current %}active{% endif %}">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'page': i,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}">
|
|
{{ i }}
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
|
|
{% if end < pagination.total %}
|
|
{% if end < pagination.total - 1 %}
|
|
<li class="page-item disabled">
|
|
<span class="page-link">...</span>
|
|
</li>
|
|
{% endif %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'page': pagination.total,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}">
|
|
{{ pagination.total }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
|
|
{% if pagination.current < pagination.total %}
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
href="{{ path_for('admin.actors.index', {}, {
|
|
'page': pagination.current + 1,
|
|
'search': filters.search,
|
|
'sort': filters.sort
|
|
}) }}"
|
|
aria-label="Next">
|
|
<span aria-hidden="true">»</span>
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</nav>
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function mergeSelected(groupName) {
|
|
const checkboxes = document.querySelectorAll(`.duplicate-checkbox[data-group="${groupName}"]:checked`);
|
|
const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
|
|
|
if (selectedIds.length < 2) {
|
|
alert('Please select at least 2 actors to merge.');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Are you sure you want to merge ${selectedIds.length} actors? The first selected actor will be kept as the master.`)) {
|
|
return;
|
|
}
|
|
|
|
// Use the first selected as master
|
|
const masterId = selectedIds[0];
|
|
const duplicates = selectedIds.slice(1);
|
|
|
|
fetch('{{ path_for("admin.actors.merge") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
master_actor_id: masterId,
|
|
duplicate_actor_ids: duplicates
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
alert(data.message);
|
|
location.reload();
|
|
} else {
|
|
alert('Error: ' + data.message);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('An error occurred while merging actors.');
|
|
});
|
|
}
|
|
|
|
function autoMergeGroup(groupName) {
|
|
if (!confirm(`Are you sure you want to auto-merge all actors in the "${groupName}" group? This will automatically choose the best master actor.`)) {
|
|
return;
|
|
}
|
|
|
|
fetch('{{ path_for("admin.actors.auto-merge") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
actor_group_ids: [groupName]
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
alert(data.message);
|
|
location.reload();
|
|
} else {
|
|
alert('Error: ' + data.message);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('An error occurred while auto-merging actors.');
|
|
});
|
|
}
|
|
|
|
function autoMergeAll() {
|
|
const groups = Array.from(document.querySelectorAll('.duplicate-checkbox')).reduce((acc, cb) => {
|
|
const group = cb.getAttribute('data-group');
|
|
if (!acc.includes(group)) {
|
|
acc.push(group);
|
|
}
|
|
return acc;
|
|
}, []);
|
|
|
|
if (groups.length === 0) {
|
|
alert('No duplicate groups found.');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Are you sure you want to auto-merge all ${groups.length} duplicate groups? This action cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
fetch('{{ path_for("admin.actors.auto-merge") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
actor_group_ids: groups
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
alert(data.message);
|
|
location.reload();
|
|
} else {
|
|
alert('Error: ' + data.message);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('An error occurred while auto-merging actors.');
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|