series management tuning and import archive extraction troubleshooting

This commit is contained in:
Bryan Van Deusen
2026-01-23 23:05:25 -05:00
parent d3e73b9533
commit 9ebeeed133
7 changed files with 373 additions and 77 deletions
+86 -6
View File
@@ -1357,6 +1357,42 @@ def reapply_artist_tags_from_paths():
return jsonify({'ok': False, 'error': str(e)}), 500
@main.route('/api/cleanup-orphaned-tags', methods=['POST'])
def cleanup_orphaned_tags():
"""
Delete tags that aren't attached to any images.
Returns the count and names of deleted tags.
"""
from sqlalchemy import func
try:
# Find tags with no associated images
# A tag is orphaned if it has no entries in the image_tags junction table
orphaned = (
Tag.query
.outerjoin(image_tags)
.group_by(Tag.id)
.having(func.count(image_tags.c.image_id) == 0)
.all()
)
deleted_tags = []
for tag in orphaned:
deleted_tags.append({'name': tag.name, 'kind': tag.kind})
db.session.delete(tag)
db.session.commit()
return jsonify({
'ok': True,
'deleted_count': len(deleted_tags),
'deleted_tags': deleted_tags
})
except Exception as e:
db.session.rollback()
return jsonify({'ok': False, 'error': str(e)}), 500
@main.route('/api/celery/status')
def celery_cluster_status():
"""
@@ -1941,7 +1977,13 @@ def get_image_series_info(image_id):
@main.post('/api/image/<int:image_id>/add-to-series')
def add_image_to_series(image_id):
"""Add a single image to a series with a specific page number."""
"""Add or update an image in a series with a specific page number.
Handles three cases:
1. Image not in any series -> create new SeriesPage
2. Image already in this series -> update page number
3. Image in a different series -> move to new series
"""
from app.models import SeriesPage, Tag
data = request.get_json() or {}
@@ -1961,14 +2003,48 @@ def add_image_to_series(image_id):
return jsonify(ok=False, error="Tag is not a series"), 400
# Check if image is already in this series
existing = SeriesPage.query.filter_by(
existing_in_target = SeriesPage.query.filter_by(
series_tag_id=series_tag_id,
image_id=image_id
).first()
if existing:
return jsonify(ok=False, error="Image already in this series"), 409
# Check if page number is taken
# Check if image is in any other series
existing_in_other = SeriesPage.query.filter(
SeriesPage.image_id == image_id,
SeriesPage.series_tag_id != series_tag_id
).first()
if existing_in_target:
# Case 2: Already in this series - update page number
if existing_in_target.page_number == page_number:
# No change needed
return jsonify(ok=True, series_page={
'id': existing_in_target.id,
'page_number': existing_in_target.page_number,
'series_tag_id': existing_in_target.series_tag_id,
'series_name': series_tag.name
})
# Check if new page number is taken by another image
page_taken = SeriesPage.query.filter(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.page_number == page_number,
SeriesPage.image_id != image_id
).first()
if page_taken:
return jsonify(ok=False, error=f"Page {page_number} already exists in this series"), 409
existing_in_target.page_number = page_number
db.session.commit()
return jsonify(ok=True, series_page={
'id': existing_in_target.id,
'page_number': existing_in_target.page_number,
'series_tag_id': existing_in_target.series_tag_id,
'series_name': series_tag.name
})
# Check if page number is taken in target series
page_taken = SeriesPage.query.filter_by(
series_tag_id=series_tag_id,
page_number=page_number
@@ -1976,7 +2052,11 @@ def add_image_to_series(image_id):
if page_taken:
return jsonify(ok=False, error=f"Page {page_number} already exists in this series"), 409
# Create the series page
if existing_in_other:
# Case 3: In a different series - move to new series
db.session.delete(existing_in_other)
# Case 1 & 3: Create new series page
sp = SeriesPage(
series_tag_id=series_tag_id,
image_id=image_id,
+68 -3
View File
@@ -105,7 +105,9 @@
if (state.selectedIds.has(id)) {
state.selectedIds.delete(id);
state.selectionOrder = state.selectionOrder.filter(x => x !== id);
// Remove from order array without creating new array (preserves window.selectionOrder reference)
const idx = state.selectionOrder.indexOf(id);
if (idx !== -1) state.selectionOrder.splice(idx, 1);
item.classList.remove('selected');
removeOrderBadge(item);
} else {
@@ -124,7 +126,8 @@
function clearSelection() {
state.selectedIds.clear();
state.selectionOrder = [];
// Clear array without creating new one (preserves window.selectionOrder reference)
state.selectionOrder.length = 0;
document.querySelectorAll('.img-clickable.selected').forEach(el => {
el.classList.remove('selected');
removeOrderBadge(el);
@@ -308,6 +311,7 @@
if (data.ok) {
loadCommonTags(); // Refresh common tags
refreshSelectedItemsTags(); // Update tag display on gallery items
} else {
console.error('Failed to add tag:', data.error);
}
@@ -329,6 +333,7 @@
if (data.ok) {
loadCommonTags(); // Refresh common tags
refreshSelectedItemsTags(); // Update tag display on gallery items
} else {
console.error('Failed to remove tag:', data.error);
}
@@ -509,6 +514,65 @@
return div.innerHTML;
}
// ---------------------------
// Tag Refresh for Gallery Items
// ---------------------------
function getTagDisplayName(name, kind) {
// Strip prefix for known prefixed kinds
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
return name.includes(':') ? name.split(':', 2)[1] : name;
}
// Regular tags get a # prefix
return '#' + name;
}
async function refreshItemTags(imageId) {
// Find the gallery item
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
if (!item) return;
try {
const r = await fetch(`/image/${imageId}/tags`);
const data = await r.json();
if (!data.ok) return;
// Filter out archive tags (they're not shown)
const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive');
// Find or create tag overlay
let overlay = item.querySelector('.tag-overlay');
if (visibleTags.length === 0) {
// No visible tags - remove overlay if exists
if (overlay) overlay.remove();
return;
}
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'tag-overlay';
item.appendChild(overlay);
}
// Render tags
overlay.innerHTML = visibleTags.map(t => {
const displayName = getTagDisplayName(t.name, t.kind);
const encodedName = encodeURIComponent(t.name);
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapeHtml(t.name)}" onclick="event.stopPropagation()">${escapeHtml(displayName)}</a>`;
}).join('');
} catch (e) {
console.error('Failed to refresh tags for image', imageId, e);
}
}
async function refreshSelectedItemsTags() {
// Refresh tags for all currently selected items
const ids = Array.from(state.selectedIds);
await Promise.all(ids.map(id => refreshItemTags(id)));
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
@@ -516,10 +580,11 @@
init();
}
// Expose global API for other scripts (like series editor)
// Expose global API for other scripts (like series editor, modal)
window.selectedImages = state.selectedIds;
window.selectionOrder = state.selectionOrder; // Ordered array of selected IDs
window.clearSelection = clearSelection;
window.enableSelectMode = enableSelectMode;
window.disableSelectMode = disableSelectMode;
window.refreshItemTags = refreshItemTags; // Refresh tags display on a single gallery item
})();
+93 -10
View File
@@ -105,6 +105,56 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// ---------------------------
// Gallery item tag refresh (fallback if bulk-select.js not loaded)
// ---------------------------
function getTagDisplayNameForOverlay(name, kind) {
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
return name.includes(':') ? name.split(':', 2)[1] : name;
}
return '#' + name;
}
async function refreshItemTagsFallback(imageId) {
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
if (!item) return;
try {
const r = await fetch(`/image/${imageId}/tags`);
const data = await r.json();
if (!data.ok) return;
const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive');
let overlay = item.querySelector('.tag-overlay');
if (visibleTags.length === 0) {
if (overlay) overlay.remove();
return;
}
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'tag-overlay';
item.appendChild(overlay);
}
overlay.innerHTML = visibleTags.map(t => {
const displayName = getTagDisplayNameForOverlay(t.name, t.kind);
const encodedName = encodeURIComponent(t.name);
const escapedName = t.name.replace(/"/g, '&quot;');
const escapedDisplay = displayName.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapedName}" onclick="event.stopPropagation()">${escapedDisplay}</a>`;
}).join('');
} catch (e) {
console.error('Failed to refresh tags for image', imageId, e);
}
}
// Register fallback if bulk-select.js hasn't registered it
if (!window.refreshItemTags) {
window.refreshItemTags = refreshItemTagsFallback;
}
// ---------------------------
// Series info helpers
// ---------------------------
@@ -120,7 +170,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
async function loadSeriesTags() {
if (!seriesSelect || seriesTagsLoaded) return;
if (!seriesSelect) return;
if (seriesTagsLoaded) return Promise.resolve();
try {
// Fetch all series tags using the kind filter
const r = await fetch('/api/tags/search?kind=series&limit=100');
@@ -144,12 +195,26 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
function showAddToSeries() {
function showAddToSeries(seriesPage = null) {
if (!addToSeriesEl) return;
loadSeriesTags();
if (seriesSelect) seriesSelect.value = '';
if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
if (addToSeriesBtn) addToSeriesBtn.disabled = true;
loadSeriesTags().then(() => {
// Pre-fill if image is already in a series
if (seriesPage && seriesSelect) {
seriesSelect.value = seriesPage.series_tag_id;
if (addSeriesPageNumber) addSeriesPageNumber.value = seriesPage.page_number;
if (addToSeriesBtn) {
addToSeriesBtn.disabled = false;
addToSeriesBtn.textContent = 'Update';
}
} else {
if (seriesSelect) seriesSelect.value = '';
if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
if (addToSeriesBtn) {
addToSeriesBtn.disabled = true;
addToSeriesBtn.textContent = 'Add';
}
}
});
if (addToSeriesStatus) addToSeriesStatus.textContent = '';
addToSeriesEl.style.display = 'block';
}
@@ -161,8 +226,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return;
const isUpdate = addToSeriesBtn?.textContent === 'Update';
if (addToSeriesBtn) addToSeriesBtn.disabled = true;
if (addToSeriesStatus) addToSeriesStatus.textContent = 'Adding...';
if (addToSeriesStatus) addToSeriesStatus.textContent = isUpdate ? 'Updating...' : 'Adding...';
try {
const r = await fetch(`/api/image/${imageId}/add-to-series`, {
@@ -174,12 +240,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (j.ok) {
if (addToSeriesStatus) {
addToSeriesStatus.textContent = '✓ Added';
addToSeriesStatus.textContent = '✓ Saved';
setTimeout(() => { addToSeriesStatus.textContent = ''; }, 2000);
}
// Switch to showing the series info instead of add-to-series
hideAddToSeries();
// Update the series info section with new data
showSeriesInfo(j.series_page);
// Re-enable the button
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
} else {
if (addToSeriesStatus) addToSeriesStatus.textContent = j.error || 'Error';
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
@@ -220,7 +287,9 @@ document.addEventListener('DOMContentLoaded', () => {
const r = await fetch(`/api/image/${imageId}/series-info`);
const j = await r.json();
if (j.ok && j.in_series) {
// Show series info (with link) and pre-fill the dropdown
showSeriesInfo(j.series_page);
showAddToSeries(j.series_page);
} else {
// Show add-to-series option when not in a series
showAddToSeries();
@@ -284,6 +353,12 @@ document.addEventListener('DOMContentLoaded', () => {
seriesSelect.addEventListener('change', () => {
if (addToSeriesBtn) {
addToSeriesBtn.disabled = !seriesSelect.value;
// Update button text: "Update" if image is in a series, "Add" if not
if (seriesSelect.value && currentSeriesPageId) {
addToSeriesBtn.textContent = 'Update';
} else if (seriesSelect.value) {
addToSeriesBtn.textContent = 'Add';
}
}
});
}
@@ -534,6 +609,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
function closeModal() {
// Get the image ID before clearing so we can refresh its tags
const imageId = getEditorImageId();
modal.classList.remove('active');
modalImageWrapper.innerHTML = '';
currentIndex = -1;
@@ -544,6 +622,11 @@ document.addEventListener('DOMContentLoaded', () => {
hideSeriesInfo();
hideAddToSeries();
history.replaceState({}, '', window.location.pathname + window.location.search);
// Refresh the gallery item's tags display (if function available from bulk-select.js)
if (imageId && window.refreshItemTags) {
window.refreshItemTags(imageId);
}
}
function showNext() {
+7 -7
View File
@@ -153,7 +153,7 @@
<label for="startPageInput">Starting page:</label>
<input type="number" id="startPageInput" value="{{ series_info.page_count + 1 }}" min="1" class="form-input">
</div>
<button id="addToSeriesBtn" class="btn primary-btn" disabled>
<button id="bulkAddToSeriesBtn" class="btn primary-btn" disabled>
Add to Series
</button>
</div>
@@ -184,15 +184,15 @@
// Series Editor functionality (integrated into bulk editor panel)
document.addEventListener('DOMContentLoaded', () => {
const startPageInput = document.getElementById('startPageInput');
const addToSeriesBtn = document.getElementById('addToSeriesBtn');
const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn');
const feedbackEl = document.getElementById('seriesEditorFeedback');
const seriesTagId = {{ series_info.tag_id }};
// Update button state based on selection
function updateAddButtonState() {
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
addToSeriesBtn.disabled = selectedCount === 0;
addToSeriesBtn.textContent = selectedCount > 0
bulkAddToSeriesBtn.disabled = selectedCount === 0;
bulkAddToSeriesBtn.textContent = selectedCount > 0
? `Add ${selectedCount} to Series`
: 'Add to Series';
}
@@ -201,15 +201,15 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('selectionChanged', updateAddButtonState);
// Add selected images to series
addToSeriesBtn.addEventListener('click', async () => {
bulkAddToSeriesBtn.addEventListener('click', async () => {
if (!window.selectedImages || window.selectedImages.size === 0) return;
// Use selectionOrder (click order) for page ordering
const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages);
const startPage = parseInt(startPageInput.value) || 1;
addToSeriesBtn.disabled = true;
addToSeriesBtn.textContent = 'Adding...';
bulkAddToSeriesBtn.disabled = true;
bulkAddToSeriesBtn.textContent = 'Adding...';
feedbackEl.innerHTML = '';
try {
+36
View File
@@ -56,6 +56,7 @@
<button id="clearCompletedBtn" class="btn secondary-btn">Clear Completed</button>
<button id="regenMissingThumbsBtn" class="btn secondary-btn">Regenerate Missing Thumbnails</button>
<button id="reapplyArtistTagsBtn" class="btn secondary-btn">Reapply Artist Tags from Paths</button>
<button id="cleanupOrphanedTagsBtn" class="btn secondary-btn">Cleanup Orphaned Tags</button>
</div>
<div id="celeryWorkerInfo" style="margin-top: 1rem;">
@@ -762,6 +763,41 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Cleanup Orphaned Tags
document.getElementById('cleanupOrphanedTagsBtn').addEventListener('click', async () => {
const btn = document.getElementById('cleanupOrphanedTagsBtn');
if (!confirm('This will delete all tags that are not attached to any images. Continue?')) {
return;
}
btn.disabled = true;
btn.textContent = 'Cleaning up...';
try {
const r = await fetch('/api/cleanup-orphaned-tags', { method: 'POST' });
const data = await r.json();
if (data.ok) {
if (data.deleted_count === 0) {
alert('No orphaned tags found.');
} else {
const tagList = data.deleted_tags.map(t => `${t.name} (${t.kind || 'user'})`).join('\n');
alert(`Deleted ${data.deleted_count} orphaned tag(s):\n\n${tagList}`);
}
// Refresh the tag dropdown if visible
loadTagsForDeletion();
} else {
alert('Failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Cleanup Orphaned Tags';
}
});
// Load import settings
fetch('/api/import-settings')
.then(r => r.json())
+21
View File
@@ -332,10 +332,25 @@ def _run_cmd(cmd: list[str]) -> tuple[int, str, str]:
return p.returncode, p.stdout, p.stderr
def _count_extracted_files(out_dir: str) -> int:
"""Count all files (recursively) in the output directory."""
count = 0
for root, dirs, files in os.walk(out_dir):
count += len(files)
return count
def extract_with_unar(archive_path: str, out_dir: str) -> None:
os.makedirs(out_dir, exist_ok=True)
rc, out, err = _run_cmd(["unar", "-o", out_dir, archive_path])
if rc != 0:
# Check if files were actually extracted (partial success)
# unar may return non-zero even when most files extracted successfully
extracted_files = _count_extracted_files(out_dir)
if extracted_files > 0:
# Partial extraction - log warning but continue
log.warning(f"unar partial extraction (rc={rc}): {extracted_files} files extracted from {archive_path}")
return
raise ExtractError(f"unar failed (rc={rc})\nSTDOUT:\n{out}\nSTDERR:\n{err}")
@@ -343,6 +358,12 @@ def extract_with_7z(archive_path: str, out_dir: str) -> None:
os.makedirs(out_dir, exist_ok=True)
rc, out, err = _run_cmd(["7z", "x", "-y", f"-o{out_dir}", archive_path])
if rc != 0:
# Check if files were actually extracted (partial success)
extracted_files = _count_extracted_files(out_dir)
if extracted_files > 0:
# Partial extraction - log warning but continue
log.warning(f"7z partial extraction (rc={rc}): {extracted_files} files extracted from {archive_path}")
return
raise ExtractError(f"7z failed (rc={rc})\nSTDOUT:\n{out}\nSTDERR:\n{err}")