series management tuning and import archive extraction troubleshooting
This commit is contained in:
+86
-6
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})();
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>');
|
||||
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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
+62
-51
@@ -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/<path:filename>` | 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/<path:filename>` | 162-220 | Serve image files |
|
||||
|
||||
### Tag API
|
||||
|
||||
| Route | Line | Purpose |
|
||||
|-------|------|---------|
|
||||
| `GET /api/tags/search` | 497-537 | Tag autocomplete search |
|
||||
| `GET /image/<id>/tags` | 540-543 | List tags for image |
|
||||
| `POST /image/<id>/tags/add` | 546-573 | Add tag to image |
|
||||
| `POST /image/<id>/tags/remove` | 576-587 | Remove tag from image |
|
||||
| `GET /api/tag/<id>` | 594-605 | Get tag details |
|
||||
| `GET /api/post-metadata/<id>` | 608-651 | Get post metadata for a post tag |
|
||||
| `GET /api/post-metadata/by-tag-name/<name>` | 654-685 | Get post metadata by tag name |
|
||||
| `POST /api/tag/<id>/update` | 688-764 | Update/merge tag |
|
||||
| `POST /api/tag/<id>/delete` | 767-776 | Delete tag |
|
||||
| `GET /api/tag/<id>/image-count` | 842-847 | Count images with tag |
|
||||
| `GET /api/tags/search` | 532-579 | Tag autocomplete search (supports `kind` filter) |
|
||||
| `GET /image/<id>/tags` | 581-585 | List tags for image |
|
||||
| `POST /image/<id>/tags/add` | 587-615 | Add tag to image |
|
||||
| `POST /image/<id>/tags/remove` | 617-633 | Remove tag from image |
|
||||
| `GET /api/tag/<id>` | 635-647 | Get tag details |
|
||||
| `GET /api/post-metadata/<id>` | 649-680 | Get post metadata for a post tag |
|
||||
| `GET /api/post-metadata/by-tag-name/<name>` | 682-714 | Get post metadata by tag name |
|
||||
| `POST /api/tag/<id>/update` | 716-793 | Update/merge tag |
|
||||
| `POST /api/tag/<id>/delete` | 795-809 | Delete tag |
|
||||
| `GET /api/tag/<id>/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/<tag_id>` | 1620-1650 | Reader view for a series (vertical scroll) |
|
||||
| `GET /api/series/<tag_id>/pages` | 1653-1685 | Get all pages in a series |
|
||||
| `POST /api/series/<tag_id>/pages/add` | 1688-1737 | Add single image to series with page number |
|
||||
| `POST /api/series/<tag_id>/pages/bulk-add` | 1740-1800 | Add multiple images with sequential page numbers |
|
||||
| `POST /api/series/page/<page_id>/update` | 1803-1828 | Update a page's page number |
|
||||
| `POST /api/series/page/<page_id>/delete` | 1831-1841 | Remove image from series |
|
||||
| `GET /api/image/<image_id>/series-info` | 1844-1858 | Check if image is in a series |
|
||||
| `GET /read/<tag_id>` | 1731-1763 | Reader view for a series (vertical scroll) |
|
||||
| `GET /api/series/<tag_id>/pages` | 1765-1795 | Get all pages in a series |
|
||||
| `POST /api/series/<tag_id>/pages/add` | 1797-1853 | Add single image to series with page number |
|
||||
| `POST /api/series/<tag_id>/pages/bulk-add` | 1855-1915 | Add multiple images with sequential page numbers |
|
||||
| `POST /api/series/page/<page_id>/update` | 1917-1946 | Update a page's page number |
|
||||
| `POST /api/series/page/<page_id>/delete` | 1948-1958 | Remove image from series |
|
||||
| `GET /api/image/<image_id>/series-info` | 1960-1976 | Check if image is in a series |
|
||||
| `POST /api/image/<image_id>/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/<id>` | 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/<id>` | 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
|
||||
|
||||
Reference in New Issue
Block a user