import filters for pixiv sidecars, tag filtering, and favicon generation

This commit is contained in:
2026-02-04 19:08:50 -05:00
parent 17403c4e6b
commit c95896693f
24 changed files with 4615 additions and 4234 deletions
+3
View File
@@ -11,3 +11,6 @@ __pycache__/
*.pyd
env/
venv/
# Claude files
.claude/
+11 -3
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Outer frame -->
<rect x="4" y="4" width="56" height="56" rx="8" fill="#6366f1"/>
<!-- Inner image area -->
<rect x="10" y="10" width="44" height="44" rx="4" fill="#3c3c46"/>
<!-- Sun -->
<circle cx="44" cy="20" r="6" fill="#fbbf24"/>
<!-- Back mountain -->
<polygon points="10,48 32,24 50,48" fill="#5a5f6b"/>
<!-- Front mountain -->
<polygon points="24,48 44,30 54,48" fill="#50555f"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

+88
View File
@@ -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;
+18 -2
View File
@@ -3,6 +3,9 @@
<head>
<meta charset="utf-8">
<title>ImageRepo - {% block title %}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
@@ -13,8 +16,21 @@
<nav class="navbar">
<a class="nav-button" href="{{ url_for('main.index') }}">Showcase</a>
<a class="nav-button" href="{{ url_for('main.gallery') }}">Gallery</a>
<a class="nav-button" href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a>
<a class="nav-button" href="{{ url_for('main.tag_list') }}">Tags</a>
<div class="nav-dropdown">
<button class="nav-button nav-dropdown-trigger">Tags</button>
<div class="nav-dropdown-menu">
<a href="{{ url_for('main.tag_list') }}">All Tags</a>
<a href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a>
<a href="{{ url_for('main.tag_list', kind='character') }}">Characters</a>
<a href="{{ url_for('main.tag_list', kind='series') }}">Series</a>
<a href="{{ url_for('main.tag_list', kind='fandom') }}">Fandoms</a>
<a href="{{ url_for('main.tag_list', kind='rating') }}">Ratings</a>
<a href="{{ url_for('main.tag_list', kind='archive') }}">Archives</a>
<a href="{{ url_for('main.tag_list', kind='source') }}">Sources</a>
<a href="{{ url_for('main.tag_list', kind='post') }}">Posts</a>
<a href="{{ url_for('main.tag_list', kind='user') }}">User Tags</a>
</div>
</div>
<a class="nav-button" href="{{ url_for('main.settings') }}">Settings</a>
</nav>
</header>
+74 -8
View File
@@ -26,6 +26,17 @@
{%- else -%}All Tags{%- endif -%}
</h1>
<!-- Search bar -->
<div class="tag-search-bar">
<input type="text"
id="tagSearchInput"
class="tag-search-input"
placeholder="Search tags..."
value="{{ search_query }}"
autocomplete="off">
<span id="tagSearchCount" class="tag-search-count">{{ total_count }} tags</span>
</div>
<!-- Tag kind filter -->
<div class="filter-bar" role="tablist" aria-label="Filter tags by kind">
<a class="filter-btn {{ 'is-active' if not active_kind }}"
@@ -84,6 +95,7 @@
<script>
window.tagListState = {
kind: {{ (active_kind | tojson) if active_kind else 'null' }},
search: {{ (search_query | tojson) if search_query else 'null' }},
offset: {{ tag_data | length }},
pageSize: {{ page_size }},
hasMore: {{ 'true' if has_more else 'false' }},
@@ -133,7 +145,7 @@
<script src="{{ url_for('static', filename='js/tag-editor.js') }}"></script>
<script>
// Tag list infinite scroll
// Tag list infinite scroll and search
(function() {
const state = window.tagListState;
if (!state) return;
@@ -141,8 +153,12 @@
const grid = document.getElementById('tagGrid');
const sentinel = document.getElementById('tagScrollSentinel');
const loading = document.getElementById('tagLoadingIndicator');
const searchInput = document.getElementById('tagSearchInput');
const searchCount = document.getElementById('tagSearchCount');
if (!grid || !sentinel) return;
let searchTimeout = null;
async function loadMoreTags() {
if (state.loading || !state.hasMore) return;
@@ -157,6 +173,9 @@
if (state.kind) {
params.set('kind', state.kind);
}
if (state.search) {
params.set('search', state.search);
}
const response = await fetch(`/api/tags/list?${params}`);
const data = await response.json();
@@ -169,9 +188,6 @@
state.offset = data.offset;
state.hasMore = data.has_more;
// Re-attach edit button listeners for new cards
attachEditListeners();
// Hide sentinel if no more data
if (!state.hasMore) {
sentinel.style.display = 'none';
@@ -185,10 +201,60 @@
}
}
// Re-attach edit button event listeners after loading new cards
function attachEditListeners() {
// The tag-editor.js handles this via event delegation on document,
// so new buttons should work automatically
async function performSearch(query) {
state.loading = true;
loading.style.display = 'flex';
state.search = query || null;
state.offset = 0;
try {
const params = new URLSearchParams({
offset: 0,
limit: state.pageSize
});
if (state.kind) {
params.set('kind', state.kind);
}
if (state.search) {
params.set('search', state.search);
}
const response = await fetch(`/api/tags/list?${params}`);
const data = await response.json();
if (data.ok) {
// Replace grid contents
grid.innerHTML = data.html || '';
// Update state
state.offset = data.offset;
state.hasMore = data.has_more;
state.total = data.total;
// Update count display
if (searchCount) {
searchCount.textContent = `${data.total} tag${data.total !== 1 ? 's' : ''}`;
}
// Show/hide sentinel
sentinel.style.display = state.hasMore ? '' : 'none';
}
} catch (error) {
console.error('Failed to search tags:', error);
} finally {
state.loading = false;
loading.style.display = 'none';
}
}
// Live search with debounce
if (searchInput) {
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(e.target.value.trim());
}, 300);
});
}
// Set up IntersectionObserver for infinite scroll
+50 -9
View File
@@ -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:
+145
View File
@@ -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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Outer frame -->
<rect x="4" y="4" width="56" height="56" rx="8" fill="#6366f1"/>
<!-- Inner image area -->
<rect x="10" y="10" width="44" height="44" rx="4" fill="#3c3c46"/>
<!-- Sun -->
<circle cx="44" cy="20" r="6" fill="#fbbf24"/>
<!-- Back mountain -->
<polygon points="10,48 32,24 50,48" fill="#5a5f6b"/>
<!-- Front mountain -->
<polygon points="24,48 44,30 54,48" fill="#50555f"/>
</svg>'''
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 <head> section:")
print('''
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
''')
if __name__ == '__main__':
main()
+4 -2
View File
@@ -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