From 9ebeeed133c1fa131d53f5744eae726f57fc0986 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 23 Jan 2026 23:05:25 -0500 Subject: [PATCH] series management tuning and import archive extraction troubleshooting --- app/main.py | 92 ++++++++++++++++++++++-- app/static/js/bulk-select.js | 71 ++++++++++++++++++- app/static/js/modal-pagination.js | 103 ++++++++++++++++++++++++--- app/templates/gallery.html | 14 ++-- app/templates/settings.html | 36 ++++++++++ app/utils/image_importer.py | 21 ++++++ summary.md | 113 ++++++++++++++++-------------- 7 files changed, 373 insertions(+), 77 deletions(-) diff --git a/app/main.py b/app/main.py index 81992b5..6f8366b 100644 --- a/app/main.py +++ b/app/main.py @@ -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//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, diff --git a/app/static/js/bulk-select.js b/app/static/js/bulk-select.js index cecdb46..484204c 100644 --- a/app/static/js/bulk-select.js +++ b/app/static/js/bulk-select.js @@ -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 `${escapeHtml(displayName)}`; + }).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 })(); diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index 85484ef..ce453c9 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -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, '"'); + const escapedDisplay = displayName.replace(//g, '>'); + return `${escapedDisplay}`; + }).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() { diff --git a/app/templates/gallery.html b/app/templates/gallery.html index b29b516..6bd9517 100644 --- a/app/templates/gallery.html +++ b/app/templates/gallery.html @@ -153,7 +153,7 @@ - @@ -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 { diff --git a/app/templates/settings.html b/app/templates/settings.html index b864df7..cd5af9c 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -56,6 +56,7 @@ +
@@ -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()) diff --git a/app/utils/image_importer.py b/app/utils/image_importer.py index e717b23..8669b45 100644 --- a/app/utils/image_importer.py +++ b/app/utils/image_importer.py @@ -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}") diff --git a/summary.md b/summary.md index 84d8d5b..c35c7ca 100644 --- a/summary.md +++ b/summary.md @@ -58,7 +58,7 @@ Relationships: tags (many-to-many via image_tags) ### Tag (lines 42-51) Tagging system for organization. ``` -Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|rating) +Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|fandom|rating) Relationships: images (many-to-many) ``` @@ -211,96 +211,104 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar | Route | Line | Purpose | |-------|------|---------| | `GET /` | 18-30 | Showcase - random images masonry | -| `GET /gallery` | 80-124 | Main gallery with infinite scroll | -| `GET /tags` | 391-475 | Tag explorer (filterable by kind) | -| `GET /artists` | 477-480 | Redirect to tag_list?kind=artist | -| `GET /settings` | 488-490 | Settings page | +| `GET /gallery` | 80-160 | Main gallery with infinite scroll | +| `GET /tags` | 426-510 | Tag explorer (filterable by kind) | +| `GET /artists` | 512-515 | Redirect to tag_list?kind=artist | +| `GET /settings` | 523-529 | Settings page | ### Gallery API | Route | Line | Purpose | |-------|------|---------| | `GET /api/random-images` | 33-77 | Random images for showcase shuffle | -| `GET /api/gallery/scroll` | 188-259 | Infinite scroll pagination | -| `GET /api/gallery/timeline` | 262-316 | Year-month groups for sidebar | -| `GET /api/gallery/jump` | 319-388 | Jump to specific year-month | -| `GET /images/` | 127-129 | Serve image files | +| `GET /api/gallery/scroll` | 223-295 | Infinite scroll pagination | +| `GET /api/gallery/timeline` | 297-352 | Year-month groups for sidebar | +| `GET /api/gallery/jump` | 354-423 | Jump to specific year-month | +| `GET /images/` | 162-220 | Serve image files | ### Tag API | Route | Line | Purpose | |-------|------|---------| -| `GET /api/tags/search` | 497-537 | Tag autocomplete search | -| `GET /image//tags` | 540-543 | List tags for image | -| `POST /image//tags/add` | 546-573 | Add tag to image | -| `POST /image//tags/remove` | 576-587 | Remove tag from image | -| `GET /api/tag/` | 594-605 | Get tag details | -| `GET /api/post-metadata/` | 608-651 | Get post metadata for a post tag | -| `GET /api/post-metadata/by-tag-name/` | 654-685 | Get post metadata by tag name | -| `POST /api/tag//update` | 688-764 | Update/merge tag | -| `POST /api/tag//delete` | 767-776 | Delete tag | -| `GET /api/tag//image-count` | 842-847 | Count images with tag | +| `GET /api/tags/search` | 532-579 | Tag autocomplete search (supports `kind` filter) | +| `GET /image//tags` | 581-585 | List tags for image | +| `POST /image//tags/add` | 587-615 | Add tag to image | +| `POST /image//tags/remove` | 617-633 | Remove tag from image | +| `GET /api/tag/` | 635-647 | Get tag details | +| `GET /api/post-metadata/` | 649-680 | Get post metadata for a post tag | +| `GET /api/post-metadata/by-tag-name/` | 682-714 | Get post metadata by tag name | +| `POST /api/tag//update` | 716-793 | Update/merge tag | +| `POST /api/tag//delete` | 795-809 | Delete tag | +| `GET /api/tag//image-count` | 870-880 | Count images with tag | ### Bulk Operations | Route | Line | Purpose | |-------|------|---------| -| `POST /api/delete-by-tag` | 703-759 | Delete all images with tag | -| `POST /api/images/common-tags` | 1394-1425 | Get common tags across images | -| `POST /api/images/bulk-add-tag` | 1428-1474 | Add tag to multiple images | -| `POST /api/images/bulk-remove-tag` | 1477-1514 | Remove tag from multiple images | +| `POST /api/delete-by-tag` | 811-868 | Delete all images with tag | +| `POST /api/images/common-tags` | 1604-1636 | Get common tags across images | +| `POST /api/images/bulk-add-tag` | 1638-1685 | Add tag to multiple images | +| `POST /api/images/bulk-remove-tag` | 1687-1729 | Remove tag from multiple images | ### Series / Reader API | Route | Line | Purpose | |-------|------|---------| -| `GET /read/` | 1620-1650 | Reader view for a series (vertical scroll) | -| `GET /api/series//pages` | 1653-1685 | Get all pages in a series | -| `POST /api/series//pages/add` | 1688-1737 | Add single image to series with page number | -| `POST /api/series//pages/bulk-add` | 1740-1800 | Add multiple images with sequential page numbers | -| `POST /api/series/page//update` | 1803-1828 | Update a page's page number | -| `POST /api/series/page//delete` | 1831-1841 | Remove image from series | -| `GET /api/image//series-info` | 1844-1858 | Check if image is in a series | +| `GET /read/` | 1731-1763 | Reader view for a series (vertical scroll) | +| `GET /api/series//pages` | 1765-1795 | Get all pages in a series | +| `POST /api/series//pages/add` | 1797-1853 | Add single image to series with page number | +| `POST /api/series//pages/bulk-add` | 1855-1915 | Add multiple images with sequential page numbers | +| `POST /api/series/page//update` | 1917-1946 | Update a page's page number | +| `POST /api/series/page//delete` | 1948-1958 | Remove image from series | +| `GET /api/image//series-info` | 1960-1976 | Check if image is in a series | +| `POST /api/image//add-to-series` | 1978-2070 | Add/update/move image in series | ### Import Queue API | Route | Line | Purpose | |-------|------|---------| -| `POST /api/import/trigger` | 961-981 | Start directory scan | -| `GET /api/import/status` | 984-1042 | Queue status and recent tasks | -| `GET /api/import/task/` | 1045-1082 | Task details | -| `POST /api/import/retry-failed` | 1085-1122 | Retry failed tasks | -| `POST /api/import/clear-completed` | 1125-1144 | Clear completed tasks | -| `GET /api/import/batches` | 1222-1250 | List import batches | +| `POST /api/import/trigger` | 1069-1090 | Start directory scan | +| `GET /api/import/status` | 1092-1151 | Queue status and recent tasks | +| `GET /api/import/task/` | 1153-1191 | Task details | +| `POST /api/import/retry-failed` | 1193-1231 | Retry failed tasks | +| `POST /api/import/clear-completed` | 1233-1253 | Clear completed tasks | +| `GET /api/import/batches` | 1432-1465 | List import batches | ### Settings API | Route | Line | Purpose | |-------|------|---------| -| `GET /api/import-settings` | 774-788 | Get import filter settings | -| `POST /api/import-settings` | 791-821 | Save import filter settings | -| `GET /api/filter-scan` | 828-902 | Scan images against filters | -| `POST /api/filter-delete` | 905-954 | Delete filtered images | +| `GET /api/import-settings` | 882-897 | Get import filter settings | +| `POST /api/import-settings` | 899-934 | Save import filter settings | +| `GET /api/filter-scan` | 936-1011 | Scan images against filters | +| `POST /api/filter-delete` | 1013-1067 | Delete filtered images | ### Thumbnail API | Route | Line | Purpose | |-------|------|---------| -| `POST /api/thumbnails/regenerate` | 1147-1164 | Regenerate all thumbnails | -| `POST /api/thumbnails/regenerate-missing` | 1167-1183 | Generate missing thumbnails | +| `POST /api/thumbnails/regenerate` | 1255-1273 | Regenerate all thumbnails | +| `POST /api/thumbnails/regenerate-missing` | 1275-1292 | Generate missing thumbnails | + +### Maintenance API + +| Route | Line | Purpose | +|-------|------|---------| +| `POST /api/reapply-artist-tags` | 1294-1358 | Re-apply artist tags from file paths | +| `POST /api/cleanup-orphaned-tags` | 1360-1394 | Delete tags with no associated images | ### Celery Status | Route | Line | Purpose | |-------|------|---------| -| `GET /api/celery/status` | 1186-1219 | Worker and queue status | +| `GET /api/celery/status` | 1396-1430 | Worker and queue status | ### Duplicate Detection | Route | Line | Purpose | |-------|------|---------| -| `POST /api/duplicates/scan` | 1257-1343 | Scan for similar images by pHash | -| `POST /api/duplicates/delete` | 1346-1387 | Delete duplicate images | +| `POST /api/duplicates/scan` | 1467-1554 | Scan for similar images by pHash | +| `POST /api/duplicates/delete` | 1556-1602 | Delete duplicate images | --- @@ -313,9 +321,9 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar | `gallery.html` | Main gallery with infinite scroll, timeline sidebar, bulk editor, series editor | | `reader.html` | Series reader with vertical scroll, page jump, thumbnail navigation | | `tags_list.html` | Tag browser with preview images | -| `settings.html` | Import settings, deletion tools, duplicate detection | -| `_gallery_item.html` | Single gallery item partial | -| `_gallery_modal.html` | Image modal with tag editor | +| `settings.html` | Import settings, deletion tools, duplicate detection, maintenance tools | +| `_gallery_item.html` | Single gallery item partial (with tag overlay) | +| `_gallery_modal.html` | Image modal with tag editor and series management | | `_gallery_grid.html` | Gallery grid partial | | `_pagination.html` | Pagination controls | @@ -324,8 +332,8 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar | File | Purpose | |------|---------| | `gallery-infinite.js` | Infinite scroll, timeline navigation | -| `modal-pagination.js` | Modal image navigation | -| `bulk-select.js` | Multi-select and bulk tag editing | +| `modal-pagination.js` | Modal image navigation, tag editing, series management (add/update/move) | +| `bulk-select.js` | Multi-select with ordered selection, bulk tag editing, tag refresh, series bulk-add | | `tag-editor.js` | Tag autocomplete and editing | | `showcase.js` | Showcase shuffle functionality | @@ -357,7 +365,10 @@ Files are stored with hash suffix: `{filename}__{hash[:10]}.{ext}` - `source` - Platform source (prefix: `source:platform`) - `post` - Post reference (prefix: `post:platform:artist:id`) - `user` - User-created tags (no prefix) -- `character`, `series`, `rating` - Content tags +- `character` - Character tags (prefix: `character:name`) +- `series` - Series for ordered reading (prefix: `series:name`) +- `fandom` - Fandom/franchise tags (prefix: `fandom:name`) +- `rating` - Content rating tags (prefix: `rating:level`) ### Import Pipeline 1. `scan_directory` walks `/import/{artist}/` folders