diff --git a/app/main.py b/app/main.py index d733672..be0880d 100644 --- a/app/main.py +++ b/app/main.py @@ -84,6 +84,8 @@ def gallery(): Server renders initial batch, then JS takes over for infinite scroll. Optional tag filter via ?tag=artist:NAME or any tag name. """ + from app.models import PostMetadata + initial_limit = 30 tag_name = request.args.get('tag') @@ -91,8 +93,11 @@ def gallery(): q = ImageRecord.query.options(joinedload(ImageRecord.tags)) # Optional tag filter + active_tag_obj = None if tag_name: - q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) + active_tag_obj = Tag.query.filter_by(name=tag_name).first() + if active_tag_obj: + q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) # Fetch initial batch ordered by date descending images = q.order_by( @@ -115,12 +120,42 @@ def gallery(): if last_dt: initial_cursor = last_dt.isoformat() + # Fetch post metadata if filtering by a post tag + post_metadata = None + if active_tag_obj and active_tag_obj.kind == 'post': + pm = PostMetadata.query.filter_by(tag_id=active_tag_obj.id).first() + if pm: + post_metadata = { + 'title': pm.title, + 'description': pm.description, + 'source_url': pm.source_url, + 'platform': pm.platform, + 'artist': pm.artist, + 'post_id': pm.post_id, + 'attachment_count': pm.attachment_count, + 'published_at': pm.published_at, + } + + # Fetch series info if filtering by a series tag + from app.models import SeriesPage + series_info = None + if active_tag_obj and active_tag_obj.kind == 'series': + page_count = SeriesPage.query.filter_by(series_tag_id=active_tag_obj.id).count() + series_info = { + 'tag_id': active_tag_obj.id, + 'name': active_tag_obj.name.split(':', 1)[1] if ':' in active_tag_obj.name else active_tag_obj.name, + 'page_count': page_count, + } + return render_template( 'gallery.html', grouped_images=grouped_images, active_tag=tag_name, + active_tag_obj=active_tag_obj, initial_cursor=initial_cursor, - has_more=has_more + has_more=has_more, + post_metadata=post_metadata, + series_info=series_info ) @@ -605,6 +640,73 @@ def get_tag(tag_id): }) +@main.get("/api/post-metadata/") +def get_post_metadata(tag_id): + """ + Get post metadata for a post tag. + Returns title, description, source URL, etc. for display in the gallery. + """ + from app.models import PostMetadata + + tag = Tag.query.get_or_404(tag_id) + + # Only post tags have metadata + if tag.kind != 'post': + return jsonify(ok=False, error="Not a post tag"), 400 + + pm = PostMetadata.query.filter_by(tag_id=tag_id).first() + if not pm: + return jsonify(ok=True, metadata=None) + + return jsonify(ok=True, metadata={ + "id": pm.id, + "tag_id": pm.tag_id, + "tag_name": tag.name, + "platform": pm.platform, + "post_id": pm.post_id, + "artist": pm.artist, + "title": pm.title, + "description": pm.description, + "source_url": pm.source_url, + "attachment_count": pm.attachment_count, + "published_at": pm.published_at.isoformat() if pm.published_at else None, + }) + + +@main.get("/api/post-metadata/by-tag-name/") +def get_post_metadata_by_name(tag_name): + """ + Get post metadata by tag name (e.g., post:patreon:artist:12345). + Useful when filtering gallery by tag name. + """ + from app.models import PostMetadata + + tag = Tag.query.filter_by(name=tag_name).first() + if not tag: + return jsonify(ok=True, metadata=None) + + if tag.kind != 'post': + return jsonify(ok=False, error="Not a post tag"), 400 + + pm = PostMetadata.query.filter_by(tag_id=tag.id).first() + if not pm: + return jsonify(ok=True, metadata=None) + + return jsonify(ok=True, metadata={ + "id": pm.id, + "tag_id": pm.tag_id, + "tag_name": tag.name, + "platform": pm.platform, + "post_id": pm.post_id, + "artist": pm.artist, + "title": pm.title, + "description": pm.description, + "source_url": pm.source_url, + "attachment_count": pm.attachment_count, + "published_at": pm.published_at.isoformat() if pm.published_at else None, + }) + + @main.post("/api/tag//update") def update_tag(tag_id): """ @@ -627,7 +729,7 @@ def update_tag(tag_id): return jsonify(ok=False, error="Name is required"), 400 # Build the full tag name based on kind - prefixed_kinds = ("artist", "archive", "character", "series", "rating") + prefixed_kinds = ("artist", "archive", "character", "series", "fandom", "rating") if new_kind in prefixed_kinds: # Remove any existing prefix from name if present if ":" in new_name: @@ -1512,3 +1614,254 @@ def bulk_remove_tag(): 'ok': True, 'removed_count': removed_count }) + + +# ============================================= +# Series / Reader API +# ============================================= + +@main.route('/read/') +def read_series(tag_id): + """ + Reader view for a series - displays images in page order. + Supports jump-to-page via ?page=N parameter. + """ + from app.models import SeriesPage + + tag = Tag.query.get_or_404(tag_id) + + if tag.kind != 'series': + return "Not a series tag", 400 + + # Get all pages in order + pages = SeriesPage.query.filter_by(series_tag_id=tag_id).order_by( + SeriesPage.page_number + ).all() + + # Get starting page from query param + start_page = request.args.get('page', 1, type=int) + + # Get series name (strip prefix if present) + series_name = tag.name.split(':', 1)[1] if ':' in tag.name else tag.name + + return render_template( + 'reader.html', + tag=tag, + series_name=series_name, + pages=pages, + start_page=start_page, + total_pages=len(pages) + ) + + +@main.get('/api/series//pages') +def get_series_pages(tag_id): + """Get all pages in a series with their images.""" + from app.models import SeriesPage + + tag = Tag.query.get_or_404(tag_id) + + if tag.kind != 'series': + return jsonify(ok=False, error="Not a series tag"), 400 + + pages = SeriesPage.query.filter_by(series_tag_id=tag_id).order_by( + SeriesPage.page_number + ).all() + + return jsonify(ok=True, pages=[ + { + 'id': p.id, + 'page_number': p.page_number, + 'image_id': p.image_id, + 'image': { + 'id': p.image.id, + 'filename': p.image.filename, + 'filepath': p.image.filepath, + 'thumb_path': p.image.thumb_path, + 'width': p.image.width, + 'height': p.image.height, + } + } + for p in pages + ], total_pages=len(pages)) + + +@main.post('/api/series//pages/add') +def add_series_page(tag_id): + """ + Add an image to a series with a specific page number. + Expects form data: image_id, page_number + """ + from app.models import SeriesPage + + tag = Tag.query.get_or_404(tag_id) + + if tag.kind != 'series': + return jsonify(ok=False, error="Not a series tag"), 400 + + image_id = request.form.get('image_id', type=int) + page_number = request.form.get('page_number', type=int) + + if not image_id or page_number is None: + return jsonify(ok=False, error="image_id and page_number required"), 400 + + # Check if image exists + image = ImageRecord.query.get(image_id) + if not image: + return jsonify(ok=False, error="Image not found"), 404 + + # Check if image is already in a series + existing = SeriesPage.query.filter_by(image_id=image_id).first() + if existing: + return jsonify(ok=False, error="Image is already in a series", + existing_series_id=existing.series_tag_id), 409 + + # Check if page number is already taken + page_exists = SeriesPage.query.filter_by( + series_tag_id=tag_id, page_number=page_number + ).first() + if page_exists: + return jsonify(ok=False, error=f"Page {page_number} already exists"), 409 + + # Create the series page + sp = SeriesPage( + series_tag_id=tag_id, + image_id=image_id, + page_number=page_number + ) + db.session.add(sp) + + # Also ensure the image has the series tag + if tag not in image.tags: + image.tags.append(tag) + + db.session.commit() + + return jsonify(ok=True, page={ + 'id': sp.id, + 'page_number': sp.page_number, + 'image_id': sp.image_id + }) + + +@main.post('/api/series//pages/bulk-add') +def bulk_add_series_pages(tag_id): + """ + Add multiple images to a series with sequential page numbers. + Expects JSON: { image_ids: [id1, id2, ...], start_page: N } + Images are added in order starting from start_page. + """ + from app.models import SeriesPage + + tag = Tag.query.get_or_404(tag_id) + + if tag.kind != 'series': + return jsonify(ok=False, error="Not a series tag"), 400 + + data = request.get_json() or {} + image_ids = data.get('image_ids', []) + start_page = data.get('start_page', 1) + + if not image_ids: + return jsonify(ok=False, error="No image IDs provided"), 400 + + added = [] + skipped = [] + current_page = start_page + + for image_id in image_ids: + image = ImageRecord.query.get(image_id) + if not image: + skipped.append({'image_id': image_id, 'reason': 'not found'}) + continue + + # Check if image is already in a series + existing = SeriesPage.query.filter_by(image_id=image_id).first() + if existing: + skipped.append({'image_id': image_id, 'reason': 'already in series'}) + continue + + # Find next available page number + while SeriesPage.query.filter_by( + series_tag_id=tag_id, page_number=current_page + ).first(): + current_page += 1 + + sp = SeriesPage( + series_tag_id=tag_id, + image_id=image_id, + page_number=current_page + ) + db.session.add(sp) + + # Ensure image has the series tag + if tag not in image.tags: + image.tags.append(tag) + + added.append({'image_id': image_id, 'page_number': current_page}) + current_page += 1 + + db.session.commit() + + return jsonify(ok=True, added=added, skipped=skipped) + + +@main.post('/api/series/page//update') +def update_series_page(page_id): + """Update a series page's page number.""" + from app.models import SeriesPage + + sp = SeriesPage.query.get_or_404(page_id) + new_page_number = request.form.get('page_number', type=int) + + if new_page_number is None: + return jsonify(ok=False, error="page_number required"), 400 + + # Check if the new page number is taken + existing = SeriesPage.query.filter( + SeriesPage.series_tag_id == sp.series_tag_id, + SeriesPage.page_number == new_page_number, + SeriesPage.id != page_id + ).first() + + if existing: + return jsonify(ok=False, error=f"Page {new_page_number} already exists"), 409 + + sp.page_number = new_page_number + db.session.commit() + + return jsonify(ok=True, page={ + 'id': sp.id, + 'page_number': sp.page_number, + 'image_id': sp.image_id + }) + + +@main.post('/api/series/page//delete') +def delete_series_page(page_id): + """Remove an image from a series (doesn't delete the image).""" + from app.models import SeriesPage + + sp = SeriesPage.query.get_or_404(page_id) + db.session.delete(sp) + db.session.commit() + + return jsonify(ok=True) + + +@main.get('/api/image//series-info') +def get_image_series_info(image_id): + """Check if an image is part of a series and return its page info.""" + from app.models import SeriesPage + + sp = SeriesPage.query.filter_by(image_id=image_id).first() + + if not sp: + return jsonify(ok=True, in_series=False) + + return jsonify(ok=True, in_series=True, series_page={ + 'id': sp.id, + 'page_number': sp.page_number, + 'series_tag_id': sp.series_tag_id, + 'series_name': sp.series_tag.name + }) diff --git a/app/models.py b/app/models.py index 5943e1c..fa95455 100644 --- a/app/models.py +++ b/app/models.py @@ -101,6 +101,69 @@ class ImportBatch(db.Model): completed_at = db.Column(db.DateTime, nullable=True) +class PostMetadata(db.Model): + """ + Stores metadata about posts from external platforms (Patreon, SubscribeStar, HentaiFoundry). + Links to a Tag with kind='post' (format: post:platform:artist:id). + """ + __tablename__ = "post_metadata" + + id = db.Column(db.Integer, primary_key=True) + + # Link to the post tag (post:platform:artist:id) + tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, unique=True) + tag = db.relationship("Tag", backref=db.backref("post_metadata", uselist=False)) + + # Platform identification + platform = db.Column(db.String(64), nullable=False) # patreon, subscribestar, hentaifoundry + post_id = db.Column(db.String(64), nullable=False) # Original post ID on platform + artist = db.Column(db.String(255), nullable=True) + + # Content + title = db.Column(db.String(512), nullable=True) + description = db.Column(db.Text, nullable=True) # May contain HTML + source_url = db.Column(db.String(1024), nullable=True) # Link to original post + + # Statistics + attachment_count = db.Column(db.Integer, default=0) # Number of images in post + + # Timestamps + published_at = db.Column(db.DateTime, nullable=True) # Original publish date + created_at = db.Column(db.DateTime, default=db.func.now()) + updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) + + +class SeriesPage(db.Model): + """ + Links images to series tags with page numbers for ordered reading. + An image can only belong to one series at a time. + """ + __tablename__ = "series_page" + + id = db.Column(db.Integer, primary_key=True) + + # Link to the series tag (must be kind='series') + series_tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="CASCADE"), nullable=False) + series_tag = db.relationship("Tag", backref=db.backref("series_pages", lazy="dynamic")) + + # Link to the image (unique - image can only be in one series) + image_id = db.Column(db.Integer, db.ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False, unique=True) + image = db.relationship("ImageRecord", backref=db.backref("series_page", uselist=False)) + + # Page number within the series + page_number = db.Column(db.Integer, nullable=False) + + # Timestamps + created_at = db.Column(db.DateTime, default=db.func.now()) + updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) + + __table_args__ = ( + # Each page number must be unique within a series + db.UniqueConstraint('series_tag_id', 'page_number', name='uq_series_page_number'), + db.Index('ix_series_page_series_tag', 'series_tag_id'), + ) + + class ImportTask(db.Model): """ Tracks the state of each file being processed through the import pipeline. diff --git a/app/static/js/bulk-select.js b/app/static/js/bulk-select.js index a22d81f..70b70e9 100644 --- a/app/static/js/bulk-select.js +++ b/app/static/js/bulk-select.js @@ -112,6 +112,7 @@ updateCount(); loadCommonTags(); + dispatchSelectionChanged(); } function clearSelection() { @@ -121,6 +122,7 @@ }); updateCount(); updateCommonTagsDisplay(); + dispatchSelectionChanged(); } function updateCount() { @@ -129,6 +131,39 @@ } } + function dispatchSelectionChanged() { + document.dispatchEvent(new CustomEvent('selectionChanged', { + detail: { count: state.selectedIds.size } + })); + } + + // Enable select mode programmatically, optionally without opening the bulk panel + function enableSelectMode(skipPanel = false) { + if (state.isSelectMode) return; + + state.isSelectMode = true; + document.body.classList.add('select-mode'); + dom.selectBtn.textContent = 'Cancel'; + dom.selectBtn.classList.add('active'); + + if (!skipPanel) { + dom.panel.classList.add('active'); + updateCommonTagsDisplay(); + } + } + + // Disable select mode + function disableSelectMode() { + if (!state.isSelectMode) return; + + state.isSelectMode = false; + document.body.classList.remove('select-mode'); + dom.selectBtn.textContent = 'Select'; + dom.selectBtn.classList.remove('active'); + dom.panel.classList.remove('active'); + clearSelection(); + } + function updateSelectionUI() { // Re-apply selected class to any newly loaded images that are in selection document.querySelectorAll('.img-clickable').forEach(item => { @@ -416,6 +451,7 @@ archive: 'πŸ—œοΈ', character: 'πŸ‘€', series: 'πŸ“Ί', + fandom: '🎭', rating: '⚠️', source: '🌐', post: 'πŸ“Œ' @@ -435,4 +471,10 @@ } else { init(); } + + // Expose global API for other scripts (like series editor) + window.selectedImages = state.selectedIds; + window.clearSelection = clearSelection; + window.enableSelectMode = enableSelectMode; + window.disableSelectMode = disableSelectMode; })(); diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index b013179..1b8d3db 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -14,6 +14,14 @@ document.addEventListener('DOMContentLoaded', () => { const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null; const tagAutocomplete = document.getElementById('tagAutocomplete'); + // Series info elements + 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; + // Autocomplete state let autocompleteItems = []; let autocompleteSelectedIndex = -1; @@ -53,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => { archive: 'πŸ—œοΈ', character: 'πŸ‘€', series: 'πŸ“Ί', + fandom: '🎭', rating: '⚠️', source: '🌐', post: 'πŸ“Œ' @@ -87,6 +96,100 @@ document.addEventListener('DOMContentLoaded', () => { // ignore } } + + // --------------------------- + // Series info helpers + // --------------------------- + function hideSeriesInfo() { + if (seriesInfoEl) seriesInfoEl.style.display = 'none'; + currentSeriesPageId = null; + if (pageUpdateStatus) pageUpdateStatus.textContent = ''; + } + + 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(); + if (!imageId) return; + try { + const r = await fetch(`/api/image/${imageId}/series-info`); + const j = await r.json(); + if (j.ok && j.in_series) { + showSeriesInfo(j.series_page); + } + } 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(); + } + }); + } async function addTag(imageId, name) { const fd = new FormData(); fd.append('name', name); @@ -299,6 +402,7 @@ document.addEventListener('DOMContentLoaded', () => { // NEW: load tags for this image into the modal editor setEditorImageId(imageId); loadTags(imageId); + loadSeriesInfo(imageId); } function openModal(index) { @@ -324,6 +428,7 @@ document.addEventListener('DOMContentLoaded', () => { // clear tag UI setEditorImageId(''); renderTags([]); + hideSeriesInfo(); history.replaceState({}, '', window.location.pathname + window.location.search); } diff --git a/app/static/js/showcase.js b/app/static/js/showcase.js index 23313e4..0d7721c 100644 --- a/app/static/js/showcase.js +++ b/app/static/js/showcase.js @@ -286,6 +286,7 @@ archive: 'πŸ—œοΈ', character: 'πŸ‘€', series: 'πŸ“Ί', + fandom: '🎭', rating: '⚠️', source: '🌐', post: 'πŸ“Œ' diff --git a/app/static/style.css b/app/static/style.css index ab3642e..44ca38d 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -585,6 +585,63 @@ header { } } +/* Series info in modal tag editor */ +.modal-series-info { + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} +.series-info-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; +} +.series-info-row:last-child { + margin-bottom: 0; +} +.series-info-label { + color: var(--text-muted); + font-size: 0.85rem; +} +.series-info-link { + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; +} +.series-info-link:hover { + text-decoration: underline; +} +.series-page-input { + width: 60px; + padding: 0.25rem 0.4rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + color: var(--text); + font-size: 0.85rem; +} +.btn-small { + padding: 0.25rem 0.5rem; + background: var(--btn-secondary); + border: none; + border-radius: 4px; + color: var(--text); + font-size: 0.8rem; + cursor: pointer; +} +.btn-small:hover { + background: var(--btn-secondary-hover); +} +.btn-small:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.page-update-status { + font-size: 0.8rem; + color: var(--text-muted); +} + .tag-editor .tags { margin: 0 0 0.5rem 0; min-height: 28px; @@ -1407,6 +1464,261 @@ select.form-input optgroup { color: var(--text); } +/* Post Info Header (shown when filtering by post tag) */ +.post-info-header { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + margin: 0 0 1.5rem 0; + overflow: hidden; +} +.post-info-content { + padding: 1.25rem 1.5rem; +} +.post-title { + margin: 0 0 0.75rem 0; + font-size: 1.35rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; +} +.post-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + margin-bottom: 0.75rem; + font-size: 0.875rem; + color: var(--text-dim); +} +.post-artist { + font-weight: 500; + color: var(--link); +} +.post-platform { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.6rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + text-transform: capitalize; + background: rgba(255, 255, 255, 0.1); + color: var(--text-dim); +} +.post-platform-patreon { + background: rgba(255, 66, 77, 0.2); + color: #ff6b6b; +} +.post-platform-subscribestar { + background: rgba(255, 153, 0, 0.2); + color: #ffaa33; +} +.post-platform-hentaifoundry { + background: rgba(139, 92, 246, 0.2); + color: #a78bfa; +} +.post-date { + color: var(--text-muted); +} +.post-count { + color: var(--text-muted); +} +.post-count::before { + content: "β€’"; + margin-right: 0.5rem; +} +.post-description { + margin: 0.75rem 0; + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + font-size: 0.9rem; + line-height: 1.6; + color: var(--text-dim); + max-height: 200px; + overflow-y: auto; +} +.post-description:empty { + display: none; +} +/* Style any HTML content in descriptions */ +.post-description a { + color: var(--link); +} +.post-description a:hover { + color: var(--link-hover); +} +.post-description p { + margin: 0 0 0.75rem 0; +} +.post-description p:last-child { + margin-bottom: 0; +} +.post-description img { + max-width: 100%; + height: auto; + border-radius: 4px; +} +.post-source-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(99, 102, 241, 0.15); + border: 1px solid rgba(99, 102, 241, 0.3); + border-radius: 6px; + color: var(--link); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.2s ease; +} +.post-source-link:hover { + background: rgba(99, 102, 241, 0.25); + border-color: rgba(99, 102, 241, 0.5); + color: var(--link-hover); +} +.post-source-link::after { + content: "β†—"; + font-size: 0.9em; +} + +/* Series Header (shown when filtering by series tag) */ +.series-header { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + margin: 0 0 1.5rem 0; + overflow: hidden; +} +.series-header-content { + padding: 1rem 1.25rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} +.series-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} +.series-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text); +} +.series-page-count { + color: var(--text-muted); + font-size: 0.9rem; + background: rgba(255, 255, 255, 0.08); + padding: 0.25rem 0.6rem; + border-radius: 4px; +} +.series-header-actions { + display: flex; + gap: 0.5rem; +} + +/* Series Editor Panel */ +.series-editor-panel { + padding: 1rem 1.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.2); +} +.series-editor-info { + margin-bottom: 0.75rem; +} +.series-editor-info p { + margin: 0; + color: var(--text-dim); + font-size: 0.85rem; +} +.series-editor-controls { + display: flex; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; +} +.series-editor-input { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.series-editor-input label { + font-size: 0.8rem; + color: var(--text-muted); +} +.series-editor-input input { + width: 100px; + padding: 0.4rem 0.6rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: var(--text); + font-size: 0.9rem; +} +.series-editor-feedback { + margin-top: 0.75rem; +} +.series-editor-feedback .feedback-success { + padding: 0.5rem 0.75rem; + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 6px; + color: #22c55e; + font-size: 0.85rem; +} +.series-editor-feedback .feedback-error { + padding: 0.5rem 0.75rem; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 6px; + color: #ef4444; + font-size: 0.85rem; +} + +#seriesEditModeBtn.active { + background: var(--btn-primary); + color: white; +} + +@media (max-width: 768px) { + .series-header-content { + flex-direction: column; + align-items: flex-start; + } + .series-editor-controls { + flex-direction: column; + align-items: stretch; + } + .series-editor-input { + flex-direction: row; + align-items: center; + gap: 0.5rem; + } +} + +@media (max-width: 768px) { + .post-info-content { + padding: 1rem; + } + .post-title { + font-size: 1.15rem; + } + .post-meta { + font-size: 0.8rem; + } + .post-description { + max-height: 150px; + font-size: 0.85rem; + } +} + /* Gallery content area */ .gallery-infinite { min-height: 50vh; @@ -1703,6 +2015,21 @@ body.select-mode .gallery-infinite-container { color: #ef4444; } +/* Series section in bulk editor */ +.bulk-editor-series { + background: rgba(100, 100, 255, 0.05); + border-top: 1px solid rgba(100, 100, 255, 0.15); +} +.bulk-editor-series h4 { + color: var(--text); +} +.bulk-editor-series .series-editor-controls { + margin-top: 0.5rem; +} +.bulk-editor-series .series-editor-feedback { + margin-top: 0.5rem; +} + /* Text muted helper */ .text-muted { color: var(--text-muted); @@ -1727,3 +2054,355 @@ body.select-mode .gallery-infinite-container { transform: translateY(0); } } + +/*------------------------------------------------------------------------------ + Series Reader View +------------------------------------------------------------------------------*/ +.reader-container { + display: flex; + flex-direction: column; + height: calc(100vh - 60px); + background: var(--bg); + margin: -1rem; +} + +.reader-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--panel); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; +} + +.reader-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.reader-back-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: var(--text); + text-decoration: none; + font-size: 1.2rem; + transition: all 0.2s ease; +} + +.reader-back-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.reader-title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text); +} + +.reader-page-count { + color: var(--text-muted); + font-size: 0.85rem; +} + +.reader-header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.reader-jump { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.reader-jump label { + color: var(--text-muted); + font-size: 0.85rem; +} + +.reader-jump-select { + padding: 0.4rem 0.75rem; + background: var(--surface); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: var(--text); + font-size: 0.85rem; + cursor: pointer; +} +.reader-jump-select option { + background: var(--surface); + color: var(--text); +} + +.reader-toggle-nav { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 8px; + color: var(--text); + font-size: 1.1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.reader-toggle-nav:hover { + background: rgba(255, 255, 255, 0.2); +} + +.reader-body { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +/* Navigation Sidebar */ +.reader-nav { + position: fixed; + top: 0; + left: 0; + width: 200px; + height: 100vh; + background: var(--panel); + border-right: 1px solid rgba(255, 255, 255, 0.1); + transform: translateX(-100%); + transition: transform 0.3s ease; + z-index: 1000; + display: flex; + flex-direction: column; +} + +.reader-nav.open { + transform: translateX(0); +} + +.reader-nav-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.reader-nav-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 500; +} + +.reader-nav-close { + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 6px; + color: var(--text); + font-size: 1.2rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.reader-nav-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.reader-nav-thumbs { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.reader-nav-thumb { + position: relative; + border-radius: 6px; + overflow: hidden; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.2s ease; +} + +.reader-nav-thumb:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.reader-nav-thumb.active { + border-color: var(--btn-primary); +} + +.reader-nav-thumb img { + width: 100%; + height: auto; + display: block; +} + +.thumb-page-num { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.75); + color: white; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; +} + +/* Main Reader Content */ +.reader-main { + flex: 1; + display: flex; + flex-direction: column; + position: relative; +} + +.reader-content { + flex: 1; + overflow-y: auto; + scroll-behavior: smooth; + padding: 0.25rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: var(--bg); +} + +.reader-page { + position: relative; + width: 100%; + display: flex; + justify-content: center; +} + +.reader-page img { + max-width: min(100%, 1200px); + height: auto; + display: block; +} + +.page-indicator { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.75); + color: white; + padding: 0.25rem 0.6rem; + border-radius: 4px; + font-size: 0.75rem; + opacity: 0; + transition: opacity 0.2s ease; +} + +.reader-page:hover .page-indicator { + opacity: 1; +} + +.reader-empty { + text-align: center; + padding: 4rem 1rem; + color: var(--text-muted); +} + +/* Floating page indicator */ +.floating-page-indicator { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.85); + color: white; + padding: 0.5rem 1rem; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 500; + box-shadow: var(--shadow-2); + z-index: 100; +} + +/* Quick nav buttons */ +.reader-quick-nav { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 100; +} + +.reader-quick-btn { + padding: 0.5rem 0.75rem; + background: var(--panel); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: var(--text-dim); + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.reader-quick-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text); +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .reader-header { + flex-wrap: wrap; + gap: 0.5rem; + } + + .reader-header-left { + flex: 1; + } + + .reader-title { + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + } + + .reader-jump label { + display: none; + } + + .reader-nav { + width: 100%; + max-width: 280px; + } + + .reader-content { + padding: 0.5rem; + } + + .floating-page-indicator { + bottom: 1rem; + font-size: 0.8rem; + } + + .reader-quick-nav { + right: 1rem; + bottom: 4rem; + } +} diff --git a/app/tasks/sidecar.py b/app/tasks/sidecar.py index 0e31331..c706109 100644 --- a/app/tasks/sidecar.py +++ b/app/tasks/sidecar.py @@ -10,7 +10,7 @@ from datetime import datetime from app.celery_app import celery from app import db -from app.models import ImageRecord, Tag +from app.models import ImageRecord, Tag, PostMetadata log = logging.getLogger('celery.tasks.sidecar') @@ -53,6 +53,7 @@ def apply_sidecar_metadata(self, image_id: int, sidecar_path: str): return {'status': 'no_metadata', 'reason': 'No metadata extracted'} tags_added = 0 + post_tag = None # Track the post tag for PostMetadata # Update date if earlier new_date = resolve_date_conflict(record.taken_at, enriched.get('taken_at')) @@ -78,17 +79,62 @@ def apply_sidecar_metadata(self, image_id: int, sidecar_path: str): record.tags.append(tag) tags_added += 1 + # Track the post tag for PostMetadata creation + if tag_kind == 'post': + post_tag = tag + + # Create or update PostMetadata for post tags + post_metadata_created = False + if post_tag and enriched.get('platform') and enriched.get('post_id'): + existing_pm = PostMetadata.query.filter_by(tag_id=post_tag.id).first() + if not existing_pm: + # Create new PostMetadata record + pm = PostMetadata( + tag_id=post_tag.id, + platform=enriched.get('platform'), + post_id=enriched.get('post_id'), + artist=enriched.get('artist'), + title=enriched.get('post_title'), + description=enriched.get('description'), + source_url=enriched.get('source_url'), + attachment_count=enriched.get('attachment_count', 0), + published_at=enriched.get('taken_at'), + ) + db.session.add(pm) + post_metadata_created = True + log.debug(f"Created PostMetadata for tag {post_tag.name}") + else: + # Update existing PostMetadata if it has missing fields + updated = False + if not existing_pm.title and enriched.get('post_title'): + existing_pm.title = enriched.get('post_title') + updated = True + if not existing_pm.description and enriched.get('description'): + existing_pm.description = enriched.get('description') + updated = True + if not existing_pm.source_url and enriched.get('source_url'): + existing_pm.source_url = enriched.get('source_url') + updated = True + # Always update attachment count to get accurate count + if enriched.get('attachment_count', 0) > existing_pm.attachment_count: + existing_pm.attachment_count = enriched.get('attachment_count', 0) + updated = True + if updated: + log.debug(f"Updated PostMetadata for tag {post_tag.name}") + db.session.commit() log.info(f"Applied sidecar metadata to {image_id}: " - f"platform={enriched.get('platform')}, tags_added={tags_added}") + f"platform={enriched.get('platform')}, tags_added={tags_added}, " + f"post_metadata_created={post_metadata_created}") return { 'status': 'applied', 'platform': enriched.get('platform'), 'artist': enriched.get('artist'), 'post_id': enriched.get('post_id'), - 'tags_added': tags_added + 'tags_added': tags_added, + 'post_metadata_created': post_metadata_created } except Exception as e: diff --git a/app/templates/_gallery_item.html b/app/templates/_gallery_item.html index fda0c31..da8f0b0 100644 --- a/app/templates/_gallery_item.html +++ b/app/templates/_gallery_item.html @@ -22,6 +22,8 @@ {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'series' %} {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} + {% elif t.kind == 'fandom' %} + {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'rating' %} {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% else %} diff --git a/app/templates/_gallery_modal.html b/app/templates/_gallery_modal.html index 13663ff..25c53ff 100644 --- a/app/templates/_gallery_modal.html +++ b/app/templates/_gallery_modal.html @@ -12,6 +12,16 @@
+
diff --git a/app/templates/gallery.html b/app/templates/gallery.html index beea1ab..a463fcb 100644 --- a/app/templates/gallery.html +++ b/app/templates/gallery.html @@ -29,6 +29,60 @@ {% endif %}
+ {% if post_metadata %} + + + {% endif %} + + {% if series_info %} + +
+
+
+

