initial functionality and styling pass
@@ -16,7 +16,7 @@ def create_app(config_class='config.Config'):
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login' # IMPORTANT: now uses auth Blueprint
|
||||
login_manager.login_view = 'auth.login'
|
||||
|
||||
from app.main import main
|
||||
from app.auth import auth
|
||||
@@ -24,4 +24,9 @@ def create_app(config_class='config.Config'):
|
||||
app.register_blueprint(main)
|
||||
app.register_blueprint(auth)
|
||||
|
||||
@app.template_filter('datetimeformat')
|
||||
def datetimeformat(value, format="%Y-%m-%d"):
|
||||
return value.strftime(format) if value else ""
|
||||
|
||||
|
||||
return app
|
||||
|
||||
@@ -15,11 +15,27 @@ def register():
|
||||
form = RegistrationForm()
|
||||
if form.validate_on_submit():
|
||||
hashed_password = generate_password_hash(form.password.data)
|
||||
user = User(username=form.username.data, email=form.email.data, password_hash=hashed_password)
|
||||
|
||||
# Check if any users exist
|
||||
is_first_user = User.query.count() == 0
|
||||
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
email=form.email.data,
|
||||
password_hash=hashed_password,
|
||||
is_admin=is_first_user # Make first user an admin
|
||||
)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
if is_first_user:
|
||||
flash('Account created as administrator.', 'success')
|
||||
else:
|
||||
flash('Account created! Please log in.', 'success')
|
||||
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('register.html', form=form)
|
||||
|
||||
@auth.route('/login', methods=['GET', 'POST'])
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
# app/main.py
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
|
||||
from app.utils.image_importer import import_images_task
|
||||
from app.models import ImageRecord
|
||||
from app import db
|
||||
import os
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
@main.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@main.route('/gallery')
|
||||
@login_required
|
||||
def gallery():
|
||||
recent_images = 21
|
||||
images = ImageRecord.query.order_by(
|
||||
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
|
||||
).limit(recent_images).all()
|
||||
return render_template('gallery.html', images=images)
|
||||
|
||||
@main.route('/static/images/<path:filename>')
|
||||
def serve_image(filename):
|
||||
return send_from_directory('/images', filename)
|
||||
|
||||
@main.route('/artist/<artist_name>')
|
||||
def artist_gallery(artist_name):
|
||||
per_page=49
|
||||
page = request.args.get('page', 1, type=int)
|
||||
images = ImageRecord.query.filter_by(artist=artist_name).order_by(
|
||||
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
|
||||
).paginate(page=page, per_page=per_page)
|
||||
return render_template('artist_gallery.html', images=images, artist=artist_name)
|
||||
|
||||
|
||||
@main.route('/settings')
|
||||
@login_required
|
||||
def settings():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
return render_template('settings.html')
|
||||
|
||||
@main.route('/import-images')
|
||||
@login_required
|
||||
def trigger_image_import():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
source_dir = '/import'
|
||||
dest_dir = '/images'
|
||||
|
||||
result = import_images_task(source_dir, dest_dir)
|
||||
flash(f"Image import completed: {result}")
|
||||
return redirect(url_for('main.settings'))
|
||||
|
||||
@main.route('/reset-db', methods=['POST'])
|
||||
@login_required
|
||||
def reset_db():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
|
||||
ImageRecord.query.delete()
|
||||
db.session.commit()
|
||||
flash("Database has been reset. All image records removed.", "info")
|
||||
return redirect(url_for('main.settings'))
|
||||
@@ -1,12 +1,32 @@
|
||||
from . import db
|
||||
from flask_login import UserMixin
|
||||
from . import login_manager
|
||||
from datetime import datetime
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
class ImageRecord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
filepath = db.Column(db.String(512), nullable=False)
|
||||
hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256
|
||||
perceptual_hash = db.Column(db.String(16), nullable=True) # hex of 64-bit hash
|
||||
artist = db.Column(db.String(255), nullable=True)
|
||||
file_size = db.Column(db.Integer, nullable=True)
|
||||
width = db.Column(db.Integer, nullable=True)
|
||||
height = db.Column(db.Integer, nullable=True)
|
||||
format = db.Column(db.String(10), nullable=True)
|
||||
camera_model = db.Column(db.String(255), nullable=True)
|
||||
taken_at = db.Column(db.DateTime, nullable=True)
|
||||
imported_at = db.Column(db.DateTime, default=db.func.now())
|
||||
is_thumbnail = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
|
||||
@@ -1,4 +1,226 @@
|
||||
.error {
|
||||
color: red;
|
||||
font-size: 0.9em;
|
||||
/* ========== General Layout ========== */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
background-color: #5d6668;
|
||||
}
|
||||
|
||||
.mainview {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
background-color: rgba(0,0,0,.4);
|
||||
}
|
||||
/* Navbar container */
|
||||
.navbar {
|
||||
display: flex;
|
||||
background-color: #1e1e1e;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Button-style nav links */
|
||||
.nav-button {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background-color: #444;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-button:active {
|
||||
background-color: #1a1a1a;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.flash.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.flash.danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* ========== Gallery Layout ========== */
|
||||
|
||||
.image-date {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #e2e2e2;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 450px;
|
||||
background-color: rgba(1,1,1,.1);
|
||||
box-shadow: 0 3px 4px rgba(0,0,0,0.7);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
box-shadow: 0 4px 4px rgba(0,0,0,0.4);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-item a {
|
||||
color: #d4aeff;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.gallery-item a:hover {
|
||||
text-decoration: underline;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
|
||||
.pagination-container {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.pagination-link {
|
||||
display: inline-block;
|
||||
margin: 0 0.3rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pagination-link.active,
|
||||
.pagination-link:hover {
|
||||
background-color: #007BFF;
|
||||
color: white;
|
||||
border-color: #007BFF;
|
||||
}
|
||||
|
||||
/* MODAL STYLES */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-body img {
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.modal-filename {
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Modal arrows and close */
|
||||
.modal-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal:hover .modal-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-prev {
|
||||
left: -60px;
|
||||
}
|
||||
|
||||
.modal-next {
|
||||
right: -60px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
font-size: 1.5rem;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ artist }}'s Gallery{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h2>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination-container">
|
||||
{% if images.has_prev %}
|
||||
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, images.pages + 1) %}
|
||||
<a class="pagination-link {% if p == images.page %}active{% endif %}" href="{{ url_for('main.artist_gallery', artist_name=artist, page=p) }}">{{ p }}</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if images.has_next %}
|
||||
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.next_num) }}">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Gallery Grid -->
|
||||
<div class="gallery-grid">
|
||||
{% for image in images %}
|
||||
<div class="gallery-item">
|
||||
<div class="gallery-thumb img-clickable"
|
||||
data-full="{{ url_for('static', filename=image.filepath) }}"
|
||||
style="background-image: url('{{ url_for('static', filename=image.filepath) }}');"
|
||||
data-full="{{ url_for('static', filename=image.filepath) }}"
|
||||
data-filename="{{ image.filename }}">
|
||||
</div>
|
||||
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
|
||||
<span class="image-date">
|
||||
{{ image.taken_at or image.imported_at | datetimeformat }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination-container">
|
||||
{% if images.has_prev %}
|
||||
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, images.pages + 1) %}
|
||||
<a class="pagination-link {% if p == images.page %}active{% endif %}" href="{{ url_for('main.artist_gallery', artist_name=artist, page=p) }}">{{ p }}</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if images.has_next %}
|
||||
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.next_num) }}">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Gallery{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Gallery Thumbnail Grid -->
|
||||
<div class="gallery-grid">
|
||||
{% for image in images %}
|
||||
<div class="gallery-item">
|
||||
<div class="gallery-thumb img-clickable"
|
||||
data-full="{{ url_for('static', filename=image.filepath) }}"
|
||||
style="background-image: url('{{ url_for('static', filename=image.filepath) }}');"
|
||||
data-full="{{ url_for('static', filename=image.filepath) }}"
|
||||
data-filename="{{ image.filename }}">
|
||||
</div>
|
||||
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
|
||||
<span class="image-date">
|
||||
{{ image.taken_at or image.imported_at | datetimeformat }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -7,18 +7,30 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="mainview">
|
||||
<header>
|
||||
{% if current_user.is_authenticated %}
|
||||
Hello {{ current_user.username }} |
|
||||
<a href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">Login</a> |
|
||||
<a href="{{ url_for('auth.register') }}">Register</a>
|
||||
<header>
|
||||
<nav class="navbar">
|
||||
<a class="nav-button" href="{{ url_for('main.gallery') if current_user.is_authenticated else url_for('main.index') }}">Home</a>
|
||||
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<a class="nav-button" href="{{ url_for('main.settings') }}">Settings</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="nav-button" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
{% else %}
|
||||
<a class="nav-button" href="{{ url_for('auth.login') }}">Login</a>
|
||||
<a class="nav-button" href="{{ url_for('auth.register') }}">Register</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- FLASH MESSAGES -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
</header>
|
||||
|
||||
|
||||
<!-- FLASH MESSAGES -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">
|
||||
@@ -26,12 +38,96 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
||||
<hr>
|
||||
<hr>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
{% block content %}{% endblock %}
|
||||
<!-- MAIN CONTENT -->
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="imageModal" class="modal">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-transparent border-0">
|
||||
<button id="modalClose" class="modal-button modal-close">✕</button>
|
||||
<button id="modalPrev" class="modal-button modal-prev">←</button>
|
||||
<button id="modalNext" class="modal-button modal-next">→</button>
|
||||
<div class="modal-body">
|
||||
<h5 id="modalFilename" class="modal-filename"></h5>
|
||||
<img id="modalImage" src="" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('imageModal');
|
||||
const modalImage = document.getElementById('modalImage');
|
||||
const modalClose = document.getElementById('modalClose');
|
||||
const modalPrev = document.getElementById('modalPrev');
|
||||
const modalNext = document.getElementById('modalNext');
|
||||
const modalFilename = document.getElementById('modalFilename');
|
||||
const images = Array.from(document.querySelectorAll('.img-clickable'));
|
||||
|
||||
let currentIndex = -1;
|
||||
|
||||
function openModal(index) {
|
||||
currentIndex = index;
|
||||
const img = images[currentIndex];
|
||||
modalImage.src = img.dataset.full;
|
||||
modalFilename.textContent = img.dataset.filename || '';
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove('active');
|
||||
modalImage.src = '';
|
||||
modalFilename.textContent = '';
|
||||
currentIndex = -1;
|
||||
}
|
||||
|
||||
function showNext() {
|
||||
if (images.length === 0) return;
|
||||
currentIndex = (currentIndex + 1) % images.length;
|
||||
const img = images[currentIndex];
|
||||
modalImage.src = img.dataset.full;
|
||||
modalFilename.textContent = img.dataset.filename || '';
|
||||
}
|
||||
|
||||
function showPrev() {
|
||||
if (images.length === 0) return;
|
||||
currentIndex = (currentIndex - 1 + images.length) % images.length;
|
||||
const img = images[currentIndex];
|
||||
modalImage.src = img.dataset.full;
|
||||
modalFilename.textContent = img.dataset.filename || '';
|
||||
}
|
||||
|
||||
images.forEach((img, index) => {
|
||||
img.addEventListener('click', () => openModal(index));
|
||||
});
|
||||
|
||||
modalClose.addEventListener('click', closeModal);
|
||||
modalNext.addEventListener('click', showNext);
|
||||
modalPrev.addEventListener('click', showPrev);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!modal.classList.contains('active')) return;
|
||||
if (e.key === 'ArrowRight') showNext();
|
||||
else if (e.key === 'ArrowLeft') showPrev();
|
||||
else if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Image Management</h2>
|
||||
<p>Use the options below to manage image data.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('main.trigger_image_import') }}" class="btn btn-primary">
|
||||
📥 Import Images
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<form method="POST" action="{{ url_for('main.reset_db') }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete all image records?');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
🗑️ Reset Database
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,129 @@
|
||||
# app/utils/image_importer.py
|
||||
|
||||
from datetime import datetime
|
||||
import os, shutil, hashlib
|
||||
from PIL import Image
|
||||
import exifread
|
||||
import mimetypes
|
||||
import imagehash
|
||||
|
||||
from app import db
|
||||
from app.models import ImageRecord
|
||||
|
||||
def import_images_task(source_dir, dest_dir):
|
||||
imported = []
|
||||
|
||||
for artist_dir in os.listdir(source_dir):
|
||||
artist_path = os.path.join(source_dir, artist_dir)
|
||||
if not os.path.isdir(artist_path):
|
||||
continue
|
||||
|
||||
for root, _, files in os.walk(artist_path):
|
||||
for file in files:
|
||||
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff')):
|
||||
full_path = os.path.join(root, file)
|
||||
filename = os.path.basename(full_path)
|
||||
file_hash = calculate_hash(full_path)
|
||||
|
||||
if ImageRecord.query.filter_by(hash=file_hash).first():
|
||||
continue
|
||||
|
||||
metadata = extract_metadata(full_path)
|
||||
|
||||
if not metadata['width'] or not metadata['height']:
|
||||
continue
|
||||
|
||||
phash = calculate_perceptual_hash(full_path)
|
||||
if phash and is_similar_image(phash, metadata['width'], metadata['height']):
|
||||
print(f"[SKIP] {filename} is visually similar to an existing larger image.")
|
||||
continue
|
||||
|
||||
target_dir = os.path.join(dest_dir, artist_dir)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
dest_path = os.path.join(target_dir, filename)
|
||||
shutil.copy2(full_path, dest_path)
|
||||
|
||||
record = ImageRecord(
|
||||
filename=filename,
|
||||
filepath=dest_path,
|
||||
hash=file_hash,
|
||||
perceptual_hash=str(phash) if phash else None,
|
||||
artist=artist_dir,
|
||||
file_size=metadata['file_size'],
|
||||
width=metadata['width'],
|
||||
height=metadata['height'],
|
||||
format=metadata['format'],
|
||||
camera_model=metadata['camera_model'],
|
||||
taken_at=metadata['taken_at'],
|
||||
imported_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(record)
|
||||
imported.append(filename)
|
||||
|
||||
db.session.commit()
|
||||
return f"Imported {len(imported)} images."
|
||||
|
||||
|
||||
def calculate_hash(file_path):
|
||||
hash_func = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b''):
|
||||
hash_func.update(chunk)
|
||||
return hash_func.hexdigest()
|
||||
|
||||
|
||||
def calculate_perceptual_hash(file_path):
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
return imagehash.phash(img)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to compute perceptual hash for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_similar_image(phash, width, height, threshold=2):
|
||||
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash != None).all():
|
||||
try:
|
||||
existing_phash = imagehash.hex_to_hash(img.perceptual_hash)
|
||||
distance = phash - existing_phash
|
||||
if distance <= threshold:
|
||||
if img.width >= width and img.height >= height:
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed pHash comparison: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def extract_metadata(file_path):
|
||||
metadata = {
|
||||
'file_size': None,
|
||||
'width': None,
|
||||
'height': None,
|
||||
'format': None,
|
||||
'camera_model': None,
|
||||
'taken_at': None
|
||||
}
|
||||
|
||||
try:
|
||||
metadata['file_size'] = os.path.getsize(file_path)
|
||||
with Image.open(file_path) as img:
|
||||
metadata['width'], metadata['height'] = img.size
|
||||
metadata['format'] = img.format
|
||||
except Exception as e:
|
||||
ext = os.path.splitext(file_path)[1]
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
print(f"[WARN] Failed to read image metadata: {file_path}")
|
||||
print(f"[WARN] Reason: {e}")
|
||||
print(f"[INFO] File extension: {ext}, MIME type: {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 as e:
|
||||
print(f"[WARN] Failed to read EXIF data: {file_path} – {e}")
|
||||
|
||||
return metadata
|
||||
@@ -2,14 +2,29 @@ import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'you-will-never-guess')
|
||||
DB_TYPE = os.environ.get('DB_TYPE', 'sqlite').lower() # 'sqlite' or 'postgresql'
|
||||
|
||||
if DB_TYPE == 'postgresql':
|
||||
DB_USER = os.environ.get('DB_USER', 'postgres')
|
||||
DB_PASS = os.environ.get('DB_PASS', 'postgres')
|
||||
DB_HOST = os.environ.get('DB_HOST', 'db')
|
||||
DB_HOST = os.environ.get('DB_HOST', 'localhost')
|
||||
DB_PORT = os.environ.get('DB_PORT', '5432')
|
||||
DB_NAME = os.environ.get('DB_NAME', 'postgres')
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
|
||||
)
|
||||
|
||||
elif DB_TYPE == 'sqlite':
|
||||
BASEDIR = os.path.abspath(os.path.dirname(__file__))
|
||||
DB_NAME = os.environ.get('DB_NAME', 'app.db')
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASEDIR, DB_NAME)}'
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported DB_TYPE '{DB_TYPE}'. Use 'sqlite' or 'postgresql'.")
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
|
||||
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: flask_app
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
FLASK_ENV: development
|
||||
SECRET_KEY: your-secret-key
|
||||
DB_USER: postgres
|
||||
DB_PASS: postgres
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_NAME: flask_app
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./:/app
|
||||
- ./import:/import
|
||||
- ./images:/images
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Wait for the database to be ready (timeout after 3 minutes)
|
||||
MAX_WAIT=180 # seconds
|
||||
WAIT_INTERVAL=2
|
||||
WAITED=0
|
||||
# Default to sqlite if not specified
|
||||
DB_TYPE="${DB_TYPE:-sqlite}"
|
||||
|
||||
until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" > /dev/null 2>&1; do
|
||||
# Wait for PostgreSQL if needed
|
||||
if [ "$DB_TYPE" = "postgresql" ]; then
|
||||
MAX_WAIT=180
|
||||
WAIT_INTERVAL=2
|
||||
WAITED=0
|
||||
echo "Database type is PostgreSQL. Waiting for database to be ready..."
|
||||
until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" > /dev/null 2>&1; do
|
||||
if [ $WAITED -ge $MAX_WAIT ]; then
|
||||
echo "Timeout: Could not connect to the database at $DB_HOST:$DB_PORT within $MAX_WAIT seconds."
|
||||
exit 1
|
||||
@@ -13,10 +17,20 @@ until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" > /dev/null 2>&1; do
|
||||
echo "Waiting for database at $DB_HOST:$DB_PORT... ($WAITED/$MAX_WAIT seconds)"
|
||||
sleep $WAIT_INTERVAL
|
||||
WAITED=$((WAITED + WAIT_INTERVAL))
|
||||
done
|
||||
done
|
||||
echo "Database is ready."
|
||||
else
|
||||
echo "Database type is SQLite. Skipping PostgreSQL readiness check."
|
||||
fi
|
||||
|
||||
echo "Database is ready. Applying migrations..."
|
||||
# Apply migrations
|
||||
echo "Applying database migrations..."
|
||||
flask db upgrade
|
||||
|
||||
# Start periodic image import in background
|
||||
echo "Starting 2h import loop"
|
||||
python image_import_worker.py &
|
||||
|
||||
# Start the Flask app
|
||||
echo "Starting Flask app..."
|
||||
exec flask run --host=0.0.0.0 --port=5000
|
||||
@@ -0,0 +1,20 @@
|
||||
# image_import_loop.py
|
||||
import time
|
||||
from app import create_app
|
||||
from app.utils.image_importer import import_images_task
|
||||
|
||||
app = create_app()
|
||||
|
||||
def looped_import(source_dir='/import', dest_dir='/images', delay_seconds=7200):
|
||||
with app.app_context():
|
||||
print("[Image Importer] Starting import task...")
|
||||
try:
|
||||
result = import_images_task(source_dir, dest_dir)
|
||||
print(f"[Image Importer] {result}")
|
||||
except Exception as e:
|
||||
print(f"[Image Importer] Error: {e}")
|
||||
print(f"[Image Importer] Sleeping for {delay_seconds / 3600:.1f} hours...\n")
|
||||
time.sleep(delay_seconds)
|
||||
|
||||
if __name__ == '__main__':
|
||||
looped_import()
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 8.8 MiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 873 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 999 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 593 KiB |
|
After Width: | Height: | Size: 847 KiB |
|
After Width: | Height: | Size: 685 KiB |
|
After Width: | Height: | Size: 977 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
|
After Width: | Height: | Size: 1016 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 984 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 749 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 897 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 779 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 652 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 159 KiB |