diff --git a/.dockerignore b/.dockerignore index 287cf79..13f7d8e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,28 +1,28 @@ -# Ignore test data -images/ -import/ - -# Ignore Python cache -__pycache__/ -*.pyc -*.pyo -*.pyd - -# Ignore environment files -.env -.env.* - -# Ignore virtual environments -env/ -venv/ - -# Ignore Git files -.git -.gitignore - -# Ignore DB files -imagerepo/ - -# Ignore Docker related files -.dockerignore +# Ignore test data +images/ +import/ + +# Ignore Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Ignore environment files +.env +.env.* + +# Ignore virtual environments +env/ +venv/ + +# Ignore Git files +.git +.gitignore + +# Ignore DB files +imagerepo/ + +# Ignore Docker related files +.dockerignore docker-compose.yml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6eb5949..283b2dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,16 @@ -# Ignore imported and generated image folders -images/ -import/ -imagerepo/ - -# Optional: Ignore SQLite DB, Python cache, virtual env, etc. -*.db -__pycache__/ -*.pyc -*.pyo -*.pyd -env/ -venv/ +# Ignore imported and generated image folders +images/ +import/ +imagerepo/ + +# Optional: Ignore SQLite DB, Python cache, virtual env, etc. +*.db +__pycache__/ +*.pyc +*.pyo +*.pyd +env/ +venv/ + +# Claude files +.claude/ \ No newline at end of file diff --git a/app/main.py b/app/main.py index 6cc0c3b..1c156a7 100644 --- a/app/main.py +++ b/app/main.py @@ -423,7 +423,7 @@ def gallery_jump(): ) -def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3): +def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None): """ Helper function to get tags with counts and preview images. Returns (tag_data list, total_count, has_more). @@ -436,6 +436,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3): count_q = db.session.query(func.count(Tag.id)) if kind: count_q = count_q.filter(Tag.kind == kind) + if search: + count_q = count_q.filter(Tag.name.ilike(f'%{search}%')) total_count = count_q.scalar() # Query 1: Get tags with image counts (paginated) @@ -446,6 +448,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3): if kind: q = q.filter(Tag.kind == kind) + if search: + q = q.filter(Tag.name.ilike(f'%{search}%')) tags_with_counts = q.order_by(Tag.name.asc()).offset(offset).limit(limit).all() @@ -505,22 +509,25 @@ def tag_list(): Generic tag explorer with infinite scroll: /tags -> all tags /tags?kind=user -> only user tags + /tags?search=xyz -> tags matching search term etc. Shows a few preview images per tag. Initially loads 35 tags, more loaded via API as user scrolls. """ kind = request.args.get('kind') + search = request.args.get('search', '').strip() or None limit = 35 images_per_tag = 3 tag_data, total_count, has_more = _get_tags_with_previews( - kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag + kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag, search=search ) return render_template('tags_list.html', tag_data=tag_data, images_per_tag=images_per_tag, active_kind=kind, + search_query=search or '', total_count=total_count, has_more=has_more, page_size=limit) @@ -533,12 +540,13 @@ def api_tags_list(): Returns JSON with tag cards HTML and pagination info. """ kind = request.args.get('kind') + search = request.args.get('search', '').strip() or None offset = request.args.get('offset', 0, type=int) limit = request.args.get('limit', 35, type=int) images_per_tag = 3 tag_data, total_count, has_more = _get_tags_with_previews( - kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag + kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag, search=search ) # Render tag cards to HTML diff --git a/app/static/apple-touch-icon.png b/app/static/apple-touch-icon.png new file mode 100644 index 0000000..840e451 Binary files /dev/null and b/app/static/apple-touch-icon.png differ diff --git a/app/static/favicon-16x16.png b/app/static/favicon-16x16.png new file mode 100644 index 0000000..ae6e2b1 Binary files /dev/null and b/app/static/favicon-16x16.png differ diff --git a/app/static/favicon-180x180.png b/app/static/favicon-180x180.png new file mode 100644 index 0000000..840e451 Binary files /dev/null and b/app/static/favicon-180x180.png differ diff --git a/app/static/favicon-192x192.png b/app/static/favicon-192x192.png new file mode 100644 index 0000000..ba1cfc6 Binary files /dev/null and b/app/static/favicon-192x192.png differ diff --git a/app/static/favicon-32x32.png b/app/static/favicon-32x32.png new file mode 100644 index 0000000..e60a375 Binary files /dev/null and b/app/static/favicon-32x32.png differ diff --git a/app/static/favicon-48x48.png b/app/static/favicon-48x48.png new file mode 100644 index 0000000..41fe36e Binary files /dev/null and b/app/static/favicon-48x48.png differ diff --git a/app/static/favicon-512x512.png b/app/static/favicon-512x512.png new file mode 100644 index 0000000..0da728c Binary files /dev/null and b/app/static/favicon-512x512.png differ diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100644 index 0000000..a2052f5 Binary files /dev/null and b/app/static/favicon.ico differ diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..cad45b0 --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/static/js/dynamic_gallery_paging.js b/app/static/js/dynamic_gallery_paging.js index 2c7bc07..50093e0 100644 --- a/app/static/js/dynamic_gallery_paging.js +++ b/app/static/js/dynamic_gallery_paging.js @@ -1,26 +1,26 @@ -document.addEventListener("DOMContentLoaded", function () { - const url = new URL(window.location.href); - const page = url.searchParams.get("page") || "1"; - const perPage = url.searchParams.get("per_page"); - - if (page !== "1" || perPage) return; - - const grid = document.querySelector(".gallery-grid"); - if (!grid) return; - - const sample = grid.querySelector(".gallery-item"); - if (!sample) return; - - const gridStyle = window.getComputedStyle(grid); - const gridGap = parseFloat(gridStyle.getPropertyValue("gap")) || 0; - const sampleWidth = sample.getBoundingClientRect().width + gridGap; - const containerWidth = grid.getBoundingClientRect().width; - - const columns = Math.floor(containerWidth / sampleWidth); - const calculatedPerPage = columns * 5; - - url.searchParams.set("per_page", calculatedPerPage); - url.searchParams.set("page", "1"); - - window.location.replace(url.toString()); -}); +document.addEventListener("DOMContentLoaded", function () { + const url = new URL(window.location.href); + const page = url.searchParams.get("page") || "1"; + const perPage = url.searchParams.get("per_page"); + + if (page !== "1" || perPage) return; + + const grid = document.querySelector(".gallery-grid"); + if (!grid) return; + + const sample = grid.querySelector(".gallery-item"); + if (!sample) return; + + const gridStyle = window.getComputedStyle(grid); + const gridGap = parseFloat(gridStyle.getPropertyValue("gap")) || 0; + const sampleWidth = sample.getBoundingClientRect().width + gridGap; + const containerWidth = grid.getBoundingClientRect().width; + + const columns = Math.floor(containerWidth / sampleWidth); + const calculatedPerPage = columns * 5; + + url.searchParams.set("per_page", calculatedPerPage); + url.searchParams.set("page", "1"); + + window.location.replace(url.toString()); +}); diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index d5d2151..f73d661 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -1,826 +1,826 @@ -// /app/static/js/view-modal.js - -document.addEventListener('DOMContentLoaded', () => { - const modal = document.getElementById('imageModal'); - const modalPrev = document.getElementById('modalPrev'); - const modalNext = document.getElementById('modalNext'); - const modalClose = document.getElementById('modalClose'); - const modalImageWrapper = document.querySelector('.modal-image-wrapper'); - - // NEW: tag editor elements - const tagEditor = document.getElementById('modalTagEditor'); - const tagList = document.getElementById('modalTagList'); - const tagForm = document.getElementById('modalTagForm'); - const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null; - const tagAutocomplete = document.getElementById('tagAutocomplete'); - - // Series info elements (when image IS in a series) - const seriesInfoEl = document.getElementById('modalSeriesInfo'); - const seriesNameEl = document.getElementById('modalSeriesName'); - const pageNumberInput = document.getElementById('modalPageNumber'); - const updatePageBtn = document.getElementById('updatePageBtn'); - const pageUpdateStatus = document.getElementById('pageUpdateStatus'); - let currentSeriesPageId = null; - - // Add to series elements (when image is NOT in a series) - const addToSeriesEl = document.getElementById('modalAddToSeries'); - const seriesSelect = document.getElementById('seriesSelect'); - const addSeriesPageNumber = document.getElementById('addSeriesPageNumber'); - const addToSeriesBtn = document.getElementById('addToSeriesBtn'); - const addToSeriesStatus = document.getElementById('addToSeriesStatus'); - let seriesTagsLoaded = false; - - // Autocomplete state - let autocompleteItems = []; - let autocompleteSelectedIndex = -1; - let autocompleteDebounce = null; - - let images = Array.from(document.querySelectorAll('.img-clickable')); - let currentIndex = -1; - let isZoomed = false; - let isDragging = false; - let startX, startY, scrollLeft, scrollTop; - let dragStartX = 0; - let dragStartY = 0; - let dragMoved = false; - - // Legacy pagination inputs (for non-infinite-scroll pages) - const hasNextPageInput = document.getElementById('hasNextPage'); - const nextPageUrlInput = document.getElementById('nextPageUrl'); - const hasPrevPageInput = document.getElementById('hasPrevPage'); - const prevPageUrlInput = document.getElementById('prevPageUrl'); - - // Check if we're in infinite scroll mode - // Infinite scroll mode is when galleryState exists (set by gallery.html template) - const isInfiniteScrollMode = typeof window.galleryState !== 'undefined'; - - // --------------------------- - // Tag editor helpers - // --------------------------- - function setEditorImageId(id) { - if (tagEditor) tagEditor.dataset.imageId = id || ''; - } - function getEditorImageId() { - return tagEditor ? tagEditor.dataset.imageId : ''; - } - function getTagIcon(kind) { - const icons = { - artist: '🎨', - archive: '🗜️', - character: '👤', - series: '📺', - fandom: '🎭', - rating: '⚠️', - source: '🌐', - post: '📌' - }; - return icons[kind] || '#'; - } - - function getTagDisplayName(name) { - // Remove prefix if present (e.g., "artist:Name" -> "Name") - return name.includes(':') ? name.split(':', 2)[1] : name; - } - - function renderTags(tags) { - if (!tagList) return; - tagList.innerHTML = ''; - (tags || []).forEach(t => { - const chip = document.createElement('span'); - chip.className = 'tag-chip'; - const icon = getTagIcon(t.kind); - const displayName = getTagDisplayName(t.name); - chip.innerHTML = `${icon} ${displayName} `; - tagList.appendChild(chip); - }); - } - async function loadTags(imageId) { - if (!imageId) { renderTags([]); return; } - try { - const r = await fetch(`/image/${imageId}/tags`); - const j = await r.json(); - if (j.ok) renderTags(j.tags); - } catch { - // ignore - } - } - - // --------------------------- - // 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 icon = getTagIcon(t.kind); - const encodedName = encodeURIComponent(t.name); - const escapedName = t.name.replace(/"/g, '"'); - const escapedDisplay = displayName.replace(//g, '>'); - const prefix = icon !== '#' ? icon + ' ' : '#'; - return `${prefix}${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 - // --------------------------- - function hideSeriesInfo() { - if (seriesInfoEl) seriesInfoEl.style.display = 'none'; - currentSeriesPageId = null; - if (pageUpdateStatus) pageUpdateStatus.textContent = ''; - } - - function hideAddToSeries() { - if (addToSeriesEl) addToSeriesEl.style.display = 'none'; - if (addToSeriesStatus) addToSeriesStatus.textContent = ''; - } - - async function loadSeriesTags() { - 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'); - const j = await r.json(); - const seriesTags = j.tags || []; - - // Clear existing options except the first placeholder - seriesSelect.innerHTML = ''; - - seriesTags.forEach(tag => { - const opt = document.createElement('option'); - opt.value = tag.id; - // Display name without prefix - opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name; - seriesSelect.appendChild(opt); - }); - - seriesTagsLoaded = true; - } catch { - // ignore - } - } - - function showAddToSeries(seriesPage = null) { - if (!addToSeriesEl) return; - 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'; - } - - async function handleAddToSeries() { - const imageId = getEditorImageId(); - const seriesTagId = seriesSelect?.value; - const pageNumber = parseInt(addSeriesPageNumber?.value); - - if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return; - - const isUpdate = addToSeriesBtn?.textContent === 'Update'; - if (addToSeriesBtn) addToSeriesBtn.disabled = true; - if (addToSeriesStatus) addToSeriesStatus.textContent = isUpdate ? 'Updating...' : 'Adding...'; - - try { - const r = await fetch(`/api/image/${imageId}/add-to-series`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber }) - }); - const j = await r.json(); - - if (j.ok) { - if (addToSeriesStatus) { - addToSeriesStatus.textContent = '✓ Saved'; - setTimeout(() => { addToSeriesStatus.textContent = ''; }, 2000); - } - // Fully reload series info to show updated management interface - await loadSeriesInfo(imageId); - } else { - if (addToSeriesStatus) addToSeriesStatus.textContent = j.error || 'Error'; - if (addToSeriesBtn) addToSeriesBtn.disabled = false; - } - } catch (e) { - if (addToSeriesStatus) addToSeriesStatus.textContent = 'Error'; - if (addToSeriesBtn) addToSeriesBtn.disabled = false; - } - } - - function showSeriesInfo(seriesPage) { - if (!seriesInfoEl || !seriesPage) return; - currentSeriesPageId = seriesPage.id; - - // Display series name (strip prefix) - const displayName = seriesPage.series_name.includes(':') - ? seriesPage.series_name.split(':', 1)[1] - : seriesPage.series_name; - if (seriesNameEl) { - seriesNameEl.textContent = displayName; - seriesNameEl.href = `/gallery?tag=${encodeURIComponent(seriesPage.series_name)}`; - } - - // Display page number - if (pageNumberInput) { - pageNumberInput.value = seriesPage.page_number; - } - - if (pageUpdateStatus) pageUpdateStatus.textContent = ''; - seriesInfoEl.style.display = 'block'; - } - - async function loadSeriesInfo(imageId) { - hideSeriesInfo(); - hideAddToSeries(); - if (!imageId) return; - try { - 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 - // Auto-select if image has exactly one series tag - const seriesTags = j.series_tags || []; - if (seriesTags.length === 1) { - showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 }); - } else { - showAddToSeries(); - } - } - } catch { - // ignore - } - } - - async function updatePageNumber() { - if (!currentSeriesPageId || !pageNumberInput) return; - const newPageNumber = parseInt(pageNumberInput.value); - if (isNaN(newPageNumber) || newPageNumber < 1) { - if (pageUpdateStatus) pageUpdateStatus.textContent = 'Invalid'; - return; - } - - if (updatePageBtn) updatePageBtn.disabled = true; - if (pageUpdateStatus) pageUpdateStatus.textContent = 'Saving...'; - - try { - const fd = new FormData(); - fd.append('page_number', newPageNumber); - const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, { - method: 'POST', - body: fd - }); - const j = await r.json(); - if (j.ok) { - if (pageUpdateStatus) { - pageUpdateStatus.textContent = '✓'; - setTimeout(() => { pageUpdateStatus.textContent = ''; }, 2000); - } - } else { - if (pageUpdateStatus) pageUpdateStatus.textContent = j.error || 'Error'; - } - } catch (e) { - if (pageUpdateStatus) pageUpdateStatus.textContent = 'Error'; - } finally { - if (updatePageBtn) updatePageBtn.disabled = false; - } - } - - // Wire up the save button - if (updatePageBtn) { - updatePageBtn.addEventListener('click', updatePageNumber); - } - - // Allow Enter key in page number input to save - if (pageNumberInput) { - pageNumberInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - updatePageNumber(); - } - }); - } - - // Enable/disable add button based on series selection - if (seriesSelect) { - 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'; - } - } - }); - } - - // Add to series button click - if (addToSeriesBtn) { - addToSeriesBtn.addEventListener('click', handleAddToSeries); - } - - // Allow Enter key in add-to-series page number input - if (addSeriesPageNumber) { - addSeriesPageNumber.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && seriesSelect?.value) { - e.preventDefault(); - handleAddToSeries(); - } - }); - } - - async function addTag(imageId, name) { - const fd = new FormData(); - fd.append('name', name); - const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd }); - const j = await r.json(); - if (j.ok) { - await loadTags(imageId); - return true; - } - return false; - } - async function removeTag(imageId, name) { - const fd = new FormData(); - fd.append('name', name); - const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd }); - const j = await r.json(); - if (j.ok) { - await loadTags(imageId); - return true; - } - return false; - } - - // --------------------------- - // Autocomplete helpers - // --------------------------- - function hideAutocomplete() { - if (tagAutocomplete) { - tagAutocomplete.classList.remove('active'); - tagAutocomplete.innerHTML = ''; - } - autocompleteItems = []; - autocompleteSelectedIndex = -1; - } - - function positionAutocomplete() { - if (!tagAutocomplete || !tagInput) return; - const rect = tagInput.getBoundingClientRect(); - tagAutocomplete.style.top = `${rect.bottom + 4}px`; - tagAutocomplete.style.left = `${rect.left}px`; - tagAutocomplete.style.width = `${rect.width}px`; - } - - function renderAutocomplete(tags) { - if (!tagAutocomplete) return; - autocompleteItems = tags; - autocompleteSelectedIndex = -1; - - if (tags.length === 0) { - tagAutocomplete.innerHTML = '
No matching tags
'; - positionAutocomplete(); - tagAutocomplete.classList.add('active'); - return; - } - - tagAutocomplete.innerHTML = tags.map((t, i) => { - const icon = getTagIcon(t.kind); - const displayName = getTagDisplayName(t.name); - return `
- ${icon} ${displayName} - ${t.kind || 'user'} -
`; - }).join(''); - positionAutocomplete(); - tagAutocomplete.classList.add('active'); - } - - function selectAutocompleteItem(index) { - const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item'); - if (!items) return; - items.forEach((el, i) => { - el.classList.toggle('selected', i === index); - }); - autocompleteSelectedIndex = index; - } - - async function fetchAutocomplete(query) { - try { - // Exclude system-managed tag kinds from autocomplete (archive, post, source) - const excludeKinds = 'archive,post,source'; - const url = query - ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}` - : `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`; - const r = await fetch(url); - const j = await r.json(); - renderAutocomplete(j.tags || []); - } catch { - hideAutocomplete(); - } - } - - // Shared function to submit a tag (avoids page refresh issues with form dispatch) - async function submitTag() { - const id = getEditorImageId(); - const name = (tagInput?.value || '').trim(); - if (!id || !name) return; - hideAutocomplete(); - await addTag(id, name); - if (tagInput) tagInput.value = ''; - } - - if (tagInput) { - // Show autocomplete on focus - tagInput.addEventListener('focus', () => { - const val = tagInput.value.trim(); - fetchAutocomplete(val); - }); - - // Hide autocomplete on blur (with delay for click) - tagInput.addEventListener('blur', () => { - setTimeout(hideAutocomplete, 200); - }); - - // Search as user types - tagInput.addEventListener('input', () => { - clearTimeout(autocompleteDebounce); - autocompleteDebounce = setTimeout(() => { - fetchAutocomplete(tagInput.value.trim()); - }, 150); - }); - - // Keyboard navigation - tagInput.addEventListener('keydown', (e) => { - if (!tagAutocomplete?.classList.contains('active')) return; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1); - selectAutocompleteItem(nextIndex); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0); - selectAutocompleteItem(prevIndex); - } else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) { - e.preventDefault(); - const selected = autocompleteItems[autocompleteSelectedIndex]; - if (selected) { - tagInput.value = selected.name; - submitTag(); - } - } else if (e.key === 'Escape') { - hideAutocomplete(); - } - }); - } - - // Click on autocomplete item - use mousedown to fire before blur - if (tagAutocomplete) { - tagAutocomplete.addEventListener('mousedown', (e) => { - e.preventDefault(); // Prevent blur from firing - e.stopPropagation(); - const item = e.target.closest('.tag-autocomplete-item'); - if (!item) return; - const name = item.dataset.name; - if (tagInput && name) { - tagInput.value = name; - submitTag(); - } - }); - } - - if (tagForm) { - tagForm.addEventListener('submit', async (e) => { - e.preventDefault(); - await submitTag(); - }); - } - if (tagList) { - tagList.addEventListener('click', async (e) => { - const btn = e.target.closest('button.x'); - if (!btn) return; - const id = getEditorImageId(); - const name = btn.dataset.name; - if (!id || !name) return; - await removeTag(id, name); - }); - } - - // --------------------------- - // Existing viewer logic - // --------------------------- - function updateImage(index) { - const img = images[index]; - const fileType = img.dataset.type || "image"; - const imageId = img.dataset.id || ""; - - // clear wrapper - modalImageWrapper.innerHTML = ""; - - if (fileType === "video") { - const video = document.createElement("video"); - video.src = img.dataset.full; - video.controls = true; - video.autoplay = true; - video.style.maxWidth = "100%"; - video.style.maxHeight = "100%"; - modalImageWrapper.appendChild(video); - } else { - const image = document.createElement("img"); - image.src = img.dataset.full; - image.alt = "Full View"; - image.setAttribute("draggable", "false"); - image.id = "modalImage"; - modalImageWrapper.appendChild(image); - } - - currentIndex = index; - resetZoom(); - - // NEW: load tags for this image into the modal editor - setEditorImageId(imageId); - loadTags(imageId); - loadSeriesInfo(imageId); - } - - function openModal(index) { - modal.classList.add('active'); - updateImage(index); - history.replaceState({ modalIndex: index }, '', window.location.href); - // Focus the tag input after modal opens (desktop only - avoids keyboard popup on mobile) - if (tagInput && !isTouchDevice()) { - setTimeout(() => tagInput.focus(), 100); - } - } - - // Detect touch devices to avoid auto-focus keyboard popup - function isTouchDevice() { - return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); - } - - 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; - resetZoom(); - // clear tag UI - setEditorImageId(''); - renderTags([]); - 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() { - if (currentIndex < images.length - 1) { - updateImage(currentIndex + 1); - } else if (isInfiniteScrollMode) { - // In infinite scroll mode, just stay at the last image - // User needs to scroll the gallery to load more - return; - } else if (hasNextPageInput?.value === "true") { - // Legacy pagination mode - fetch next page - fetch(nextPageUrlInput.value) - .then(res => res.text()) - .then(html => { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const newGallery = doc.querySelector('.gallery-grid'); - const newImages = doc.querySelectorAll('.img-clickable'); - - if (newGallery && newImages.length > 0) { - const gallery = document.querySelector('.gallery-grid'); - gallery.innerHTML = newGallery.innerHTML; - - images = Array.from(document.querySelectorAll('.img-clickable')); - images.forEach((img, i) => { - img.addEventListener('click', () => openModal(i)); - }); - - hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false"; - nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || ""; - hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false"; - prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || ""; - - history.pushState({}, '', nextPageUrlInput.value); - openModal(0); - } - }); - } - } - - function showPrev() { - if (currentIndex > 0) { - updateImage(currentIndex - 1); - } else if (isInfiniteScrollMode) { - // In infinite scroll mode, just stay at the first image - return; - } else if (hasPrevPageInput?.value === "true") { - // Legacy pagination mode - fetch previous page - fetch(prevPageUrlInput.value) - .then(res => res.text()) - .then(html => { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const newGallery = doc.querySelector('.gallery-grid'); - const newImages = doc.querySelectorAll('.img-clickable'); - - if (newGallery && newImages.length > 0) { - const gallery = document.querySelector('.gallery-grid'); - gallery.innerHTML = newGallery.innerHTML; - - images = Array.from(document.querySelectorAll('.img-clickable')); - images.forEach((img, i) => { - img.addEventListener('click', () => openModal(i)); - }); - - hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false"; - nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || ""; - hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false"; - prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || ""; - - history.pushState({}, '', prevPageUrlInput.value); - openModal(images.length - 1); - } - }); - } - } - - function toggleZoom() { - const isImage = modalImageWrapper.querySelector('img') !== null; - if (!isImage) return; - isZoomed = !isZoomed; - modalImageWrapper.classList.toggle('zoomed', isZoomed); - modalImageWrapper.style.cursor = isZoomed ? 'grab' : ''; - if (!isZoomed) { - modalImageWrapper.scrollLeft = 0; - modalImageWrapper.scrollTop = 0; - } - } - - function resetZoom() { - isZoomed = false; - modalImageWrapper.classList.remove('zoomed'); - modalImageWrapper.scrollLeft = 0; - modalImageWrapper.scrollTop = 0; - modalImageWrapper.style.cursor = ''; - } - - modalImageWrapper.addEventListener('mousedown', (e) => { - const isImage = modalImageWrapper.querySelector('img') !== null; - if (!isZoomed || !isImage) return; - isDragging = true; - dragMoved = false; - modalImageWrapper.style.cursor = 'grabbing'; - startX = e.pageX - modalImageWrapper.offsetLeft; - startY = e.pageY - modalImageWrapper.offsetTop; - scrollLeft = modalImageWrapper.scrollLeft; - scrollTop = modalImageWrapper.scrollTop; - dragStartX = e.pageX; - dragStartY = e.pageY; - }); - - modalImageWrapper.addEventListener('mouseleave', () => { - isDragging = false; - const isImage = modalImageWrapper.querySelector('img') !== null; - if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab'; - }); - - modalImageWrapper.addEventListener('mouseup', () => { - isDragging = false; - const isImage = modalImageWrapper.querySelector('img') !== null; - if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab'; - }); - - modalImageWrapper.addEventListener('mousemove', (e) => { - const isImage = modalImageWrapper.querySelector('img') !== null; - if (!isDragging || !isZoomed || !isImage) return; - e.preventDefault(); - dragMoved = true; - const x = e.pageX - modalImageWrapper.offsetLeft; - const y = e.pageY - modalImageWrapper.offsetTop; - const walkX = x - startX; - const walkY = y - startY; - modalImageWrapper.scrollLeft = scrollLeft - walkX; - modalImageWrapper.scrollTop = scrollTop - walkY; - }); - - modalImageWrapper.addEventListener('click', (e) => { - if (dragMoved) { - dragMoved = false; - return; - } - toggleZoom(); - }); - - modalClose?.addEventListener('click', closeModal); - modalPrev?.addEventListener('click', showPrev); - modalNext?.addEventListener('click', showNext); - - modal.addEventListener('click', (e) => { - if (e.target === modal) closeModal(); - }); - - document.addEventListener('keydown', (e) => { - if (!modal.classList.contains('active')) return; - if (e.key === 'ArrowRight') showNext(); - if (e.key === 'ArrowLeft') showPrev(); - if (e.key === 'Escape') closeModal(); - }); - - // Use event delegation on the document for .img-clickable clicks - // This allows dynamically added elements (e.g., from shuffle) to work - document.addEventListener('click', (e) => { - const clickable = e.target.closest('.img-clickable'); - if (!clickable) return; - - // Re-query images to get current state (may have changed from shuffle) - images = Array.from(document.querySelectorAll('.img-clickable')); - const index = images.indexOf(clickable); - if (index !== -1) { - openModal(index); - } - }); - - // Listen for showcase updates to refresh the images array - document.addEventListener('showcaseUpdated', () => { - images = Array.from(document.querySelectorAll('.img-clickable')); - }); - - // Listen for gallery infinite scroll updates to refresh the images array - document.addEventListener('galleryUpdated', () => { - images = Array.from(document.querySelectorAll('.img-clickable')); - }); - - window.addEventListener('popstate', () => { - if (modal.classList.contains('active')) { - closeModal(); - } else { - location.reload(); - } - }); -}); +// /app/static/js/view-modal.js + +document.addEventListener('DOMContentLoaded', () => { + const modal = document.getElementById('imageModal'); + const modalPrev = document.getElementById('modalPrev'); + const modalNext = document.getElementById('modalNext'); + const modalClose = document.getElementById('modalClose'); + const modalImageWrapper = document.querySelector('.modal-image-wrapper'); + + // NEW: tag editor elements + const tagEditor = document.getElementById('modalTagEditor'); + const tagList = document.getElementById('modalTagList'); + const tagForm = document.getElementById('modalTagForm'); + const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null; + const tagAutocomplete = document.getElementById('tagAutocomplete'); + + // Series info elements (when image IS in a series) + const seriesInfoEl = document.getElementById('modalSeriesInfo'); + const seriesNameEl = document.getElementById('modalSeriesName'); + const pageNumberInput = document.getElementById('modalPageNumber'); + const updatePageBtn = document.getElementById('updatePageBtn'); + const pageUpdateStatus = document.getElementById('pageUpdateStatus'); + let currentSeriesPageId = null; + + // Add to series elements (when image is NOT in a series) + const addToSeriesEl = document.getElementById('modalAddToSeries'); + const seriesSelect = document.getElementById('seriesSelect'); + const addSeriesPageNumber = document.getElementById('addSeriesPageNumber'); + const addToSeriesBtn = document.getElementById('addToSeriesBtn'); + const addToSeriesStatus = document.getElementById('addToSeriesStatus'); + let seriesTagsLoaded = false; + + // Autocomplete state + let autocompleteItems = []; + let autocompleteSelectedIndex = -1; + let autocompleteDebounce = null; + + let images = Array.from(document.querySelectorAll('.img-clickable')); + let currentIndex = -1; + let isZoomed = false; + let isDragging = false; + let startX, startY, scrollLeft, scrollTop; + let dragStartX = 0; + let dragStartY = 0; + let dragMoved = false; + + // Legacy pagination inputs (for non-infinite-scroll pages) + const hasNextPageInput = document.getElementById('hasNextPage'); + const nextPageUrlInput = document.getElementById('nextPageUrl'); + const hasPrevPageInput = document.getElementById('hasPrevPage'); + const prevPageUrlInput = document.getElementById('prevPageUrl'); + + // Check if we're in infinite scroll mode + // Infinite scroll mode is when galleryState exists (set by gallery.html template) + const isInfiniteScrollMode = typeof window.galleryState !== 'undefined'; + + // --------------------------- + // Tag editor helpers + // --------------------------- + function setEditorImageId(id) { + if (tagEditor) tagEditor.dataset.imageId = id || ''; + } + function getEditorImageId() { + return tagEditor ? tagEditor.dataset.imageId : ''; + } + function getTagIcon(kind) { + const icons = { + artist: '🎨', + archive: '🗜️', + character: '👤', + series: '📺', + fandom: '🎭', + rating: '⚠️', + source: '🌐', + post: '📌' + }; + return icons[kind] || '#'; + } + + function getTagDisplayName(name) { + // Remove prefix if present (e.g., "artist:Name" -> "Name") + return name.includes(':') ? name.split(':', 2)[1] : name; + } + + function renderTags(tags) { + if (!tagList) return; + tagList.innerHTML = ''; + (tags || []).forEach(t => { + const chip = document.createElement('span'); + chip.className = 'tag-chip'; + const icon = getTagIcon(t.kind); + const displayName = getTagDisplayName(t.name); + chip.innerHTML = `${icon} ${displayName} `; + tagList.appendChild(chip); + }); + } + async function loadTags(imageId) { + if (!imageId) { renderTags([]); return; } + try { + const r = await fetch(`/image/${imageId}/tags`); + const j = await r.json(); + if (j.ok) renderTags(j.tags); + } catch { + // ignore + } + } + + // --------------------------- + // 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 icon = getTagIcon(t.kind); + const encodedName = encodeURIComponent(t.name); + const escapedName = t.name.replace(/"/g, '"'); + const escapedDisplay = displayName.replace(//g, '>'); + const prefix = icon !== '#' ? icon + ' ' : '#'; + return `${prefix}${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 + // --------------------------- + function hideSeriesInfo() { + if (seriesInfoEl) seriesInfoEl.style.display = 'none'; + currentSeriesPageId = null; + if (pageUpdateStatus) pageUpdateStatus.textContent = ''; + } + + function hideAddToSeries() { + if (addToSeriesEl) addToSeriesEl.style.display = 'none'; + if (addToSeriesStatus) addToSeriesStatus.textContent = ''; + } + + async function loadSeriesTags() { + 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'); + const j = await r.json(); + const seriesTags = j.tags || []; + + // Clear existing options except the first placeholder + seriesSelect.innerHTML = ''; + + seriesTags.forEach(tag => { + const opt = document.createElement('option'); + opt.value = tag.id; + // Display name without prefix + opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name; + seriesSelect.appendChild(opt); + }); + + seriesTagsLoaded = true; + } catch { + // ignore + } + } + + function showAddToSeries(seriesPage = null) { + if (!addToSeriesEl) return; + 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'; + } + + async function handleAddToSeries() { + const imageId = getEditorImageId(); + const seriesTagId = seriesSelect?.value; + const pageNumber = parseInt(addSeriesPageNumber?.value); + + if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return; + + const isUpdate = addToSeriesBtn?.textContent === 'Update'; + if (addToSeriesBtn) addToSeriesBtn.disabled = true; + if (addToSeriesStatus) addToSeriesStatus.textContent = isUpdate ? 'Updating...' : 'Adding...'; + + try { + const r = await fetch(`/api/image/${imageId}/add-to-series`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber }) + }); + const j = await r.json(); + + if (j.ok) { + if (addToSeriesStatus) { + addToSeriesStatus.textContent = '✓ Saved'; + setTimeout(() => { addToSeriesStatus.textContent = ''; }, 2000); + } + // Fully reload series info to show updated management interface + await loadSeriesInfo(imageId); + } else { + if (addToSeriesStatus) addToSeriesStatus.textContent = j.error || 'Error'; + if (addToSeriesBtn) addToSeriesBtn.disabled = false; + } + } catch (e) { + if (addToSeriesStatus) addToSeriesStatus.textContent = 'Error'; + if (addToSeriesBtn) addToSeriesBtn.disabled = false; + } + } + + function showSeriesInfo(seriesPage) { + if (!seriesInfoEl || !seriesPage) return; + currentSeriesPageId = seriesPage.id; + + // Display series name (strip prefix) + const displayName = seriesPage.series_name.includes(':') + ? seriesPage.series_name.split(':', 1)[1] + : seriesPage.series_name; + if (seriesNameEl) { + seriesNameEl.textContent = displayName; + seriesNameEl.href = `/gallery?tag=${encodeURIComponent(seriesPage.series_name)}`; + } + + // Display page number + if (pageNumberInput) { + pageNumberInput.value = seriesPage.page_number; + } + + if (pageUpdateStatus) pageUpdateStatus.textContent = ''; + seriesInfoEl.style.display = 'block'; + } + + async function loadSeriesInfo(imageId) { + hideSeriesInfo(); + hideAddToSeries(); + if (!imageId) return; + try { + 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 + // Auto-select if image has exactly one series tag + const seriesTags = j.series_tags || []; + if (seriesTags.length === 1) { + showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 }); + } else { + showAddToSeries(); + } + } + } catch { + // ignore + } + } + + async function updatePageNumber() { + if (!currentSeriesPageId || !pageNumberInput) return; + const newPageNumber = parseInt(pageNumberInput.value); + if (isNaN(newPageNumber) || newPageNumber < 1) { + if (pageUpdateStatus) pageUpdateStatus.textContent = 'Invalid'; + return; + } + + if (updatePageBtn) updatePageBtn.disabled = true; + if (pageUpdateStatus) pageUpdateStatus.textContent = 'Saving...'; + + try { + const fd = new FormData(); + fd.append('page_number', newPageNumber); + const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, { + method: 'POST', + body: fd + }); + const j = await r.json(); + if (j.ok) { + if (pageUpdateStatus) { + pageUpdateStatus.textContent = '✓'; + setTimeout(() => { pageUpdateStatus.textContent = ''; }, 2000); + } + } else { + if (pageUpdateStatus) pageUpdateStatus.textContent = j.error || 'Error'; + } + } catch (e) { + if (pageUpdateStatus) pageUpdateStatus.textContent = 'Error'; + } finally { + if (updatePageBtn) updatePageBtn.disabled = false; + } + } + + // Wire up the save button + if (updatePageBtn) { + updatePageBtn.addEventListener('click', updatePageNumber); + } + + // Allow Enter key in page number input to save + if (pageNumberInput) { + pageNumberInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + updatePageNumber(); + } + }); + } + + // Enable/disable add button based on series selection + if (seriesSelect) { + 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'; + } + } + }); + } + + // Add to series button click + if (addToSeriesBtn) { + addToSeriesBtn.addEventListener('click', handleAddToSeries); + } + + // Allow Enter key in add-to-series page number input + if (addSeriesPageNumber) { + addSeriesPageNumber.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && seriesSelect?.value) { + e.preventDefault(); + handleAddToSeries(); + } + }); + } + + async function addTag(imageId, name) { + const fd = new FormData(); + fd.append('name', name); + const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd }); + const j = await r.json(); + if (j.ok) { + await loadTags(imageId); + return true; + } + return false; + } + async function removeTag(imageId, name) { + const fd = new FormData(); + fd.append('name', name); + const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd }); + const j = await r.json(); + if (j.ok) { + await loadTags(imageId); + return true; + } + return false; + } + + // --------------------------- + // Autocomplete helpers + // --------------------------- + function hideAutocomplete() { + if (tagAutocomplete) { + tagAutocomplete.classList.remove('active'); + tagAutocomplete.innerHTML = ''; + } + autocompleteItems = []; + autocompleteSelectedIndex = -1; + } + + function positionAutocomplete() { + if (!tagAutocomplete || !tagInput) return; + const rect = tagInput.getBoundingClientRect(); + tagAutocomplete.style.top = `${rect.bottom + 4}px`; + tagAutocomplete.style.left = `${rect.left}px`; + tagAutocomplete.style.width = `${rect.width}px`; + } + + function renderAutocomplete(tags) { + if (!tagAutocomplete) return; + autocompleteItems = tags; + autocompleteSelectedIndex = -1; + + if (tags.length === 0) { + tagAutocomplete.innerHTML = '
No matching tags
'; + positionAutocomplete(); + tagAutocomplete.classList.add('active'); + return; + } + + tagAutocomplete.innerHTML = tags.map((t, i) => { + const icon = getTagIcon(t.kind); + const displayName = getTagDisplayName(t.name); + return `
+ ${icon} ${displayName} + ${t.kind || 'user'} +
`; + }).join(''); + positionAutocomplete(); + tagAutocomplete.classList.add('active'); + } + + function selectAutocompleteItem(index) { + const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item'); + if (!items) return; + items.forEach((el, i) => { + el.classList.toggle('selected', i === index); + }); + autocompleteSelectedIndex = index; + } + + async function fetchAutocomplete(query) { + try { + // Exclude system-managed tag kinds from autocomplete (archive, post, source) + const excludeKinds = 'archive,post,source'; + const url = query + ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}` + : `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`; + const r = await fetch(url); + const j = await r.json(); + renderAutocomplete(j.tags || []); + } catch { + hideAutocomplete(); + } + } + + // Shared function to submit a tag (avoids page refresh issues with form dispatch) + async function submitTag() { + const id = getEditorImageId(); + const name = (tagInput?.value || '').trim(); + if (!id || !name) return; + hideAutocomplete(); + await addTag(id, name); + if (tagInput) tagInput.value = ''; + } + + if (tagInput) { + // Show autocomplete on focus + tagInput.addEventListener('focus', () => { + const val = tagInput.value.trim(); + fetchAutocomplete(val); + }); + + // Hide autocomplete on blur (with delay for click) + tagInput.addEventListener('blur', () => { + setTimeout(hideAutocomplete, 200); + }); + + // Search as user types + tagInput.addEventListener('input', () => { + clearTimeout(autocompleteDebounce); + autocompleteDebounce = setTimeout(() => { + fetchAutocomplete(tagInput.value.trim()); + }, 150); + }); + + // Keyboard navigation + tagInput.addEventListener('keydown', (e) => { + if (!tagAutocomplete?.classList.contains('active')) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1); + selectAutocompleteItem(nextIndex); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0); + selectAutocompleteItem(prevIndex); + } else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) { + e.preventDefault(); + const selected = autocompleteItems[autocompleteSelectedIndex]; + if (selected) { + tagInput.value = selected.name; + submitTag(); + } + } else if (e.key === 'Escape') { + hideAutocomplete(); + } + }); + } + + // Click on autocomplete item - use mousedown to fire before blur + if (tagAutocomplete) { + tagAutocomplete.addEventListener('mousedown', (e) => { + e.preventDefault(); // Prevent blur from firing + e.stopPropagation(); + const item = e.target.closest('.tag-autocomplete-item'); + if (!item) return; + const name = item.dataset.name; + if (tagInput && name) { + tagInput.value = name; + submitTag(); + } + }); + } + + if (tagForm) { + tagForm.addEventListener('submit', async (e) => { + e.preventDefault(); + await submitTag(); + }); + } + if (tagList) { + tagList.addEventListener('click', async (e) => { + const btn = e.target.closest('button.x'); + if (!btn) return; + const id = getEditorImageId(); + const name = btn.dataset.name; + if (!id || !name) return; + await removeTag(id, name); + }); + } + + // --------------------------- + // Existing viewer logic + // --------------------------- + function updateImage(index) { + const img = images[index]; + const fileType = img.dataset.type || "image"; + const imageId = img.dataset.id || ""; + + // clear wrapper + modalImageWrapper.innerHTML = ""; + + if (fileType === "video") { + const video = document.createElement("video"); + video.src = img.dataset.full; + video.controls = true; + video.autoplay = true; + video.style.maxWidth = "100%"; + video.style.maxHeight = "100%"; + modalImageWrapper.appendChild(video); + } else { + const image = document.createElement("img"); + image.src = img.dataset.full; + image.alt = "Full View"; + image.setAttribute("draggable", "false"); + image.id = "modalImage"; + modalImageWrapper.appendChild(image); + } + + currentIndex = index; + resetZoom(); + + // NEW: load tags for this image into the modal editor + setEditorImageId(imageId); + loadTags(imageId); + loadSeriesInfo(imageId); + } + + function openModal(index) { + modal.classList.add('active'); + updateImage(index); + history.replaceState({ modalIndex: index }, '', window.location.href); + // Focus the tag input after modal opens (desktop only - avoids keyboard popup on mobile) + if (tagInput && !isTouchDevice()) { + setTimeout(() => tagInput.focus(), 100); + } + } + + // Detect touch devices to avoid auto-focus keyboard popup + function isTouchDevice() { + return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); + } + + 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; + resetZoom(); + // clear tag UI + setEditorImageId(''); + renderTags([]); + 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() { + if (currentIndex < images.length - 1) { + updateImage(currentIndex + 1); + } else if (isInfiniteScrollMode) { + // In infinite scroll mode, just stay at the last image + // User needs to scroll the gallery to load more + return; + } else if (hasNextPageInput?.value === "true") { + // Legacy pagination mode - fetch next page + fetch(nextPageUrlInput.value) + .then(res => res.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newGallery = doc.querySelector('.gallery-grid'); + const newImages = doc.querySelectorAll('.img-clickable'); + + if (newGallery && newImages.length > 0) { + const gallery = document.querySelector('.gallery-grid'); + gallery.innerHTML = newGallery.innerHTML; + + images = Array.from(document.querySelectorAll('.img-clickable')); + images.forEach((img, i) => { + img.addEventListener('click', () => openModal(i)); + }); + + hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false"; + nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || ""; + hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false"; + prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || ""; + + history.pushState({}, '', nextPageUrlInput.value); + openModal(0); + } + }); + } + } + + function showPrev() { + if (currentIndex > 0) { + updateImage(currentIndex - 1); + } else if (isInfiniteScrollMode) { + // In infinite scroll mode, just stay at the first image + return; + } else if (hasPrevPageInput?.value === "true") { + // Legacy pagination mode - fetch previous page + fetch(prevPageUrlInput.value) + .then(res => res.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newGallery = doc.querySelector('.gallery-grid'); + const newImages = doc.querySelectorAll('.img-clickable'); + + if (newGallery && newImages.length > 0) { + const gallery = document.querySelector('.gallery-grid'); + gallery.innerHTML = newGallery.innerHTML; + + images = Array.from(document.querySelectorAll('.img-clickable')); + images.forEach((img, i) => { + img.addEventListener('click', () => openModal(i)); + }); + + hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false"; + nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || ""; + hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false"; + prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || ""; + + history.pushState({}, '', prevPageUrlInput.value); + openModal(images.length - 1); + } + }); + } + } + + function toggleZoom() { + const isImage = modalImageWrapper.querySelector('img') !== null; + if (!isImage) return; + isZoomed = !isZoomed; + modalImageWrapper.classList.toggle('zoomed', isZoomed); + modalImageWrapper.style.cursor = isZoomed ? 'grab' : ''; + if (!isZoomed) { + modalImageWrapper.scrollLeft = 0; + modalImageWrapper.scrollTop = 0; + } + } + + function resetZoom() { + isZoomed = false; + modalImageWrapper.classList.remove('zoomed'); + modalImageWrapper.scrollLeft = 0; + modalImageWrapper.scrollTop = 0; + modalImageWrapper.style.cursor = ''; + } + + modalImageWrapper.addEventListener('mousedown', (e) => { + const isImage = modalImageWrapper.querySelector('img') !== null; + if (!isZoomed || !isImage) return; + isDragging = true; + dragMoved = false; + modalImageWrapper.style.cursor = 'grabbing'; + startX = e.pageX - modalImageWrapper.offsetLeft; + startY = e.pageY - modalImageWrapper.offsetTop; + scrollLeft = modalImageWrapper.scrollLeft; + scrollTop = modalImageWrapper.scrollTop; + dragStartX = e.pageX; + dragStartY = e.pageY; + }); + + modalImageWrapper.addEventListener('mouseleave', () => { + isDragging = false; + const isImage = modalImageWrapper.querySelector('img') !== null; + if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab'; + }); + + modalImageWrapper.addEventListener('mouseup', () => { + isDragging = false; + const isImage = modalImageWrapper.querySelector('img') !== null; + if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab'; + }); + + modalImageWrapper.addEventListener('mousemove', (e) => { + const isImage = modalImageWrapper.querySelector('img') !== null; + if (!isDragging || !isZoomed || !isImage) return; + e.preventDefault(); + dragMoved = true; + const x = e.pageX - modalImageWrapper.offsetLeft; + const y = e.pageY - modalImageWrapper.offsetTop; + const walkX = x - startX; + const walkY = y - startY; + modalImageWrapper.scrollLeft = scrollLeft - walkX; + modalImageWrapper.scrollTop = scrollTop - walkY; + }); + + modalImageWrapper.addEventListener('click', (e) => { + if (dragMoved) { + dragMoved = false; + return; + } + toggleZoom(); + }); + + modalClose?.addEventListener('click', closeModal); + modalPrev?.addEventListener('click', showPrev); + modalNext?.addEventListener('click', showNext); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + + document.addEventListener('keydown', (e) => { + if (!modal.classList.contains('active')) return; + if (e.key === 'ArrowRight') showNext(); + if (e.key === 'ArrowLeft') showPrev(); + if (e.key === 'Escape') closeModal(); + }); + + // Use event delegation on the document for .img-clickable clicks + // This allows dynamically added elements (e.g., from shuffle) to work + document.addEventListener('click', (e) => { + const clickable = e.target.closest('.img-clickable'); + if (!clickable) return; + + // Re-query images to get current state (may have changed from shuffle) + images = Array.from(document.querySelectorAll('.img-clickable')); + const index = images.indexOf(clickable); + if (index !== -1) { + openModal(index); + } + }); + + // Listen for showcase updates to refresh the images array + document.addEventListener('showcaseUpdated', () => { + images = Array.from(document.querySelectorAll('.img-clickable')); + }); + + // Listen for gallery infinite scroll updates to refresh the images array + document.addEventListener('galleryUpdated', () => { + images = Array.from(document.querySelectorAll('.img-clickable')); + }); + + window.addEventListener('popstate', () => { + if (modal.classList.contains('active')) { + closeModal(); + } else { + location.reload(); + } + }); +}); diff --git a/app/static/style.css b/app/static/style.css index 09a654a..b05dd52 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -143,6 +143,58 @@ header { transform: translateY(0); } +/* Nav dropdown */ +.nav-dropdown { + position: relative; + display: inline-block; +} + +.nav-dropdown-trigger { + cursor: pointer; + border: none; + background: transparent; +} + +.nav-dropdown-trigger::after { + content: " \25BC"; + font-size: 0.65em; + opacity: 0.7; +} + +.nav-dropdown-menu { + display: none; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + min-width: 150px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 1001; + padding: 0.5rem 0; + margin-top: 0.25rem; +} + +.nav-dropdown-menu a { + display: block; + padding: 0.5rem 1rem; + color: var(--text); + text-decoration: none; + font-size: 0.95rem; + transition: background 0.15s ease; +} + +.nav-dropdown-menu a:hover { + background: var(--hover); +} + +.nav-dropdown:hover .nav-dropdown-menu, +.nav-dropdown:focus-within .nav-dropdown-menu { + display: block; +} + /*------------------------------------------------------------------------------ Flash messages ------------------------------------------------------------------------------*/ @@ -1003,6 +1055,42 @@ header { filter: brightness(1.1); } +/* Tag search bar */ +.tag-search-bar { + display: flex; + align-items: center; + gap: 1rem; + max-width: 500px; + margin: 0 auto 1rem; +} + +.tag-search-input { + flex: 1; + padding: 0.6rem 1rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font-size: 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.tag-search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.tag-search-input::placeholder { + color: var(--muted); +} + +.tag-search-count { + color: var(--muted); + font-size: 0.9rem; + white-space: nowrap; +} + /* Filter pills (top of tags_list) */ .filter-bar { display: flex; diff --git a/app/templates/_pagination.html b/app/templates/_pagination.html index 7e5c26a..6655cd2 100644 --- a/app/templates/_pagination.html +++ b/app/templates/_pagination.html @@ -1,74 +1,74 @@ - -{% if images.pages > 1 %} -
- {% set total_pages = images.pages %} - {% set current_page = images.page %} - {% set window_size = 7 %} - {% set endpoint = request.endpoint %} - - {# Build a merged param dict: query args + path args #} - {% set params = request.args.to_dict(flat=True) %} - {% for k, v in request.view_args.items() %} - {% set _ = params.update({k: v}) %} - {% endfor %} - - {# Helper to make a page URL while preserving all other params #} - {% macro page_url(p) -%} - {%- set pmap = params.copy() -%} - {%- set _ = pmap.update({'page': p}) -%} - {{ url_for(endpoint, **pmap) }} - {%- endmacro %} - - {# First and Prev #} - {% if images.has_prev %} - {% if current_page > 1 %} - « First - {% endif %} - ← Previous - {% endif %} - - {# Page window logic #} - {% if total_pages <= window_size %} - {% set start_page = 1 %} - {% set end_page = total_pages %} - {% elif current_page <= 4 %} - {% set start_page = 1 %} - {% set end_page = window_size %} - {% elif current_page > total_pages - 4 %} - {% set start_page = total_pages - window_size + 1 %} - {% set end_page = total_pages %} - {% else %} - {% set start_page = current_page - 3 %} - {% set end_page = current_page + 3 %} - {% endif %} - - {# Left ellipsis #} - {% if start_page > 1 %} - 1 - {% if start_page > 2 %} - - {% endif %} - {% endif %} - - {# Page numbers #} - {% for p in range(start_page, end_page + 1) %} - {{ p }} - {% endfor %} - - {# Right ellipsis #} - {% if end_page < total_pages %} - {% if end_page < total_pages - 1 %} - - {% endif %} - {{ total_pages }} - {% endif %} - - {# Next and Last #} - {% if images.has_next %} - Next → - {% if current_page < total_pages %} - Last » - {% endif %} - {% endif %} -
-{% endif %} + +{% if images.pages > 1 %} +
+ {% set total_pages = images.pages %} + {% set current_page = images.page %} + {% set window_size = 7 %} + {% set endpoint = request.endpoint %} + + {# Build a merged param dict: query args + path args #} + {% set params = request.args.to_dict(flat=True) %} + {% for k, v in request.view_args.items() %} + {% set _ = params.update({k: v}) %} + {% endfor %} + + {# Helper to make a page URL while preserving all other params #} + {% macro page_url(p) -%} + {%- set pmap = params.copy() -%} + {%- set _ = pmap.update({'page': p}) -%} + {{ url_for(endpoint, **pmap) }} + {%- endmacro %} + + {# First and Prev #} + {% if images.has_prev %} + {% if current_page > 1 %} + « First + {% endif %} + ← Previous + {% endif %} + + {# Page window logic #} + {% if total_pages <= window_size %} + {% set start_page = 1 %} + {% set end_page = total_pages %} + {% elif current_page <= 4 %} + {% set start_page = 1 %} + {% set end_page = window_size %} + {% elif current_page > total_pages - 4 %} + {% set start_page = total_pages - window_size + 1 %} + {% set end_page = total_pages %} + {% else %} + {% set start_page = current_page - 3 %} + {% set end_page = current_page + 3 %} + {% endif %} + + {# Left ellipsis #} + {% if start_page > 1 %} + 1 + {% if start_page > 2 %} + + {% endif %} + {% endif %} + + {# Page numbers #} + {% for p in range(start_page, end_page + 1) %} + {{ p }} + {% endfor %} + + {# Right ellipsis #} + {% if end_page < total_pages %} + {% if end_page < total_pages - 1 %} + + {% endif %} + {{ total_pages }} + {% endif %} + + {# Next and Last #} + {% if images.has_next %} + Next → + {% if current_page < total_pages %} + Last » + {% endif %} + {% endif %} +
+{% endif %} diff --git a/app/templates/gallery.html b/app/templates/gallery.html index b3bd6d8..8e65a02 100644 --- a/app/templates/gallery.html +++ b/app/templates/gallery.html @@ -1,268 +1,268 @@ - -{% extends "layout.html" %} -{% block title %}Gallery{% endblock %} - -{% block content %} - -
- -
- - - - -{% include '_gallery_modal.html' %} - - - - - - - - - - - -{% if series_info %} - -{% endif %} -{% endblock %} + +{% extends "layout.html" %} +{% block title %}Gallery{% endblock %} + +{% block content %} + +
+ +
+ + + + +{% include '_gallery_modal.html' %} + + + + + + + + + + + +{% if series_info %} + +{% endif %} +{% endblock %} diff --git a/app/templates/layout.html b/app/templates/layout.html index f9147a0..d5dcb2a 100644 --- a/app/templates/layout.html +++ b/app/templates/layout.html @@ -3,6 +3,9 @@ ImageRepo - {% block title %}{% endblock %} + + + @@ -13,8 +16,21 @@ diff --git a/app/templates/settings.html b/app/templates/settings.html index a485ac6..bc25ab9 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -1,1819 +1,1819 @@ -{% extends "layout.html" %} -{% block title %}Settings{% endblock %} - -{% block content %} -

Settings

- - -
-
-
-
-

System Overview

-
- - -
-
-
-
- - - Total Images -
-
- - - Total Tags -
-
- - - Storage Used -
-
- - - Files to Import -
-
- - - Archives to Import -
-
-
-
-
- - -
-
-
-

Import Queue Status

-

Monitor and control the Celery-based import queue system.

- -
-
-
- - - Pending -
-
- - - Queued -
-
- - - Processing -
-
- - - Complete -
-
- - - Skipped -
-
- - - Failed -
-
- - - - - - -
- - - - -
- - -
-
- Clear tasks: - - - -
-

- Note: Failed/skipped tasks older than 7 days are auto-cleaned daily. -

-
- -
- - - -
- -
-

- Workers: - | - Active Tasks: - -

-
-
- - -
-
-

Import Tasks

-
- - - -
-
- -
- Showing 0 of 0 tasks -
- -
- - - - - - - - - - - - - -
FileTypeStatusError/ReasonTime
Loading...
-
- -
- -
-
- - - -
-
-
- -
- -
-
-
-

Import Filters

-

Configure filters to skip certain images during import.

- -
-
- - -
- -
- - -
- -
- -

Useful for filtering out UI elements, overlays, and buttons.

-
- - - -
- - -

Controls how similar images must be to be considered duplicates.

-
- - -
-
-
-
- - -
-
-
-

Delete Images by Tag

-

Permanently delete all images associated with a specific tag.

- -
- -
-
- - -
- - - - - -
- -
- - -
-
-
- -
-
-

Clean Up Existing Images

-

Scan and remove images that don't meet filter criteria.

- -
-
- - -
- -
-
- - -

Images with more than this % transparent pixels will be flagged.

-
-
- - - - -
- - -
-
-
-
- - -
-
-

Duplicate Detection

-

Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.

- -
-
- - -

Lower values find only very similar images. Higher values find more potential duplicates but may include false positives.

-
- - -
- - -
-
- - - - -{% endblock %} +{% extends "layout.html" %} +{% block title %}Settings{% endblock %} + +{% block content %} +

Settings

+ + +
+
+
+
+

System Overview

+
+ + +
+
+
+
+ - + Total Images +
+
+ - + Total Tags +
+
+ - + Storage Used +
+
+ - + Files to Import +
+
+ - + Archives to Import +
+
+
+
+
+ + +
+
+
+

Import Queue Status

+

Monitor and control the Celery-based import queue system.

+ +
+
+
+ - + Pending +
+
+ - + Queued +
+
+ - + Processing +
+
+ - + Complete +
+
+ - + Skipped +
+
+ - + Failed +
+
+ + + + + + +
+ + + + +
+ + +
+
+ Clear tasks: + + + +
+

+ Note: Failed/skipped tasks older than 7 days are auto-cleaned daily. +

+
+ +
+ + + +
+ +
+

+ Workers: - | + Active Tasks: - +

+
+
+ + +
+
+

Import Tasks

+
+ + + +
+
+ +
+ Showing 0 of 0 tasks +
+ +
+ + + + + + + + + + + + + +
FileTypeStatusError/ReasonTime
Loading...
+
+ +
+ +
+
+ + + +
+
+
+ +
+ +
+
+
+

Import Filters

+

Configure filters to skip certain images during import.

+ +
+
+ + +
+ +
+ + +
+ +
+ +

Useful for filtering out UI elements, overlays, and buttons.

+
+ + + +
+ + +

Controls how similar images must be to be considered duplicates.

+
+ + +
+
+
+
+ + +
+
+
+

Delete Images by Tag

+

Permanently delete all images associated with a specific tag.

+ +
+ +
+
+ + +
+ + + + + +
+ +
+ + +
+
+
+ +
+
+

Clean Up Existing Images

+

Scan and remove images that don't meet filter criteria.

+ +
+
+ + +
+ +
+
+ + +

Images with more than this % transparent pixels will be flagged.

+
+
+ + + + +
+ + +
+
+
+
+ + +
+
+

Duplicate Detection

+

Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.

+ +
+
+ + +

Lower values find only very similar images. Higher values find more potential duplicates but may include false positives.

+
+ + +
+ + +
+
+ + + + +{% endblock %} diff --git a/app/templates/tags_list.html b/app/templates/tags_list.html index 257ce18..9dc2ac5 100644 --- a/app/templates/tags_list.html +++ b/app/templates/tags_list.html @@ -1,221 +1,287 @@ -{% extends "layout.html" %} -{% block title %} - {%- if active_kind == 'artist' -%}Artists - {%- elif active_kind == 'character' -%}Characters - {%- elif active_kind == 'series' -%}Series - {%- elif active_kind == 'fandom' -%}Fandoms - {%- elif active_kind == 'rating' -%}Ratings - {%- elif active_kind == 'archive' -%}Archives - {%- elif active_kind == 'source' -%}Sources - {%- elif active_kind == 'post' -%}Posts - {%- elif active_kind == 'user' -%}User Tags - {%- else -%}Tags{%- endif -%} -{% endblock %} - -{% block content %} -

- {%- if active_kind == 'artist' -%}Artists - {%- elif active_kind == 'character' -%}Characters - {%- elif active_kind == 'series' -%}Series - {%- elif active_kind == 'fandom' -%}Fandoms - {%- elif active_kind == 'rating' -%}Ratings - {%- elif active_kind == 'archive' -%}Archives - {%- elif active_kind == 'source' -%}Sources - {%- elif active_kind == 'post' -%}Posts - {%- elif active_kind == 'user' -%}User Tags - {%- else -%}All Tags{%- endif -%} -

- - -
- 🏷️ All - - 🎨 Artists - - 👤 Characters - - 📺 Series - - 🎭 Fandoms - - ⚠️ Ratings - - 🗜️ Archives - - 🌐 Sources - - 📌 Posts - - # User -
- -{% if tag_data %} -
- {% include '_tag_cards.html' %} -
- - -
- - - - -{% else %} -

No tags found in this category.

-{% endif %} - - - - - - -{% endblock %} +{% extends "layout.html" %} +{% block title %} + {%- if active_kind == 'artist' -%}Artists + {%- elif active_kind == 'character' -%}Characters + {%- elif active_kind == 'series' -%}Series + {%- elif active_kind == 'fandom' -%}Fandoms + {%- elif active_kind == 'rating' -%}Ratings + {%- elif active_kind == 'archive' -%}Archives + {%- elif active_kind == 'source' -%}Sources + {%- elif active_kind == 'post' -%}Posts + {%- elif active_kind == 'user' -%}User Tags + {%- else -%}Tags{%- endif -%} +{% endblock %} + +{% block content %} +

+ {%- if active_kind == 'artist' -%}Artists + {%- elif active_kind == 'character' -%}Characters + {%- elif active_kind == 'series' -%}Series + {%- elif active_kind == 'fandom' -%}Fandoms + {%- elif active_kind == 'rating' -%}Ratings + {%- elif active_kind == 'archive' -%}Archives + {%- elif active_kind == 'source' -%}Sources + {%- elif active_kind == 'post' -%}Posts + {%- elif active_kind == 'user' -%}User Tags + {%- else -%}All Tags{%- endif -%} +

+ + + + + +
+ 🏷️ All + + 🎨 Artists + + 👤 Characters + + 📺 Series + + 🎭 Fandoms + + ⚠️ Ratings + + 🗜️ Archives + + 🌐 Sources + + 📌 Posts + + # User +
+ +{% if tag_data %} +
+ {% include '_tag_cards.html' %} +
+ + +
+ + + + +{% else %} +

No tags found in this category.

+{% endif %} + + + + + + +{% endblock %} diff --git a/app/utils/image_importer.py b/app/utils/image_importer.py index 31618c6..7e071b1 100644 --- a/app/utils/image_importer.py +++ b/app/utils/image_importer.py @@ -1,945 +1,945 @@ -# app/utils/image_importer.py - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -import hashlib -import json -import logging -import mimetypes -import os -import re -import shutil -import subprocess -import tempfile -import uuid - -log = logging.getLogger(__name__) - -from PIL import Image, ImageOps -import exifread -import imagehash - -from app import db -from app.models import ImageRecord, Tag, ArchiveRecord - -FFMPEG_THUMB_TIMEOUT = int(os.environ.get("THUMBS_FFMPEG_TIMEOUT", "60")) # seconds -FFMPEG_TRANSCODE_TIMEOUT = int(os.environ.get("FFMPEG_TRANSCODE_TIMEOUT", "600")) # 10 min default -Image.MAX_IMAGE_PIXELS = int(os.environ.get("PIL_MAX_IMAGE_PIXELS", "178956970")) -ARCHIVE_NUM_WIDTH = int(os.environ.get("ARCHIVE_NUM_WIDTH", "4")) - - -# ============================================================================= -# Configuration & Constants -# ============================================================================= - -THUMB_SIZE = (400, 400) - -# Media and archive extensions we handle -IMAGE_EXTS = (".jpg", ".jpeg", ".jfif", ".png", ".gif", ".bmp", ".tiff", ".webp") -VIDEO_EXTS = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv") -VIDEO_EXTS_NEED_TRANSCODE = (".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv") # Non-MP4 formats -ALLOWED_MEDIA_EXTS = IMAGE_EXTS + VIDEO_EXTS -ALLOWED_ARCHIVE_EXTS = ( - ".zip", ".rar", ".cbr", ".7z", - ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz" -) - -# Import filter settings (loaded from file or env) -IMPORT_SETTINGS_PATH = "/import/settings.json" - - -def load_import_settings() -> dict: - """ - Load import filter settings from database, with file and environment fallbacks. - Priority: DB settings > file settings > environment variables - """ - # Start with environment defaults - defaults = { - "min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")), - "min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")), - "skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true", - "transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")), - "skip_single_color": os.environ.get("IMPORT_SKIP_SINGLE_COLOR", "true").lower() == "true", - "single_color_threshold": float(os.environ.get("IMPORT_SINGLE_COLOR_THRESHOLD", "0.95")), - "single_color_tolerance": int(os.environ.get("IMPORT_SINGLE_COLOR_TOLERANCE", "30")), - "phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "10")), # Default for 256-bit - "supersede_smaller": os.environ.get("IMPORT_SUPERSEDE_SMALLER", "true").lower() == "true", - } - - # Try to load from file (legacy support) - try: - if os.path.exists(IMPORT_SETTINGS_PATH): - with open(IMPORT_SETTINGS_PATH, "r") as f: - file_settings = json.load(f) - defaults.update(file_settings) - except Exception as e: - print(f"[WARN] Failed to load import settings from file: {e}") - - # Try to load from database (preferred) - try: - from app.utils.version_migration import get_import_settings - db_settings = get_import_settings() - # Only override if DB has non-default values - for key, value in db_settings.items(): - if value is not None: - defaults[key] = value - except Exception as e: - print(f"[WARN] Failed to load import settings from DB: {e}") - - return defaults - - -def is_mostly_transparent(file_path: str, threshold: float = 0.9) -> bool: - """ - Check if an image is mostly transparent (> threshold % of pixels are transparent). - Returns False for images without alpha channel. - - Args: - file_path: Path to the image file - threshold: Percentage of pixels that must be transparent (0.0-1.0, default 0.9 = 90%) - - Returns: - True if the image has > threshold% transparent pixels - """ - try: - with Image.open(file_path) as img: - original_mode = img.mode - - # Handle different image modes that may have transparency - if original_mode == "RGBA": - # Already has alpha channel - pass - elif original_mode == "LA": - # Grayscale with alpha - img = img.convert("RGBA") - elif original_mode == "P": - # Palette mode - check for transparency - if "transparency" in img.info: - img = img.convert("RGBA") - else: - return False - elif original_mode == "PA": - # Palette with alpha - img = img.convert("RGBA") - else: - # RGB, L, etc. - no alpha channel - return False - - # Get alpha channel - bands = img.split() - if len(bands) < 4: - return False - - alpha = bands[3] # Alpha is the 4th channel (index 3) - alpha_data = alpha.getdata() - total_pixels = alpha.size[0] * alpha.size[1] - - if total_pixels == 0: - return False - - # Count pixels where alpha < 128 (more than 50% transparent) - transparent_count = sum(1 for p in alpha_data if p < 128) - transparency_ratio = transparent_count / total_pixels - - return transparency_ratio > threshold - except Exception as e: - print(f"[WARN] Failed to check transparency for {file_path}: {e}") - return False - - -def is_mostly_single_color(file_path: str, threshold: float = 0.95, tolerance: int = 30) -> bool: - """ - Check if an image is mostly a single color (> threshold % of pixels are similar). - Uses color distance tolerance to group similar colors. - - Args: - file_path: Path to the image file - threshold: Percentage of pixels that must be similar (0.0-1.0, default 0.95 = 95%) - tolerance: Maximum color distance to be considered "same" color (0-255 per channel, default 30) - - Returns: - True if the image is mostly a single solid color - """ - try: - with Image.open(file_path) as img: - # Convert to RGB for consistent color comparison - if img.mode in ("RGBA", "LA"): - # For images with alpha, only consider non-transparent pixels - img_rgba = img.convert("RGBA") if img.mode != "RGBA" else img - pixels = list(img_rgba.getdata()) - # Filter out mostly transparent pixels (alpha < 128) - opaque_pixels = [(r, g, b) for r, g, b, a in pixels if a >= 128] - if len(opaque_pixels) == 0: - return True # Fully transparent = effectively single color - pixels = opaque_pixels - elif img.mode == "P": - img = img.convert("RGB") - pixels = list(img.getdata()) - elif img.mode == "L": - # Grayscale - convert to RGB tuples - pixels = [(p, p, p) for p in img.getdata()] - else: - if img.mode != "RGB": - img = img.convert("RGB") - pixels = list(img.getdata()) - - if len(pixels) == 0: - return True - - # Sample pixels for performance on large images - total_pixels = len(pixels) - if total_pixels > 10000: - # Sample evenly distributed pixels - step = total_pixels // 10000 - pixels = pixels[::step] - - # Find the dominant color (most common pixel or average of sampled area) - # Use the pixel at center as reference - center_idx = len(pixels) // 2 - ref_color = pixels[center_idx] - - # Count pixels within tolerance of reference color - similar_count = 0 - for pixel in pixels: - if isinstance(pixel, int): - pixel = (pixel, pixel, pixel) - # Calculate color distance (simple RGB distance) - dist = abs(pixel[0] - ref_color[0]) + abs(pixel[1] - ref_color[1]) + abs(pixel[2] - ref_color[2]) - if dist <= tolerance * 3: # tolerance per channel * 3 channels - similar_count += 1 - - similarity_ratio = similar_count / len(pixels) - return similarity_ratio > threshold - - except Exception as e: - print(f"[WARN] Failed to check single color for {file_path}: {e}") - return False - - -def supersede_image(old_record: "ImageRecord", new_filepath: str, new_thumb_path: str, - new_hash: str, new_phash, new_metadata: dict, new_filename: str) -> "ImageRecord": - """ - Replace a smaller image with a larger version, preserving tags and metadata. - - Args: - old_record: The existing ImageRecord to supersede - new_filepath: Path to the new larger image file - new_thumb_path: Path to the new thumbnail - new_hash: SHA256 hash of the new file - new_phash: Perceptual hash of the new image - new_metadata: Metadata dict for the new image - new_filename: Original filename of the new image - - Returns: - The updated ImageRecord - """ - # Store old file paths for cleanup - old_filepath = old_record.filepath - old_thumb_path = old_record.thumb_path - - print(f"[SUPERSEDE] Replacing {old_record.filename} ({old_record.width}x{old_record.height}) " - f"with {new_filename} ({new_metadata['width']}x{new_metadata['height']})") - - # Update record with new file info - old_record.filename = new_filename - old_record.filepath = new_filepath - old_record.thumb_path = new_thumb_path - old_record.hash = new_hash - old_record.perceptual_hash = str(new_phash) if new_phash else None - old_record.file_size = new_metadata["file_size"] - old_record.width = new_metadata["width"] - old_record.height = new_metadata["height"] - old_record.format = new_metadata["format"] - # Keep the earlier taken_at date (prefer original date) - if new_metadata.get("taken_at") and old_record.taken_at: - if new_metadata["taken_at"] < old_record.taken_at: - old_record.taken_at = new_metadata["taken_at"] - elif new_metadata.get("taken_at") and not old_record.taken_at: - old_record.taken_at = new_metadata["taken_at"] - # Don't update imported_at - keep original import time - - # Delete old files - try: - if old_filepath and os.path.exists(old_filepath): - os.remove(old_filepath) - print(f"[SUPERSEDE] Deleted old file: {old_filepath}") - except Exception as e: - print(f"[WARN] Failed to delete old file {old_filepath}: {e}") - - try: - if old_thumb_path and os.path.exists(old_thumb_path): - os.remove(old_thumb_path) - print(f"[SUPERSEDE] Deleted old thumbnail: {old_thumb_path}") - except Exception as e: - print(f"[WARN] Failed to delete old thumbnail {old_thumb_path}: {e}") - - return old_record - - -# Regex for multi-part detection -RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE) -SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE) -OLD_RAR_VOL_RE = re.compile(r"\.r\d{2}$", re.IGNORECASE) # .r00, .r01, ... - -# Environment configuration (optional) -ENV_TMPDIR = "ARCHIVE_TMPDIR" # where to extract archives (default: system tmp) -ENV_MINFREE = "ARCHIVE_MIN_FREE_GB" # min free GB required to start extraction (float; 0 = disabled) - - -# ============================================================================= -# Utilities: Temp space management & free-space guard -# ============================================================================= - -def get_tmp_base() -> Path: - """Return the base temp directory for archive extraction.""" - return Path(os.environ.get(ENV_TMPDIR, tempfile.gettempdir())).resolve() - - -def cleanup_stale_tempdirs(prefix: str = "extract_") -> None: - """Delete any leftover extraction directories from previous crashes.""" - base = get_tmp_base() - base.mkdir(parents=True, exist_ok=True) - for entry in base.glob(f"{prefix}*"): - if entry.is_dir(): - try: - shutil.rmtree(entry, ignore_errors=True) - except Exception: - pass - - -def ensure_free_space_or_raise(path: Path, min_free_gb: float) -> None: - """Raise if there is not enough free space at path.""" - if min_free_gb <= 0: - return - total, used, free = shutil.disk_usage(str(path)) - free_gb = free / (1024 ** 3) - if free_gb < min_free_gb: - raise RuntimeError( - f"Insufficient free space at {path}: {free_gb:.1f} GB free " - f"(required >= {min_free_gb:.1f} GB)." - ) - - -# ============================================================================= -# Utilities: External extractors with resilient fallback -# ============================================================================= - -class ExtractError(Exception): - """Raised when archive extraction fails with all strategies.""" - - -def _run_cmd(cmd: list[str]) -> tuple[int, str, str]: - p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - 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}") - - -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}") - - -def looks_password_protected_with_lsar(archive_path: str) -> bool: - """Best-effort pre-check for 'Encrypted/Password' in lsar output.""" - try: - rc, out, err = _run_cmd(["lsar", archive_path]) - txt = (out + "\n" + err).lower() - return ("encrypted" in txt) or ("password" in txt) - except Exception: - return False - - -def extract_archive_resilient(archive_path: str, out_dir: str) -> None: - """ - Prefer unar for RAR/CBR (RAR5-friendly); fallback to 7z. - For other formats, try 7z first then unar. - Surfaces stderr so logs show CRC/password/permissions issues. - """ - lower = archive_path.lower() - errors = [] - - # Optional password hint to skip early - if lower.endswith((".rar", ".cbr")) and looks_password_protected_with_lsar(archive_path): - raise ExtractError("Archive appears password-protected; skipping (no password provided).") - - if lower.endswith((".rar", ".cbr")): - order = (extract_with_unar, extract_with_7z) - else: - order = (extract_with_7z, extract_with_unar) - - for fn in order: - try: - fn(archive_path, out_dir) - return - except ExtractError as e: - errors.append(str(e)) - - raise ExtractError("Both extractors failed:\n" + "\n---\n".join(errors)) - - -# ============================================================================= -# Content-addressed storage helpers -# ============================================================================= - -def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: str) -> str: - """ - Always produce a content-addressed destination: - /__ - If that path already exists, append a numeric suffix. - """ - base, ext = os.path.splitext(original_name) - candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}{ext}") - if not os.path.exists(candidate): - return candidate - - # Rare: collision (e.g., concurrent import) → add counter - i = 2 - while True: - candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}_{i}{ext}") - if not os.path.exists(candidate): - return candidate - i += 1 - - -# ============================================================================= -# Helpers: Thumbnails, metadata, hashing, similarity -# ============================================================================= - -def generate_thumbnail( - image_path, - size=(400, 400), - overwrite=False, - prefer_jpeg=False, # False = auto-pick PNG for alpha images; True = always JPEG - jpeg_bg=(0, 0, 0), # background used when flattening to JPEG -): - """ - Save thumbnails mirrored under /images/thumbs/<...>. - If prefer_jpeg is False (default), transparent images save as PNG, others as JPEG. - If prefer_jpeg is True, all thumbs are JPEG with transparency flattened to `jpeg_bg`. - - For images with extreme aspect ratios (very tall like comic strips, or very wide), - crops to the center region before thumbnailing to produce better quality previews. - - Returns the absolute path to the thumbnail. - """ - images_root = Path("/images").resolve() - image_path = Path(image_path).resolve() - - try: - rel_path = image_path.relative_to(images_root) - except ValueError: - raise ValueError(f"{image_path} is not under {images_root}") - - # Base path under /images/thumbs with same subfolders/filename (we may change the suffix) - thumb_base = (images_root / "thumbs" / rel_path).with_suffix("") # drop original suffix for control - - # Decide output extension/format - with Image.open(image_path) as img: - img = ImageOps.exif_transpose(img) # honor camera rotation - - # Handle extreme aspect ratios by cropping to a more balanced region - # This prevents very tall/wide images from becoming tiny slivers - w, h = img.size - aspect_ratio = w / h if h > 0 else 1 - - # If aspect ratio is extreme (< 0.5 means very tall, > 2.0 means very wide) - # crop to a more balanced region before thumbnailing - if aspect_ratio < 0.5: - # Very tall image (like comic strip) - crop from top portion - # Take a region that's closer to square, from the top - crop_height = min(h, w * 2) # At most 2:1 tall - img = img.crop((0, 0, w, crop_height)) - elif aspect_ratio > 2.0: - # Very wide image - crop from center - crop_width = min(w, h * 2) # At most 2:1 wide - left = (w - crop_width) // 2 - img = img.crop((left, 0, left + crop_width, h)) - - # Use LANCZOS resampling for high-quality downscaling - img.thumbnail(size, Image.Resampling.LANCZOS) - - has_alpha = _has_alpha(img) - - if prefer_jpeg: - # Force JPEG for consistency - out_path = thumb_base.with_suffix(".jpg") - out_path.parent.mkdir(parents=True, exist_ok=True) - if not overwrite and out_path.exists(): - return str(out_path) - img_rgb = _flatten_to_rgb(img, bg=jpeg_bg) - img_rgb.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True) - return str(out_path) - else: - # Auto-pick: PNG for alpha, JPEG otherwise - if has_alpha: - out_path = thumb_base.with_suffix(".png") - out_path.parent.mkdir(parents=True, exist_ok=True) - if not overwrite and out_path.exists(): - return str(out_path) - # Keep transparency if present - # Convert palette to RGBA for reliable PNG writing - if img.mode == "P": - img = img.convert("RGBA") - img.save(out_path, format="PNG", optimize=True) - return str(out_path) - else: - out_path = thumb_base.with_suffix(".jpg") - out_path.parent.mkdir(parents=True, exist_ok=True) - if not overwrite and out_path.exists(): - return str(out_path) - if img.mode != "RGB": - img = img.convert("RGB") - img.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True) - return str(out_path) - -def generate_video_thumbnail(video_path: str, output_path: str, time_position: str = "00:00:01") -> str | None: - """ - Extract a single JPG frame from a video using ffmpeg and write it to output_path. - Returns output_path on success, or None on failure. - """ - try: - os.makedirs(os.path.dirname(output_path), exist_ok=True) - cmd = [ - "ffmpeg", "-y", - "-ss", time_position, - "-i", str(video_path), - "-vframes", "1", - "-vf", f"scale={THUMB_SIZE[0]}:-1", - str(output_path), - ] - subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=FFMPEG_THUMB_TIMEOUT) - return output_path - except subprocess.CalledProcessError as e: - print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}") - return None - - -def generate_video_thumbnail_mirrored(video_path: str, time_position: str = "00:00:01") -> str | None: - """ - Mirror the stored (hashed) video path under /images/thumbs/... and save as .jpg. - """ - images_root = Path("/images").resolve() - video_path_p = Path(video_path).resolve() - rel_path = video_path_p.relative_to(images_root) # raises if not under /images - thumb_path = (images_root / "thumbs" / rel_path).with_suffix(".jpg") - return generate_video_thumbnail(str(video_path_p), str(thumb_path), time_position=time_position) - - -def transcode_video_to_mp4(input_path: str, output_path: str = None) -> str | None: - """ - Transcode a video to H.264 MP4 for universal browser playback. - - Args: - input_path: Path to source video file - output_path: Optional destination path. If None, replaces extension with .mp4 - in a temp location. - - Returns: - Path to transcoded MP4 file on success, None on failure. - """ - input_p = Path(input_path) - - # Determine output path - if output_path is None: - # Create temp file with .mp4 extension - output_path = str(input_p.with_suffix(".mp4")) - if output_path == input_path: - # Already .mp4, use temp suffix - output_path = str(input_p.with_suffix(".transcoded.mp4")) - - try: - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # FFmpeg command for H.264/AAC transcoding with good browser compatibility - # -movflags +faststart: Enables progressive playback (moov atom at start) - # -preset medium: Balance between speed and compression - # -crf 23: Good quality (lower = better, 18-28 typical range) - # -c:v libx264: H.264 video codec - # -c:a aac: AAC audio codec - # -pix_fmt yuv420p: Pixel format for maximum compatibility - cmd = [ - "ffmpeg", "-y", - "-i", str(input_path), - "-c:v", "libx264", - "-preset", "medium", - "-crf", "23", - "-pix_fmt", "yuv420p", - "-c:a", "aac", - "-b:a", "128k", - "-movflags", "+faststart", - str(output_path), - ] - - print(f"[INFO] Transcoding video: {input_path} -> {output_path}") - result = subprocess.run( - cmd, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=FFMPEG_TRANSCODE_TIMEOUT - ) - print(f"[INFO] Transcoding complete: {output_path}") - return output_path - - except subprocess.TimeoutExpired: - print(f"[WARN] Video transcode timed out after {FFMPEG_TRANSCODE_TIMEOUT}s: {input_path}") - # Clean up partial output - if os.path.exists(output_path): - os.remove(output_path) - return None - except subprocess.CalledProcessError as e: - print(f"[WARN] Failed to transcode video {input_path}: {e}") - if e.stderr: - print(f"[WARN] FFmpeg stderr: {e.stderr.decode('utf-8', errors='ignore')[:500]}") - # Clean up partial output - if os.path.exists(output_path): - os.remove(output_path) - return None - except Exception as e: - print(f"[WARN] Unexpected error transcoding video {input_path}: {e}") - if os.path.exists(output_path): - os.remove(output_path) - return None - - -def needs_transcode(file_path: str) -> bool: - """Check if a video file needs transcoding to MP4.""" - ext = Path(file_path).suffix.lower() - return ext in VIDEO_EXTS_NEED_TRANSCODE - - -def calculate_hash(file_path: str) -> str: - hash_func = hashlib.sha256() - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): # 1MB chunks work well for big files - hash_func.update(chunk) - return hash_func.hexdigest() - - -def calculate_perceptual_hash(file_path: str, hash_size: int = 16): - """ - Calculate a perceptual hash for an image. - - Args: - file_path: Path to the image file - hash_size: Size of the hash grid (default 16 for 256-bit hash) - hash_size=8 gives 64-bit, hash_size=16 gives 256-bit - - Returns: - ImageHash object or None if hashing fails (e.g., for videos) - """ - try: - with Image.open(file_path) as img: - return imagehash.phash(img, hash_size=hash_size) - except Exception: - # videos or unreadable images land here (phash not applicable) - return None - - -def find_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10): - """ - Find visually similar images and determine relationship. - - Args: - phash: Perceptual hash of the new image - width: Width of the new image - height: Height of the new image - existing_phashes: List of (phash, width, height, image_id) tuples for existing images - threshold: Maximum Hamming distance to consider images similar - Default is 10 for 256-bit hashes (was 2 for 64-bit) - - Returns: - tuple: (relationship, image_id) where relationship is one of: - - None: No similar image found - - "larger_exists": A larger or equal similar image exists (skip new image) - - "smaller_exists": A smaller similar image exists (supersede it) - image_id is the ID of the matching image, or None - """ - for existing_phash, ew, eh, image_id in existing_phashes: - try: - distance = phash - existing_phash - if distance <= threshold: - # Similar image found - check size relationship - if ew >= width and eh >= height: - # Existing is larger or equal - skip new image - return ("larger_exists", image_id) - elif width > ew or height > eh: - # New image is larger - supersede the existing one - return ("smaller_exists", image_id) - except Exception as e: - print(f"[WARN] Failed pHash comparison: {e}") - return (None, None) - - -def is_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10) -> bool: - """ - Check if an image is visually similar to any existing larger image. - - Legacy wrapper for find_similar_image - returns True only if a larger version exists. - """ - relationship, _ = find_similar_image(phash, width, height, existing_phashes, threshold) - return relationship == "larger_exists" - - -def extract_metadata(file_path: str) -> dict: - metadata = { - "file_size": None, - "width": None, - "height": None, - "format": None, - "camera_model": None, - "taken_at": None - } - - metadata["file_size"] = os.path.getsize(file_path) - ext = os.path.splitext(file_path)[1].lower() - - # Get file modification time as fallback for taken_at - try: - file_mtime = datetime.fromtimestamp(os.path.getmtime(file_path)) - except Exception: - file_mtime = None - - # Video: use ffprobe - if ext in (".mp4", ".mov"): - try: - cmd = [ - "ffprobe", "-v", "error", - "-select_streams", "v:0", - "-show_entries", "stream=width,height", - "-of", "json", file_path - ] - result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False) - data = json.loads(result.stdout or "{}") - stream = data.get("streams", [{}])[0] - metadata["width"] = stream.get("width") - metadata["height"] = stream.get("height") - metadata["format"] = ext.lstrip(".") - # Use file mtime for videos since they don't have EXIF - metadata["taken_at"] = file_mtime - except Exception as e: - print(f"[WARN] Failed to extract video metadata: {file_path} – {e}") - return metadata - - # Image: PIL + EXIF (best-effort) - try: - with Image.open(file_path) as img: - metadata["width"], metadata["height"] = img.size - metadata["format"] = (img.format or "").lower() - except Exception as e: - mime_type, _ = mimetypes.guess_type(file_path) - print(f"[WARN] Failed to read image metadata: {file_path} ({e}) ext={ext} mime={mime_type}") - - try: - with open(file_path, "rb") as f: - tags = exifread.process_file(f, stop_tag="UNDEF", details=False) - if "EXIF DateTimeOriginal" in tags: - metadata["taken_at"] = datetime.strptime(str(tags["EXIF DateTimeOriginal"]), "%Y:%m:%d %H:%M:%S") - if "Image Model" in tags: - metadata["camera_model"] = str(tags["Image Model"]) - except Exception: - pass - - # Fallback to file modification time if no EXIF date - if metadata["taken_at"] is None: - metadata["taken_at"] = file_mtime - - return metadata - -def _has_alpha(img: Image.Image) -> bool: - """Return True if the image has any transparency channel.""" - if img.mode in ("RGBA", "LA"): - return True - if img.mode == "P" and "transparency" in img.info: - return True - return False - -def _flatten_to_rgb(img: Image.Image, bg=(0, 0, 0)) -> Image.Image: - """ - Flatten an RGBA/LA/Palette-with-alpha image onto a solid background, - returning an RGB image suitable for saving as JPEG. - """ - # Normalize to RGBA so we can use the alpha channel as a mask - if img.mode == "P": - img = img.convert("RGBA") - if img.mode in ("RGBA", "LA"): - # Ensure we have an explicit alpha channel as last band - if img.mode == "LA": - img = img.convert("RGBA") - alpha = img.split()[-1] # A channel - bg_img = Image.new("RGB", img.size, bg) - bg_img.paste(img, mask=alpha) - return bg_img - # No alpha, just convert to RGB - return img.convert("RGB") - - -# ============================================================================= -# Helpers: Multi-part detection -# ============================================================================= - -def is_first_volume(path: str) -> bool: - """ - Determine if 'path' is the first volume of a multi-part archive. - We only attempt extraction from the first volume. - """ - name = os.path.basename(path).lower() - - # RAR: .partN.rar (only N==1 is first) - m = RAR_PART_RE.search(name) - if m: - n = m.group(1) - return n in ("1", "01", "001") - - # RAR old-style: first is .rar, subsequent are .r00, .r01... - if name.endswith(".rar"): - return True - if OLD_RAR_VOL_RE.search(name): - return False - - # CBR is typically a single-volume RAR - if name.endswith(".cbr"): - return True - - # 7z multi-part: .7z.001 is first - m = SEVENZ_PART_RE.search(name) - if m: - n = m.group(1) - return n in ("1", "01", "001") - if name.endswith(".7z"): - return True # single-volume 7z - - # ZIP and tarballs: treat as single-volume (we don't include .z01 in our allowlist) - if name.endswith((".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")): - return True - - return True - -def extract_archive_name(filename: str) -> str: - """ - Extract a clean archive name from a filename by removing common prefixes. - - Handles patterns like: - - "43387617_attachment_83109081_October 2020 Rewards.rar" -> "October 2020 Rewards" - - "01_Archive Name.zip" -> "Archive Name" - - "Archive Name.rar" -> "Archive Name" - - Returns the cleaned name without extension. - """ - import re - from pathlib import Path - - # Remove extension - stem = Path(filename).stem - - # Pattern 1: Gallery-DL style "{id}_attachment_{id}_{name}" or "{id}_{name}" - # Match one or more numeric_prefix patterns at the start - cleaned = re.sub(r'^(\d+_)+(attachment_)?(\d+_)?', '', stem) - - # Pattern 2: Simple numeric prefix "01_name" or "001_name" - if cleaned == stem: # No match from pattern 1 - cleaned = re.sub(r'^\d+_', '', stem) - - # If we stripped everything or got empty, fall back to original stem - if not cleaned.strip(): - cleaned = stem - - return cleaned.strip() - - -def compute_archive_tag_name(artist: str, archive_filename: str) -> str: - """ - Generate an archive tag name in the format: 'archive:{artist}:{archive_name}' - - Similar to post tags (post:{platform}:{artist}:{id}), archive tags use - a consistent prefix and colon-separated components. - - Examples: - - artist="InCaseArt", filename="43387617_attachment_83109081_October 2020 Rewards.rar" - -> "archive:InCaseArt:October 2020 Rewards" - - If a tag with the same name already exists, appends a numeric suffix. - """ - archive_name = extract_archive_name(archive_filename) - base_tag = f"archive:{artist}:{archive_name}" - - # Check if tag already exists - existing = Tag.query.filter_by(name=base_tag, kind='archive').first() - if not existing: - return base_tag - - # Tag exists, add numeric suffix - n = 2 - while True: - candidate = f"{base_tag} ({n})" - if not Tag.query.filter_by(name=candidate, kind='archive').first(): - return candidate - n += 1 - - -def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str: - """ - DEPRECATED: Use compute_archive_tag_name() instead. - - Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ... - - Looks only at Tag(kind='archive') with names starting 'archive:{artist}/' - - Finds max numeric suffix and returns next number (zero-padded). - - Ensures no collision even if tags were renamed. - """ - prefix = f"archive:{artist}/" - # Pull existing tag names for this artist/prefix - rows = (Tag.query - .with_entities(Tag.name) - .filter(Tag.kind == 'archive', - Tag.name.like(prefix + '%')) - .all()) - - max_n = 0 - for (name,) in rows: - tail = name[len(prefix):] - if tail.isdigit(): - try: - n = int(tail) - if n > max_n: - max_n = n - except Exception: - pass - - # Propose next number and make sure it doesn't already exist - n = max_n + 1 - while True: - candidate = f"{prefix}{str(n).zfill(width)}" - if not Tag.query.filter_by(name=candidate).first(): - return candidate +# app/utils/image_importer.py + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import hashlib +import json +import logging +import mimetypes +import os +import re +import shutil +import subprocess +import tempfile +import uuid + +log = logging.getLogger(__name__) + +from PIL import Image, ImageOps +import exifread +import imagehash + +from app import db +from app.models import ImageRecord, Tag, ArchiveRecord + +FFMPEG_THUMB_TIMEOUT = int(os.environ.get("THUMBS_FFMPEG_TIMEOUT", "60")) # seconds +FFMPEG_TRANSCODE_TIMEOUT = int(os.environ.get("FFMPEG_TRANSCODE_TIMEOUT", "600")) # 10 min default +Image.MAX_IMAGE_PIXELS = int(os.environ.get("PIL_MAX_IMAGE_PIXELS", "178956970")) +ARCHIVE_NUM_WIDTH = int(os.environ.get("ARCHIVE_NUM_WIDTH", "4")) + + +# ============================================================================= +# Configuration & Constants +# ============================================================================= + +THUMB_SIZE = (400, 400) + +# Media and archive extensions we handle +IMAGE_EXTS = (".jpg", ".jpeg", ".jfif", ".png", ".gif", ".bmp", ".tiff", ".webp") +VIDEO_EXTS = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv") +VIDEO_EXTS_NEED_TRANSCODE = (".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv") # Non-MP4 formats +ALLOWED_MEDIA_EXTS = IMAGE_EXTS + VIDEO_EXTS +ALLOWED_ARCHIVE_EXTS = ( + ".zip", ".rar", ".cbr", ".7z", + ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz" +) + +# Import filter settings (loaded from file or env) +IMPORT_SETTINGS_PATH = "/import/settings.json" + + +def load_import_settings() -> dict: + """ + Load import filter settings from database, with file and environment fallbacks. + Priority: DB settings > file settings > environment variables + """ + # Start with environment defaults + defaults = { + "min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")), + "min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")), + "skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true", + "transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")), + "skip_single_color": os.environ.get("IMPORT_SKIP_SINGLE_COLOR", "true").lower() == "true", + "single_color_threshold": float(os.environ.get("IMPORT_SINGLE_COLOR_THRESHOLD", "0.95")), + "single_color_tolerance": int(os.environ.get("IMPORT_SINGLE_COLOR_TOLERANCE", "30")), + "phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "10")), # Default for 256-bit + "supersede_smaller": os.environ.get("IMPORT_SUPERSEDE_SMALLER", "true").lower() == "true", + } + + # Try to load from file (legacy support) + try: + if os.path.exists(IMPORT_SETTINGS_PATH): + with open(IMPORT_SETTINGS_PATH, "r") as f: + file_settings = json.load(f) + defaults.update(file_settings) + except Exception as e: + print(f"[WARN] Failed to load import settings from file: {e}") + + # Try to load from database (preferred) + try: + from app.utils.version_migration import get_import_settings + db_settings = get_import_settings() + # Only override if DB has non-default values + for key, value in db_settings.items(): + if value is not None: + defaults[key] = value + except Exception as e: + print(f"[WARN] Failed to load import settings from DB: {e}") + + return defaults + + +def is_mostly_transparent(file_path: str, threshold: float = 0.9) -> bool: + """ + Check if an image is mostly transparent (> threshold % of pixels are transparent). + Returns False for images without alpha channel. + + Args: + file_path: Path to the image file + threshold: Percentage of pixels that must be transparent (0.0-1.0, default 0.9 = 90%) + + Returns: + True if the image has > threshold% transparent pixels + """ + try: + with Image.open(file_path) as img: + original_mode = img.mode + + # Handle different image modes that may have transparency + if original_mode == "RGBA": + # Already has alpha channel + pass + elif original_mode == "LA": + # Grayscale with alpha + img = img.convert("RGBA") + elif original_mode == "P": + # Palette mode - check for transparency + if "transparency" in img.info: + img = img.convert("RGBA") + else: + return False + elif original_mode == "PA": + # Palette with alpha + img = img.convert("RGBA") + else: + # RGB, L, etc. - no alpha channel + return False + + # Get alpha channel + bands = img.split() + if len(bands) < 4: + return False + + alpha = bands[3] # Alpha is the 4th channel (index 3) + alpha_data = alpha.getdata() + total_pixels = alpha.size[0] * alpha.size[1] + + if total_pixels == 0: + return False + + # Count pixels where alpha < 128 (more than 50% transparent) + transparent_count = sum(1 for p in alpha_data if p < 128) + transparency_ratio = transparent_count / total_pixels + + return transparency_ratio > threshold + except Exception as e: + print(f"[WARN] Failed to check transparency for {file_path}: {e}") + return False + + +def is_mostly_single_color(file_path: str, threshold: float = 0.95, tolerance: int = 30) -> bool: + """ + Check if an image is mostly a single color (> threshold % of pixels are similar). + Uses color distance tolerance to group similar colors. + + Args: + file_path: Path to the image file + threshold: Percentage of pixels that must be similar (0.0-1.0, default 0.95 = 95%) + tolerance: Maximum color distance to be considered "same" color (0-255 per channel, default 30) + + Returns: + True if the image is mostly a single solid color + """ + try: + with Image.open(file_path) as img: + # Convert to RGB for consistent color comparison + if img.mode in ("RGBA", "LA"): + # For images with alpha, only consider non-transparent pixels + img_rgba = img.convert("RGBA") if img.mode != "RGBA" else img + pixels = list(img_rgba.getdata()) + # Filter out mostly transparent pixels (alpha < 128) + opaque_pixels = [(r, g, b) for r, g, b, a in pixels if a >= 128] + if len(opaque_pixels) == 0: + return True # Fully transparent = effectively single color + pixels = opaque_pixels + elif img.mode == "P": + img = img.convert("RGB") + pixels = list(img.getdata()) + elif img.mode == "L": + # Grayscale - convert to RGB tuples + pixels = [(p, p, p) for p in img.getdata()] + else: + if img.mode != "RGB": + img = img.convert("RGB") + pixels = list(img.getdata()) + + if len(pixels) == 0: + return True + + # Sample pixels for performance on large images + total_pixels = len(pixels) + if total_pixels > 10000: + # Sample evenly distributed pixels + step = total_pixels // 10000 + pixels = pixels[::step] + + # Find the dominant color (most common pixel or average of sampled area) + # Use the pixel at center as reference + center_idx = len(pixels) // 2 + ref_color = pixels[center_idx] + + # Count pixels within tolerance of reference color + similar_count = 0 + for pixel in pixels: + if isinstance(pixel, int): + pixel = (pixel, pixel, pixel) + # Calculate color distance (simple RGB distance) + dist = abs(pixel[0] - ref_color[0]) + abs(pixel[1] - ref_color[1]) + abs(pixel[2] - ref_color[2]) + if dist <= tolerance * 3: # tolerance per channel * 3 channels + similar_count += 1 + + similarity_ratio = similar_count / len(pixels) + return similarity_ratio > threshold + + except Exception as e: + print(f"[WARN] Failed to check single color for {file_path}: {e}") + return False + + +def supersede_image(old_record: "ImageRecord", new_filepath: str, new_thumb_path: str, + new_hash: str, new_phash, new_metadata: dict, new_filename: str) -> "ImageRecord": + """ + Replace a smaller image with a larger version, preserving tags and metadata. + + Args: + old_record: The existing ImageRecord to supersede + new_filepath: Path to the new larger image file + new_thumb_path: Path to the new thumbnail + new_hash: SHA256 hash of the new file + new_phash: Perceptual hash of the new image + new_metadata: Metadata dict for the new image + new_filename: Original filename of the new image + + Returns: + The updated ImageRecord + """ + # Store old file paths for cleanup + old_filepath = old_record.filepath + old_thumb_path = old_record.thumb_path + + print(f"[SUPERSEDE] Replacing {old_record.filename} ({old_record.width}x{old_record.height}) " + f"with {new_filename} ({new_metadata['width']}x{new_metadata['height']})") + + # Update record with new file info + old_record.filename = new_filename + old_record.filepath = new_filepath + old_record.thumb_path = new_thumb_path + old_record.hash = new_hash + old_record.perceptual_hash = str(new_phash) if new_phash else None + old_record.file_size = new_metadata["file_size"] + old_record.width = new_metadata["width"] + old_record.height = new_metadata["height"] + old_record.format = new_metadata["format"] + # Keep the earlier taken_at date (prefer original date) + if new_metadata.get("taken_at") and old_record.taken_at: + if new_metadata["taken_at"] < old_record.taken_at: + old_record.taken_at = new_metadata["taken_at"] + elif new_metadata.get("taken_at") and not old_record.taken_at: + old_record.taken_at = new_metadata["taken_at"] + # Don't update imported_at - keep original import time + + # Delete old files + try: + if old_filepath and os.path.exists(old_filepath): + os.remove(old_filepath) + print(f"[SUPERSEDE] Deleted old file: {old_filepath}") + except Exception as e: + print(f"[WARN] Failed to delete old file {old_filepath}: {e}") + + try: + if old_thumb_path and os.path.exists(old_thumb_path): + os.remove(old_thumb_path) + print(f"[SUPERSEDE] Deleted old thumbnail: {old_thumb_path}") + except Exception as e: + print(f"[WARN] Failed to delete old thumbnail {old_thumb_path}: {e}") + + return old_record + + +# Regex for multi-part detection +RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE) +SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE) +OLD_RAR_VOL_RE = re.compile(r"\.r\d{2}$", re.IGNORECASE) # .r00, .r01, ... + +# Environment configuration (optional) +ENV_TMPDIR = "ARCHIVE_TMPDIR" # where to extract archives (default: system tmp) +ENV_MINFREE = "ARCHIVE_MIN_FREE_GB" # min free GB required to start extraction (float; 0 = disabled) + + +# ============================================================================= +# Utilities: Temp space management & free-space guard +# ============================================================================= + +def get_tmp_base() -> Path: + """Return the base temp directory for archive extraction.""" + return Path(os.environ.get(ENV_TMPDIR, tempfile.gettempdir())).resolve() + + +def cleanup_stale_tempdirs(prefix: str = "extract_") -> None: + """Delete any leftover extraction directories from previous crashes.""" + base = get_tmp_base() + base.mkdir(parents=True, exist_ok=True) + for entry in base.glob(f"{prefix}*"): + if entry.is_dir(): + try: + shutil.rmtree(entry, ignore_errors=True) + except Exception: + pass + + +def ensure_free_space_or_raise(path: Path, min_free_gb: float) -> None: + """Raise if there is not enough free space at path.""" + if min_free_gb <= 0: + return + total, used, free = shutil.disk_usage(str(path)) + free_gb = free / (1024 ** 3) + if free_gb < min_free_gb: + raise RuntimeError( + f"Insufficient free space at {path}: {free_gb:.1f} GB free " + f"(required >= {min_free_gb:.1f} GB)." + ) + + +# ============================================================================= +# Utilities: External extractors with resilient fallback +# ============================================================================= + +class ExtractError(Exception): + """Raised when archive extraction fails with all strategies.""" + + +def _run_cmd(cmd: list[str]) -> tuple[int, str, str]: + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + 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}") + + +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}") + + +def looks_password_protected_with_lsar(archive_path: str) -> bool: + """Best-effort pre-check for 'Encrypted/Password' in lsar output.""" + try: + rc, out, err = _run_cmd(["lsar", archive_path]) + txt = (out + "\n" + err).lower() + return ("encrypted" in txt) or ("password" in txt) + except Exception: + return False + + +def extract_archive_resilient(archive_path: str, out_dir: str) -> None: + """ + Prefer unar for RAR/CBR (RAR5-friendly); fallback to 7z. + For other formats, try 7z first then unar. + Surfaces stderr so logs show CRC/password/permissions issues. + """ + lower = archive_path.lower() + errors = [] + + # Optional password hint to skip early + if lower.endswith((".rar", ".cbr")) and looks_password_protected_with_lsar(archive_path): + raise ExtractError("Archive appears password-protected; skipping (no password provided).") + + if lower.endswith((".rar", ".cbr")): + order = (extract_with_unar, extract_with_7z) + else: + order = (extract_with_7z, extract_with_unar) + + for fn in order: + try: + fn(archive_path, out_dir) + return + except ExtractError as e: + errors.append(str(e)) + + raise ExtractError("Both extractors failed:\n" + "\n---\n".join(errors)) + + +# ============================================================================= +# Content-addressed storage helpers +# ============================================================================= + +def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: str) -> str: + """ + Always produce a content-addressed destination: + /__ + If that path already exists, append a numeric suffix. + """ + base, ext = os.path.splitext(original_name) + candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}{ext}") + if not os.path.exists(candidate): + return candidate + + # Rare: collision (e.g., concurrent import) → add counter + i = 2 + while True: + candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}_{i}{ext}") + if not os.path.exists(candidate): + return candidate + i += 1 + + +# ============================================================================= +# Helpers: Thumbnails, metadata, hashing, similarity +# ============================================================================= + +def generate_thumbnail( + image_path, + size=(400, 400), + overwrite=False, + prefer_jpeg=False, # False = auto-pick PNG for alpha images; True = always JPEG + jpeg_bg=(0, 0, 0), # background used when flattening to JPEG +): + """ + Save thumbnails mirrored under /images/thumbs/<...>. + If prefer_jpeg is False (default), transparent images save as PNG, others as JPEG. + If prefer_jpeg is True, all thumbs are JPEG with transparency flattened to `jpeg_bg`. + + For images with extreme aspect ratios (very tall like comic strips, or very wide), + crops to the center region before thumbnailing to produce better quality previews. + + Returns the absolute path to the thumbnail. + """ + images_root = Path("/images").resolve() + image_path = Path(image_path).resolve() + + try: + rel_path = image_path.relative_to(images_root) + except ValueError: + raise ValueError(f"{image_path} is not under {images_root}") + + # Base path under /images/thumbs with same subfolders/filename (we may change the suffix) + thumb_base = (images_root / "thumbs" / rel_path).with_suffix("") # drop original suffix for control + + # Decide output extension/format + with Image.open(image_path) as img: + img = ImageOps.exif_transpose(img) # honor camera rotation + + # Handle extreme aspect ratios by cropping to a more balanced region + # This prevents very tall/wide images from becoming tiny slivers + w, h = img.size + aspect_ratio = w / h if h > 0 else 1 + + # If aspect ratio is extreme (< 0.5 means very tall, > 2.0 means very wide) + # crop to a more balanced region before thumbnailing + if aspect_ratio < 0.5: + # Very tall image (like comic strip) - crop from top portion + # Take a region that's closer to square, from the top + crop_height = min(h, w * 2) # At most 2:1 tall + img = img.crop((0, 0, w, crop_height)) + elif aspect_ratio > 2.0: + # Very wide image - crop from center + crop_width = min(w, h * 2) # At most 2:1 wide + left = (w - crop_width) // 2 + img = img.crop((left, 0, left + crop_width, h)) + + # Use LANCZOS resampling for high-quality downscaling + img.thumbnail(size, Image.Resampling.LANCZOS) + + has_alpha = _has_alpha(img) + + if prefer_jpeg: + # Force JPEG for consistency + out_path = thumb_base.with_suffix(".jpg") + out_path.parent.mkdir(parents=True, exist_ok=True) + if not overwrite and out_path.exists(): + return str(out_path) + img_rgb = _flatten_to_rgb(img, bg=jpeg_bg) + img_rgb.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True) + return str(out_path) + else: + # Auto-pick: PNG for alpha, JPEG otherwise + if has_alpha: + out_path = thumb_base.with_suffix(".png") + out_path.parent.mkdir(parents=True, exist_ok=True) + if not overwrite and out_path.exists(): + return str(out_path) + # Keep transparency if present + # Convert palette to RGBA for reliable PNG writing + if img.mode == "P": + img = img.convert("RGBA") + img.save(out_path, format="PNG", optimize=True) + return str(out_path) + else: + out_path = thumb_base.with_suffix(".jpg") + out_path.parent.mkdir(parents=True, exist_ok=True) + if not overwrite and out_path.exists(): + return str(out_path) + if img.mode != "RGB": + img = img.convert("RGB") + img.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True) + return str(out_path) + +def generate_video_thumbnail(video_path: str, output_path: str, time_position: str = "00:00:01") -> str | None: + """ + Extract a single JPG frame from a video using ffmpeg and write it to output_path. + Returns output_path on success, or None on failure. + """ + try: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + cmd = [ + "ffmpeg", "-y", + "-ss", time_position, + "-i", str(video_path), + "-vframes", "1", + "-vf", f"scale={THUMB_SIZE[0]}:-1", + str(output_path), + ] + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=FFMPEG_THUMB_TIMEOUT) + return output_path + except subprocess.CalledProcessError as e: + print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}") + return None + + +def generate_video_thumbnail_mirrored(video_path: str, time_position: str = "00:00:01") -> str | None: + """ + Mirror the stored (hashed) video path under /images/thumbs/... and save as .jpg. + """ + images_root = Path("/images").resolve() + video_path_p = Path(video_path).resolve() + rel_path = video_path_p.relative_to(images_root) # raises if not under /images + thumb_path = (images_root / "thumbs" / rel_path).with_suffix(".jpg") + return generate_video_thumbnail(str(video_path_p), str(thumb_path), time_position=time_position) + + +def transcode_video_to_mp4(input_path: str, output_path: str = None) -> str | None: + """ + Transcode a video to H.264 MP4 for universal browser playback. + + Args: + input_path: Path to source video file + output_path: Optional destination path. If None, replaces extension with .mp4 + in a temp location. + + Returns: + Path to transcoded MP4 file on success, None on failure. + """ + input_p = Path(input_path) + + # Determine output path + if output_path is None: + # Create temp file with .mp4 extension + output_path = str(input_p.with_suffix(".mp4")) + if output_path == input_path: + # Already .mp4, use temp suffix + output_path = str(input_p.with_suffix(".transcoded.mp4")) + + try: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # FFmpeg command for H.264/AAC transcoding with good browser compatibility + # -movflags +faststart: Enables progressive playback (moov atom at start) + # -preset medium: Balance between speed and compression + # -crf 23: Good quality (lower = better, 18-28 typical range) + # -c:v libx264: H.264 video codec + # -c:a aac: AAC audio codec + # -pix_fmt yuv420p: Pixel format for maximum compatibility + cmd = [ + "ffmpeg", "-y", + "-i", str(input_path), + "-c:v", "libx264", + "-preset", "medium", + "-crf", "23", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "128k", + "-movflags", "+faststart", + str(output_path), + ] + + print(f"[INFO] Transcoding video: {input_path} -> {output_path}") + result = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=FFMPEG_TRANSCODE_TIMEOUT + ) + print(f"[INFO] Transcoding complete: {output_path}") + return output_path + + except subprocess.TimeoutExpired: + print(f"[WARN] Video transcode timed out after {FFMPEG_TRANSCODE_TIMEOUT}s: {input_path}") + # Clean up partial output + if os.path.exists(output_path): + os.remove(output_path) + return None + except subprocess.CalledProcessError as e: + print(f"[WARN] Failed to transcode video {input_path}: {e}") + if e.stderr: + print(f"[WARN] FFmpeg stderr: {e.stderr.decode('utf-8', errors='ignore')[:500]}") + # Clean up partial output + if os.path.exists(output_path): + os.remove(output_path) + return None + except Exception as e: + print(f"[WARN] Unexpected error transcoding video {input_path}: {e}") + if os.path.exists(output_path): + os.remove(output_path) + return None + + +def needs_transcode(file_path: str) -> bool: + """Check if a video file needs transcoding to MP4.""" + ext = Path(file_path).suffix.lower() + return ext in VIDEO_EXTS_NEED_TRANSCODE + + +def calculate_hash(file_path: str) -> str: + hash_func = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): # 1MB chunks work well for big files + hash_func.update(chunk) + return hash_func.hexdigest() + + +def calculate_perceptual_hash(file_path: str, hash_size: int = 16): + """ + Calculate a perceptual hash for an image. + + Args: + file_path: Path to the image file + hash_size: Size of the hash grid (default 16 for 256-bit hash) + hash_size=8 gives 64-bit, hash_size=16 gives 256-bit + + Returns: + ImageHash object or None if hashing fails (e.g., for videos) + """ + try: + with Image.open(file_path) as img: + return imagehash.phash(img, hash_size=hash_size) + except Exception: + # videos or unreadable images land here (phash not applicable) + return None + + +def find_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10): + """ + Find visually similar images and determine relationship. + + Args: + phash: Perceptual hash of the new image + width: Width of the new image + height: Height of the new image + existing_phashes: List of (phash, width, height, image_id) tuples for existing images + threshold: Maximum Hamming distance to consider images similar + Default is 10 for 256-bit hashes (was 2 for 64-bit) + + Returns: + tuple: (relationship, image_id) where relationship is one of: + - None: No similar image found + - "larger_exists": A larger or equal similar image exists (skip new image) + - "smaller_exists": A smaller similar image exists (supersede it) + image_id is the ID of the matching image, or None + """ + for existing_phash, ew, eh, image_id in existing_phashes: + try: + distance = phash - existing_phash + if distance <= threshold: + # Similar image found - check size relationship + if ew >= width and eh >= height: + # Existing is larger or equal - skip new image + return ("larger_exists", image_id) + elif width > ew or height > eh: + # New image is larger - supersede the existing one + return ("smaller_exists", image_id) + except Exception as e: + print(f"[WARN] Failed pHash comparison: {e}") + return (None, None) + + +def is_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10) -> bool: + """ + Check if an image is visually similar to any existing larger image. + + Legacy wrapper for find_similar_image - returns True only if a larger version exists. + """ + relationship, _ = find_similar_image(phash, width, height, existing_phashes, threshold) + return relationship == "larger_exists" + + +def extract_metadata(file_path: str) -> dict: + metadata = { + "file_size": None, + "width": None, + "height": None, + "format": None, + "camera_model": None, + "taken_at": None + } + + metadata["file_size"] = os.path.getsize(file_path) + ext = os.path.splitext(file_path)[1].lower() + + # Get file modification time as fallback for taken_at + try: + file_mtime = datetime.fromtimestamp(os.path.getmtime(file_path)) + except Exception: + file_mtime = None + + # Video: use ffprobe + if ext in (".mp4", ".mov"): + try: + cmd = [ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "json", file_path + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False) + data = json.loads(result.stdout or "{}") + stream = data.get("streams", [{}])[0] + metadata["width"] = stream.get("width") + metadata["height"] = stream.get("height") + metadata["format"] = ext.lstrip(".") + # Use file mtime for videos since they don't have EXIF + metadata["taken_at"] = file_mtime + except Exception as e: + print(f"[WARN] Failed to extract video metadata: {file_path} – {e}") + return metadata + + # Image: PIL + EXIF (best-effort) + try: + with Image.open(file_path) as img: + metadata["width"], metadata["height"] = img.size + metadata["format"] = (img.format or "").lower() + except Exception as e: + mime_type, _ = mimetypes.guess_type(file_path) + print(f"[WARN] Failed to read image metadata: {file_path} ({e}) ext={ext} mime={mime_type}") + + try: + with open(file_path, "rb") as f: + tags = exifread.process_file(f, stop_tag="UNDEF", details=False) + if "EXIF DateTimeOriginal" in tags: + metadata["taken_at"] = datetime.strptime(str(tags["EXIF DateTimeOriginal"]), "%Y:%m:%d %H:%M:%S") + if "Image Model" in tags: + metadata["camera_model"] = str(tags["Image Model"]) + except Exception: + pass + + # Fallback to file modification time if no EXIF date + if metadata["taken_at"] is None: + metadata["taken_at"] = file_mtime + + return metadata + +def _has_alpha(img: Image.Image) -> bool: + """Return True if the image has any transparency channel.""" + if img.mode in ("RGBA", "LA"): + return True + if img.mode == "P" and "transparency" in img.info: + return True + return False + +def _flatten_to_rgb(img: Image.Image, bg=(0, 0, 0)) -> Image.Image: + """ + Flatten an RGBA/LA/Palette-with-alpha image onto a solid background, + returning an RGB image suitable for saving as JPEG. + """ + # Normalize to RGBA so we can use the alpha channel as a mask + if img.mode == "P": + img = img.convert("RGBA") + if img.mode in ("RGBA", "LA"): + # Ensure we have an explicit alpha channel as last band + if img.mode == "LA": + img = img.convert("RGBA") + alpha = img.split()[-1] # A channel + bg_img = Image.new("RGB", img.size, bg) + bg_img.paste(img, mask=alpha) + return bg_img + # No alpha, just convert to RGB + return img.convert("RGB") + + +# ============================================================================= +# Helpers: Multi-part detection +# ============================================================================= + +def is_first_volume(path: str) -> bool: + """ + Determine if 'path' is the first volume of a multi-part archive. + We only attempt extraction from the first volume. + """ + name = os.path.basename(path).lower() + + # RAR: .partN.rar (only N==1 is first) + m = RAR_PART_RE.search(name) + if m: + n = m.group(1) + return n in ("1", "01", "001") + + # RAR old-style: first is .rar, subsequent are .r00, .r01... + if name.endswith(".rar"): + return True + if OLD_RAR_VOL_RE.search(name): + return False + + # CBR is typically a single-volume RAR + if name.endswith(".cbr"): + return True + + # 7z multi-part: .7z.001 is first + m = SEVENZ_PART_RE.search(name) + if m: + n = m.group(1) + return n in ("1", "01", "001") + if name.endswith(".7z"): + return True # single-volume 7z + + # ZIP and tarballs: treat as single-volume (we don't include .z01 in our allowlist) + if name.endswith((".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")): + return True + + return True + +def extract_archive_name(filename: str) -> str: + """ + Extract a clean archive name from a filename by removing common prefixes. + + Handles patterns like: + - "43387617_attachment_83109081_October 2020 Rewards.rar" -> "October 2020 Rewards" + - "01_Archive Name.zip" -> "Archive Name" + - "Archive Name.rar" -> "Archive Name" + + Returns the cleaned name without extension. + """ + import re + from pathlib import Path + + # Remove extension + stem = Path(filename).stem + + # Pattern 1: Gallery-DL style "{id}_attachment_{id}_{name}" or "{id}_{name}" + # Match one or more numeric_prefix patterns at the start + cleaned = re.sub(r'^(\d+_)+(attachment_)?(\d+_)?', '', stem) + + # Pattern 2: Simple numeric prefix "01_name" or "001_name" + if cleaned == stem: # No match from pattern 1 + cleaned = re.sub(r'^\d+_', '', stem) + + # If we stripped everything or got empty, fall back to original stem + if not cleaned.strip(): + cleaned = stem + + return cleaned.strip() + + +def compute_archive_tag_name(artist: str, archive_filename: str) -> str: + """ + Generate an archive tag name in the format: 'archive:{artist}:{archive_name}' + + Similar to post tags (post:{platform}:{artist}:{id}), archive tags use + a consistent prefix and colon-separated components. + + Examples: + - artist="InCaseArt", filename="43387617_attachment_83109081_October 2020 Rewards.rar" + -> "archive:InCaseArt:October 2020 Rewards" + + If a tag with the same name already exists, appends a numeric suffix. + """ + archive_name = extract_archive_name(archive_filename) + base_tag = f"archive:{artist}:{archive_name}" + + # Check if tag already exists + existing = Tag.query.filter_by(name=base_tag, kind='archive').first() + if not existing: + return base_tag + + # Tag exists, add numeric suffix + n = 2 + while True: + candidate = f"{base_tag} ({n})" + if not Tag.query.filter_by(name=candidate, kind='archive').first(): + return candidate + n += 1 + + +def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str: + """ + DEPRECATED: Use compute_archive_tag_name() instead. + + Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ... + - Looks only at Tag(kind='archive') with names starting 'archive:{artist}/' + - Finds max numeric suffix and returns next number (zero-padded). + - Ensures no collision even if tags were renamed. + """ + prefix = f"archive:{artist}/" + # Pull existing tag names for this artist/prefix + rows = (Tag.query + .with_entities(Tag.name) + .filter(Tag.kind == 'archive', + Tag.name.like(prefix + '%')) + .all()) + + max_n = 0 + for (name,) in rows: + tail = name[len(prefix):] + if tail.isdigit(): + try: + n = int(tail) + if n > max_n: + max_n = n + except Exception: + pass + + # Propose next number and make sure it doesn't already exist + n = max_n + 1 + while True: + candidate = f"{prefix}{str(n).zfill(width)}" + if not Tag.query.filter_by(name=candidate).first(): + return candidate n += 1 \ No newline at end of file diff --git a/app/utils/metadata_enrichment.py b/app/utils/metadata_enrichment.py index 14b247a..bedcf78 100644 --- a/app/utils/metadata_enrichment.py +++ b/app/utils/metadata_enrichment.py @@ -31,6 +31,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]: Gallery-DL creates JSON files with various naming patterns: - Patreon: {filename}.json in same directory - HentaiFoundry: {artist}-{index}-{title}.json (may differ from image filename) + - Pixiv: {id}_p{num}.json for image named {id}_{title}_{num}.png - UUID-based: {uuid}.json where UUID is extracted from filename Returns the path to the sidecar JSON if found, None otherwise. @@ -50,7 +51,19 @@ def find_sidecar_json(image_path: str) -> Optional[str]: if direct_json.exists(): return str(direct_json) - # Pattern 3: Extract UUID from filename and look for {uuid}.json + # Pattern 3: Pixiv naming pattern + # Image: {id}_{title}_{num}.png (e.g., "115358744_Koseki Bijou (Bunny)_00.png") + # JSON: {id}_p{num}.json (e.g., "115358744_p0.json") + # Extract the leading numeric ID and trailing index + pixiv_match = re.match(r'^(\d{6,12})_.*_(\d{2})$', stem) + if pixiv_match: + pixiv_id = pixiv_match.group(1) + pixiv_num = int(pixiv_match.group(2)) # Convert "00" to 0 + pixiv_json = parent_dir / f"{pixiv_id}_p{pixiv_num}.json" + if pixiv_json.exists(): + return str(pixiv_json) + + # Pattern 4: Extract UUID from filename and look for {uuid}.json # Common patterns: "01_UUID", "UUID", "{num}_{UUID}" uuid_pattern = re.compile(r'([A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12})', re.IGNORECASE) match = uuid_pattern.search(stem) @@ -60,7 +73,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]: if uuid_json.exists(): return str(uuid_json) - # Pattern 4: Just the stem without prefix numbers + # Pattern 5: Just the stem without prefix numbers # e.g., "01_filename" -> look for "filename.json" stem_no_prefix = re.sub(r'^\d+_', '', stem) if stem_no_prefix != stem: @@ -68,7 +81,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]: if alt_json.exists(): return str(alt_json) - # Pattern 5: Extract numeric ID from filename and find JSON with same ID + # Pattern 6: Extract numeric ID from filename and find JSON with same ID # Handles HentaiFoundry where image is "hentaifoundry_1000220_title.jpg" # but JSON is "Artist-1000220-title.json" # Look for a 5-7 digit number that could be a post ID @@ -165,7 +178,7 @@ def load_sidecar_metadata(json_path: str) -> Optional[dict]: def extract_platform(metadata: dict) -> Optional[str]: """Extract the platform name from metadata.""" - # Check common fields + # Check common fields (covers Pixiv which uses "category": "pixiv") if "category" in metadata: return metadata["category"].lower() @@ -179,6 +192,8 @@ def extract_platform(metadata: dict) -> Optional[str]: return "subscribestar" if "discord.com" in url: return "discord" + if "pixiv.net" in url or "pximg.net" in url: + return "pixiv" return None @@ -195,8 +210,13 @@ def extract_artist(metadata: dict) -> Optional[str]: if "artist" in metadata: return metadata["artist"] - # For hentaifoundry - if "user" in metadata: + # For Pixiv - user is an object with name and account fields + if "user" in metadata and isinstance(metadata["user"], dict): + # Prefer account (username) over display name for consistency + return metadata["user"].get("account") or metadata["user"].get("name") + + # For hentaifoundry - user is a string + if "user" in metadata and isinstance(metadata["user"], str): return metadata["user"] return None @@ -238,6 +258,7 @@ def extract_description(metadata: dict) -> Optional[str]: - Patreon: 'content' (may be HTML) - SubscribeStar: 'content' (may be HTML) - HentaiFoundry: 'description' (plain text) + - Pixiv: 'caption' (plain text or HTML) """ # Try description first (HentaiFoundry uses this) if "description" in metadata: @@ -245,6 +266,12 @@ def extract_description(metadata: dict) -> Optional[str]: if desc and isinstance(desc, str) and desc.strip(): return desc.strip() + # Try caption (Pixiv uses this) + if "caption" in metadata: + caption = metadata["caption"] + if caption and isinstance(caption, str) and caption.strip(): + return caption.strip() + # Try content (Patreon/SubscribeStar use this) if "content" in metadata: content = metadata["content"] @@ -268,14 +295,23 @@ def extract_source_url(metadata: dict) -> Optional[str]: - Patreon: 'url' (direct post link) - SubscribeStar: construct from author_name and post_id - HentaiFoundry: construct from user and index + - Pixiv: construct from id """ platform = extract_platform(metadata) - # Direct URL field (Patreon) + # Construct URL for Pixiv (do this first to avoid using the pximg.net image URL) + if platform == "pixiv": + post_id = metadata.get("id") + if post_id: + return f"https://www.pixiv.net/artworks/{post_id}" + + # Direct URL field (Patreon) - but not for Pixiv where 'url' is the image URL if "url" in metadata: url = metadata["url"] if url and isinstance(url, str) and url.startswith("http"): - return url + # Skip pximg.net URLs (these are image URLs, not post URLs) + if "pximg.net" not in url: + return url # Construct URL for SubscribeStar if platform == "subscribestar": @@ -298,6 +334,10 @@ def extract_attachment_count(metadata: dict) -> int: """ Extract the number of attachments/images in the post. """ + # Pixiv: use page_count field + if "page_count" in metadata: + return int(metadata["page_count"]) + # Patreon: check images array or post_metadata.image_order if "images" in metadata and isinstance(metadata["images"], list): return len(metadata["images"]) @@ -319,7 +359,8 @@ def extract_date(metadata: dict) -> Optional[datetime]: Extract the publication/post date from metadata. Tries multiple common date fields and formats. """ - date_fields = ["published_at", "date", "created_at", "timestamp"] + # Pixiv uses create_date, others use published_at, date, etc. + date_fields = ["published_at", "create_date", "date", "created_at", "timestamp"] for field in date_fields: if field not in metadata: diff --git a/generate_favicon.py b/generate_favicon.py new file mode 100644 index 0000000..2145938 --- /dev/null +++ b/generate_favicon.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Generate favicon files for ImageRepo. +Creates a simple gallery/image icon in multiple formats and sizes. +""" + +from PIL import Image, ImageDraw +import os + +# Output directory +STATIC_DIR = os.path.join(os.path.dirname(__file__), 'app', 'static') + +# Color scheme (dark theme friendly) +BG_COLOR = (45, 45, 55) # Dark background +FRAME_COLOR = (99, 102, 241) # Indigo accent (matches --accent) +IMAGE_BG = (60, 60, 70) # Slightly lighter for image area +MOUNTAIN_COLOR = (80, 85, 95) # Mountain silhouette +SUN_COLOR = (251, 191, 36) # Warm yellow sun + + +def create_favicon_image(size): + """Create a gallery icon at the specified size.""" + img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Padding and dimensions + padding = max(1, size // 16) + frame_width = max(1, size // 10) + corner_radius = max(2, size // 8) + + # Outer frame (rounded rectangle) + frame_box = (padding, padding, size - padding, size - padding) + draw.rounded_rectangle(frame_box, radius=corner_radius, fill=FRAME_COLOR) + + # Inner image area + inner_padding = padding + frame_width + inner_box = (inner_padding, inner_padding, size - inner_padding, size - inner_padding) + inner_radius = max(1, corner_radius - frame_width // 2) + draw.rounded_rectangle(inner_box, radius=inner_radius, fill=IMAGE_BG) + + # Simple landscape scene inside + inner_width = size - 2 * inner_padding + inner_height = size - 2 * inner_padding + + if inner_width > 4 and inner_height > 4: + # Sun (small circle in upper right) + sun_size = max(2, inner_width // 5) + sun_x = inner_padding + inner_width - sun_size - max(1, inner_width // 6) + sun_y = inner_padding + max(1, inner_height // 6) + draw.ellipse( + (sun_x, sun_y, sun_x + sun_size, sun_y + sun_size), + fill=SUN_COLOR + ) + + # Mountains (triangular shapes) + mountain_base_y = size - inner_padding - max(1, inner_height // 8) + + # Left mountain (taller) + peak1_x = inner_padding + inner_width // 3 + peak1_y = inner_padding + inner_height // 3 + draw.polygon([ + (inner_padding, mountain_base_y), + (peak1_x, peak1_y), + (inner_padding + int(inner_width * 0.6), mountain_base_y) + ], fill=MOUNTAIN_COLOR) + + # Right mountain (shorter, overlapping) + peak2_x = inner_padding + int(inner_width * 0.7) + peak2_y = inner_padding + int(inner_height * 0.5) + draw.polygon([ + (inner_padding + inner_width // 3, mountain_base_y), + (peak2_x, peak2_y), + (size - inner_padding, mountain_base_y) + ], fill=(90, 95, 105)) # Slightly lighter for depth + + return img + + +def create_svg_favicon(): + """Create an SVG favicon.""" + svg_content = ''' + + + + + + + + + + +''' + + svg_path = os.path.join(STATIC_DIR, 'favicon.svg') + with open(svg_path, 'w') as f: + f.write(svg_content) + print(f"Created: {svg_path}") + return svg_path + + +def main(): + os.makedirs(STATIC_DIR, exist_ok=True) + + # Generate SVG (scalable, modern browsers) + create_svg_favicon() + + # Generate PNG favicons at various sizes + sizes = [16, 32, 48, 180, 192, 512] + png_images = {} + + for size in sizes: + img = create_favicon_image(size) + filename = f'favicon-{size}x{size}.png' + filepath = os.path.join(STATIC_DIR, filename) + img.save(filepath, 'PNG') + png_images[size] = img + print(f"Created: {filepath}") + + # Create apple-touch-icon (180x180) + apple_path = os.path.join(STATIC_DIR, 'apple-touch-icon.png') + png_images[180].save(apple_path, 'PNG') + print(f"Created: {apple_path}") + + # Create ICO file with multiple sizes (16, 32, 48) + ico_path = os.path.join(STATIC_DIR, 'favicon.ico') + ico_sizes = [png_images[16], png_images[32], png_images[48]] + ico_sizes[0].save( + ico_path, + format='ICO', + sizes=[(16, 16), (32, 32), (48, 48)], + append_images=ico_sizes[1:] + ) + print(f"Created: {ico_path}") + + print("\nFavicon generation complete!") + print("\nAdd these lines to your layout.html section:") + print(''' + + + +''') + + +if __name__ == '__main__': + main() diff --git a/summary.md b/summary.md index 548a301..57757da 100644 --- a/summary.md +++ b/summary.md @@ -4,7 +4,7 @@ A Flask-based image repository with Celery task processing for importing, organi > **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications. > -> **Last Updated**: 2026-02-01 (Settings dashboard: system stats, task filtering, error details) +> **Last Updated**: 2026-02-04 (Pixiv sidecar support, Tags navbar dropdown, tag search) --- @@ -422,12 +422,14 @@ Trigger via API: `POST /api/import/trigger` with `deep=true` ### Sidecar Metadata Gallery-DL JSON files provide: -- Platform (patreon, hentaifoundry, etc.) +- Platform (patreon, hentaifoundry, subscribestar, pixiv) - Artist name - Post ID and title - Publication date - Source URL +Pixiv-specific: Uses `{id}_p{num}.json` naming pattern, extracts from `user.account`, `create_date`, `caption`, constructs URL as `https://www.pixiv.net/artworks/{id}` + --- ## Environment Variables