{{ series_info.name }}

+ {{ series_info.page_count }} pages +
+
+ {% if series_info.page_count > 0 %} + + Read Series + + {% endif %} +
+
+
+ {% endif %} + + {% if series_info %} +
+

πŸ“š Add to Series

+

Add selected images as pages to "{{ series_info.name }}"

+
+
+ + +
+ +
+
+
+ {% endif %}
@@ -100,11 +170,98 @@ window.galleryState = { cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }}, hasMore: {{ 'true' if has_more else 'false' }}, - activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }} + activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }}, + seriesInfo: {{ series_info | tojson | safe if series_info else 'null' }} }; + +{% if series_info %} + +{% endif %} {% endblock %} diff --git a/app/templates/reader.html b/app/templates/reader.html new file mode 100644 index 0000000..83b58c2 --- /dev/null +++ b/app/templates/reader.html @@ -0,0 +1,234 @@ +{% extends "layout.html" %} +{% block title %}{{ series_name }} - Reader{% endblock %} + +{% block content %} +
+ +
+
+ + + +

{{ series_name }}

+ {{ total_pages }} pages +
+
+
+ + +
+ +
+
+ +
+ + + + +
+
+ {% if pages %} + {% for page in pages %} +
+ Page {{ page.page_number }} +
{{ page.page_number }} / {{ total_pages }}
+
+ {% endfor %} + {% else %} +
+

No pages in this series yet.

+ + Go to gallery to add pages + +
+ {% endif %} +
+ + +
+ 1 / {{ total_pages }} +
+ + +
+ + +
+
+
+
+ + +{% endblock %} diff --git a/app/templates/tags_list.html b/app/templates/tags_list.html index 17c869c..84725d5 100644 --- a/app/templates/tags_list.html +++ b/app/templates/tags_list.html @@ -3,6 +3,7 @@ {%- 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 @@ -16,6 +17,7 @@ {%- 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 @@ -42,6 +44,10 @@ href="{{ url_for('main.tag_list', kind='series') }}" aria-current="{{ 'page' if active_kind=='series' else 'false' }}">πŸ“Ί Series + 🎭 Fandoms + ⚠️ Ratings @@ -84,6 +90,7 @@ {%- if tag.kind == 'artist' -%}🎨 {%- elif tag.kind == 'character' -%}πŸ‘€ {%- elif tag.kind == 'series' -%}πŸ“Ί + {%- elif tag.kind == 'fandom' -%}🎭 {%- elif tag.kind == 'rating' -%}⚠️ {%- elif tag.kind == 'archive' -%}πŸ—œοΈ {%- elif tag.kind == 'source' -%}🌐 @@ -122,6 +129,7 @@ + diff --git a/app/utils/metadata_enrichment.py b/app/utils/metadata_enrichment.py index a9f2fd7..3eb52c9 100644 --- a/app/utils/metadata_enrichment.py +++ b/app/utils/metadata_enrichment.py @@ -157,7 +157,95 @@ def extract_post_id(metadata: dict) -> Optional[str]: def extract_post_title(metadata: dict) -> Optional[str]: """Extract the post title from metadata.""" - return metadata.get("title") + title = metadata.get("title") + # Return None for empty strings + if title and isinstance(title, str) and title.strip(): + return title.strip() + return None + + +def extract_description(metadata: dict) -> Optional[str]: + """ + Extract the post description/content from metadata. + + Platform-specific fields: + - Patreon: 'content' (may be HTML) + - SubscribeStar: 'content' (may be HTML) + - HentaiFoundry: 'description' (plain text) + """ + # Try description first (HentaiFoundry uses this) + if "description" in metadata: + desc = metadata["description"] + if desc and isinstance(desc, str) and desc.strip(): + return desc.strip() + + # Try content (Patreon/SubscribeStar use this) + if "content" in metadata: + content = metadata["content"] + if content and isinstance(content, str) and content.strip(): + return content.strip() + + # Try teaser_text as fallback (Patreon) + if "teaser_text" in metadata: + teaser = metadata["teaser_text"] + if teaser and isinstance(teaser, str) and teaser.strip(): + return teaser.strip() + + return None + + +def extract_source_url(metadata: dict) -> Optional[str]: + """ + Extract the URL to the original post. + + Platform-specific fields: + - Patreon: 'url' (direct post link) + - SubscribeStar: construct from author_name and post_id + - HentaiFoundry: construct from user and index + """ + platform = extract_platform(metadata) + + # Direct URL field (Patreon) + if "url" in metadata: + url = metadata["url"] + if url and isinstance(url, str) and url.startswith("http"): + return url + + # Construct URL for SubscribeStar + if platform == "subscribestar": + author = metadata.get("author_name") + post_id = metadata.get("post_id") + if author and post_id: + return f"https://subscribestar.adult/{author}/posts/{post_id}" + + # Construct URL for HentaiFoundry + if platform == "hentaifoundry": + user = metadata.get("user") or metadata.get("artist") + index = metadata.get("index") + if user and index: + return f"https://www.hentai-foundry.com/pictures/user/{user}/{index}" + + return None + + +def extract_attachment_count(metadata: dict) -> int: + """ + Extract the number of attachments/images in the post. + """ + # Patreon: check images array or post_metadata.image_order + if "images" in metadata and isinstance(metadata["images"], list): + return len(metadata["images"]) + + if "post_metadata" in metadata: + pm = metadata["post_metadata"] + if isinstance(pm, dict) and "image_order" in pm: + return len(pm["image_order"]) + + # Default to 1 if we have a file + if "filename" in metadata or "file" in metadata: + return 1 + + return 0 def extract_date(metadata: dict) -> Optional[datetime]: @@ -336,6 +424,9 @@ def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = Non "artist": str or None, "post_id": str or None, "post_title": str or None, + "description": str or None, + "source_url": str or None, + "attachment_count": int, "taken_at": datetime or None, "tags": [{"name": str, "kind": str}, ...], "raw_metadata": dict or None (the full sidecar content) @@ -346,6 +437,9 @@ def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = Non "artist": None, "post_id": None, "post_title": None, + "description": None, + "source_url": None, + "attachment_count": 0, "taken_at": None, "tags": [], "raw_metadata": None, @@ -367,6 +461,9 @@ def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = Non result["artist"] = extract_artist(metadata) result["post_id"] = extract_post_id(metadata) result["post_title"] = extract_post_title(metadata) + result["description"] = extract_description(metadata) + result["source_url"] = extract_source_url(metadata) + result["attachment_count"] = extract_attachment_count(metadata) # Extract date and resolve conflicts with existing sidecar_date = extract_date(metadata) diff --git a/migrations/versions/d26012201_add_post_metadata.py b/migrations/versions/d26012201_add_post_metadata.py new file mode 100644 index 0000000..9a7b168 --- /dev/null +++ b/migrations/versions/d26012201_add_post_metadata.py @@ -0,0 +1,46 @@ +"""Add PostMetadata table for storing post information from external platforms + +Revision ID: d26012201 +Revises: c26012001 +Create Date: 2026-01-22 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd26012201' +down_revision = 'c26012001' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create post_metadata table + op.create_table( + 'post_metadata', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.Column('platform', sa.String(length=64), nullable=False), + sa.Column('post_id', sa.String(length=64), nullable=False), + sa.Column('artist', sa.String(length=255), nullable=True), + sa.Column('title', sa.String(length=512), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('source_url', sa.String(length=1024), nullable=True), + sa.Column('attachment_count', sa.Integer(), nullable=True), + sa.Column('published_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tag_id') + ) + op.create_index(op.f('ix_post_metadata_platform'), 'post_metadata', ['platform'], unique=False) + op.create_index('ix_post_metadata_platform_post_id', 'post_metadata', ['platform', 'post_id'], unique=False) + + +def downgrade(): + op.drop_index('ix_post_metadata_platform_post_id', table_name='post_metadata') + op.drop_index(op.f('ix_post_metadata_platform'), table_name='post_metadata') + op.drop_table('post_metadata') diff --git a/migrations/versions/e26012202_add_series_page.py b/migrations/versions/e26012202_add_series_page.py new file mode 100644 index 0000000..ca09ba2 --- /dev/null +++ b/migrations/versions/e26012202_add_series_page.py @@ -0,0 +1,40 @@ +"""Add SeriesPage table for ordered series/comic reading + +Revision ID: e26012202 +Revises: d26012201 +Create Date: 2026-01-22 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e26012202' +down_revision = 'd26012201' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create series_page table + op.create_table( + 'series_page', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('series_tag_id', sa.Integer(), nullable=False), + sa.Column('image_id', sa.Integer(), nullable=False), + sa.Column('page_number', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('image_id'), # Image can only be in one series + sa.UniqueConstraint('series_tag_id', 'page_number', name='uq_series_page_number') + ) + op.create_index('ix_series_page_series_tag', 'series_page', ['series_tag_id'], unique=False) + + +def downgrade(): + op.drop_index('ix_series_page_series_tag', table_name='series_page') + op.drop_table('series_page') diff --git a/summary.md b/summary.md new file mode 100644 index 0000000..84d8d5b --- /dev/null +++ b/summary.md @@ -0,0 +1,407 @@ +# ImageRepo - Codebase Summary + +A Flask-based image repository with Celery task processing for importing, organizing, and managing images/videos with tagging, duplicate detection, and Gallery-DL metadata integration. + +--- + +## Architecture Overview + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Flask Web β”‚ β”‚ Celery Worker β”‚ β”‚ Celery Beat β”‚ +β”‚ (port 5000) β”‚ β”‚ (task queues) β”‚ β”‚ (scheduler) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ PostgreSQLβ”‚ β”‚ Redis β”‚ β”‚ /images β”‚ + β”‚ (database)β”‚ β”‚ (broker) β”‚ β”‚ /import β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## File Structure + +### Core Application Files + +| File | Purpose | +|------|---------| +| `run.py` | Entry point - creates Flask app via `create_app()` | +| `config.py` | Configuration class with DB, Redis, Celery settings | +| `docker-compose.yml` | Container orchestration for all services | + +### Application Package (`app/`) + +| File | Purpose | +|------|---------| +| `app/__init__.py:56-86` | Flask app factory `create_app()`, CLI commands, initializes extensions | +| `app/main.py` | Blueprint with all HTTP routes and API endpoints | +| `app/models.py` | SQLAlchemy models for database schema | +| `app/celery_app.py` | Celery configuration and task routing | + +--- + +## Database Models (`app/models.py`) + +### ImageRecord (lines 19-40) +Primary model for images/videos. +``` +Fields: id, filename, filepath, thumb_path, hash (SHA256), perceptual_hash (256-bit), + file_size, width, height, format, camera_model, taken_at, imported_at, is_thumbnail +Relationships: tags (many-to-many via image_tags) +``` + +### Tag (lines 42-51) +Tagging system for organization. +``` +Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|rating) +Relationships: images (many-to-many) +``` + +### ArchiveRecord (lines 53-66) +Tracks processed archives to prevent re-importing. +``` +Fields: id, filename, file_size, hash, imported_at, artist, tag_id +``` + +### AppSettings (lines 69-78) +Key-value store for application configuration. +``` +Fields: id, key, value, updated_at +``` + +### ImportBatch (lines 81-101) +Groups import tasks for batch tracking. +``` +Fields: id, source_directory, status, total_files, processed_files, + imported_count, skipped_count, failed_count, started_at, completed_at +``` + +### PostMetadata (lines 104-133) +Stores metadata about posts from external platforms (Patreon, SubscribeStar, HentaiFoundry). +``` +Fields: id, tag_id (FK to post tag), platform, post_id, artist, + title, description (may contain HTML), source_url, + attachment_count, published_at, created_at, updated_at +Relationships: tag (one-to-one with Tag where kind='post') +``` + +### SeriesPage (lines 136-168) +Links images to series tags with page numbers for ordered reading. +``` +Fields: id, series_tag_id (FK to tag), image_id (FK to image, unique), + page_number, created_at, updated_at +Constraints: unique(series_tag_id, page_number), unique(image_id) +Relationships: series_tag, image +``` + +### ImportTask (lines 171-222) +Individual file import task tracking. +``` +Fields: id, source_path, file_hash, file_size, task_type, status, celery_task_id, + context (JSON), batch_id, result_image_id, error_message, retry_count, timestamps +Status values: pending, queued, processing, complete, failed, skipped +Task types: scan, import_image, import_archive, thumbnail, sidecar +``` + +--- + +## Celery Tasks (`app/tasks/`) + +### Scan Tasks (`app/tasks/scan.py`) + +| Function | Line | Purpose | +|----------|------|---------| +| `scan_directory()` | 29-154 | Walks `/import` directory, creates ImportTask records, queues import tasks | +| `_create_task_if_new()` | 157-228 | Creates ImportTask if file not already processed | +| `recover_interrupted_tasks()` | 231-302 | Re-queues stuck tasks (called periodically by Beat) | +| `update_batch_stats()` | 305-349 | Updates ImportBatch statistics | + +### Import Tasks (`app/tasks/import_file.py`) + +| Function | Line | Purpose | +|----------|------|---------| +| `import_media_file()` | 23-252 | Main import task - filters, deduplicates, copies, creates thumbnail | +| `import_archive()` | 255-407 | Extracts archive, queues child import tasks | +| `_fail_task()` | 410-425 | Marks task as failed | +| `_skip_task()` | 428-443 | Marks task as skipped | +| `_add_artist_tag()` | 446-459 | Adds artist:name tag to image | +| `_supersede_existing()` | 472-539 | Replaces smaller image with larger version | + +### Thumbnail Tasks (`app/tasks/thumbnail.py`) + +| Function | Line | Purpose | +|----------|------|---------| +| `generate_thumbnail_task()` | 18-69 | Generate thumbnail for single image/video | +| `regenerate_all_thumbnails()` | 72-108 | Bulk regenerate all thumbnails | +| `regenerate_missing_thumbnails()` | 111-153 | Generate only missing thumbnails | + +### Sidecar Tasks (`app/tasks/sidecar.py`) + +| Function | Line | Purpose | +|----------|------|---------| +| `apply_sidecar_metadata()` | 18-97 | Apply Gallery-DL JSON metadata to image | +| `scan_and_match_sidecars()` | 100-147 | Find and match sidecar files to images | +| `reprocess_sidecars_for_tag()` | 150-181 | Re-run sidecar extraction for tagged images | + +--- + +## Utility Functions (`app/utils/`) + +### Image Importer (`app/utils/image_importer.py`) + +| Function | Line | Purpose | +|----------|------|---------| +| `load_import_settings()` | 50-88 | Load filter settings from DB/file/env | +| `is_mostly_transparent()` | 91-146 | Check if image is mostly transparent | +| `is_mostly_single_color()` | 149-215 | Check if image is solid color | +| `supersede_image()` | 218-275 | Replace smaller image with larger version | +| `extract_archive_resilient()` | 359-384 | Extract archive using unar/7z fallback | +| `build_hashed_dest_path()` | 391-408 | Generate content-addressed file path | +| `generate_thumbnail()` | 415-500 | Create thumbnail for image | +| `generate_video_thumbnail_mirrored()` | 524-532 | Create thumbnail for video | +| `transcode_video_to_mp4()` | 535-609 | Transcode video to H.264 MP4 | +| `needs_transcode()` | 612-615 | Check if video needs transcoding | +| `calculate_hash()` | 618-623 | SHA256 content hash | +| `calculate_perceptual_hash()` | 626-643 | 256-bit pHash for similarity | +| `find_similar_image()` | 646-678 | Find similar images by pHash | +| `extract_metadata()` | 691-754 | Extract EXIF/video metadata | +| `is_first_volume()` | 788-823 | Check if archive is first volume of multi-part | +| `compute_next_archive_tag_name()` | 825-890 | Generate unique archive:artist/NNNN tag | + +### Metadata Enrichment (`app/utils/metadata_enrichment.py`) + +| Function | Line | Purpose | +|----------|------|---------| +| `find_sidecar_json()` | 27-83 | Find Gallery-DL sidecar JSON for image | +| `load_sidecar_metadata()` | 86-93 | Parse sidecar JSON file | +| `extract_platform()` | 100-117 | Get platform from metadata | +| `extract_artist()` | 120-136 | Get artist/creator name | +| `extract_post_id()` | 139-155 | Get post ID | +| `extract_post_title()` | 158-164 | Get post title | +| `extract_description()` | 167-194 | Get post description/content (HTML or plain text) | +| `extract_source_url()` | 197-228 | Get URL to original post (constructs for SS/HF) | +| `extract_attachment_count()` | 231-248 | Get number of attachments in post | +| `extract_date()` | 251-271 | Extract publication date | +| `generate_source_tags()` | 338-369 | Generate source:platform and post:... tags | +| `resolve_date_conflict()` | 376-405 | Keep earliest date | +| `enrich_from_sidecar()` | 412-470 | Main enrichment function (returns platform, artist, title, description, source_url, etc.) | + +### Version Migration (`app/utils/version_migration.py`) + +| Function | Line | Purpose | +|----------|------|---------| +| `get_setting()` / `set_setting()` | 43-74 | Read/write AppSettings | +| `get_setting_int/float/bool()` | 77-104 | Typed setting getters | +| `get_import_settings()` | 111-122 | Load import filter settings from DB | +| `calculate_phash_256()` | 129-139 | Calculate 256-bit perceptual hash | +| `recompute_all_phashes()` | 146-218 | Batch recompute all pHashes | +| `check_and_run_migrations()` | 221-268 | Run data migrations on version change | + +--- + +## HTTP Routes (`app/main.py`) + +### Page Routes + +| Route | Line | Purpose | +|-------|------|---------| +| `GET /` | 18-30 | Showcase - random images masonry | +| `GET /gallery` | 80-124 | Main gallery with infinite scroll | +| `GET /tags` | 391-475 | Tag explorer (filterable by kind) | +| `GET /artists` | 477-480 | Redirect to tag_list?kind=artist | +| `GET /settings` | 488-490 | Settings page | + +### Gallery API + +| Route | Line | Purpose | +|-------|------|---------| +| `GET /api/random-images` | 33-77 | Random images for showcase shuffle | +| `GET /api/gallery/scroll` | 188-259 | Infinite scroll pagination | +| `GET /api/gallery/timeline` | 262-316 | Year-month groups for sidebar | +| `GET /api/gallery/jump` | 319-388 | Jump to specific year-month | +| `GET /images/` | 127-129 | Serve image files | + +### Tag API + +| Route | Line | Purpose | +|-------|------|---------| +| `GET /api/tags/search` | 497-537 | Tag autocomplete search | +| `GET /image//tags` | 540-543 | List tags for image | +| `POST /image//tags/add` | 546-573 | Add tag to image | +| `POST /image//tags/remove` | 576-587 | Remove tag from image | +| `GET /api/tag/` | 594-605 | Get tag details | +| `GET /api/post-metadata/` | 608-651 | Get post metadata for a post tag | +| `GET /api/post-metadata/by-tag-name/` | 654-685 | Get post metadata by tag name | +| `POST /api/tag//update` | 688-764 | Update/merge tag | +| `POST /api/tag//delete` | 767-776 | Delete tag | +| `GET /api/tag//image-count` | 842-847 | Count images with tag | + +### Bulk Operations + +| Route | Line | Purpose | +|-------|------|---------| +| `POST /api/delete-by-tag` | 703-759 | Delete all images with tag | +| `POST /api/images/common-tags` | 1394-1425 | Get common tags across images | +| `POST /api/images/bulk-add-tag` | 1428-1474 | Add tag to multiple images | +| `POST /api/images/bulk-remove-tag` | 1477-1514 | Remove tag from multiple images | + +### Series / Reader API + +| Route | Line | Purpose | +|-------|------|---------| +| `GET /read/` | 1620-1650 | Reader view for a series (vertical scroll) | +| `GET /api/series//pages` | 1653-1685 | Get all pages in a series | +| `POST /api/series//pages/add` | 1688-1737 | Add single image to series with page number | +| `POST /api/series//pages/bulk-add` | 1740-1800 | Add multiple images with sequential page numbers | +| `POST /api/series/page//update` | 1803-1828 | Update a page's page number | +| `POST /api/series/page//delete` | 1831-1841 | Remove image from series | +| `GET /api/image//series-info` | 1844-1858 | Check if image is in a series | + +### Import Queue API + +| Route | Line | Purpose | +|-------|------|---------| +| `POST /api/import/trigger` | 961-981 | Start directory scan | +| `GET /api/import/status` | 984-1042 | Queue status and recent tasks | +| `GET /api/import/task/` | 1045-1082 | Task details | +| `POST /api/import/retry-failed` | 1085-1122 | Retry failed tasks | +| `POST /api/import/clear-completed` | 1125-1144 | Clear completed tasks | +| `GET /api/import/batches` | 1222-1250 | List import batches | + +### Settings API + +| Route | Line | Purpose | +|-------|------|---------| +| `GET /api/import-settings` | 774-788 | Get import filter settings | +| `POST /api/import-settings` | 791-821 | Save import filter settings | +| `GET /api/filter-scan` | 828-902 | Scan images against filters | +| `POST /api/filter-delete` | 905-954 | Delete filtered images | + +### Thumbnail API + +| Route | Line | Purpose | +|-------|------|---------| +| `POST /api/thumbnails/regenerate` | 1147-1164 | Regenerate all thumbnails | +| `POST /api/thumbnails/regenerate-missing` | 1167-1183 | Generate missing thumbnails | + +### Celery Status + +| Route | Line | Purpose | +|-------|------|---------| +| `GET /api/celery/status` | 1186-1219 | Worker and queue status | + +### Duplicate Detection + +| Route | Line | Purpose | +|-------|------|---------| +| `POST /api/duplicates/scan` | 1257-1343 | Scan for similar images by pHash | +| `POST /api/duplicates/delete` | 1346-1387 | Delete duplicate images | + +--- + +## Frontend Templates (`app/templates/`) + +| Template | Purpose | +|----------|---------| +| `layout.html` | Base template with navbar | +| `showcase.html` | Random image masonry view | +| `gallery.html` | Main gallery with infinite scroll, timeline sidebar, bulk editor, series editor | +| `reader.html` | Series reader with vertical scroll, page jump, thumbnail navigation | +| `tags_list.html` | Tag browser with preview images | +| `settings.html` | Import settings, deletion tools, duplicate detection | +| `_gallery_item.html` | Single gallery item partial | +| `_gallery_modal.html` | Image modal with tag editor | +| `_gallery_grid.html` | Gallery grid partial | +| `_pagination.html` | Pagination controls | + +### JavaScript Files (`app/static/js/`) + +| File | Purpose | +|------|---------| +| `gallery-infinite.js` | Infinite scroll, timeline navigation | +| `modal-pagination.js` | Modal image navigation | +| `bulk-select.js` | Multi-select and bulk tag editing | +| `tag-editor.js` | Tag autocomplete and editing | +| `showcase.js` | Showcase shuffle functionality | + +--- + +## Docker Services + +| Service | Purpose | +|---------|---------| +| `redis` | Message broker for Celery | +| `postgres` | PostgreSQL database | +| `web` | Flask application (port 5000) | +| `celery-worker` | Task processor (queues: scan, import, thumbnail, sidecar, default) | +| `celery-beat` | Periodic task scheduler | + +--- + +## Key Concepts + +### Content-Addressed Storage +Files are stored with hash suffix: `{filename}__{hash[:10]}.{ext}` + +### Perceptual Hashing +256-bit pHash for duplicate detection (threshold default: 10) + +### Tag Kinds +- `artist` - Artist/creator (prefix: `artist:name`) +- `archive` - Archive source (prefix: `archive:artist/NNNN`) +- `source` - Platform source (prefix: `source:platform`) +- `post` - Post reference (prefix: `post:platform:artist:id`) +- `user` - User-created tags (no prefix) +- `character`, `series`, `rating` - Content tags + +### Import Pipeline +1. `scan_directory` walks `/import/{artist}/` folders +2. Creates ImportTask for each file +3. `import_media_file` or `import_archive` processes file +4. Applies filters (dimensions, transparency, duplicates) +5. Copies to `/images/{artist}/` with hash suffix +6. Generates thumbnail +7. Applies sidecar metadata if found +8. Creates ImageRecord with tags + +### Sidecar Metadata +Gallery-DL JSON files provide: +- Platform (patreon, hentaifoundry, etc.) +- Artist name +- Post ID and title +- Publication date +- Source URL + +--- + +## Environment Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `DB_USER` | imagerepo | PostgreSQL user | +| `DB_PASS` | postgres | PostgreSQL password | +| `DB_HOST` | postgres | PostgreSQL host | +| `DB_NAME` | imagerepo | Database name | +| `CELERY_BROKER_URL` | redis://redis:6379/0 | Redis broker | +| `CELERY_WORKER_CONCURRENCY` | 2 | Worker processes | +| `IMPORT_EVERY_SECONDS` | 28800 | Auto-scan interval (8h) | +| `IMPORT_MIN_WIDTH` | 0 | Minimum image width | +| `IMPORT_MIN_HEIGHT` | 0 | Minimum image height | +| `IMPORT_SKIP_TRANSPARENT` | false | Skip transparent images | +| `IMPORT_TRANSPARENCY_THRESHOLD` | 0.9 | Transparency % threshold | +| `IMPORT_PHASH_THRESHOLD` | 10 | pHash similarity threshold | + +--- + +## CLI Commands + +```bash +flask version-check # Run data migrations +flask celery-worker # Start Celery worker +flask celery-beat # Start Celery Beat scheduler +```